Compare commits

..

93 Commits

Author SHA1 Message Date
Greyson Parrelli
7738c286c2 Bump version to 6.12.3 2023-02-16 16:08:47 -05:00
Greyson Parrelli
697670b334 Updated language translations. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
4cfba86cb1 Fix bug where username wasn't synced to ContactRecord. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
ce4e84aadc Remove crash for group remap updates. 2023-02-16 16:08:47 -05:00
Cody Henthorne
23d0152767 Use newer APIs for wave form generation. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
d714590d3f Address bioauth issues on API 28. 2023-02-16 16:08:47 -05:00
Alex Hart
65bc1263f3 Add final copy for username deletion snackbar. 2023-02-16 16:08:47 -05:00
Alex Hart
730065fc76 Add support URL for usernames. 2023-02-16 16:08:47 -05:00
Alex Hart
1e10b82769 Fix gif sizing in conversation. 2023-02-16 16:08:47 -05:00
Cody Henthorne
01f477a587 Fix voice note UX issues. 2023-02-16 16:08:47 -05:00
Alex Hart
76383fe1bc Fix RTL corners in AlbumThumbnailView. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
c61f45b88b Remove old private certificate authority. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
e559198495 Fix crash when creating backup. 2023-02-16 16:08:47 -05:00
Greyson Parrelli
8676cb27ae Don't show add contact button if the user has no e164.
Fixes #12570
2023-02-16 16:08:46 -05:00
Alex Hart
7215ca6a28 Fix issue where view padding would not properly update on rotation. 2023-02-16 16:08:46 -05:00
Alex Hart
ef11a8d98d Fix navbar flashing on transform. 2023-02-16 16:08:46 -05:00
Greyson Parrelli
6efd501f1c Don't send MR accept for unblocking.
That's handled by storage service now.
2023-02-15 18:07:40 -05:00
Greyson Parrelli
66c650e859 Bump version to 6.12.2 2023-02-15 17:35:23 -05:00
Greyson Parrelli
8141f7a5cf Updated language translations. 2023-02-15 17:35:23 -05:00
Greyson Parrelli
0fcdf61e76 Revert "Don't run FTS optimize job (for now)."
This reverts commit f26b2c0b2a.
2023-02-15 17:35:23 -05:00
Greyson Parrelli
fa571f14e6 Update SQLCipher to 4.5.3-FTS-S2 2023-02-15 17:35:23 -05:00
Cody Henthorne
583860053b Cancel scheduled message alarm if no messages are scheduled. 2023-02-15 17:35:23 -05:00
Alex Hart
ad70baf557 Add documentation to DisplaySecondaryInformation. 2023-02-15 17:35:23 -05:00
Cody Henthorne
2b0e9783a7 Fix invalid attachment data during sms export. 2023-02-15 17:35:23 -05:00
Alex Hart
c75a9b577d Prefer about over phone number. 2023-02-15 17:35:23 -05:00
Cody Henthorne
e8ff1a04ed Fix scrolling issue in transfer lock dialog for small displays. 2023-02-15 17:35:22 -05:00
Cody Henthorne
a22a696722 Fix missing padding in schedule message time picker. 2023-02-15 17:35:22 -05:00
Alex Hart
66494fa418 Fix transition for very tall images. 2023-02-15 17:35:22 -05:00
Greyson Parrelli
9fd763fe83 Bump version to 6.12.1 2023-02-15 13:32:37 -05:00
Greyson Parrelli
1d508ad5cc Updated language translations. 2023-02-15 13:31:58 -05:00
Alex Hart
c1c7f57ec0 Fix sticker scaling. 2023-02-15 13:31:58 -05:00
Cody Henthorne
6100160e18 Address various issues with dark theme registration flow. 2023-02-15 13:24:15 -05:00
Alex Hart
e2c3db3eda Fix miscalculation of groups in common. 2023-02-15 13:24:15 -05:00
Alex Hart
6759b59507 Fix toolbar height and fade in. 2023-02-15 13:24:15 -05:00
Greyson Parrelli
e36844fe78 Improve logging around unblocking. 2023-02-15 13:24:15 -05:00
Alex Hart
5cf937215a Fix stub of TransferControlsView. 2023-02-15 13:24:15 -05:00
Alex Hart
1b49b9bffb Hide system UI until the shared element transition completes. 2023-02-15 13:24:15 -05:00
Alex Hart
a3a29d5cb2 Prevent shared element animation when we're not on the initial media. 2023-02-15 13:24:15 -05:00
Nicholas
6fbfb87bd6 Restore entered phone number post-captcha. 2023-02-15 13:24:15 -05:00
Alex Hart
2bff2d3a30 Disable shared element transitions from bubble. 2023-02-15 13:24:15 -05:00
Cody Henthorne
a88410faaf Fix incorrect quick react emojis for story replies. 2023-02-15 13:24:15 -05:00
Greyson Parrelli
f26b2c0b2a Don't run FTS optimize job (for now). 2023-02-15 13:24:15 -05:00
Alex Hart
6f1b03eac6 Utilize fade instead of just setting alpha to 0. 2023-02-15 10:57:54 -04:00
Cody Henthorne
9610339f38 Improve UX around seeing audio wave forms.
- Attempts to generate the wave form on download instead on display
- Allows multi-threaded generation of wave forms instead of serial
  executor
2023-02-15 09:43:16 -05:00
Alex Hart
d4ce8458a4 Ensure e164s are pretty-printed. 2023-02-15 10:10:25 -04:00
Greyson Parrelli
384cdf8610 Bump version to 6.12.0 2023-02-14 22:47:48 -05:00
Greyson Parrelli
3ee30808de Updated language translations. 2023-02-14 22:47:20 -05:00
Greyson Parrelli
78c64880f7 Fix instance where PNI may be accessed too early. 2023-02-14 14:51:29 -05:00
Greyson Parrelli
b99ce9cc1d Fix typo in string. 2023-02-14 14:30:25 -05:00
Greyson Parrelli
41f796d809 Update CDSI_MRENCLAVE. 2023-02-14 14:28:22 -05:00
Alex Hart
ec504af593 Improve conversation open speed. 2023-02-14 15:10:08 -04:00
Greyson Parrelli
60874ba57b Fix contact name syncing to storage service. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
4397b5af25 Add support for storing systemNickname from storage service. 2023-02-14 14:03:09 -05:00
Rashad Sookram
07234443c6 Update verification metadata for MacOS. 2023-02-14 14:03:09 -05:00
Alex Hart
c027203e8c Polish thumbnail animation. 2023-02-14 14:03:09 -05:00
Alex Hart
417db2341b Utilize drawable instead of bitmap for transition. 2023-02-14 14:03:09 -05:00
Bernie Dolan
6aa4ef95b5 Update payments to 4.0.0.1 2023-02-14 14:03:09 -05:00
Greyson Parrelli
6145fa213e Move common gradle config into convention plugins. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
9fa4741e49 Update ContactRecord.hidden field to value 20. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
b9d5fb54c3 Allow using the location picker with approximate location. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
c0fe156897 Do not fail message inserts on bad quote attachments.
Fixes #12721
2023-02-14 14:03:09 -05:00
Alex Hart
22cad64089 Clean up ThumbnailView warnings. 2023-02-14 14:03:09 -05:00
Alex Hart
702cf6ef71 Remove unused layout class. 2023-02-14 14:03:09 -05:00
Alex Hart
d7c3112602 Speed up thumbnail transition. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
d9c31a6cd6 Update AGP to 7.4.0 2023-02-14 14:03:09 -05:00
Greyson Parrelli
408c288936 Convert MediaTable to kotlin. 2023-02-14 14:03:09 -05:00
Greyson Parrelli
af6f16bdb6 Move Backups.proto to Wire. 2023-02-14 14:03:09 -05:00
Cody Henthorne
055ceba398 Add 'AnyAddressPorts' calling field trial flag. 2023-02-14 14:03:08 -05:00
Greyson Parrelli
3f81a94176 Fix case where we were performing remote inserts. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
a02d2e467b Revert "Remove the unknown insert validation."
This reverts commit 320669c54e.
2023-02-14 14:02:23 -05:00
Greyson Parrelli
414550861e Prevent recursive early content processing. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
afbce6f800 Re-enable FTS optimization after deletes. 2023-02-14 14:02:23 -05:00
Alex Hart
dda5037429 Add stubbing to ConversationThumbnailView and caching to a typeface. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
ffbebe0670 Update SQLCipher to 4.5.3-FTS-S1 2023-02-14 14:02:23 -05:00
Alex Hart
cf250b4b32 Add catch for candidate generation error to be treated the same as username unavailable. 2023-02-14 14:02:23 -05:00
Nicholas Tinsley
b14aea0922 Support dark mode in verification code keyboard. 2023-02-14 14:02:23 -05:00
Alex Hart
d0de43a6b2 Add thumbnail shared element animation. 2023-02-14 14:02:23 -05:00
Alex Hart
2c48d40375 Update API endpoints and integration for usernames. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
803154c544 Add a new PNP build flavor. 2023-02-14 14:02:23 -05:00
Greyson Parrelli
684150dc1e Handle split contacts in storage service when in PNP mode. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
fdcf0a76e8 Split unregistered contacts when in PNP mode. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
9e056e5dd0 Add support for rendering session switchover events. 2023-02-14 14:02:22 -05:00
Cody Henthorne
03c68375db Fix bad group state when requesting to rejoin a group. 2023-02-14 14:02:22 -05:00
Alex Hart
5d328857aa Upgrade libsignal to 0.22.0 2023-02-14 14:02:22 -05:00
Cody Henthorne
3a0dbe6e67 Use alarm clock for scheduling message sends. 2023-02-14 14:02:22 -05:00
Cody Henthorne
56b35f3767 Fix quoted links from rendering as clickable. 2023-02-14 14:02:22 -05:00
Greyson Parrelli
7f0221c5c6 Prefer MessageRecord mismatches when updating SN's. 2023-02-14 14:02:22 -05:00
Cody Henthorne
23050152de Replace time duration picker dialog for screen lock timeout. 2023-02-14 14:02:22 -05:00
Alex Hart
db65edb7df Mark DSL api discouraged. 2023-02-14 14:02:22 -05:00
Alex Hart
605289aca4 Upgrade ktlint and add twitter compose rules. 2023-02-14 14:02:22 -05:00
Jim Gustafson
52e9b31554 Update to RingRTC v2.24.0 2023-02-14 14:02:22 -05:00
Alex Hart
c8e6ccc0c0 Add extended colors to SignalTheme. 2023-02-14 14:02:22 -05:00
Alex Hart
f20d929292 Add Buttons object for properly themed compose buttons. 2023-02-14 14:02:22 -05:00
522 changed files with 7799 additions and 4592 deletions

View File

@@ -2,3 +2,4 @@ root = true
[*.kt]
indent_size = 2
twitter_compose_allowed_composition_locals=LocalExtendedColors

View File

@@ -43,10 +43,7 @@
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
<value />
</option>
</JetCodeStyleSettings>
<codeStyleSettings language="HTML">

View File

@@ -1,39 +1,21 @@
import com.android.build.api.dsl.ManagedVirtualDevice
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'com.google.protobuf'
apply plugin: 'androidx.navigation.safeargs'
apply plugin: 'org.jlleitschuh.gradle.ktlint'
apply from: 'translations.gradle'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'app.cash.exhaustive'
apply plugin: 'kotlin-parcelize'
apply plugin: 'com.squareup.wire'
apply from: 'static-ips.gradle'
repositories {
maven {
url "https://raw.githubusercontent.com/signalapp/maven/master/sqlcipher/release/"
content {
includeGroupByRegex "org\\.signal.*"
}
}
google()
mavenCentral()
mavenLocal()
maven {
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
}
jcenter {
content {
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
}
}
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'com.google.protobuf'
id 'androidx.navigation.safeargs'
id 'org.jlleitschuh.gradle.ktlint'
id 'org.jetbrains.kotlin.android'
id 'app.cash.exhaustive'
id 'kotlin-parcelize'
id 'com.squareup.wire'
id 'android-constants'
}
apply from: 'translations.gradle'
apply from: 'static-ips.gradle'
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.18.0'
@@ -61,11 +43,11 @@ wire {
ktlint {
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
version = "0.43.2"
version = "0.47.1"
}
def canonicalVersionCode = 1213
def canonicalVersionName = "6.11.7"
def canonicalVersionCode = 1217
def canonicalVersionName = "6.12.3"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -80,6 +62,9 @@ def selectableVariants = [
'nightlyProdSpinner',
'nightlyProdPerf',
'nightlyProdRelease',
'nightlyStagingRelease',
'nightlyPnpPerf',
'nightlyPnpRelease',
'playProdDebug',
'playProdSpinner',
'playProdPerf',
@@ -89,14 +74,18 @@ def selectableVariants = [
'playStagingSpinner',
'playStagingPerf',
'playStagingInstrumentation',
'playPnpDebug',
'playPnpSpinner',
'playStagingRelease',
'websiteProdSpinner',
'websiteProdRelease',
]
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
namespace 'org.thoughtcrime.securesms'
buildToolsVersion BUILD_TOOLS_VERSION
compileSdkVersion COMPILE_SDK_VERSION
flavorDimensions 'distribution', 'environment'
useLibrary 'org.apache.http.legacy'
@@ -137,12 +126,6 @@ android {
}
}
lintOptions {
checkReleaseBuilds false
abortOnError true
baseline file("lint-baseline.xml")
disable "LintError"
}
sourceSets {
test {
@@ -159,19 +142,13 @@ android {
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
packagingOptions {
exclude 'LICENSE.txt'
exclude 'LICENSE'
exclude 'NOTICE'
exclude 'asm-license.txt'
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude 'libsignal_jni.dylib'
exclude 'signal_jni.dll'
resources {
excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE', 'META-INF/proguard/androidx-annotations.pro', 'libsignal_jni.dylib', 'signal_jni.dll']
}
}
buildFeatures {
viewBinding true
compose true
@@ -185,8 +162,8 @@ android {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
minSdkVersion MIN_SDK_VERSION
targetSdkVersion TARGET_SDK_VERSION
multiDexEnabled true
@@ -220,8 +197,7 @@ android {
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -313,11 +289,13 @@ android {
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
}
release {
minifyEnabled true
proguardFiles = buildTypes.debug.proguardFiles
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
}
perf {
initWith debug
isDefault false
@@ -376,7 +354,6 @@ android {
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
"\"9dbc6855c198e04f21b5cc35df839fdcd51b53658454dfa3f817afefaffc95ef\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -392,6 +369,22 @@ android {
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
}
pnp {
dimension 'environment'
initWith staging
applicationIdSuffix ".pnp"
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\""
}
}
lint {
abortOnError true
baseline file('lint-baseline.xml')
checkReleaseBuilds false
disable 'LintError'
}
android.applicationVariants.all { variant ->
@@ -523,7 +516,6 @@ dependencies {
implementation libs.greenrobot.eventbus
implementation libs.waitingdots
implementation libs.google.zxing.android.integration
implementation libs.time.duration.picker
implementation libs.google.zxing.core
implementation libs.google.flexbox
implementation (libs.subsampling.scale.image.view) {
@@ -596,6 +588,7 @@ dependencies {
androidTestUtil testLibs.androidx.test.orchestrator
implementation project(':core-ui')
ktlintRuleset libs.ktlint.twitter.compose
}
def getLastCommitTimestamp() {

View File

@@ -113,7 +113,7 @@ class ChangeNumberViewModelTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v1/accounts/number") { MockResponse().failure(500) },
Put("/v1/accounts/number") { MockResponse().failure(500) }
)
// WHEN

View File

@@ -69,7 +69,7 @@ class ConversationItemPreviewer {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = System.currentTimeMillis(),
receivedTimeMillis = System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
@@ -88,7 +88,7 @@ class ConversationItemPreviewer {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = System.currentTimeMillis(),
receivedTimeMillis = System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
val insert = SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()

View File

@@ -224,6 +224,24 @@ class GroupTableTest {
assertEquals(g2, gr2.get().id)
}
@Test
fun givenASharedActiveGroupWithoutAThread_whenISearchForRecipientsWithGroupsInCommon_thenIExpectThatGroup() {
val groupInCommon = insertPushGroup()
val expected = Recipient.resolved(harness.others[0])
SignalDatabase.recipients.setProfileSharing(expected.id, false)
SignalDatabase.recipients.queryGroupMemberContacts("Buddy")!!.use {
assertTrue(it.moveToFirst())
assertEquals(1, it.count)
assertEquals(expected.id.toLong(), it.requireLong(RecipientTable.ID))
}
val groups = groupTable.getPushGroupsContainingMember(expected.id)
assertEquals(1, groups.size)
assertEquals(groups[0].id, groupInCommon)
}
private fun insertThread(groupId: GroupId): Long {
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))

View File

@@ -128,7 +128,7 @@ class MmsTableTest_stories {
sentTimeMillis = 2,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
@@ -160,7 +160,7 @@ class MmsTableTest_stories {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
@@ -174,7 +174,7 @@ class MmsTableTest_stories {
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
).get().messageId
@@ -219,7 +219,7 @@ class MmsTableTest_stories {
sentTimeMillis = 200,
serverTimeMillis = 2,
receivedTimeMillis = 2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
),
-1L
)

View File

@@ -11,6 +11,11 @@ import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientTableTest {
@@ -159,4 +164,47 @@ class RecipientTableTest {
assertNotEquals(0, results.size)
assertFalse(blockedRecipient in results)
}
@Test
fun givenARecipientWithPniAndAci_whenIMarkItUnregistered_thenIExpectItToBeSplit() {
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
val mainId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
SignalDatabase.recipients.markUnregistered(mainId)
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
assertEquals(mainId, byAci)
assertEquals(byE164, byPni)
assertNotEquals(byAci, byE164)
}
@Test
fun givenARecipientWithPniAndAci_whenISplitItForStorageSync_thenIExpectItToBeSplit() {
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
val mainId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
val mainRecord = SignalDatabase.recipients.getRecord(mainId)
SignalDatabase.recipients.splitForStorageSync(mainRecord.storageId!!)
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
assertEquals(mainId, byAci)
assertEquals(byE164, byPni)
assertNotEquals(byAci, byE164)
}
companion object {
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
const val E164_A = "+12222222222"
}
}

View File

@@ -58,6 +58,33 @@ class RecipientTableTest_getAndPossiblyMerge {
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
}
@Test
fun allNonMergeTests() {
test("e164-only insert") {
val id = process(E164_A, null, null)
expect(E164_A, null, null)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.UNKNOWN, record.registered)
}
test("pni-only insert") {
val id = process(null, PNI_A, null)
expect(null, PNI_A, null)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
test("aci-only insert") {
val id = process(null, null, ACI_A)
expect(null, null, ACI_A)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
}
@Test
fun allSimpleTests() {
test("no match, e164-only") {
@@ -346,6 +373,32 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent(E164_A)
}
test("merge, e164 & pni & aci, all provided, no threads") {
given(E164_A, null, null, createThread = false)
given(null, PNI_A, null, createThread = false)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
}
test("merge, e164 & pni & aci, all provided, pni session no threads") {
given(E164_A, null, null, createThread = false)
given(null, PNI_A, null, createThread = true, pniSession = true)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("merge, e164 & pni, no aci provided") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -382,7 +435,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided, existing pni session") {
test("merge, e164 & pni, aci provided, existing pni session, thread merge shadows") {
given(E164_A, null, null)
given(null, PNI_A, null, pniSession = true)
@@ -392,6 +445,17 @@ class RecipientTableTest_getAndPossiblyMerge {
expectDeleted()
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided, existing pni session, no thread merge") {
given(E164_A, null, null, createThread = true)
given(null, PNI_A, null, createThread = false, pniSession = true)
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectDeleted()
expectSessionSwitchoverEvent(E164_A)
}
@@ -407,7 +471,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent("")
}
test("merge, e164+pni & aci") {
test("merge, e164+pni & aci, no pni session") {
given(E164_A, PNI_A, null)
given(null, null, ACI_A)
@@ -419,6 +483,52 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, thread merge shadows") {
given(E164_A, PNI_A, null, pniSession = true)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, no thread merge") {
given(E164_A, PNI_A, null, createThread = true, pniSession = true)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, no thread merge, pni verified") {
given(E164_A, PNI_A, null, createThread = true, pniSession = true)
given(null, null, ACI_A, createThread = false)
process(E164_A, PNI_A, ACI_A, pniVerified = true)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
}
test("merge, e164+pni & aci, pni session, pni verified") {
given(E164_A, PNI_A, null, pniSession = true)
given(null, null, ACI_A)
process(E164_A, PNI_A, ACI_A, pniVerified = true)
expectDeleted()
expect(E164_A, PNI_A, ACI_A)
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & e164+pni+aci, change number") {
given(E164_A, PNI_A, null)
given(E164_B, PNI_B, ACI_A)
@@ -758,9 +868,10 @@ class RecipientTableTest_getAndPossiblyMerge {
return id
}
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false) {
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = false, changeSelf = changeSelf)
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false, pniVerified: Boolean = false): RecipientId {
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
generatedIds += outputRecipientId
return outputRecipientId
}
fun expect(e164: String?, pni: PNI?, aci: ACI?) {

View File

@@ -1,842 +0,0 @@
package org.thoughtcrime.securesms.database
import androidx.core.content.contentValuesOf
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import java.lang.AssertionError
import java.lang.IllegalStateException
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class RecipientTableTest_processPnpTupleToChangeSet {
@Rule
@JvmField
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
private lateinit var db: RecipientTable
@Before
fun setup() {
db = SignalDatabase.recipients
}
@Test
fun noMatch_e164Only() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, null, null, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, null, null)
),
changeSet
)
}
@Test
fun noMatch_e164AndPni() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, null, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, null)
),
changeSet
)
}
@Test
fun noMatch_aciOnly() {
val changeSet = db.processPnpTupleToChangeSet(null, null, ACI_A, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(null, null, ACI_A)
),
changeSet
)
}
@Test(expected = IllegalStateException::class)
fun noMatch_noData() {
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
}
@Test
fun noMatch_allFields() {
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, ACI_A, pniVerified = false)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, ACI_A)
),
changeSet
)
}
@Test
fun fullMatch() {
val result = applyAndAssert(
Input(E164_A, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id)
),
result.changeSet
)
}
@Test
fun onlyE164Matches() {
val result = applyAndAssert(
Input(E164_A, null, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_existingPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_B, null, pniSession = true),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
)
}
@Test
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_B, null),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun e164AndPniMatches_noExistingSession() {
val result = applyAndAssert(
Input(E164_A, PNI_A, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun e164AndPniMatches_existingPniSession() {
val result = applyAndAssert(
Input(E164_A, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
)
}
@Test
fun e164AndAciMatches() {
val result = applyAndAssert(
Input(E164_A, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_noExistingSession() {
val result = applyAndAssert(
Input(null, PNI_A, null),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_existingPniSession() {
val result = applyAndAssert(
Input(null, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
)
}
@Test
fun onlyPniMatches_existingPniSession_changeNumber() {
val result = applyAndAssert(
Input(E164_B, PNI_A, null, pniSession = true),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetAci(result.id, ACI_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
),
PnpOperation.SessionSwitchoverInsert(result.id, E164_A)
)
),
result.changeSet
)
}
@Test
fun pniAndAciMatches() {
val result = applyAndAssert(
Input(null, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
)
),
result.changeSet
)
}
@Test
fun pniAndAciMatches_changeNumber() {
val result = applyAndAssert(
Input(E164_B, PNI_A, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun onlyAciMatches() {
val result = applyAndAssert(
Input(null, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A)
)
),
result.changeSet
)
}
@Test
fun onlyAciMatches_changeNumber() {
val result = applyAndAssert(
Input(E164_B, null, ACI_A),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.id),
operations = linkedSetOf(
PnpOperation.SetE164(result.id, E164_A),
PnpOperation.SetPni(result.id, PNI_A),
PnpOperation.ChangeNumberInsert(
recipientId = result.id,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_aciOnly() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
Input(null, null, ACI_A)
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.thirdId),
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
PnpOperation.Merge(
primaryId = result.thirdId,
secondaryId = result.firstId
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
PnpOperation.SetAci(
recipientId = result.firstId,
aci = ACI_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164Only_pniAndE164_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, null, null),
Input(E164_B, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(
recipientId = result.firstId,
pni = PNI_A
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_pniOnly_noAciProvided() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null),
Input(null, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.Merge(
primaryId = result.firstId,
secondaryId = result.secondId
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null),
Input(E164_B, PNI_A, null),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPni_noAciProvided_sessionsExist() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_B, null, pniSession = true),
Input(E164_B, PNI_A, null, pniSession = true),
),
Update(E164_A, PNI_A, null),
Output(E164_A, PNI_A, null)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.firstId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.SetPni(result.firstId, PNI_A),
PnpOperation.SessionSwitchoverInsert(result.secondId, E164_A),
PnpOperation.SessionSwitchoverInsert(result.firstId, E164_A)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(null, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = linkedSetOf(
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
val result = applyAndAssert(
listOf(
Input(E164_B, PNI_A, null),
Input(null, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.SetPni(
recipientId = result.secondId,
pni = PNI_A,
),
PnpOperation.SetE164(
recipientId = result.secondId,
e164 = E164_A,
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_B, PNI_A, null),
Input(E164_C, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.firstId),
PnpOperation.SetPni(
recipientId = result.secondId,
pni = PNI_A,
),
PnpOperation.SetE164(
recipientId = result.secondId,
e164 = E164_A,
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_C,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(E164_B, PNI_B, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = linkedSetOf(
PnpOperation.RemovePni(result.secondId),
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
@Test
fun merge_e164AndPni_e164Aci_changeNumber() {
val result = applyAndAssert(
listOf(
Input(E164_A, PNI_A, null),
Input(E164_B, null, ACI_A),
),
Update(E164_A, PNI_A, ACI_A),
Output(E164_A, PNI_A, ACI_A)
)
assertEquals(
PnpChangeSet(
id = PnpIdResolver.PnpNoopId(result.secondId),
operations = linkedSetOf(
PnpOperation.RemoveE164(result.secondId),
PnpOperation.Merge(
primaryId = result.secondId,
secondaryId = result.firstId
),
PnpOperation.ChangeNumberInsert(
recipientId = result.secondId,
oldE164 = E164_B,
newE164 = E164_A
)
)
),
result.changeSet
)
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientTable.TABLE_NAME,
null,
contentValuesOf(
RecipientTable.PHONE to e164,
RecipientTable.SERVICE_ID to (aci ?: pni)?.toString(),
RecipientTable.PNI_COLUMN to pni?.toString(),
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
)
)
return RecipientId.from(id)
}
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
SignalDatabase.rawDatabase.insert(
SessionTable.TABLE_NAME, null,
contentValuesOf(
SessionTable.ACCOUNT_ID to account.toString(),
SessionTable.ADDRESS to address.toString(),
SessionTable.DEVICE to 1,
SessionTable.RECORD to Util.getSecretBytes(32)
)
)
}
data class Input(val e164: String?, val pni: PNI?, val aci: ACI?, val pniSession: Boolean = false, val aciSession: Boolean = false)
data class Update(val e164: String?, val pni: PNI?, val aci: ACI?, val pniVerified: Boolean = false)
data class Output(val e164: String?, val pni: PNI?, val aci: ACI?)
data class PnpMatchResult(val ids: List<RecipientId>, val changeSet: PnpChangeSet) {
val id
get() = if (ids.size == 1) {
ids[0]
} else {
throw IllegalStateException("There are multiple IDs, but you assumed 1!")
}
val firstId
get() = ids[0]
val secondId
get() = ids[1]
val thirdId
get() = ids[2]
}
private fun applyAndAssert(input: Input, update: Update, output: Output): PnpMatchResult {
return applyAndAssert(listOf(input), update, output)
}
/**
* Helper method that will call insert your recipients, call [RecipientTable.processPnpTupleToChangeSet] with your params,
* and then verify your output matches what you expect.
*
* It results the inserted ID's and changeset for additional verification.
*
* But basically this is here to make the tests more readable. It gives you a clear list of:
* - input
* - update
* - output
*
* that you can spot check easily.
*
* Important: The output will only include records that contain fields from the input. That means
* for:
*
* Input: E164_B, PNI_A, null
* Update: E164_A, PNI_A, null
*
* You will get:
* Output: E164_A, PNI_A, null
*
* Even though there was an update that will also result in the row (E164_B, null, null)
*/
private fun applyAndAssert(input: List<Input>, update: Update, output: Output): PnpMatchResult {
val ids = input.map { insert(it.e164, it.pni, it.aci) }
input
.filter { it.pniSession }
.forEach { insertMockSessionFor(databaseRule.localAci, it.pni!!) }
input
.filter { it.aciSession }
.forEach { insertMockSessionFor(databaseRule.localAci, it.aci!!) }
val byE164 = update.e164?.let { db.getByE164(it).orElse(null) }
val byPniSid = update.pni?.let { db.getByServiceId(it).orElse(null) }
val byAciSid = update.aci?.let { db.getByServiceId(it).orElse(null) }
val data = PnpDataSet(
e164 = update.e164,
pni = update.pni,
aci = update.aci,
byE164 = byE164,
byPniSid = byPniSid,
byPniOnly = update.pni?.let { db.getByPni(it).orElse(null) },
byAciSid = byAciSid,
e164Record = byE164?.let { db.getRecord(it) },
pniSidRecord = byPniSid?.let { db.getRecord(it) },
aciSidRecord = byAciSid?.let { db.getRecord(it) }
)
val changeSet = db.processPnpTupleToChangeSet(update.e164, update.pni, update.aci, pniVerified = update.pniVerified)
val finalData = data.perform(changeSet.operations)
val finalRecords = setOfNotNull(finalData.e164Record, finalData.pniSidRecord, finalData.aciSidRecord)
assertEquals("There's still multiple records in the resulting record set! $finalRecords", 1, finalRecords.size)
finalRecords.firstOrNull { record -> record.e164 == output.e164 && record.pni == output.pni && record.serviceId == (output.aci ?: output.pni) }
?: throw AssertionError("Expected output was not found in the result set! Expected: $output")
return PnpMatchResult(
ids = ids,
changeSet = changeSet
)
}
companion object {
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
val PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
const val E164_A = "+12221234567"
const val E164_B = "+13331234567"
const val E164_C = "+14441234567"
}
}

View File

@@ -65,17 +65,17 @@ class StorySendTableTest {
messageId1 = MmsHelper.insert(
recipient = distributionListRecipient1,
storyType = StoryType.STORY_WITHOUT_REPLIES,
storyType = StoryType.STORY_WITHOUT_REPLIES
)
messageId2 = MmsHelper.insert(
recipient = distributionListRecipient2,
storyType = StoryType.STORY_WITH_REPLIES,
storyType = StoryType.STORY_WITH_REPLIES
)
messageId3 = MmsHelper.insert(
recipient = distributionListRecipient3,
storyType = StoryType.STORY_WITHOUT_REPLIES,
storyType = StoryType.STORY_WITHOUT_REPLIES
)
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)

View File

@@ -22,7 +22,6 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
@@ -66,15 +65,13 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
),
arrayOf(SignalContactDiscoveryUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
emptyList(),
Optional.of(SignalServiceNetworkAccess.DNS),
Optional.empty(),
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
true
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
)
serviceNetworkAccessMock = mock {

View File

@@ -69,7 +69,7 @@ class PreKeysSyncJobTest {
Put("/v2/keys/signed?identity=pni") { r ->
pniSignedPreKey = r.parsedRequestBody()
MockResponse().success()
},
}
)
// WHEN
@@ -107,7 +107,7 @@ class PreKeysSyncJobTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) },
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) }
)
// WHEN
@@ -134,7 +134,7 @@ class PreKeysSyncJobTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
Put("/v2/keys/signed?identity=pni") { MockResponse().success() },
Put("/v2/keys/signed?identity=pni") { MockResponse().success() }
)
// WHEN
@@ -173,7 +173,7 @@ class PreKeysSyncJobTest {
Put("/v2/keys/?identity=pni") { r ->
pniPreKeyStateRequest = r.parsedRequestBody()
MockResponse().success()
},
}
)
// WHEN

View File

@@ -30,7 +30,7 @@ abstract class MessageContentProcessorTest {
protected fun createNormalContentTestSubject(): MessageContentProcessor {
val context = ApplicationProvider.getApplicationContext<Application>()
return MessageContentProcessor.forNormalContent(context)
return MessageContentProcessor.create(context)
}
/**

View File

@@ -100,7 +100,7 @@ class UsernameEditFragmentTest {
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Put("/v1/accounts/username/reserved") {
MockResponse().success(ReserveUsernameResponse(username, "reservationToken"))
MockResponse().success(ReserveUsernameResponse(username))
},
Put("/v1/accounts/username/confirm") {
MockResponse().success()

View File

@@ -0,0 +1,127 @@
package org.thoughtcrime.securesms.storage
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.update
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ContactRecordProcessorTest {
@Before
fun setup() {
SignalStore.account().setE164(E164_SELF)
SignalStore.account().setAci(ACI_SELF)
SignalStore.account().setPni(PNI_SELF)
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
}
@Test
fun process_splitContact_normalSplit() {
// GIVEN
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setServiceId(ACI_A.toString())
setUnregisteredAtTimestamp(100)
}
val remote2 = buildRecord(STORAGE_ID_C) {
setServiceId(PNI_A.toString())
setServicePni(PNI_A.toString())
setServiceE164(E164_A)
}
// WHEN
val subject = ContactRecordProcessor()
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
// THEN
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
assertEquals(originalId, byAci)
assertEquals(byE164, byPni)
assertNotEquals(byAci, byE164)
}
@Test
fun process_splitContact_doNotSplitIfAciRecordIsRegistered() {
// GIVEN
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setServiceId(ACI_A.toString())
setUnregisteredAtTimestamp(0)
}
val remote2 = buildRecord(STORAGE_ID_C) {
setServiceId(PNI_A.toString())
setServicePni(PNI_A.toString())
setServiceE164(E164_A)
}
// WHEN
val subject = ContactRecordProcessor()
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
// THEN
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
assertEquals(originalId, byAci)
assertEquals(byE164, byPni)
assertEquals(byAci, byE164)
}
private fun buildRecord(id: StorageId, applyParams: ContactRecord.Builder.() -> ContactRecord.Builder): SignalContactRecord {
return SignalContactRecord(id, ContactRecord.getDefaultInstance().toBuilder().applyParams().build())
}
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {
SignalDatabase.rawDatabase
.update(RecipientTable.TABLE_NAME)
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeBytes(storageId.raw))
.where("${RecipientTable.ID} = ?", recipientId)
.run()
}
companion object {
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))
val ACI_SELF = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
val PNI_B = PNI.from(UUID.fromString("bbbb1111-cd55-40bf-adda-c35a85375533"))
val PNI_SELF = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val E164_A = "+12222222222"
const val E164_B = "+13333333333"
const val E164_SELF = "+10000000000"
val STORAGE_ID_A: StorageId = StorageId.forContact(byteArrayOf(1, 2, 3, 4))
val STORAGE_ID_B: StorageId = StorageId.forContact(byteArrayOf(5, 6, 7, 8))
val STORAGE_ID_C: StorageId = StorageId.forContact(byteArrayOf(9, 10, 11, 12))
}
}

View File

@@ -13,7 +13,7 @@ class RxTestSchedulerRule(
val ioTestScheduler: TestScheduler = defaultTestScheduler,
val computationTestScheduler: TestScheduler = defaultTestScheduler,
val singleTestScheduler: TestScheduler = defaultTestScheduler,
val newThreadTestScheduler: TestScheduler = defaultTestScheduler,
val newThreadTestScheduler: TestScheduler = defaultTestScheduler
) : ExternalResource() {
override fun before() {

View File

@@ -21,7 +21,7 @@ class TestProtos private constructor() {
}
fun metadata(
address: AddressProto = address().build(),
address: AddressProto = address().build()
): MetadataProto.Builder {
return MetadataProto.newBuilder()
.setAddress(address)

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.thoughtcrime.securesms">
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="org.thoughtcrime.securesms">
xmlns:tools="http://schemas.android.com/tools">
<uses-sdk tools:overrideLibrary="androidx.camera.core,androidx.camera.camera2,androidx.camera.lifecycle,androidx.camera.view" />
@@ -465,7 +464,7 @@
<activity android:name=".registration.RegistrationNavigationActivity"
android:launchMode="singleTask"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

View File

@@ -105,6 +105,8 @@ import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.security.Security;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.CompletableObserver;
@@ -164,7 +166,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
.addBlocking("app-migrations", this::initializeApplicationMigrations)
.addBlocking("ring-rtc", this::initializeRingRtc)
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete(this))
.addBlocking("lifecycle-observer", () -> ApplicationDependencies.getAppForegroundObserver().addListener(this))
.addBlocking("message-retriever", this::initializeMessageRetrieval)
@@ -177,6 +178,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addBlocking("ring-rtc", this::initializeRingRtc)
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
.addNonBlocking(this::checkIsGooglePayReady)
.addNonBlocking(this::cleanAvatarStorage)
@@ -416,7 +418,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeRingRtc() {
try {
CallManager.initialize(this, new RingRtcLogger(), Collections.emptyMap());
Map<String, String> fieldTrials = new HashMap<>();
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
}
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);
}

View File

@@ -10,10 +10,11 @@ import androidx.lifecycle.Observer;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
@@ -23,6 +24,7 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -113,5 +115,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
}
}

View File

@@ -25,6 +25,14 @@ class BiometricDeviceAuthentication(
const val TAG: String = "BiometricDeviceAuth"
const val BIOMETRIC_AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK
const val ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS or BiometricManager.Authenticators.DEVICE_CREDENTIAL
/**
* From the docs on [BiometricManager.canAuthenticate]
*
* > Note that not all combinations of authenticator types are supported prior to Android 11 (API 30). Specifically, DEVICE_CREDENTIAL alone is unsupported
* > prior to API 30, and BIOMETRIC_STRONG | DEVICE_CREDENTIAL is unsupported on API 28-29.
*/
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
}
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
@@ -35,7 +43,7 @@ class BiometricDeviceAuthentication(
return false
}
return if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
return if (!DISALLOWED_BIOMETRIC_VERSIONS.contains(Build.VERSION.SDK_INT) && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
if (force) {
Log.i(TAG, "Listening for biometric authentication...")
biometricPrompt.authenticate(biometricPromptInfo)

View File

@@ -13,11 +13,11 @@ class ContactSelectionListAdapter(
context: Context,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displayPhoneNumber: DisplayPhoneNumber,
displaySecondaryInformation: DisplaySecondaryInformation,
onClickCallbacks: OnContactSelectionClick,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks
) : ContactSearchAdapter(context, emptySet(), displayCheckBox, displaySmsTag, displayPhoneNumber, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
) : ContactSearchAdapter(context, emptySet(), displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))

View File

@@ -338,7 +338,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
selectionLimit,
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplayPhoneNumber.ALWAYS,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
@Override
@@ -347,11 +347,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
},
false,
(context, fixedContacts, displayCheckBox, displaySmsTag, displayPhoneNumber, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
context,
displayCheckBox,
displaySmsTag,
displayPhoneNumber,
displaySecondaryInformation,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onNewGroupClicked() {

View File

@@ -40,28 +40,22 @@ import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

View File

@@ -17,7 +17,7 @@ import java.io.IOException
*/
class SignalBackupAgent : BackupAgent() {
private val items: List<AndroidBackupItem> = listOf(
KbsAuthTokens,
KbsAuthTokens
)
override fun onBackup(oldState: ParcelFileDescriptor?, data: BackupDataOutput, newState: ParcelFileDescriptor) {

View File

@@ -11,7 +11,7 @@ public abstract class AnimationCompleteListener implements Animator.AnimatorList
public abstract void onAnimationEnd(Animator animation);
@Override
public final void onAnimationCancel(Animator animation) {}
public void onAnimationCancel(Animator animation) {}
@Override
public final void onAnimationRepeat(Animator animation) {}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.animation
import android.animation.Animator
abstract class AnimationStartListener : Animator.AnimatorListener {
override fun onAnimationEnd(animation: Animator) = Unit
override fun onAnimationCancel(animation: Animator) = Unit
override fun onAnimationRepeat(animation: Animator) = Unit
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.audio;
import androidx.annotation.NonNull;
import com.google.protobuf.ByteString;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import java.util.concurrent.TimeUnit;
public class AudioFileInfo {
private final long durationUs;
private final byte[] waveFormBytes;
private final float[] waveForm;
public static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
}
AudioFileInfo(long durationUs, byte[] waveFormBytes) {
this.durationUs = durationUs;
this.waveFormBytes = waveFormBytes;
this.waveForm = new float[waveFormBytes.length];
for (int i = 0; i < waveFormBytes.length; i++) {
int unsigned = waveFormBytes[i] & 0xff;
this.waveForm[i] = unsigned / 255f;
}
}
public long getDuration(@NonNull TimeUnit timeUnit) {
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
}
public float[] getWaveForm() {
return waveForm;
}
public @NonNull AudioWaveFormData toDatabaseProtobuf() {
return AudioWaveFormData.newBuilder()
.setDurationUs(durationUs)
.setWaveForm(ByteString.copyFrom(waveFormBytes))
.build();
}
}

View File

@@ -1,325 +0,0 @@
package org.thoughtcrime.securesms.audio;
import android.content.Context;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import android.os.Build;
import android.util.LruCache;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import com.google.protobuf.ByteString;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
import org.thoughtcrime.securesms.media.MediaInput;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Locale;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@RequiresApi(api = Build.VERSION_CODES.M)
public final class AudioWaveForm {
private static final String TAG = Log.tag(AudioWaveForm.class);
private static final int BAR_COUNT = 46;
private static final int SAMPLES_PER_BAR = 4;
private final Context context;
private final AudioSlide slide;
public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) {
this.context = context.getApplicationContext();
this.slide = slide;
}
private static final LruCache<String, AudioFileInfo> WAVE_FORM_CACHE = new LruCache<>(200);
private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED);
@AnyThread
public void getWaveForm(@NonNull Consumer<AudioFileInfo> onSuccess, @NonNull Runnable onFailure) {
Uri uri = slide.getUri();
Attachment attachment = slide.asAttachment();
if (uri == null) {
Log.w(TAG, "No uri");
ThreadUtil.runOnMain(onFailure);
return;
}
String cacheKey = uri.toString();
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
if (cached != null) {
Log.i(TAG, "Loaded wave form from cache " + cacheKey);
ThreadUtil.runOnMain(() -> onSuccess.accept(cached));
return;
}
AUDIO_DECODER_EXECUTOR.execute(() -> {
AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey);
if (cachedInExecutor != null) {
Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey);
ThreadUtil.runOnMain(() -> onSuccess.accept(cachedInExecutor));
return;
}
AudioHash audioHash = attachment.getAudioHash();
if (audioHash != null) {
AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm());
if (audioFileInfo.waveForm.length == 0) {
Log.w(TAG, "Recovering from a wave form generation error " + cacheKey);
ThreadUtil.runOnMain(onFailure);
return;
} else if (audioFileInfo.waveForm.length != BAR_COUNT) {
Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey);
} else {
WAVE_FORM_CACHE.put(cacheKey, audioFileInfo);
Log.i(TAG, "Loaded wave form from DB " + cacheKey);
ThreadUtil.runOnMain(() -> onSuccess.accept(audioFileInfo));
return;
}
}
if (attachment instanceof DatabaseAttachment) {
try {
AttachmentTable attachmentDatabase = SignalDatabase.attachments();
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
long startTime = System.currentTimeMillis();
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (Throwable e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
}
} else {
try {
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
long startTime = System.currentTimeMillis();
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (IOException e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
}
}
});
}
/**
* Based on decode sample from:
* <p>
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
*/
@WorkerThread
@RequiresApi(api = 23)
private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
MediaExtractor extractor = dataSource.createExtractor();
if (extractor.getTrackCount() == 0) {
throw new IOException("No audio track");
}
MediaFormat format = extractor.getTrackFormat(0);
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
throw new IOException("Unknown duration");
}
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
String mime = format.getString(MediaFormat.KEY_MIME);
if (!mime.startsWith("audio/")) {
throw new IOException("Mime not audio");
}
MediaCodec codec = MediaCodec.createDecoderByType(mime);
if (totalDurationUs == 0) {
throw new IOException("Zero duration");
}
codec.configure(format, null, null, 0);
codec.start();
ByteBuffer[] codecInputBuffers = codec.getInputBuffers();
ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers();
extractor.selectTrack(0);
long kTimeOutUs = 5000;
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean sawInputEOS = false;
boolean sawOutputEOS = false;
int noOutputCounter = 0;
while (!sawOutputEOS && noOutputCounter < 50) {
noOutputCounter++;
if (!sawInputEOS) {
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
if (inputBufIndex >= 0) {
ByteBuffer dstBuf = codecInputBuffers[inputBufIndex];
int sampleSize = extractor.readSampleData(dstBuf, 0);
long presentationTimeUs = 0;
if (sampleSize < 0) {
sawInputEOS = true;
sampleSize = 0;
} else {
presentationTimeUs = extractor.getSampleTime();
}
codec.queueInputBuffer(
inputBufIndex,
0,
sampleSize,
presentationTimeUs,
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
if (!sawInputEOS) {
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
sawInputEOS = !extractor.advance();
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
sawInputEOS = !extractor.advance();
if (!sawInputEOS) {
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
}
}
}
}
}
int outputBufferIndex;
do {
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
if (outputBufferIndex >= 0) {
if (info.size > 0) {
noOutputCounter = 0;
}
ByteBuffer buf = codecOutputBuffers[outputBufferIndex];
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
long total = 0;
for (int i = 0; i < info.size; i += 2 * 4) {
short aShort = buf.getShort(i);
total += Math.abs(aShort);
}
if (barIndex >= 0 && barIndex < wave.length) {
wave[barIndex] += total;
waveSamples[barIndex] += info.size / 2;
}
codec.releaseOutputBuffer(outputBufferIndex, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
sawOutputEOS = true;
}
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
codecOutputBuffers = codec.getOutputBuffers();
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
}
} while (outputBufferIndex >= 0);
}
codec.stop();
codec.release();
extractor.release();
float[] floats = new float[BAR_COUNT];
byte[] bytes = new byte[BAR_COUNT];
float max = 0;
for (int i = 0; i < BAR_COUNT; i++) {
if (waveSamples[i] == 0) continue;
floats[i] = wave[i] / (float) waveSamples[i];
if (floats[i] > max) {
max = floats[i];
}
}
for (int i = 0; i < BAR_COUNT; i++) {
float normalized = floats[i] / max;
bytes[i] = (byte) (255 * normalized);
}
return new AudioFileInfo(totalDurationUs, bytes);
}
}
public static class AudioFileInfo {
private final long durationUs;
private final byte[] waveFormBytes;
private final float[] waveForm;
private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
}
private AudioFileInfo(long durationUs, byte[] waveFormBytes) {
this.durationUs = durationUs;
this.waveFormBytes = waveFormBytes;
this.waveForm = new float[waveFormBytes.length];
for (int i = 0; i < waveFormBytes.length; i++) {
int unsigned = waveFormBytes[i] & 0xff;
this.waveForm[i] = unsigned / 255f;
}
}
public long getDuration(@NonNull TimeUnit timeUnit) {
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
}
public float[] getWaveForm() {
return waveForm;
}
private @NonNull AudioWaveFormData toDatabaseProtobuf() {
return AudioWaveFormData.newBuilder()
.setDurationUs(durationUs)
.setWaveForm(ByteString.copyFrom(waveFormBytes))
.build();
}
}
}

View File

@@ -0,0 +1,168 @@
package org.thoughtcrime.securesms.audio;
import android.content.Context;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
import org.thoughtcrime.securesms.media.MediaInput;
import java.io.IOException;
import java.nio.ByteBuffer;
@RequiresApi(api = 23)
public final class AudioWaveFormGenerator {
private static final String TAG = Log.tag(AudioWaveFormGenerator.class);
public static final int BAR_COUNT = 46;
private static final int SAMPLES_PER_BAR = 4;
private AudioWaveFormGenerator() {}
/**
* Based on decode sample from:
* <p>
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
*/
@WorkerThread
public static @NonNull AudioFileInfo generateWaveForm(@NonNull Context context, @NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
MediaExtractor extractor = dataSource.createExtractor();
if (extractor.getTrackCount() == 0) {
throw new IOException("No audio track");
}
MediaFormat format = extractor.getTrackFormat(0);
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
throw new IOException("Unknown duration");
}
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
String mime = format.getString(MediaFormat.KEY_MIME);
if (!mime.startsWith("audio/")) {
throw new IOException("Mime not audio");
}
MediaCodec codec = MediaCodec.createDecoderByType(mime);
if (totalDurationUs == 0) {
throw new IOException("Zero duration");
}
codec.configure(format, null, null, 0);
codec.start();
extractor.selectTrack(0);
long kTimeOutUs = 5000;
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean sawInputEOS = false;
boolean sawOutputEOS = false;
int noOutputCounter = 0;
while (!sawOutputEOS && noOutputCounter < 50) {
noOutputCounter++;
if (!sawInputEOS) {
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
if (inputBufIndex >= 0) {
ByteBuffer dstBuf = codec.getInputBuffer(inputBufIndex);
int sampleSize = extractor.readSampleData(dstBuf, 0);
long presentationTimeUs = 0;
if (sampleSize < 0) {
sawInputEOS = true;
sampleSize = 0;
} else {
presentationTimeUs = extractor.getSampleTime();
}
codec.queueInputBuffer(
inputBufIndex,
0,
sampleSize,
presentationTimeUs,
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
if (!sawInputEOS) {
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
sawInputEOS = !extractor.advance();
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
sawInputEOS = !extractor.advance();
if (!sawInputEOS) {
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
}
}
}
}
}
int outputBufferIndex;
do {
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
if (outputBufferIndex >= 0) {
if (info.size > 0) {
noOutputCounter = 0;
}
ByteBuffer buf = codec.getOutputBuffer(outputBufferIndex);
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
long total = 0;
for (int i = 0; i < info.size; i += 2 * 4) {
short aShort = buf.getShort(i);
total += Math.abs(aShort);
}
if (barIndex >= 0 && barIndex < wave.length) {
wave[barIndex] += total;
waveSamples[barIndex] += info.size / 2;
}
codec.releaseOutputBuffer(outputBufferIndex, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
sawOutputEOS = true;
}
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
}
} while (outputBufferIndex >= 0);
}
codec.stop();
codec.release();
extractor.release();
float[] floats = new float[BAR_COUNT];
byte[] bytes = new byte[BAR_COUNT];
float max = 0;
for (int i = 0; i < BAR_COUNT; i++) {
if (waveSamples[i] == 0) continue;
floats[i] = wave[i] / (float) waveSamples[i];
if (floats[i] > max) {
max = floats[i];
}
}
for (int i = 0; i < BAR_COUNT; i++) {
float normalized = floats[i] / max;
bytes[i] = (byte) (255 * normalized);
}
return new AudioFileInfo(totalDurationUs, bytes);
}
}
}

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms.audio
import android.content.Context
import android.net.Uri
import android.util.LruCache
import androidx.annotation.AnyThread
import androidx.annotation.RequiresApi
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData
import java.io.IOException
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.read
import kotlin.concurrent.write
/**
* Uses [AudioWaveFormGenerator] to generate audio wave forms.
*
* Maintains an in-memory cache of recently requested wave forms.
*/
@RequiresApi(23)
object AudioWaveForms {
private val TAG = Log.tag(AudioWaveForms::class.java)
private val cache = ThreadSafeLruCache(200)
@AnyThread
@JvmStatic
fun getWaveForm(context: Context, attachment: Attachment): Single<AudioFileInfo> {
val uri = attachment.uri
if (uri == null) {
Log.i(TAG, "No uri")
return Single.error(IllegalArgumentException("No uri from attachment"))
}
val cacheKey = uri.toString()
val cachedInfo = cache.get(cacheKey)
if (cachedInfo != null) {
Log.i(TAG, "Loaded wave form from cache $cacheKey")
return Single.just(cachedInfo)
}
val databaseCache = Single.fromCallable {
val audioHash = attachment.audioHash
return@fromCallable if (audioHash != null) {
checkDatabaseCache(cacheKey, audioHash.audioWaveForm)
} else {
Miss
}
}.subscribeOn(Schedulers.io())
val generateWaveForm: Single<CacheCheckResult> = if (attachment is DatabaseAttachment) {
Single.fromCallable { generateWaveForm(context, uri, cacheKey, attachment.attachmentId) }
} else {
Single.fromCallable { generateWaveForm(context, uri, cacheKey) }
}.subscribeOn(Schedulers.io())
return databaseCache
.flatMap { r ->
if (r is Miss) {
generateWaveForm
} else {
Single.just(r)
}
}
.map { r ->
if (r is Success) {
r.audioFileInfo
} else {
throw IOException("Unable to generate wave form")
}
}
}
private fun checkDatabaseCache(cacheKey: String, audioWaveForm: AudioWaveFormData): CacheCheckResult {
val audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioWaveForm)
if (audioFileInfo.waveForm.isEmpty()) {
Log.w(TAG, "Recovering from a wave form generation error $cacheKey")
return Failure
} else if (audioFileInfo.waveForm.size != AudioWaveFormGenerator.BAR_COUNT) {
Log.w(TAG, "Wave form from database does not match bar count, regenerating $cacheKey")
} else {
cache.put(cacheKey, audioFileInfo)
Log.i(TAG, "Loaded wave form from DB $cacheKey")
return Success(audioFileInfo)
}
return Miss
}
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String, attachmentId: AttachmentId): CacheCheckResult {
try {
val startTime = System.currentTimeMillis()
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData.getDefaultInstance())
Log.i(TAG, "Starting wave form generation ($cacheKey)")
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
Log.i(TAG, "Audio wave form generation time ${System.currentTimeMillis() - startTime} ms ($cacheKey)")
SignalDatabase.attachments.writeAudioHash(attachmentId, fileInfo.toDatabaseProtobuf())
cache.put(cacheKey, fileInfo)
return Success(fileInfo)
} catch (e: Throwable) {
Log.w(TAG, "Failed to create audio wave form for $cacheKey", e)
return Failure
}
}
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String): CacheCheckResult {
try {
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.")
val startTime = System.currentTimeMillis()
Log.i(TAG, "Starting wave form generation ($cacheKey)")
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
Log.i(TAG, "Audio wave form generation time ${System.currentTimeMillis() - startTime} ms ($cacheKey)")
cache.put(cacheKey, fileInfo)
return Success(fileInfo)
} catch (e: Throwable) {
Log.w(TAG, "Failed to create audio wave form for $cacheKey", e)
return Failure
}
}
private class ThreadSafeLruCache(maxSize: Int) {
private val cache = LruCache<String, AudioFileInfo>(maxSize)
private val lock = ReentrantReadWriteLock()
fun put(key: String, info: AudioFileInfo) {
lock.write { cache.put(key, info) }
}
fun get(key: String): AudioFileInfo? {
return lock.read { cache.get(key) }
}
}
private sealed class CacheCheckResult
private class Success(val audioFileInfo: AudioFileInfo) : CacheCheckResult()
private object Failure : CacheCheckResult()
private object Miss : CacheCheckResult()
}

View File

@@ -21,7 +21,7 @@ sealed class Avatar(
data class Text(
val text: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)
@@ -35,7 +35,7 @@ sealed class Avatar(
data class Vector(
val key: String,
val color: Avatars.ColorPair,
override val databaseId: DatabaseId,
override val databaseId: DatabaseId
) : Avatar(databaseId) {
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
return copy(databaseId = databaseId)

View File

@@ -6,6 +6,7 @@ import android.graphics.Canvas
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.annotation.MainThread
import androidx.appcompat.content.res.AppCompatResources
import com.airbnb.lottie.SimpleColorFilter
import org.signal.core.util.concurrent.SignalExecutors
@@ -28,8 +29,13 @@ object AvatarRenderer {
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
private var typeface: Typeface? = null
@MainThread
fun getTypeface(context: Context): Typeface {
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
val interMedium = typeface ?: Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
typeface = interMedium
return interMedium
}
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {

View File

@@ -74,7 +74,7 @@ object Avatars {
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220")
)
@DrawableRes

View File

@@ -87,8 +87,9 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
val selectedPosition = items.indexOfFirst { it.isSelected }
adapter.submitList(items) {
if (selectedPosition > -1)
if (selectedPosition > -1) {
recycler.smoothScrollToPosition(selectedPosition)
}
}
}

View File

@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class TextAvatarCreationState(
val currentAvatar: Avatar.Text,
val currentAvatar: Avatar.Text
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -5,7 +5,7 @@ import org.thoughtcrime.securesms.avatar.AvatarColorItem
import org.thoughtcrime.securesms.avatar.Avatars
data class VectorAvatarCreationState(
val currentAvatar: Avatar.Vector,
val currentAvatar: Avatar.Vector
) {
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
}

View File

@@ -6,6 +6,8 @@ import org.signal.core.util.Conversions;
import org.signal.core.util.StreamUtil;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
import org.thoughtcrime.securesms.backup.proto.Header;
import java.io.IOException;
import java.io.InputStream;
@@ -45,21 +47,21 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
byte[] headerFrame = new byte[headerLength];
StreamUtil.readFully(in, headerFrame);
BackupProtos.BackupFrame frame = BackupProtos.BackupFrame.parseFrom(headerFrame);
BackupFrame frame = BackupFrame.ADAPTER.decode(headerFrame);
if (!frame.hasHeader()) {
if (frame.header_ == null) {
throw new IOException("Backup stream does not start with header!");
}
BackupProtos.Header header = frame.getHeader();
Header header = frame.header_;
this.iv = header.getIv().toByteArray();
this.iv = header.iv.toByteArray();
if (iv.length != 16) {
throw new IOException("Invalid IV length!");
}
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
byte[] key = getBackupKey(passphrase, header.salt != null ? header.salt.toByteArray() : null);
byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64);
byte[][] split = ByteUtil.split(derived, 32, 32);
@@ -76,7 +78,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
BackupProtos.BackupFrame readFrame() throws IOException {
BackupFrame readFrame() throws IOException {
return readFrame(in);
}
@@ -128,7 +130,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
private BackupProtos.BackupFrame readFrame(InputStream in) throws IOException {
private BackupFrame readFrame(InputStream in) throws IOException {
try {
byte[] length = new byte[4];
StreamUtil.readFully(in, length);
@@ -151,7 +153,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
return BackupProtos.BackupFrame.parseFrom(plaintext);
return BackupFrame.ADAPTER.decode(plaintext);
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e);
}

View File

@@ -2,7 +2,10 @@ package org.thoughtcrime.securesms.backup
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
import org.thoughtcrime.securesms.backup.proto.Attachment
import org.thoughtcrime.securesms.backup.proto.Avatar
import org.thoughtcrime.securesms.backup.proto.BackupFrame
import org.thoughtcrime.securesms.backup.proto.Sticker
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@@ -23,11 +26,11 @@ object BackupVerifier {
var frame: BackupFrame = inputStream.readFrame()
cipherStream.use {
while (!frame.end && !cancellationSignal.isCanceled) {
while (frame.end != true && !cancellationSignal.isCanceled) {
val verified = when {
frame.hasAttachment() -> verifyAttachment(frame.attachment, inputStream)
frame.hasSticker() -> verifySticker(frame.sticker, inputStream)
frame.hasAvatar() -> verifyAvatar(frame.avatar, inputStream)
frame.attachment != null -> verifyAttachment(frame.attachment!!, inputStream)
frame.sticker != null -> verifySticker(frame.sticker!!, inputStream)
frame.avatar != null -> verifyAvatar(frame.avatar!!, inputStream)
else -> true
}
@@ -48,9 +51,9 @@ object BackupVerifier {
return true
}
private fun verifyAttachment(attachment: BackupProtos.Attachment, inputStream: BackupRecordInputStream): Boolean {
private fun verifyAttachment(attachment: Attachment, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, attachment.length)
inputStream.readAttachmentTo(NullOutputStream, attachment.length ?: 0)
} catch (e: IOException) {
Log.w(TAG, "Bad attachment id: ${attachment.attachmentId} len: ${attachment.length}", e)
return false
@@ -59,9 +62,9 @@ object BackupVerifier {
return true
}
private fun verifySticker(sticker: BackupProtos.Sticker, inputStream: BackupRecordInputStream): Boolean {
private fun verifySticker(sticker: Sticker, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, sticker.length)
inputStream.readAttachmentTo(NullOutputStream, sticker.length ?: 0)
} catch (e: IOException) {
Log.w(TAG, "Bad sticker id: ${sticker.rowId} len: ${sticker.length}", e)
return false
@@ -69,9 +72,9 @@ object BackupVerifier {
return true
}
private fun verifyAvatar(avatar: BackupProtos.Avatar, inputStream: BackupRecordInputStream): Boolean {
private fun verifyAvatar(avatar: Avatar, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, avatar.length)
inputStream.readAttachmentTo(NullOutputStream, avatar.length ?: 0)
} catch (e: IOException) {
Log.w(TAG, "Bad avatar id: ${avatar.recipientId} len: ${avatar.length}", e)
return false

View File

@@ -12,7 +12,6 @@ import androidx.annotation.VisibleForTesting;
import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import com.google.protobuf.ByteString;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
@@ -26,6 +25,15 @@ import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.kdf.HKDF;
import org.signal.libsignal.protocol.util.ByteUtil;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.backup.proto.Attachment;
import org.thoughtcrime.securesms.backup.proto.Avatar;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
import org.thoughtcrime.securesms.backup.proto.DatabaseVersion;
import org.thoughtcrime.securesms.backup.proto.Header;
import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.backup.proto.Sticker;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
@@ -80,6 +88,8 @@ import javax.crypto.NoSuchPaddingException;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import okio.ByteString;
public class FullBackupExporter extends FullBackupBase {
private static final String TAG = Log.tag(FullBackupExporter.class);
@@ -187,7 +197,7 @@ public class FullBackupExporter extends FullBackupBase {
stopwatch.split("table::" + table);
}
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
for (SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
throwIfCanceled(cancellationSignal);
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(preference);
@@ -287,7 +297,7 @@ public class FullBackupExporter extends FullBackupBase {
String statement = createStatementsByTable.get(table);
if (statement != null) {
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(statement).build());
outputStream.write(new SqlStatement.Builder().statement(statement).build());
} else {
throw new IOException("Failed to find a create statement for table: " + table);
}
@@ -299,7 +309,7 @@ public class FullBackupExporter extends FullBackupBase {
String name = cursor.getString(1);
if (isTableAllowed(name)) {
outputStream.write(BackupProtos.SqlStatement.newBuilder().setStatement(sql).build());
outputStream.write(new SqlStatement.Builder().statement(sql).build());
}
}
}
@@ -393,8 +403,10 @@ public class FullBackupExporter extends FullBackupBase {
throwIfCanceled(cancellationSignal);
if (predicate == null || predicate.test(cursor)) {
StringBuilder statement = new StringBuilder(template);
BackupProtos.SqlStatement.Builder statementBuilder = BackupProtos.SqlStatement.newBuilder();
StringBuilder statement = new StringBuilder(template);
SqlStatement.Builder statementBuilder = new SqlStatement.Builder();
statementBuilder.parameters = new ArrayList<>();
statement.append('(');
@@ -402,15 +414,15 @@ public class FullBackupExporter extends FullBackupBase {
statement.append('?');
if (cursor.getType(i) == Cursor.FIELD_TYPE_STRING) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setStringParamter(cursor.getString(i)));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().stringParamter(cursor.getString(i)).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_FLOAT) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setDoubleParameter(cursor.getDouble(i)));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().doubleParameter(cursor.getDouble(i)).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setIntegerParameter(cursor.getLong(i)));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().integerParameter(cursor.getLong(i)).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_BLOB) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setBlobParameter(ByteString.copyFrom(cursor.getBlob(i))));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().blobParameter(new ByteString(cursor.getBlob(i))).build());
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_NULL) {
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setNullparameter(true));
statementBuilder.parameters.add(new SqlStatement.SqlParameter.Builder().nullparameter(true).build());
} else {
throw new AssertionError("unknown type?" + cursor.getType(i));
}
@@ -423,7 +435,7 @@ public class FullBackupExporter extends FullBackupBase {
statement.append(')');
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
outputStream.write(statementBuilder.statement(statement.toString()).build());
if (postProcess != null) {
count = postProcess.postProcess(cursor, count);
@@ -539,29 +551,30 @@ public class FullBackupExporter extends FullBackupBase {
if (!dataSet.containsKey(key)) {
continue;
}
BackupProtos.KeyValue.Builder builder = BackupProtos.KeyValue.newBuilder()
.setKey(key);
KeyValue.Builder builder = new KeyValue.Builder()
.key(key);
Class<?> type = dataSet.getType(key);
if (type == byte[].class) {
byte[] data = dataSet.getBlob(key, null);
if (data != null) {
builder.setBlobValue(ByteString.copyFrom(dataSet.getBlob(key, null)));
builder.blobValue(new ByteString(dataSet.getBlob(key, null)));
} else {
Log.w(TAG, "Skipping storing null blob for key: " + key);
}
} else if (type == Boolean.class) {
builder.setBooleanValue(dataSet.getBoolean(key, false));
builder.booleanValue(dataSet.getBoolean(key, false));
} else if (type == Float.class) {
builder.setFloatValue(dataSet.getFloat(key, 0));
builder.floatValue(dataSet.getFloat(key, 0));
} else if (type == Integer.class) {
builder.setIntegerValue(dataSet.getInteger(key, 0));
builder.integerValue(dataSet.getInteger(key, 0));
} else if (type == Long.class) {
builder.setLongValue(dataSet.getLong(key, 0));
builder.longValue(dataSet.getLong(key, 0));
} else if (type == String.class) {
String data = dataSet.getString(key, null);
if (data != null) {
builder.setStringValue(dataSet.getString(key, null));
builder.stringValue(dataSet.getString(key, null));
} else {
Log.w(TAG, "Skipping storing null string for key: " + key);
}
@@ -631,10 +644,12 @@ public class FullBackupExporter extends FullBackupBase {
mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
byte[] header = BackupProtos.BackupFrame.newBuilder().setHeader(BackupProtos.Header.newBuilder()
.setIv(ByteString.copyFrom(iv))
.setSalt(ByteString.copyFrom(salt)))
.build().toByteArray();
byte[] header = new BackupFrame.Builder().header_(new Header.Builder()
.iv(new okio.ByteString(iv))
.salt(new okio.ByteString(salt))
.build())
.build()
.encode();
outputStream.write(Conversions.intToByteArray(header.length));
outputStream.write(header);
@@ -643,26 +658,26 @@ public class FullBackupExporter extends FullBackupBase {
}
}
public void write(BackupProtos.SharedPreference preference) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setPreference(preference).build());
public void write(SharedPreference preference) throws IOException {
write(outputStream, new BackupFrame.Builder().preference(preference).build());
}
public void write(BackupProtos.KeyValue keyValue) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setKeyValue(keyValue).build());
public void write(KeyValue keyValue) throws IOException {
write(outputStream, new BackupFrame.Builder().keyValue(keyValue).build());
}
public void write(BackupProtos.SqlStatement statement) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setStatement(statement).build());
public void write(SqlStatement statement) throws IOException {
write(outputStream, new BackupFrame.Builder().statement(statement).build());
}
public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
try {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setAvatar(BackupProtos.Avatar.newBuilder()
.setRecipientId(avatarName)
.setLength(Util.toIntExact(size))
.build())
.build());
write(outputStream, new BackupFrame.Builder()
.avatar(new Avatar.Builder()
.recipientId(avatarName)
.length(Util.toIntExact(size))
.build())
.build());
} catch (ArithmeticException e) {
Log.w(TAG, "Unable to write avatar to backup", e);
throw new InvalidBackupStreamException();
@@ -675,13 +690,13 @@ public class FullBackupExporter extends FullBackupBase {
public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
try {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setAttachment(BackupProtos.Attachment.newBuilder()
.setRowId(attachmentId.getRowId())
.setAttachmentId(attachmentId.getUniqueId())
.setLength(Util.toIntExact(size))
.build())
.build());
write(outputStream, new BackupFrame.Builder()
.attachment(new Attachment.Builder()
.rowId(attachmentId.getRowId())
.attachmentId(attachmentId.getUniqueId())
.length(Util.toIntExact(size))
.build())
.build());
} catch (ArithmeticException e) {
Log.w(TAG, "Unable to write " + attachmentId + " to backup", e);
throw new InvalidBackupStreamException();
@@ -694,12 +709,12 @@ public class FullBackupExporter extends FullBackupBase {
public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
try {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setSticker(BackupProtos.Sticker.newBuilder()
.setRowId(rowId)
.setLength(Util.toIntExact(size))
.build())
.build());
write(outputStream, new BackupFrame.Builder()
.sticker(new Sticker.Builder()
.rowId(rowId)
.length(Util.toIntExact(size))
.build())
.build());
} catch (ArithmeticException e) {
Log.w(TAG, "Unable to write sticker to backup", e);
throw new InvalidBackupStreamException();
@@ -711,13 +726,13 @@ public class FullBackupExporter extends FullBackupBase {
}
void writeDatabaseVersion(int version) throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder()
.setVersion(BackupProtos.DatabaseVersion.newBuilder().setVersion(version))
.build());
write(outputStream, new BackupFrame.Builder()
.version(new DatabaseVersion.Builder().version(version).build())
.build());
}
void writeEnd() throws IOException {
write(outputStream, BackupProtos.BackupFrame.newBuilder().setEnd(true).build());
write(outputStream, new BackupFrame.Builder().end(true).build());
}
/**
@@ -758,12 +773,12 @@ public class FullBackupExporter extends FullBackupBase {
}
}
private void write(@NonNull OutputStream out, @NonNull BackupProtos.BackupFrame frame) throws IOException {
private void write(@NonNull OutputStream out, @NonNull BackupFrame frame) throws IOException {
try {
Conversions.intToByteArray(iv, 0, counter++);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
byte[] frameCiphertext = cipher.doFinal(frame.toByteArray());
byte[] frameCiphertext = cipher.doFinal(frame.encode());
byte[] frameMac = mac.doFinal(frameCiphertext);
byte[] length = Conversions.intToByteArray(frameCiphertext.length + 10);

View File

@@ -17,12 +17,14 @@ import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
import org.thoughtcrime.securesms.backup.BackupProtos.SharedPreference;
import org.thoughtcrime.securesms.backup.BackupProtos.SqlStatement;
import org.thoughtcrime.securesms.backup.BackupProtos.Sticker;
import org.thoughtcrime.securesms.backup.proto.Attachment;
import org.thoughtcrime.securesms.backup.proto.Avatar;
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
import org.thoughtcrime.securesms.backup.proto.DatabaseVersion;
import org.thoughtcrime.securesms.backup.proto.KeyValue;
import org.thoughtcrime.securesms.backup.proto.SharedPreference;
import org.thoughtcrime.securesms.backup.proto.SqlStatement;
import org.thoughtcrime.securesms.backup.proto.Sticker;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
@@ -87,17 +89,17 @@ public class FullBackupImporter extends FullBackupBase {
BackupFrame frame;
while (!(frame = inputStream.readFrame()).getEnd()) {
while ((frame = inputStream.readFrame()).end != Boolean.TRUE) {
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
count++;
if (frame.hasVersion()) processVersion(db, frame.getVersion());
else if (frame.hasStatement()) tryProcessStatement(db, frame.getStatement());
else if (frame.hasPreference()) processPreference(context, frame.getPreference());
else if (frame.hasAttachment()) processAttachment(context, attachmentSecret, db, frame.getAttachment(), inputStream);
else if (frame.hasSticker()) processSticker(context, attachmentSecret, db, frame.getSticker(), inputStream);
else if (frame.hasAvatar()) processAvatar(context, db, frame.getAvatar(), inputStream);
else if (frame.hasKeyValue()) processKeyValue(frame.getKeyValue());
if (frame.version != null) processVersion(db, frame.version);
else if (frame.statement != null) tryProcessStatement(db, frame.statement);
else if (frame.preference != null) processPreference(context, frame.preference);
else if (frame.attachment != null) processAttachment(context, attachmentSecret, db, frame.attachment, inputStream);
else if (frame.sticker != null) processSticker(context, attachmentSecret, db, frame.sticker, inputStream);
else if (frame.avatar != null) processAvatar(context, db, frame.avatar, inputStream);
else if (frame.keyValue != null) processKeyValue(frame.keyValue);
else count--;
}
@@ -120,11 +122,11 @@ public class FullBackupImporter extends FullBackupBase {
}
private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException {
if (version.getVersion() > db.getVersion()) {
throw new DatabaseDowngradeException(db.getVersion(), version.getVersion());
if (version.version == null || version.version > db.getVersion()) {
throw new DatabaseDowngradeException(db.getVersion(), version.version != null ? version.version : -1);
}
db.setVersion(version.getVersion());
db.setVersion(version.version);
}
private static void tryProcessStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
@@ -132,9 +134,9 @@ public class FullBackupImporter extends FullBackupBase {
processStatement(db, statement);
} catch (SQLiteConstraintException e) {
String tableName = "?";
String statementString = statement.getStatement();
String statementString = statement.statement;
if (statementString.startsWith("INSERT INTO ")) {
if (statementString != null && statementString.startsWith("INSERT INTO ")) {
int nameStart = "INSERT INTO ".length();
int nameEnd = statementString.indexOf(" ", "INSERT INTO ".length());
@@ -153,27 +155,32 @@ public class FullBackupImporter extends FullBackupBase {
}
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
boolean isForMmsFtsSecretTable = statement.getStatement().contains(SearchTable.FTS_TABLE_NAME + "_");
boolean isForEmojiSecretTable = statement.getStatement().contains(EmojiSearchTable.TABLE_NAME + "_");
boolean isForSqliteSecretTable = statement.getStatement().toLowerCase().startsWith("create table sqlite_");
if (statement.statement == null) {
Log.w(TAG, "Null statement!");
return;
}
boolean isForMmsFtsSecretTable = statement.statement.contains(SearchTable.FTS_TABLE_NAME + "_");
boolean isForEmojiSecretTable = statement.statement.contains(EmojiSearchTable.TABLE_NAME + "_");
boolean isForSqliteSecretTable = statement.statement.toLowerCase().startsWith("create table sqlite_");
if (isForMmsFtsSecretTable || isForEmojiSecretTable || isForSqliteSecretTable) {
Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
Log.i(TAG, "Ignoring import for statement: " + statement.statement);
return;
}
List<Object> parameters = new LinkedList<>();
for (SqlStatement.SqlParameter parameter : statement.getParametersList()) {
if (parameter.hasStringParamter()) parameters.add(parameter.getStringParamter());
else if (parameter.hasDoubleParameter()) parameters.add(parameter.getDoubleParameter());
else if (parameter.hasIntegerParameter()) parameters.add(parameter.getIntegerParameter());
else if (parameter.hasBlobParameter()) parameters.add(parameter.getBlobParameter().toByteArray());
else if (parameter.hasNullparameter()) parameters.add(null);
for (SqlStatement.SqlParameter parameter : statement.parameters) {
if (parameter.stringParamter != null) parameters.add(parameter.stringParamter);
else if (parameter.doubleParameter != null) parameters.add(parameter.doubleParameter);
else if (parameter.integerParameter != null) parameters.add(parameter.integerParameter);
else if (parameter.blobParameter != null) parameters.add(parameter.blobParameter.toByteArray());
else if (parameter.nullparameter != null) parameters.add(null);
}
if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
else db.execSQL(statement.getStatement());
if (parameters.size() > 0) db.execSQL(statement.statement, parameters.toArray());
else db.execSQL(statement.statement);
}
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
@@ -185,12 +192,12 @@ public class FullBackupImporter extends FullBackupBase {
ContentValues contentValues = new ContentValues();
try {
inputStream.readAttachmentTo(output.second, attachment.getLength());
inputStream.readAttachmentTo(output.second, attachment.length);
contentValues.put(AttachmentTable.DATA, dataFile.getAbsolutePath());
contentValues.put(AttachmentTable.DATA_RANDOM, output.first);
} catch (BackupRecordInputStream.BadMacException e) {
Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e);
Log.w(TAG, "Bad MAC for attachment " + attachment.attachmentId + "! Can't restore it.", e);
dataFile.delete();
contentValues.put(AttachmentTable.DATA, (String) null);
contentValues.put(AttachmentTable.DATA_RANDOM, (String) null);
@@ -198,7 +205,7 @@ public class FullBackupImporter extends FullBackupBase {
db.update(AttachmentTable.TABLE_NAME, contentValues,
AttachmentTable.ROW_ID + " = ? AND " + AttachmentTable.UNIQUE_ID + " = ?",
new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
new String[] {String.valueOf(attachment.rowId), String.valueOf(attachment.attachmentId)});
}
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
@@ -209,52 +216,57 @@ public class FullBackupImporter extends FullBackupBase {
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
inputStream.readAttachmentTo(output.second, sticker.getLength());
inputStream.readAttachmentTo(output.second, sticker.length);
ContentValues contentValues = new ContentValues();
contentValues.put(StickerTable.FILE_PATH, dataFile.getAbsolutePath());
contentValues.put(StickerTable.FILE_LENGTH, sticker.getLength());
contentValues.put(StickerTable.FILE_LENGTH, sticker.length);
contentValues.put(StickerTable.FILE_RANDOM, output.first);
db.update(StickerTable.TABLE_NAME, contentValues,
StickerTable._ID + " = ?",
new String[] {String.valueOf(sticker.getRowId())});
new String[] {String.valueOf(sticker.rowId)});
}
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull BackupProtos.Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.hasRecipientId()) {
RecipientId recipientId = RecipientId.from(avatar.getRecipientId());
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.getLength());
private static void processAvatar(@NonNull Context context, @NonNull SQLiteDatabase db, @NonNull Avatar avatar, @NonNull BackupRecordInputStream inputStream) throws IOException {
if (avatar.recipientId != null) {
RecipientId recipientId = RecipientId.from(avatar.recipientId);
inputStream.readAttachmentTo(AvatarHelper.getOutputStream(context, recipientId, false), avatar.length);
} else {
if (avatar.hasName() && SqlUtil.tableExists(db, "recipient_preferences")) {
if (avatar.name != null && SqlUtil.tableExists(db, "recipient_preferences")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar (legacy) so it can be fetched later.");
db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.getName() });
} else if (avatar.hasName() && SqlUtil.tableExists(db, "recipient")) {
db.execSQL("UPDATE recipient_preferences SET signal_profile_avatar = NULL WHERE recipient_ids = ?", new String[] { avatar.name });
} else if (avatar.name != null && SqlUtil.tableExists(db, "recipient")) {
Log.w(TAG, "Avatar is missing a recipientId. Clearing signal_profile_avatar so it can be fetched later.");
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.getName() });
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.name });
} else {
Log.w(TAG, "Avatar is missing a recipientId. Skipping avatar restore.");
}
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.getLength());
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.length);
}
}
private static void processKeyValue(BackupProtos.KeyValue keyValue) {
private static void processKeyValue(KeyValue keyValue) {
KeyValueDataSet dataSet = new KeyValueDataSet();
if (keyValue.hasBlobValue()) {
dataSet.putBlob(keyValue.getKey(), keyValue.getBlobValue().toByteArray());
} else if (keyValue.hasBooleanValue()) {
dataSet.putBoolean(keyValue.getKey(), keyValue.getBooleanValue());
} else if (keyValue.hasFloatValue()) {
dataSet.putFloat(keyValue.getKey(), keyValue.getFloatValue());
} else if (keyValue.hasIntegerValue()) {
dataSet.putInteger(keyValue.getKey(), keyValue.getIntegerValue());
} else if (keyValue.hasLongValue()) {
dataSet.putLong(keyValue.getKey(), keyValue.getLongValue());
} else if (keyValue.hasStringValue()) {
dataSet.putString(keyValue.getKey(), keyValue.getStringValue());
if (keyValue.key == null) {
Log.w(TAG, "Null preference key!");
return;
}
if (keyValue.blobValue != null) {
dataSet.putBlob(keyValue.key, keyValue.blobValue.toByteArray());
} else if (keyValue.booleanValue != null) {
dataSet.putBoolean(keyValue.key, keyValue.booleanValue);
} else if (keyValue.floatValue != null) {
dataSet.putFloat(keyValue.key, keyValue.floatValue);
} else if (keyValue.integerValue != null) {
dataSet.putInteger(keyValue.key, keyValue.integerValue);
} else if (keyValue.longValue != null) {
dataSet.putLong(keyValue.key, keyValue.longValue);
} else if (keyValue.stringValue != null) {
dataSet.putString(keyValue.key, keyValue.stringValue);
} else {
Log.i(TAG, "Unknown KeyValue backup value, skipping");
return;
@@ -265,25 +277,25 @@ public class FullBackupImporter extends FullBackupBase {
@SuppressLint("ApplySharedPref")
private static void processPreference(@NonNull Context context, SharedPreference preference) {
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
SharedPreferences preferences = context.getSharedPreferences(preference.file_, 0);
// Identity keys were moved from shared prefs into SignalStore. Need to handle importing backups made before the migration.
if ("SecureSMS-Preferences".equals(preference.getFile())) {
if ("pref_identity_public_v3".equals(preference.getKey()) && preference.hasValue()) {
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.getValue());
} else if ("pref_identity_private_v3".equals(preference.getKey()) && preference.hasValue()) {
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.getValue());
if ("SecureSMS-Preferences".equals(preference.file_)) {
if ("pref_identity_public_v3".equals(preference.key) && preference.value_ != null) {
SignalStore.account().restoreLegacyIdentityPublicKeyFromBackup(preference.value_);
} else if ("pref_identity_private_v3".equals(preference.key) && preference.value_ != null) {
SignalStore.account().restoreLegacyIdentityPrivateKeyFromBackup(preference.value_);
}
return;
}
if (preference.hasValue()) {
preferences.edit().putString(preference.getKey(), preference.getValue()).commit();
} else if (preference.hasBooleanValue()) {
preferences.edit().putBoolean(preference.getKey(), preference.getBooleanValue()).commit();
} else if (preference.hasIsStringSetValue() && preference.getIsStringSetValue()) {
preferences.edit().putStringSet(preference.getKey(), new HashSet<>(preference.getStringSetValueList())).commit();
if (preference.value_ != null) {
preferences.edit().putString(preference.key, preference.value_).commit();
} else if (preference.booleanValue != null) {
preferences.edit().putBoolean(preference.key, preference.booleanValue).commit();
} else if (preference.isStringSetValue == Boolean.TRUE) {
preferences.edit().putStringSet(preference.key, new HashSet<>(preference.stringSetValue)).commit();
}
}

View File

@@ -31,7 +31,8 @@ object ExpiredGiftSheetConfiguration {
textPref(
title = DSLSettingsText.from(
stringId = R.string.ExpiredGiftSheetConfiguration__your_badge_has_expired,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -117,7 +117,7 @@ class GiftFlowViewModel(
private fun getLoadState(
oldState: GiftFlowState,
giftPrices: Map<Currency, FiatMoney>? = null,
giftBadge: Badge? = null,
giftBadge: Badge? = null
): GiftFlowState.Stage {
if (oldState.stage != GiftFlowState.Stage.INIT) {
return oldState.stage

View File

@@ -123,7 +123,8 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
}
errorDialog = DonationErrorDialogs.show(
requireContext(), throwable,
requireContext(),
throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
@@ -158,7 +159,8 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
noPadTextPref(
title = DSLSettingsText.from(
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__s_made_a_donation_for_you, state.recipient.getShortDisplayName(requireContext())),
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -66,7 +66,8 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
noPadTextPref(
title = DSLSettingsText.from(
stringId = R.string.ViewSentGiftBottomSheet__thanks_for_your_support,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -133,7 +133,7 @@ data class Badge(
.downsample(DownsampleStrategy.NONE)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.transform(
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context))
)
.into(badge)

View File

@@ -27,13 +27,15 @@ public class AlbumThumbnailView extends FrameLayout {
private int currentSizeClass;
private final int[] corners = new int[4];
private ViewGroup albumCellContainer;
private Stub<TransferControlView> transferControls;
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
if (thumbnailClickListener != null) {
thumbnailClickListener.onClick(v, slide);
}
if (thumbnailClickListener != null) {
thumbnailClickListener.onClick(v, slide);
}
};
private final OnLongClickListener defaultLongClickListener = v -> this.performLongClick();
@@ -82,6 +84,7 @@ public class AlbumThumbnailView extends FrameLayout {
}
showSlides(glideRequests, slides);
applyCorners();
}
public void setCellBackgroundColor(@ColorInt int color) {
@@ -102,6 +105,15 @@ public class AlbumThumbnailView extends FrameLayout {
downloadClickListener = listener;
}
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
corners[0] = topLeft;
corners[1] = topRight;
corners[2] = bottomRight;
corners[3] = bottomLeft;
applyCorners();
}
private void inflateLayout(int sizeClass) {
albumCellContainer.removeAllViews();
@@ -124,6 +136,83 @@ public class AlbumThumbnailView extends FrameLayout {
}
}
private void applyCorners() {
if (currentSizeClass < 2) {
return;
}
switch (currentSizeClass) {
case 2:
applyCornersForSizeClass2();
break;
case 3:
applyCornersForSizeClass3();
break;
case 4:
applyCornersForSizeClass4();
break;
case 5:
applyCornersForSizeClass5();
break;
default:
applyCornersForManySizeClass();
}
}
private ThumbnailView[] getCells() {
ThumbnailView one = findViewById(R.id.album_cell_1);
ThumbnailView two = findViewById(R.id.album_cell_2);
ThumbnailView three = findViewById(R.id.album_cell_3);
ThumbnailView four = findViewById(R.id.album_cell_4);
ThumbnailView five = findViewById(R.id.album_cell_5);
return new ThumbnailView[] { one, two, three, four, five };
}
private void applyCornersForSizeClass2() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, corners[3]);
setRelativeRadii(cells[1], 0, corners[1], corners[2], 0);
}
private void applyCornersForSizeClass3() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, corners[3]);
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
setRelativeRadii(cells[2], 0, 0, corners[2], 0);
}
private void applyCornersForSizeClass4() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, 0);
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
setRelativeRadii(cells[2], 0, 0, 0, corners[3]);
setRelativeRadii(cells[3], 0, 0, corners[2], 0);
}
private void applyCornersForSizeClass5() {
ThumbnailView[] cells = getCells();
setRelativeRadii(cells[0], corners[0], 0, 0, 0);
setRelativeRadii(cells[1], 0, corners[1], 0, 0);
setRelativeRadii(cells[2], 0, 0, 0, corners[3]);
setRelativeRadii(cells[3], 0, 0, 0, 0);
setRelativeRadii(cells[4], 0, 0, corners[2], 0);
}
private void setRelativeRadii(@NonNull ThumbnailView cell, int topLeft, int topRight, int bottomRight, int bottomLeft) {
boolean isLTR = getRootView().getLayoutDirection() == LAYOUT_DIRECTION_LTR;
cell.setRadii(
isLTR ? topLeft : topRight,
isLTR ? topRight : topLeft,
isLTR ? bottomRight : bottomLeft,
isLTR ? bottomLeft : bottomRight
);
}
private void applyCornersForManySizeClass() {
applyCornersForSizeClass5();
}
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);

View File

@@ -54,7 +54,7 @@ public class AnimatingToggle extends FrameLayout {
}
public void display(@Nullable View view) {
if (view == current) return;
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
if (view != null) ViewUtil.animateIn(view, inAnimation);
@@ -62,7 +62,7 @@ public class AnimatingToggle extends FrameLayout {
}
public void displayQuick(@Nullable View view) {
if (view == current) return;
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) current.setVisibility(View.GONE);
if (view != null) view.setVisibility(View.VISIBLE);

View File

@@ -33,7 +33,7 @@ import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.audio.AudioWaveForm;
import org.thoughtcrime.securesms.audio.AudioWaveForms;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
@@ -43,6 +43,9 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
public final class AudioView extends FrameLayout {
private static final String TAG = Log.tag(AudioView.class);
@@ -77,6 +80,8 @@ public final class AudioView extends FrameLayout {
private AudioSlide audioSlide;
private Callbacks callbacks;
private Disposable disposable = Disposable.disposed();
private final Observer<VoiceNotePlaybackState> playbackStateObserver = this::onPlaybackState;
public AudioView(Context context) {
@@ -155,6 +160,7 @@ public final class AudioView extends FrameLayout {
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
disposable.dispose();
}
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
@@ -170,6 +176,7 @@ public final class AudioView extends FrameLayout {
final boolean showControls,
final boolean forceHideDuration)
{
this.disposable.dispose();
this.callbacks = callbacks;
if (duration != null) {
@@ -212,16 +219,19 @@ public final class AudioView extends FrameLayout {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint);
if (android.os.Build.VERSION.SDK_INT >= 23) {
new AudioWaveForm(getContext(), audio).getWaveForm(
data -> {
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
updateProgress(0, 0);
if (!forceHideDuration && duration != null) {
duration.setVisibility(VISIBLE);
}
waveFormView.setWaveData(data.getWaveForm());
},
() -> waveFormView.setWaveMode(false));
disposable = AudioWaveForms.getWaveForm(getContext(), audioSlide.asAttachment())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
data -> {
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
updateProgress(0, 0);
if (!forceHideDuration && duration != null) {
duration.setVisibility(VISIBLE);
}
waveFormView.setWaveData(data.getWaveForm());
},
t -> waveFormView.setWaveMode(false)
);
} else {
waveFormView.setWaveMode(false);
if (duration != null) {

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -58,10 +59,10 @@ public class BorderlessImageView extends FrameLayout {
boolean showControls = slide.asAttachment().getUri() == null;
if (slide.hasSticker()) {
image.setFit(new CenterInside());
image.setScaleType(ImageView.ScaleType.FIT_CENTER);
image.setImageResource(glideRequests, slide, showControls, false);
} else {
image.setFit(new CenterCrop());
image.setScaleType(ImageView.ScaleType.CENTER_CROP);
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
}

View File

@@ -1,221 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import android.widget.ImageView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.UiThread;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
public class ConversationItemThumbnail extends FrameLayout {
private ThumbnailView thumbnail;
private AlbumThumbnailView album;
private ImageView shade;
private ConversationItemFooter footer;
private CornerMask cornerMask;
private Outliner pulseOutliner;
private boolean borderless;
private int[] normalBounds;
private int[] gifBounds;
private int minimumThumbnailWidth;
private int maximumThumbnailHeight;
public ConversationItemThumbnail(Context context) {
super(context);
init(null);
}
public ConversationItemThumbnail(Context context, AttributeSet attrs) {
super(context, attrs);
init(attrs);
}
public ConversationItemThumbnail(final Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
init(attrs);
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_thumbnail, this);
this.thumbnail = findViewById(R.id.conversation_thumbnail_image);
this.album = findViewById(R.id.conversation_thumbnail_album);
this.shade = findViewById(R.id.conversation_thumbnail_shade);
this.footer = findViewById(R.id.conversation_thumbnail_footer);
this.cornerMask = new CornerMask(this);
int gifWidth = ViewUtil.dpToPx(260);
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0);
normalBounds = new int[]{
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0)
};
gifWidth = typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_gifWidth, gifWidth);
typedArray.recycle();
} else {
normalBounds = new int[]{0, 0, 0, 0};
}
gifBounds = new int[]{
gifWidth,
gifWidth,
1,
Integer.MAX_VALUE
};
minimumThumbnailWidth = -1;
maximumThumbnailHeight = -1;
}
@SuppressWarnings("SuspiciousNameCombination")
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if (!borderless) {
cornerMask.mask(canvas);
}
if (pulseOutliner != null) {
pulseOutliner.draw(canvas);
}
}
public void hideThumbnailView() {
thumbnail.setAlpha(0f);
}
public void showThumbnailView() {
thumbnail.setAlpha(1f);
}
public @NonNull Projection.Corners getCorners() {
return new Projection.Corners(cornerMask.getRadii());
}
public void setPulseOutliner(@NonNull Outliner outliner) {
this.pulseOutliner = outliner;
}
@Override
public void setFocusable(boolean focusable) {
thumbnail.setFocusable(focusable);
album.setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
thumbnail.setClickable(clickable);
album.setClickable(clickable);
}
@Override
public void setOnLongClickListener(@Nullable OnLongClickListener l) {
thumbnail.setOnLongClickListener(l);
album.setOnLongClickListener(l);
}
public void showShade(boolean show) {
shade.setVisibility(show ? VISIBLE : GONE);
forceLayout();
}
public void setCorners(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
}
public void setMinimumThumbnailWidth(@Px int width) {
minimumThumbnailWidth = width;
thumbnail.setMinimumThumbnailWidth(width);
}
public void setMaximumThumbnailHeight(@Px int height) {
maximumThumbnailHeight = height;
thumbnail.setMaximumThumbnailHeight(height);
}
public void setBorderless(boolean borderless) {
this.borderless = borderless;
}
public ConversationItemFooter getFooter() {
return footer;
}
@UiThread
public void setImageResource(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides,
boolean showControls, boolean isPreview)
{
if (slides.size() == 1) {
Slide slide = slides.get(0);
if (slide.isVideoGif()) {
setThumbnailBounds(gifBounds);
} else {
setThumbnailBounds(normalBounds);
if (minimumThumbnailWidth != -1) {
thumbnail.setMinimumThumbnailWidth(minimumThumbnailWidth);
}
if (maximumThumbnailHeight != -1) {
thumbnail.setMaximumThumbnailHeight(maximumThumbnailHeight);
}
}
thumbnail.setVisibility(VISIBLE);
album.setVisibility(GONE);
Attachment attachment = slides.get(0).asAttachment();
thumbnail.setImageResource(glideRequests, slides.get(0), showControls, isPreview, attachment.getWidth(), attachment.getHeight());
setTouchDelegate(thumbnail.getTouchDelegate());
} else {
thumbnail.setVisibility(GONE);
album.setVisibility(VISIBLE);
album.setSlides(glideRequests, slides, showControls);
setTouchDelegate(album.getTouchDelegate());
}
}
public void setConversationColor(@ColorInt int color) {
if (album.getVisibility() == VISIBLE) {
album.setCellBackgroundColor(color);
}
}
public void setThumbnailClickListener(SlideClickListener listener) {
thumbnail.setThumbnailClickListener(listener);
album.setThumbnailClickListener(listener);
}
public void setDownloadClickListener(SlidesClickedListener listener) {
thumbnail.setDownloadClickListener(listener);
album.setDownloadClickListener(listener);
}
private void setThumbnailBounds(@NonNull int[] bounds) {
thumbnail.setBounds(bounds[0], bounds[1], bounds[2], bounds[3]);
}
}

View File

@@ -0,0 +1,274 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.Canvas
import android.os.Bundle
import android.os.Parcelable
import android.util.AttributeSet
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.annotation.ColorInt
import androidx.annotation.Px
import androidx.annotation.UiThread
import androidx.core.os.bundleOf
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideClickListener
import org.thoughtcrime.securesms.mms.SlidesClickedListener
import org.thoughtcrime.securesms.util.Projection.Corners
import org.thoughtcrime.securesms.util.views.Stub
class ConversationItemThumbnail @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {
private var state: ConversationItemThumbnailState
private var thumbnail: Stub<ThumbnailView>
private var album: Stub<AlbumThumbnailView>
private var shade: ImageView
var footer: Stub<ConversationItemFooter>
private set
private var cornerMask: CornerMask
private var borderless = false
private var normalBounds: IntArray
private var gifBounds: IntArray
private var minimumThumbnailWidth = 0
private var maximumThumbnailHeight = 0
init {
inflate(context, R.layout.conversation_item_thumbnail, this)
thumbnail = Stub(findViewById(R.id.thumbnail_view_stub))
album = Stub(findViewById(R.id.album_view_stub))
shade = findViewById(R.id.conversation_thumbnail_shade)
footer = Stub(findViewById(R.id.footer_view_stub))
cornerMask = CornerMask(this)
var gifWidth = 260.dp
if (attrs != null) {
val typedArray = context.theme.obtainStyledAttributes(attrs, R.styleable.ConversationItemThumbnail, 0, 0)
normalBounds = intArrayOf(
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxWidth, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_minHeight, 0),
typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_maxHeight, 0)
)
gifWidth = typedArray.getDimensionPixelSize(R.styleable.ConversationItemThumbnail_conversationThumbnail_gifWidth, gifWidth)
typedArray.recycle()
} else {
normalBounds = intArrayOf(0, 0, 0, 0)
}
gifBounds = intArrayOf(
gifWidth,
gifWidth,
1,
Int.MAX_VALUE
)
minimumThumbnailWidth = -1
maximumThumbnailHeight = -1
state = ConversationItemThumbnailState()
}
override fun dispatchDraw(canvas: Canvas) {
super.dispatchDraw(canvas)
if (!borderless) {
cornerMask.mask(canvas)
}
}
override fun onSaveInstanceState(): Parcelable? {
val root = super.onSaveInstanceState()
return bundleOf(
STATE_ROOT to root,
STATE_STATE to state
)
}
override fun onRestoreInstanceState(state: Parcelable) {
if (state is Bundle && state.containsKey(STATE_STATE)) {
val root = state.getParcelable<Parcelable>(STATE_ROOT)
this.state = state.getParcelable(STATE_STATE)!!
super.onRestoreInstanceState(root)
} else {
super.onRestoreInstanceState(state)
}
}
override fun setFocusable(focusable: Boolean) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(focusable = focusable),
albumViewState = state.albumViewState.copy(focusable = focusable)
)
state.applyState(thumbnail, album)
}
override fun setClickable(clickable: Boolean) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(clickable = clickable),
albumViewState = state.albumViewState.copy(clickable = clickable)
)
state.applyState(thumbnail, album)
}
override fun setOnLongClickListener(l: OnLongClickListener?) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(longClickListener = l),
albumViewState = state.albumViewState.copy(longClickListener = l)
)
state.applyState(thumbnail, album)
}
fun hideThumbnailView() {
state = state.copy(thumbnailViewState = state.thumbnailViewState.copy(alpha = 0f))
state.thumbnailViewState.applyState(thumbnail)
}
fun showThumbnailView() {
state = state.copy(thumbnailViewState = state.thumbnailViewState.copy(alpha = 1f))
state.thumbnailViewState.applyState(thumbnail)
}
val corners: Corners
get() = Corners(cornerMask.radii)
fun showShade(show: Boolean) {
shade.visibility = if (show) VISIBLE else GONE
forceLayout()
}
fun setCorners(topLeft: Int, topRight: Int, bottomRight: Int, bottomLeft: Int) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft)
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(
cornerTopLeft = topLeft,
cornerTopRight = topRight,
cornerBottomRight = bottomRight,
cornerBottomLeft = bottomLeft
),
albumViewState = state.albumViewState.copy(
cornerTopLeft = topLeft,
cornerTopRight = topRight,
cornerBottomRight = bottomRight,
cornerBottomLeft = bottomLeft
)
)
state.applyState(thumbnail, album)
}
fun setMinimumThumbnailWidth(@Px width: Int) {
minimumThumbnailWidth = width
state = state.copy(thumbnailViewState = state.thumbnailViewState.copy(minWidth = width))
state.thumbnailViewState.applyState(thumbnail)
}
fun setMaximumThumbnailHeight(@Px height: Int) {
maximumThumbnailHeight = height
state = state.copy(thumbnailViewState = state.thumbnailViewState.copy(maxHeight = height))
state.thumbnailViewState.applyState(thumbnail)
}
fun setBorderless(borderless: Boolean) {
this.borderless = borderless
}
@UiThread
fun setImageResource(
glideRequests: GlideRequests,
slides: List<Slide>,
showControls: Boolean,
isPreview: Boolean
) {
if (slides.size == 1) {
val slide = slides[0]
if (slide.isVideoGif) {
setThumbnailBounds(gifBounds)
} else {
setThumbnailBounds(normalBounds)
if (minimumThumbnailWidth != -1) {
state = state.copy(thumbnailViewState = state.thumbnailViewState.copy(minWidth = minimumThumbnailWidth))
}
if (maximumThumbnailHeight != -1) {
state = state.copy(thumbnailViewState = state.thumbnailViewState.copy(maxHeight = maximumThumbnailHeight))
}
}
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(visibility = VISIBLE),
albumViewState = state.albumViewState.copy(visibility = GONE)
)
state.applyState(thumbnail, album)
val attachment = slides[0].asAttachment()
thumbnail.get().setImageResource(glideRequests, slides[0], showControls, isPreview, attachment.width, attachment.height)
touchDelegate = thumbnail.get().touchDelegate
} else {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(visibility = GONE),
albumViewState = state.albumViewState.copy(visibility = VISIBLE)
)
state.applyState(thumbnail, album)
album.get().setSlides(glideRequests, slides, showControls)
touchDelegate = album.get().touchDelegate
}
}
fun setConversationColor(@ColorInt color: Int) {
state = state.copy(albumViewState = state.albumViewState.copy(cellBackgroundColor = color))
state.albumViewState.applyState(album)
}
fun setThumbnailClickListener(listener: SlideClickListener?) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(clickListener = listener),
albumViewState = state.albumViewState.copy(clickListener = listener)
)
state.applyState(thumbnail, album)
}
fun setDownloadClickListener(listener: SlidesClickedListener?) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(downloadClickListener = listener),
albumViewState = state.albumViewState.copy(downloadClickListener = listener)
)
state.applyState(thumbnail, album)
}
private fun setThumbnailBounds(bounds: IntArray) {
val (minWidth, maxWidth, minHeight, maxHeight) = bounds
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(
minWidth = minWidth,
maxWidth = maxWidth,
minHeight = minHeight,
maxHeight = maxHeight
)
)
state.thumbnailViewState.applyState(thumbnail)
}
companion object {
private const val STATE_ROOT = "state.root"
private const val STATE_STATE = "state.state"
}
}

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.components
import android.graphics.Color
import android.os.Parcelable
import android.view.View
import android.view.View.OnLongClickListener
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.mms.SlideClickListener
import org.thoughtcrime.securesms.mms.SlidesClickedListener
import org.thoughtcrime.securesms.util.views.Stub
/**
* Parcelable state object for [ConversationItemThumbnail]
* This allows us to manage inputs for [ThumbnailView] and [AlbumThumbnailView] without
* actually having them inflated. When the views are finally inflated, we 'apply'
*/
@Parcelize
data class ConversationItemThumbnailState(
val thumbnailViewState: ThumbnailViewState = ThumbnailViewState(),
val albumViewState: AlbumViewState = AlbumViewState()
) : Parcelable {
@Parcelize
data class ThumbnailViewState(
private val alpha: Float = 0f,
private val focusable: Boolean = true,
private val clickable: Boolean = true,
@IgnoredOnParcel
private val clickListener: SlideClickListener? = null,
@IgnoredOnParcel
private val downloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val longClickListener: OnLongClickListener? = null,
private val visibility: Int = View.GONE,
private val minWidth: Int = -1,
private val maxWidth: Int = -1,
private val minHeight: Int = -1,
private val maxHeight: Int = -1,
private val cornerTopLeft: Int = 0,
private val cornerTopRight: Int = 0,
private val cornerBottomRight: Int = 0,
private val cornerBottomLeft: Int = 0
) : Parcelable {
fun applyState(thumbnailView: Stub<ThumbnailView>) {
thumbnailView.visibility = visibility
if (visibility == View.GONE) {
return
}
thumbnailView.get().alpha = alpha
thumbnailView.get().isFocusable = focusable
thumbnailView.get().isClickable = clickable
thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
thumbnailView.get().setThumbnailClickListener(clickListener)
thumbnailView.get().setDownloadClickListener(downloadClickListener)
thumbnailView.get().setOnLongClickListener(longClickListener)
thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight)
}
}
@Parcelize
data class AlbumViewState(
private val focusable: Boolean = true,
private val clickable: Boolean = true,
@IgnoredOnParcel
private val clickListener: SlideClickListener? = null,
@IgnoredOnParcel
private val downloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val longClickListener: OnLongClickListener? = null,
private val visibility: Int = View.GONE,
private val cellBackgroundColor: Int = Color.TRANSPARENT,
private val cornerTopLeft: Int = 0,
private val cornerTopRight: Int = 0,
private val cornerBottomRight: Int = 0,
private val cornerBottomLeft: Int = 0
) : Parcelable {
fun applyState(albumView: Stub<AlbumThumbnailView>) {
albumView.visibility = visibility
if (visibility == View.GONE) {
return
}
albumView.get().isFocusable = focusable
albumView.get().isClickable = clickable
albumView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
albumView.get().setThumbnailClickListener(clickListener)
albumView.get().setDownloadClickListener(downloadClickListener)
albumView.get().setOnLongClickListener(longClickListener)
albumView.get().setCellBackgroundColor(cellBackgroundColor)
}
}
fun applyState(thumbnailView: Stub<ThumbnailView>, albumView: Stub<AlbumThumbnailView>) {
thumbnailViewState.applyState(thumbnailView)
albumViewState.applyState(albumView)
}
}

View File

@@ -33,6 +33,7 @@ import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.animation.AnimationStartListener;
import org.thoughtcrime.securesms.audio.AudioRecordingHandler;
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
@@ -573,11 +574,39 @@ public class InputPanel extends LinearLayout
}
private void fadeIn(@NonNull View v) {
v.animate().alpha(1).setDuration(FADE_TIME).start();
v.animate()
.setListener(new AnimationStartListener() {
@Override
public void onAnimationStart(@NonNull Animator animation) {
v.setVisibility(View.VISIBLE);
}
@Override
public void onAnimationCancel(@NonNull Animator animation) {
v.setVisibility(View.INVISIBLE);
}
})
.alpha(1)
.setDuration(FADE_TIME)
.start();
}
private void fadeOut(@NonNull View v) {
v.animate().alpha(0).setDuration(FADE_TIME).start();
v.animate()
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
v.setVisibility(View.INVISIBLE);
}
@Override
public void onAnimationCancel(Animator animation) {
v.setVisibility(View.VISIBLE);
}
})
.alpha(0)
.setDuration(FADE_TIME)
.start();
}
private void updateVisibility() {

View File

@@ -4,6 +4,8 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
@@ -22,6 +24,7 @@ import org.thoughtcrime.securesms.mms.ImageSlide;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.Stub;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
@@ -34,23 +37,27 @@ import okhttp3.HttpUrl;
*/
public class LinkPreviewView extends FrameLayout {
private static final String STATE_ROOT = "linkPreviewView.state.root";
private static final String STATE_STATE = "linkPreviewView.state.state";
private static final int TYPE_CONVERSATION = 0;
private static final int TYPE_COMPOSE = 1;
private ViewGroup container;
private OutlinedThumbnailView thumbnail;
private TextView title;
private TextView description;
private TextView site;
private View divider;
private View closeButton;
private View spinner;
private TextView noPreview;
private ViewGroup container;
private Stub<OutlinedThumbnailView> thumbnail;
private TextView title;
private TextView description;
private TextView site;
private View divider;
private View closeButton;
private View spinner;
private TextView noPreview;
private int type;
private int defaultRadius;
private CornerMask cornerMask;
private CloseClickedListener closeClickedListener;
private int type;
private int defaultRadius;
private CornerMask cornerMask;
private CloseClickedListener closeClickedListener;
private LinkPreviewViewThumbnailState thumbnailState = new LinkPreviewViewThumbnailState();
public LinkPreviewView(Context context) {
super(context);
@@ -66,7 +73,7 @@ public class LinkPreviewView extends FrameLayout {
inflate(getContext(), R.layout.link_preview, this);
container = findViewById(R.id.linkpreview_container);
thumbnail = findViewById(R.id.linkpreview_thumbnail);
thumbnail = new Stub<>(findViewById(R.id.linkpreview_thumbnail));
title = findViewById(R.id.linkpreview_title);
description = findViewById(R.id.linkpreview_description);
site = findViewById(R.id.linkpreview_site);
@@ -101,6 +108,30 @@ public class LinkPreviewView extends FrameLayout {
setWillNotDraw(false);
}
@Override
protected @NonNull Parcelable onSaveInstanceState() {
Parcelable root = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(STATE_ROOT, root);
bundle.putParcelable(STATE_STATE, thumbnailState);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Parcelable root = ((Bundle) state).getParcelable(STATE_ROOT);
thumbnailState = ((Bundle) state).getParcelable(STATE_STATE);
thumbnailState.applyState(thumbnail);
super.onRestoreInstanceState(root);
} else {
super.onRestoreInstanceState(state);
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
@@ -173,8 +204,9 @@ public class LinkPreviewView extends FrameLayout {
if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
thumbnail.setVisibility(VISIBLE);
thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.showDownloadText(false);
thumbnailState.applyState(thumbnail);
thumbnail.get().setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.get().showDownloadText(false);
} else {
thumbnail.setVisibility(GONE);
}
@@ -183,10 +215,24 @@ public class LinkPreviewView extends FrameLayout {
public void setCorners(int topStart, int topEnd) {
if (ViewUtil.isRtl(this)) {
cornerMask.setRadii(topEnd, topStart, 0, 0);
thumbnail.setCorners(defaultRadius, topEnd, defaultRadius, defaultRadius);
thumbnailState = thumbnailState.copy(
defaultRadius,
topEnd,
defaultRadius,
defaultRadius,
thumbnailState.getDownloadListener()
);
thumbnailState.applyState(thumbnail);
} else {
cornerMask.setRadii(topStart, topEnd, 0, 0);
thumbnail.setCorners(topStart, defaultRadius, defaultRadius, defaultRadius);
thumbnailState.copy(
topStart,
defaultRadius,
defaultRadius,
defaultRadius,
thumbnailState.getDownloadListener()
);
thumbnailState.applyState(thumbnail);
}
postInvalidate();
}
@@ -196,7 +242,8 @@ public class LinkPreviewView extends FrameLayout {
}
public void setDownloadClickedListener(SlidesClickedListener listener) {
thumbnail.setDownloadClickListener(listener);
thumbnailState = thumbnailState.withDownloadListener(listener);
thumbnailState.applyState(thumbnail);
}
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.components
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.mms.SlidesClickedListener
import org.thoughtcrime.securesms.util.views.Stub
@Parcelize
data class LinkPreviewViewThumbnailState(
val cornerTopLeft: Int = 0,
val cornerTopRight: Int = 0,
val cornerBottomRight: Int = 0,
val cornerBottomLeft: Int = 0,
@IgnoredOnParcel
val downloadListener: SlidesClickedListener? = null
) : Parcelable {
fun withDownloadListener(downloadListener: SlidesClickedListener?): LinkPreviewViewThumbnailState {
return copy(downloadListener = downloadListener)
}
fun applyState(thumbnail: Stub<OutlinedThumbnailView>) {
if (thumbnail.resolved()) {
thumbnail.get().setCorners(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
thumbnail.get().setDownloadClickListener(downloadListener)
}
}
}

View File

@@ -1,35 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build.VERSION_CODES;
import android.util.AttributeSet;
import android.widget.LinearLayout;
public class SquareLinearLayout extends LinearLayout {
@SuppressWarnings("unused")
public SquareLinearLayout(Context context) {
super(context);
}
@SuppressWarnings("unused")
public SquareLinearLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
@TargetApi(VERSION_CODES.HONEYCOMB) @SuppressWarnings("unused")
public SquareLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@TargetApi(VERSION_CODES.LOLLIPOP) @SuppressWarnings("unused")
public SquareLinearLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//noinspection SuspiciousNameCombination
super.onMeasure(widthMeasureSpec, widthMeasureSpec);
}
}

View File

@@ -19,6 +19,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.MediaUtil;
@@ -97,7 +98,8 @@ public class ThreadPhotoRailView extends FrameLayout {
}
imageView.setOnClickListener(v -> {
if (clickedListener != null) clickedListener.onItemClicked(mediaRecord);
MediaPreviewCache.INSTANCE.setDrawable(imageView.getImageDrawable());
if (clickedListener != null) clickedListener.onItemClicked(imageView, mediaRecord);
});
}
@@ -118,6 +120,6 @@ public class ThreadPhotoRailView extends FrameLayout {
}
public interface OnItemClickedListener {
void onItemClicked(MediaTable.MediaRecord mediaRecord);
void onItemClicked(View itemView, MediaTable.MediaRecord mediaRecord);
}
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.Drawable;
@@ -22,10 +23,6 @@ import androidx.appcompat.widget.AppCompatImageView;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.FitCenter;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.request.Request;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.RequestOptions;
@@ -45,14 +42,13 @@ import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.thoughtcrime.securesms.util.views.Stub;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import static com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions.withCrossFade;
@@ -68,24 +64,25 @@ public class ThumbnailView extends FrameLayout {
private static final int MAX_HEIGHT = 3;
private final ImageView image;
private final ImageView blurhash;
private final ImageView blurHash;
private final View playOverlay;
private final View captionIcon;
private final AppCompatImageView errorImage;
private OnClickListener parentClickListener;
private OnClickListener parentClickListener;
private final int[] dimens = new int[2];
private final int[] bounds = new int[4];
private final int[] measureDimens = new int[2];
private Optional<TransferControlView> transferControls = Optional.empty();
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
private BitmapTransformation fit = new CenterCrop();
private final CornerMask cornerMask;
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
private Stub<TransferControlView> transferControlViewStub;
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
private int radius;
public ThumbnailView(Context context) {
this(context, null);
@@ -100,11 +97,13 @@ public class ThumbnailView extends FrameLayout {
inflate(context, R.layout.thumbnail_view, this);
this.image = findViewById(R.id.thumbnail_image);
this.blurhash = findViewById(R.id.thumbnail_blurhash);
this.playOverlay = findViewById(R.id.play_overlay);
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
this.errorImage = findViewById(R.id.thumbnail_error);
this.image = findViewById(R.id.thumbnail_image);
this.blurHash = findViewById(R.id.thumbnail_blurhash);
this.playOverlay = findViewById(R.id.play_overlay);
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
this.errorImage = findViewById(R.id.thumbnail_error);
this.cornerMask = new CornerMask(this);
this.transferControlViewStub = new Stub<>(findViewById(R.id.transfer_controls_stub));
super.setOnClickListener(new ThumbnailClickDispatcher());
@@ -114,8 +113,9 @@ public class ThumbnailView extends FrameLayout {
bounds[MAX_WIDTH] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxWidth, 0);
bounds[MIN_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_minHeight, 0);
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
fit = typedArray.getInt(R.styleable.ThumbnailView_thumbnail_fit, 0) == 1 ? new FitCenter() : new CenterCrop();
float radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
cornerMask.setRadius((int) radius);
int transparentOverlayColor = typedArray.getColor(R.styleable.ThumbnailView_transparent_overlay_color, -1);
if (transparentOverlayColor > 0) {
@@ -126,7 +126,8 @@ public class ThumbnailView extends FrameLayout {
typedArray.recycle();
} else {
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
float radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
cornerMask.setRadius((int) radius);
image.setColorFilter(null);
}
}
@@ -146,6 +147,7 @@ public class ThumbnailView extends FrameLayout {
MeasureSpec.makeMeasureSpec(finalHeight, MeasureSpec.EXACTLY));
}
@SuppressWarnings("SpellCheckingInspection")
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
@@ -156,7 +158,7 @@ public class ThumbnailView extends FrameLayout {
if (playOverlayWidth * 2 > getWidth()) {
playOverlayScale /= 2;
captionIconScale = 0;
captionIconScale = 0;
}
playOverlay.setScaleX(playOverlayScale);
@@ -166,6 +168,13 @@ public class ThumbnailView extends FrameLayout {
captionIcon.setScaleY(captionIconScale);
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
cornerMask.mask(canvas);
}
public void setMinimumThumbnailWidth(@Px int width) {
bounds[MIN_WIDTH] = width;
invalidate();
@@ -187,7 +196,7 @@ public class ThumbnailView extends FrameLayout {
}
if (dimensAreInvalid || dimensFilledCount == 0 || boundsFilledCount == 0) {
targetDimens[WIDTH] = 0;
targetDimens[WIDTH] = 0;
targetDimens[HEIGHT] = 0;
return;
}
@@ -219,10 +228,10 @@ public class ThumbnailView extends FrameLayout {
if (maxWidthRatio > 1 || maxHeightRatio > 1) {
if (maxWidthRatio >= maxHeightRatio) {
measuredWidth /= maxWidthRatio;
measuredWidth /= maxWidthRatio;
measuredHeight /= maxWidthRatio;
} else {
measuredWidth /= maxHeightRatio;
measuredWidth /= maxHeightRatio;
measuredHeight /= maxHeightRatio;
}
@@ -231,10 +240,10 @@ public class ThumbnailView extends FrameLayout {
} else if (minWidthRatio < 1 || minHeightRatio < 1) {
if (minWidthRatio <= minHeightRatio) {
measuredWidth /= minWidthRatio;
measuredWidth /= minWidthRatio;
measuredHeight /= minWidthRatio;
} else {
measuredWidth /= minHeightRatio;
measuredWidth /= minHeightRatio;
measuredHeight /= minHeightRatio;
}
@@ -247,9 +256,9 @@ public class ThumbnailView extends FrameLayout {
targetDimens[HEIGHT] = (int) measuredHeight;
}
private int getNonZeroCount(int[] vals) {
private int getNonZeroCount(int[] values) {
int count = 0;
for (int val : vals) {
for (int val : values) {
if (val > 0) {
count++;
}
@@ -265,20 +274,19 @@ public class ThumbnailView extends FrameLayout {
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
if (transferControls.isPresent()) transferControls.get().setFocusable(focusable);
transferControlsState = transferControlsState.withFocusable(focusable);
transferControlsState.applyState(transferControlViewStub);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
if (transferControls.isPresent()) transferControls.get().setClickable(clickable);
transferControlsState = transferControlsState.withClickable(clickable);
transferControlsState.applyState(transferControlViewStub);
}
private TransferControlView getTransferControls() {
if (!transferControls.isPresent()) {
transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub));
}
return transferControls.get();
public @Nullable Drawable getImageDrawable() {
return image.getDrawable();
}
public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) {
@@ -292,10 +300,10 @@ public class ThumbnailView extends FrameLayout {
public void setImageDrawable(@NonNull GlideRequests glideRequests, @Nullable Drawable drawable) {
glideRequests.clear(image);
glideRequests.clear(blurhash);
glideRequests.clear(blurHash);
image.setImageDrawable(drawable);
blurhash.setImageDrawable(null);
blurHash.setImageDrawable(null);
}
@UiThread
@@ -313,11 +321,11 @@ public class ThumbnailView extends FrameLayout {
if (slide.asAttachment().isPermanentlyFailed()) {
this.slide = slide;
transferControls.ifPresent(c -> c.setVisibility(View.GONE));
transferControlViewStub.setVisibility(View.GONE);
playOverlay.setVisibility(View.GONE);
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
glideRequests.clear(blurHash);
blurHash.setImageDrawable(null);
glideRequests.clear(image);
image.setImageDrawable(null);
@@ -339,10 +347,18 @@ public class ThumbnailView extends FrameLayout {
}
if (showControls) {
getTransferControls().setSlide(slide);
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
} else if (transferControls.isPresent()) {
getTransferControls().setVisibility(View.GONE);
int transferState = TransferControlView.getTransferState(Collections.singletonList(slide));
if (transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
transferControlViewStub.setVisibility(View.GONE);
} else {
transferControlViewStub.setVisibility(View.VISIBLE);
}
transferControlsState = transferControlsState.withSlide(slide)
.withDownloadClickListener(new DownloadClickDispatcher());
transferControlsState.applyState(transferControlViewStub);
} else {
transferControlViewStub.setVisibility(View.GONE);
}
if (slide.getUri() != null && slide.hasPlayOverlay() &&
@@ -358,7 +374,7 @@ public class ThumbnailView extends FrameLayout {
return new SettableFuture<>(false);
}
if (this.slide != null && this.slide.getFastPreflightId() != null &&
if (this.slide != null && this.slide.getFastPreflightId() != null &&
(!slide.hasVideo() || Util.equals(this.slide.getUri(), slide.getUri())) &&
Util.equals(this.slide.getFastPreflightId(), slide.getFastPreflightId()))
{
@@ -371,7 +387,7 @@ public class ThumbnailView extends FrameLayout {
+ ", progress " + slide.getTransferState() + ", fast preflight id: " +
slide.asAttachment().getFastPreflightId());
BlurHash previousBlurhash = this.slide != null ? this.slide.getPlaceholderBlur() : null;
BlurHash previousBlurHash = this.slide != null ? this.slide.getPlaceholderBlur() : null;
this.slide = slide;
@@ -385,19 +401,19 @@ public class ThumbnailView extends FrameLayout {
SettableFuture<Boolean> result = new SettableFuture<>();
boolean resultHandled = false;
if (slide.hasPlaceholder() && (previousBlurhash == null || !Objects.equals(slide.getPlaceholderBlur(), previousBlurhash))) {
buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(blurhash, result));
if (slide.hasPlaceholder() && (previousBlurHash == null || !Objects.equals(slide.getPlaceholderBlur(), previousBlurHash))) {
buildPlaceholderGlideRequest(glideRequests, slide).into(new GlideBitmapListeningTarget(blurHash, result));
resultHandled = true;
} else if (!slide.hasPlaceholder()) {
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
glideRequests.clear(blurHash);
blurHash.setImageDrawable(null);
}
if (slide.getUri() != null) {
if (!MediaUtil.isJpegType(slide.getContentType()) && !MediaUtil.isVideoType(slide.getContentType())) {
SettableFuture<Boolean> thumbnailFuture = new SettableFuture<>();
thumbnailFuture.deferTo(result);
thumbnailFuture.addListener(new BlurhashClearListener(glideRequests, blurhash));
thumbnailFuture.addListener(new BlurHashClearListener(glideRequests, blurHash));
}
buildThumbnailGlideRequest(glideRequests, slide).into(new GlideDrawableListeningTarget(image, result));
@@ -426,7 +442,7 @@ public class ThumbnailView extends FrameLayout {
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height, boolean animate, @Nullable ThumbnailRequestListener listener) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
transferControlViewStub.setVisibility(View.GONE);
GlideRequest<Drawable> request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
@@ -440,15 +456,9 @@ public class ThumbnailView extends FrameLayout {
request = request.override(width, height);
}
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {
request = request.transforms(new CenterCrop());
}
GlideDrawableListeningTarget target = new GlideDrawableListeningTarget(image, future);
Request previousRequest = target.getRequest();
boolean previousRequestRunning = previousRequest != null && previousRequest.isRunning();
GlideDrawableListeningTarget target = new GlideDrawableListeningTarget(image, future);
Request previousRequest = target.getRequest();
boolean previousRequestRunning = previousRequest != null && previousRequest.isRunning();
request.into(target);
if (listener != null) {
listener.onLoadScheduled();
@@ -457,7 +467,7 @@ public class ThumbnailView extends FrameLayout {
}
}
blurhash.setImageDrawable(null);
blurHash.setImageDrawable(null);
return future;
}
@@ -465,25 +475,19 @@ public class ThumbnailView extends FrameLayout {
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull StoryTextPostModel model, int width, int height) {
SettableFuture<Boolean> future = new SettableFuture<>();
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
transferControlViewStub.setVisibility(View.GONE);
GlideRequest request = glideRequests.load(model)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.placeholder(model.getPlaceholder())
.transition(withCrossFade());
GlideRequest<Drawable> request = glideRequests.load(model)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.placeholder(model.getPlaceholder())
.transition(withCrossFade());
if (width > 0 && height > 0) {
request = request.override(width, height);
}
if (radius > 0) {
request = request.transforms(new CenterCrop(), new RoundedCorners(radius));
} else {
request = request.transforms(new CenterCrop());
}
request.into(new GlideDrawableListeningTarget(image, future));
blurhash.setImageDrawable(null);
blurHash.setImageDrawable(null);
return future;
}
@@ -500,44 +504,54 @@ public class ThumbnailView extends FrameLayout {
glideRequests.clear(image);
image.setImageDrawable(null);
if (transferControls.isPresent()) {
getTransferControls().clear();
if (transferControlViewStub.resolved()) {
transferControlViewStub.get().clear();
}
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
glideRequests.clear(blurHash);
blurHash.setImageDrawable(null);
slide = null;
}
public void showDownloadText(boolean showDownloadText) {
getTransferControls().setShowDownloadText(showDownloadText);
transferControlsState = transferControlsState.withDownloadText(showDownloadText);
transferControlsState.applyState(transferControlViewStub);
}
public void showProgressSpinner() {
getTransferControls().showProgressSpinner();
transferControlViewStub.get().showProgressSpinner();
}
public void setFit(@NonNull BitmapTransformation fit) {
this.fit = fit;
public void setScaleType(@NonNull ImageView.ScaleType scaleType) {
image.setScaleType(scaleType);
}
protected void setRadius(int radius) {
this.radius = radius;
cornerMask.setRadius(radius);
invalidate();
}
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getUri()))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()), fit);
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
cornerMask.setRadii(topLeft, topRight, bottomRight, bottomLeft);
invalidate();
}
private GlideRequest<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Drawable> request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri())))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()));
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
if (slide.isInProgress() || doNotShowMissingThumbnailImage) return request;
else return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
if (slide.isInProgress() || doNotShowMissingThumbnailImage) {
return request;
} else {
return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
}
}
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
private RequestBuilder<Bitmap> buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Bitmap> bitmap = glideRequests.asBitmap();
BlurHash placeholderBlur = slide.getPlaceholderBlur();
@@ -547,10 +561,10 @@ public class ThumbnailView extends FrameLayout {
bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme()));
}
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE), new CenterCrop());
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
}
private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) {
private <TranscodeType> GlideRequest<TranscodeType> applySizing(@NonNull GlideRequest<TranscodeType> request) {
int[] size = new int[2];
fillTargetDimensions(size, dimens, bounds);
if (size[WIDTH] == 0 && size[HEIGHT] == 0) {
@@ -558,13 +572,7 @@ public class ThumbnailView extends FrameLayout {
size[HEIGHT] = getDefaultHeight();
}
request = request.override(size[WIDTH], size[HEIGHT]);
if (radius > 0) {
return request.transforms(fitting, new RoundedCorners(radius));
} else {
return request.transforms(fitting);
}
return request.override(size[WIDTH], size[HEIGHT]);
}
private int getDefaultWidth() {
@@ -585,6 +593,7 @@ public class ThumbnailView extends FrameLayout {
public interface ThumbnailRequestListener extends RequestListener<Drawable> {
void onLoadCanceled();
void onLoadScheduled();
}
@@ -612,31 +621,31 @@ public class ThumbnailView extends FrameLayout {
if (downloadClickListener != null && slide != null) {
downloadClickListener.onClick(view, Collections.singletonList(slide));
} else {
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(downloadClickListener));
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + slide + " downloadClickListener: " + downloadClickListener);
}
}
}
private static class BlurhashClearListener implements ListenableFuture.Listener<Boolean> {
private static class BlurHashClearListener implements ListenableFuture.Listener<Boolean> {
private final GlideRequests glideRequests;
private final ImageView blurhash;
private final ImageView blurHash;
private BlurhashClearListener(@NonNull GlideRequests glideRequests, @NonNull ImageView blurhash) {
private BlurHashClearListener(@NonNull GlideRequests glideRequests, @NonNull ImageView blurHash) {
this.glideRequests = glideRequests;
this.blurhash = blurhash;
this.blurHash = blurHash;
}
@Override
public void onSuccess(Boolean result) {
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
glideRequests.clear(blurHash);
blurHash.setImageDrawable(null);
}
@Override
public void onFailure(ExecutionException e) {
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
glideRequests.clear(blurHash);
blurHash.setImageDrawable(null);
}
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.components
import android.view.View.OnClickListener
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.util.views.Stub
/**
* State object for transfer controls.
*/
data class ThumbnailViewTransferControlsState(
val isFocusable: Boolean = true,
val isClickable: Boolean = true,
val slide: Slide? = null,
val downloadClickedListener: OnClickListener? = null,
val showDownloadText: Boolean = true
) {
fun withFocusable(isFocusable: Boolean): ThumbnailViewTransferControlsState = copy(isFocusable = isFocusable)
fun withClickable(isClickable: Boolean): ThumbnailViewTransferControlsState = copy(isClickable = isClickable)
fun withSlide(slide: Slide?): ThumbnailViewTransferControlsState = copy(slide = slide)
fun withDownloadClickListener(downloadClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(downloadClickedListener = downloadClickedListener)
fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText)
fun applyState(transferControlView: Stub<TransferControlView>) {
if (transferControlView.resolved()) {
transferControlView.get().isFocusable = isFocusable
transferControlView.get().isClickable = isClickable
if (slide != null) {
transferControlView.get().setSlide(slide)
}
transferControlView.get().setDownloadClickListener(downloadClickedListener)
transferControlView.get().setShowDownloadText(showDownloadText)
}
}
}

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.components
import android.app.Dialog
import android.os.Bundle
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.TimeDurationPickerDialogBinding
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* Time duration dialog for selection a duration of hours and minutes. Currently
* designed specifically for screen lock but could easily be generalized in the future
* if needed.
*
* Uses [setFragmentResult] to pass the provided duration back in milliseconds.
*/
class TimeDurationPickerDialog : DialogFragment(), NumericKeyboardView.Listener {
private var _binding: TimeDurationPickerDialogBinding? = null
private val binding: TimeDurationPickerDialogBinding
get() = _binding!!
private var duration: String = "0000"
private var full: Boolean = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
_binding = TimeDurationPickerDialogBinding.inflate(layoutInflater)
binding.durationKeyboard.listener = this
setDuration(requireArguments().getLong(ARGUMENT_DURATION).milliseconds)
return MaterialAlertDialogBuilder(requireContext())
.setView(binding.root)
.setPositiveButton(R.string.TimeDurationPickerDialog_positive_button) { _, _ ->
setFragmentResult(
RESULT_DURATION,
bundleOf(
RESULT_KEY_DURATION_MILLISECONDS to getDuration().inWholeMilliseconds
)
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
override fun onKeyPress(keyCode: Int) {
if (full && keyCode != -1) {
return
}
duration = if (keyCode == -1) {
"0" + duration.substring(0, 3)
} else {
duration.substring(1) + keyCode
}
updateDuration()
}
private fun updateDuration() {
binding.durationHour.text = duration.substring(0, 2)
binding.durationMinute.text = duration.substring(2)
full = duration.toInt() > 1000
}
private fun setDuration(duration: Duration) {
val hour = duration.inWholeMinutes / 60
val minute = duration.inWholeMinutes.mod(60)
this.duration = String.format("%02d%02d", hour, minute)
updateDuration()
}
private fun getDuration(): Duration {
val hours = duration.substring(0, 2).toInt()
val minutes = duration.substring(2).toInt()
return hours.hours.plus(minutes.minutes)
}
companion object {
const val RESULT_DURATION = "RESULT_DURATION"
const val RESULT_KEY_DURATION_MILLISECONDS = "RESULT_KEY_DURATION_MILLISECONDS"
private const val ARGUMENT_DURATION = "ARGUMENT_DURATION"
fun create(duration: Duration): TimeDurationPickerDialog {
return TimeDurationPickerDialog().apply {
arguments = bundleOf(
ARGUMENT_DURATION to duration.inWholeMilliseconds
)
}
}
}
}

View File

@@ -182,7 +182,7 @@ public final class TransferControlView extends FrameLayout {
return true;
}
private int getTransferState(@NonNull List<Slide> slides) {
static int getTransferState(@NonNull List<Slide> slides) {
int transferState = AttachmentTable.TRANSFER_PROGRESS_DONE;
boolean allFailed = true;

View File

@@ -11,5 +11,5 @@ data class ActionItem @JvmOverloads constructor(
@DrawableRes val iconRes: Int,
val title: CharSequence,
@ColorRes val tintRes: Int = R.color.signal_colorOnSurface,
val action: Runnable,
val action: Runnable
)

View File

@@ -65,7 +65,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
private class ItemViewHolder(
itemView: View,
private val onItemClick: () -> Unit,
private val onItemClick: () -> Unit
) : MappingViewHolder<DisplayItem>(itemView) {
val icon: ImageView = itemView.findViewById(R.id.signal_context_menu_item_icon)
val title: TextView = itemView.findViewById(R.id.signal_context_menu_item_title)

View File

@@ -36,7 +36,7 @@ class SignalContextMenu private constructor(
private val contextMenuList = ContextMenuList(
recyclerView = contentView.findViewById(R.id.signal_context_menu_list),
onItemClick = { dismiss() },
onItemClick = { dismiss() }
)
init {

View File

@@ -43,12 +43,14 @@ class VerificationCodeView @JvmOverloads constructor(context: Context, attrs: At
fun delete() {
if (index <= 0) return
containers[--index].editText?.setText("")
containers[index].editText?.requestFocus()
}
fun clear() {
if (index != 0) {
containers.forEach { it.editText?.setText("") }
index = 0
containers[index].editText?.requestFocus()
}
}

View File

@@ -211,6 +211,7 @@ class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchL
corners[6] = radius.toFloat()
corners[7] = radius.toFloat()
}
segments.lastIndex -> {
corners.indices.forEach { corners[it] = 0f }
corners[2] = radius.toFloat()
@@ -227,6 +228,7 @@ class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchL
path.addRoundRect(rectangle, corners, Path.Direction.CW)
canvas?.drawPath(path, drawingComponents.second[drawingIndex])
}
else -> canvas?.drawRect(
rectangle,
drawingComponents.second[drawingIndex]
@@ -325,12 +327,14 @@ class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchL
segments.mapIndexed { index, segment ->
if (offset > 0) {
if (index < nextSegmentIndex) segment.animationState =
Segment.AnimationState.ANIMATED
if (index < nextSegmentIndex) {
segment.animationState = Segment.AnimationState.ANIMATED
}
} else if (offset < 0) {
if (index > nextSegmentIndex - 1) segment.animationState =
Segment.AnimationState.IDLE
} else if (offset == 0) {
if (index > nextSegmentIndex - 1) {
segment.animationState = Segment.AnimationState.IDLE
}
} else {
if (index == nextSegmentIndex) segment.animationState = Segment.AnimationState.IDLE
}
}

View File

@@ -38,7 +38,6 @@ fun SegmentedProgressBar.getDrawingComponents(
segment: Segment,
segmentIndex: Int
): Pair<MutableList<RectF>, MutableList<Paint>> {
val rectangles = mutableListOf<RectF>()
val paints = mutableListOf<Paint>()
val segmentWidth = segmentWidth

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings
import android.os.Bundle
import androidx.activity.OnBackPressedCallback
import androidx.annotation.Discouraged
import androidx.navigation.NavController
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
@@ -10,6 +11,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
open class DSLSettingsActivity : PassphraseRequiredActivity() {
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()

View File

@@ -10,6 +10,7 @@ import android.widget.ImageView
import android.widget.RadioButton
import android.widget.TextView
import androidx.annotation.CallSuper
import androidx.annotation.Discouraged
import androidx.core.content.ContextCompat
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
@@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.views.LearnMoreTextView
import org.thoughtcrime.securesms.util.visible
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
class DSLSettingsAdapter : MappingAdapter() {
init {
registerFactory(ClickPreference::class.java, LayoutFactory(::ClickPreferenceViewHolder, R.layout.dsl_preference_item))

View File

@@ -7,6 +7,7 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.EdgeEffect
import androidx.annotation.Discouraged
import androidx.annotation.LayoutRes
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.LinearLayoutManager
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.WindowUtil
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
abstract class DSLSettingsBottomSheetFragment(
@LayoutRes private val layoutId: Int = R.layout.dsl_settings_bottom_sheet,
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) },

View File

@@ -6,6 +6,7 @@ import android.os.Bundle
import android.view.View
import android.widget.EdgeEffect
import androidx.annotation.CallSuper
import androidx.annotation.Discouraged
import androidx.annotation.LayoutRes
import androidx.annotation.MenuRes
import androidx.annotation.StringRes
@@ -20,6 +21,7 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import java.lang.UnsupportedOperationException
@Discouraged("The DSL API can be completely replaced by compose. See ComposeFragment or ComposeBottomSheetFragment for an alternative to this API")
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int = -1,
@MenuRes private val menuId: Int = -1,

View File

@@ -32,7 +32,7 @@ sealed class DSLSettingsIcon {
@ColorRes private val iconTintId: Int,
@DrawableRes private val backgroundId: Int,
@ColorRes private val backgroundTint: Int,
@Px private val insetPx: Int,
@Px private val insetPx: Int
) : DSLSettingsIcon() {
override fun resolve(context: Context): Drawable {
return LayerDrawable(

View File

@@ -46,7 +46,6 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
private fun getConfiguration(state: AppSettingsState): DSLConfiguration {
return configure {
customPref(
BioPreference(state.self) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)

View File

@@ -59,7 +59,6 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
private fun getConfiguration(state: AccountSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(R.string.preferences_app_protection__signal_pin)
@Suppress("DEPRECATION")

View File

@@ -161,7 +161,9 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
ContinueStatus.CAN_CONTINUE -> findNavController().safeNavigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
ContinueStatus.INVALID_NUMBER -> {
Dialogs.showAlertDialog(
context, getString(R.string.RegistrationActivity_invalid_number), String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number)
context,
getString(R.string.RegistrationActivity_invalid_number),
String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number)
)
}
ContinueStatus.OLD_NUMBER_DOESNT_MATCH -> {

View File

@@ -44,7 +44,6 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
private fun getConfiguration(state: ChatsSettingsState): DSLConfiguration {
return configure {
if (!state.useAsDefaultSmsApp) {
when (state.smsExportState) {
SmsExportState.FETCHING -> Unit

View File

@@ -60,7 +60,6 @@ class SmsSettingsFragment : DSLSettingsFragment(R.string.preferences__sms_mms) {
private fun getConfiguration(state: SmsSettingsState): DSLConfiguration {
return configure {
if (state.useAsDefaultSmsApp) {
customPref(
OutlinedLearnMore.Model(

View File

@@ -11,7 +11,7 @@ class DataAndStorageSettingsRepository {
fun getTotalStorageUse(consumer: (Long) -> Unit) {
SignalExecutors.BOUNDED.execute {
val breakdown = SignalDatabase.media.storageBreakdown
val breakdown = SignalDatabase.media.getStorageBreakdown()
consumer(listOf(breakdown.audioSize, breakdown.documentSize, breakdown.photoSize, breakdown.videoSize).sum())
}

View File

@@ -108,7 +108,6 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
}
)
} else {
clickPref(
title = DSLSettingsText.from(R.string.preferences__sound),
summary = DSLSettingsText.from(getRingtoneSummary(state.messageNotificationsState.sound)),

View File

@@ -41,7 +41,6 @@ class NotificationProfileSelectionFragment : DSLSettingsBottomSheetFragment() {
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(state.notificationProfiles)
return configure {
state.notificationProfiles.sortedDescending().forEach { profile ->
customPref(
NotificationProfileSelection.Entry(

View File

@@ -148,7 +148,7 @@ class EditNotificationProfileFragment : DSLSettingsFragment(layoutId = R.layout.
NotificationProfileNamePreset.Model("\uD83D\uDE34", R.string.EditNotificationProfileFragment__sleep, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE97", R.string.EditNotificationProfileFragment__driving, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDE0A", R.string.EditNotificationProfileFragment__downtime, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDCA1", R.string.EditNotificationProfileFragment__focus, onClick),
NotificationProfileNamePreset.Model("\uD83D\uDCA1", R.string.EditNotificationProfileFragment__focus, onClick)
)
)
}

View File

@@ -41,7 +41,7 @@ private val DAY_TO_STARTING_LETTER: Map<DayOfWeek, Int> = mapOf(
DayOfWeek.WEDNESDAY to R.string.EditNotificationProfileSchedule__wednesday_first_letter,
DayOfWeek.THURSDAY to R.string.EditNotificationProfileSchedule__thursday_first_letter,
DayOfWeek.FRIDAY to R.string.EditNotificationProfileSchedule__friday_first_letter,
DayOfWeek.SATURDAY to R.string.EditNotificationProfileSchedule__saturday_first_letter,
DayOfWeek.SATURDAY to R.string.EditNotificationProfileSchedule__saturday_first_letter
)
/**

View File

@@ -95,7 +95,6 @@ class NotificationProfileDetailsFragment : DSLSettingsFragment() {
val (profile: NotificationProfile, recipients: List<Recipient>, isOn: Boolean, expanded: Boolean) = state
return configure {
customPref(
NotificationProfilePreference.Model(
title = DSLSettingsText.from(profile.name),

View File

@@ -23,17 +23,15 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.preference.PreferenceManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import mobi.upod.timedurationpicker.TimeDurationPicker
import mobi.upod.timedurationpicker.TimeDurationPickerDialog
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.PassphraseChangeActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TimeDurationPickerDialog
import org.thoughtcrime.securesms.components.settings.ClickPreference
import org.thoughtcrime.securesms.components.settings.ClickPreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
@@ -56,8 +54,9 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.lang.Integer.max
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private val TAG = Log.tag(PrivacySettingsFragment::class.java)
@@ -257,14 +256,13 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
clickPref(
title = DSLSettingsText.from(R.string.preferences__inactivity_timeout_interval),
onClick = {
TimeDurationPickerDialog(
context,
{ _: TimeDurationPicker?, duration: Long ->
val timeoutMinutes = max(TimeUnit.MILLISECONDS.toMinutes(duration).toInt(), 1)
viewModel.setObsoletePasswordTimeout(timeoutMinutes)
},
0, TimeDurationPicker.HH_MM
).show()
childFragmentManager.clearFragmentResult(TimeDurationPickerDialog.RESULT_DURATION)
childFragmentManager.clearFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION)
childFragmentManager.setFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION, this@PrivacySettingsFragment) { _, bundle ->
val timeout = bundle.getLong(TimeDurationPickerDialog.RESULT_KEY_DURATION_MILLISECONDS).milliseconds.inWholeMinutes.toInt()
viewModel.setObsoletePasswordTimeout(max(timeout, 1))
}
TimeDurationPickerDialog.create(state.screenLockActivityTimeout.seconds).show(childFragmentManager, null)
}
)
} else {
@@ -291,14 +289,12 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
summary = DSLSettingsText.from(getScreenLockInactivityTimeoutSummary(state.screenLockActivityTimeout)),
isEnabled = isKeyguardSecure && state.screenLock,
onClick = {
TimeDurationPickerDialog(
context,
{ _: TimeDurationPicker?, duration: Long ->
val timeoutSeconds = TimeUnit.MILLISECONDS.toSeconds(duration)
viewModel.setScreenLockTimeout(timeoutSeconds)
},
0, TimeDurationPicker.HH_MM
).show()
childFragmentManager.clearFragmentResult(TimeDurationPickerDialog.RESULT_DURATION)
childFragmentManager.clearFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION)
childFragmentManager.setFragmentResultListener(TimeDurationPickerDialog.RESULT_DURATION, this@PrivacySettingsFragment) { _, bundle ->
viewModel.setScreenLockTimeout(bundle.getLong(TimeDurationPickerDialog.RESULT_KEY_DURATION_MILLISECONDS).milliseconds.inWholeSeconds)
}
TimeDurationPickerDialog.create(state.screenLockActivityTimeout.seconds).show(childFragmentManager, null)
}
)
}
@@ -328,7 +324,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
)
textPref(
summary = DSLSettingsText.from(incognitoSummary),
summary = DSLSettingsText.from(incognitoSummary)
)
dividerPref()

View File

@@ -107,7 +107,6 @@ class AdvancedPrivacySettingsFragment : DSLSettingsFragment(R.string.preferences
private fun getConfiguration(state: AdvancedPrivacySettingsState): DSLConfiguration {
return configure {
switchPref(
title = DSLSettingsText.from(R.string.preferences__signal_messages_and_calls),
summary = DSLSettingsText.from(getPushToggleSummary(state.isPushEnabled)),

View File

@@ -24,5 +24,5 @@ enum class CensorshipCircumventionState(val available: Boolean) {
AVAILABLE_AUTOMATICALLY_ENABLED(true),
/** The setting is generically available */
AVAILABLE(true),
AVAILABLE(true)
}

View File

@@ -7,7 +7,7 @@ data class ExpireTimerSettingsState(
val userSetTimer: Int? = null,
val saveState: ProcessState<Int> = ProcessState.Idle(),
val isGroupCreate: Boolean = false,
val isForRecipient: Boolean = isGroupCreate,
val isForRecipient: Boolean = isGroupCreate
) {
val currentTimer: Int
get() = userSetTimer ?: initialTimer

View File

@@ -108,7 +108,7 @@ data class Boost(
val isCustomAmountFocused: Boolean,
val isCustomAmountTooSmall: Boolean,
val onCustomAmountChanged: (String) -> Unit,
val onCustomAmountFocusChanged: (Boolean) -> Unit,
val onCustomAmountFocusChanged: (Boolean) -> Unit
) : PreferenceModel<SelectionModel>(isEnabled = isEnabled) {
override fun areItemsTheSame(newItem: SelectionModel): Boolean = true
@@ -156,7 +156,8 @@ data class Boost(
error.text = context.getString(
R.string.Boost__the_minimum_amount_you_can_donate_is_s,
FiatMoneyUtil.format(
context.resources, model.minimumAmount,
context.resources,
model.minimumAmount,
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
)
)
@@ -253,7 +254,6 @@ data class Boost(
dstart: Int,
dend: Int
): CharSequence? {
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
val resultWithoutCurrencyPrefix = StringUtil.stripBidiIndicator(result.removePrefix(symbol).removeSuffix(symbol).trim())

View File

@@ -247,7 +247,8 @@ class DonateToSignalViewModel(
selectableCurrencyCodes = availableCurrencies.map(Currency::getCurrencyCode),
isCustomAmountFocused = false,
customAmount = FiatMoney(
BigDecimal.ZERO, currency
BigDecimal.ZERO,
currency
)
)
)

View File

@@ -247,7 +247,8 @@ class DonationCheckoutDelegate(
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
fragment!!.requireContext(), throwable,
fragment!!.requireContext(),
throwable,
object : DonationErrorDialogs.DialogCallback() {
var tryCCAgain = false

View File

@@ -8,7 +8,7 @@ import androidx.activity.OnBackPressedCallback
*/
class DonationWebViewOnBackPressedCallback(
private val dismissAllowingStateLoss: () -> Unit,
private val webView: WebView,
private val webView: WebView
) : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (webView.canGoBack()) {

View File

@@ -19,7 +19,6 @@ class YourInformationIsPrivateBottomSheet : DSLSettingsBottomSheetFragment() {
private fun getConfiguration(): DSLConfiguration {
return configure {
space(10.dp)
noPadTextPref(

View File

@@ -97,14 +97,16 @@ class DonationErrorParams<V> private constructor(
return when (declinedError.declineCode) {
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
}
)
StripeDeclineCode.Code.CALL_ISSUER -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
@@ -112,21 +114,24 @@ class DonationErrorParams<V> private constructor(
)
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase)
StripeDeclineCode.Code.EXPIRED_CARD -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired
}
)
StripeDeclineCode.Code.INCORRECT_NUMBER -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
}
)
StripeDeclineCode.Code.INCORRECT_CVC -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
@@ -134,28 +139,32 @@ class DonationErrorParams<V> private constructor(
)
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, R.string.DeclineCode__your_card_does_not_have_sufficient_funds)
StripeDeclineCode.Code.INVALID_CVC -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
}
)
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month
}
)
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year
}
)
StripeDeclineCode.Code.INVALID_NUMBER -> getStripeDeclineCodePositiveActionParams(
context, callback,
context,
callback,
when (declinedError.method) {
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect

View File

@@ -105,7 +105,8 @@ class ManageDonationsFragment :
noPadTextPref(
title = DSLSettingsText.from(
R.string.DonateToSignalFragment__privacy_over_profit,
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
DSLSettingsText.CenterModifier,
DSLSettingsText.TitleLargeModifier
)
)

View File

@@ -74,7 +74,7 @@ class ThanksForYourSupportBottomSheetDialogFragment : FixedRoundedCornerBottomSh
.append(
SpanUtil.clickable(
getString(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__become_a_montly_sustainer),
ContextCompat.getColor(requireContext(), R.color.signal_accent_primary),
ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)
) {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))

View File

@@ -18,7 +18,6 @@ abstract class SettingsWrapperFragment : Fragment(R.layout.settings_wrapper_frag
private set
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener {

View File

@@ -8,6 +8,7 @@ import android.view.View
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.util.Pair
import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.groups.GroupId
@@ -22,6 +23,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
ActivityCompat.postponeEnterTransition(this)
setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
super.onCreate(savedInstanceState, ready)
}
@@ -55,7 +57,7 @@ class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettings
ActivityOptionsCompat.makeSceneTransitionAnimation(
context,
avatar,
"avatar",
"avatar"
).toBundle()
} else {
null

Some files were not shown because too many files have changed in this diff Show More