Compare commits

..

4 Commits

Author SHA1 Message Date
Alex Hart
9c3711d757 Bump version to 6.10.7 2023-02-07 12:15:30 -04:00
Cody Henthorne
37e64ff819 Fix backup scheduling looping bug. 2023-02-07 11:12:59 -05:00
Alex Hart
53a6eec82a Bump version to 6.10.6 2023-02-06 13:12:17 -04:00
Nicholas Tinsley
ff131087db Catch new audio recording error states. 2023-02-06 13:02:04 -04:00
908 changed files with 14669 additions and 32037 deletions

View File

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

View File

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

View File

@@ -1,21 +1,37 @@
import com.android.build.api.dsl.ManagedVirtualDevice
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 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 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"
}
}
}
protobuf {
protoc {
artifact = 'com.google.protobuf:protoc:3.18.0'
@@ -31,30 +47,20 @@ protobuf {
}
}
wire {
kotlin {
javaInterop = true
}
sourcePath {
srcDir 'src/main/protowire'
}
}
ktlint {
// Use a newer version to resolve https://github.com/JLLeitschuh/ktlint-gradle/issues/507
version = "0.47.1"
version = "0.43.2"
}
def canonicalVersionCode = 1224
def canonicalVersionName = "6.13.4"
def canonicalVersionCode = 1205
def canonicalVersionName = "6.10.6"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
'armeabi-v7a' : 1,
'arm64-v8a' : 2,
'x86' : 3,
'x86_64' : 4]
def abiPostFix = ['universal' : 10,
'armeabi-v7a' : 11,
'arm64-v8a' : 12,
'x86' : 13,
'x86_64' : 14]
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
@@ -62,9 +68,6 @@ def selectableVariants = [
'nightlyProdSpinner',
'nightlyProdPerf',
'nightlyProdRelease',
'nightlyStagingRelease',
'nightlyPnpPerf',
'nightlyPnpRelease',
'playProdDebug',
'playProdSpinner',
'playProdPerf',
@@ -74,18 +77,14 @@ def selectableVariants = [
'playStagingSpinner',
'playStagingPerf',
'playStagingInstrumentation',
'playPnpDebug',
'playPnpSpinner',
'playStagingRelease',
'websiteProdSpinner',
'websiteProdRelease',
]
android {
namespace 'org.thoughtcrime.securesms'
buildToolsVersion BUILD_TOOLS_VERSION
compileSdkVersion COMPILE_SDK_VERSION
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
flavorDimensions 'distribution', 'environment'
useLibrary 'org.apache.http.legacy'
@@ -107,6 +106,11 @@ android {
}
}
dependenciesInfo {
includeInBundle false
includeInApk false
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
@@ -126,6 +130,12 @@ android {
}
}
lintOptions {
checkReleaseBuilds false
abortOnError true
baseline file("lint-baseline.xml")
disable "LintError"
}
sourceSets {
test {
@@ -142,28 +152,29 @@ android {
sourceCompatibility JAVA_VERSION
targetCompatibility JAVA_VERSION
}
packagingOptions {
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']
}
}
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'
}
buildFeatures {
viewBinding true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.3.2'
}
defaultConfig {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion MIN_SDK_VERSION
targetSdkVersion TARGET_SDK_VERSION
minSdkVersion MINIMUM_SDK
targetSdkVersion TARGET_SDK
multiDexEnabled true
@@ -197,7 +208,8 @@ android {
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -289,13 +301,11 @@ 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
@@ -354,6 +364,7 @@ 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\")"
@@ -369,22 +380,6 @@ 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 ->
@@ -516,6 +511,7 @@ 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) {
@@ -586,9 +582,6 @@ dependencies {
implementation libs.rxdogtag
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

@@ -170,48 +170,6 @@ class GroupTableTest {
assertEquals(mmsGroup, actual)
}
@Test
fun givenMultipleMmsGroups_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1])
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2])
val group1: GroupId = insertMmsGroup(group1Members)
val group2: GroupId = insertMmsGroup(group2Members)
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.toSet())
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.toSet())
assertEquals(group1, group1Result)
assertEquals(group2, group2Result)
assertNotEquals(group1Result, group2Result)
}
@Test
fun givenMultipleMmsGroupsWithDifferentMemberOrders_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1], harness.others[2]).shuffled()
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2], harness.others[3]).shuffled()
val group1: GroupId = insertMmsGroup(group1Members)
val group2: GroupId = insertMmsGroup(group2Members)
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.shuffled().toSet())
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.shuffled().toSet())
assertEquals(group1, group1Result)
assertEquals(group2, group2Result)
assertNotEquals(group1Result, group2Result)
}
@Test
fun givenMmsGroupWithOneMember_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val groupMembers: List<RecipientId> = listOf(harness.self.id)
val group: GroupId = insertMmsGroup(groupMembers)
val groupResult: GroupId = groupTable.getOrCreateMmsGroupForMembers(groupMembers.toSet())
assertEquals(group, groupResult)
}
@Test
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
val g1 = insertPushGroup(listOf())
@@ -224,24 +182,6 @@ 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,11 +11,6 @@ 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 {
@@ -164,47 +159,4 @@ 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,33 +58,6 @@ 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") {
@@ -373,32 +346,6 @@ 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)
@@ -435,7 +382,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent("")
}
test("merge, e164 & pni, aci provided, existing pni session, thread merge shadows") {
test("merge, e164 & pni, aci provided, existing pni session") {
given(E164_A, null, null)
given(null, PNI_A, null, pniSession = true)
@@ -445,17 +392,6 @@ 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)
}
@@ -471,7 +407,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent("")
}
test("merge, e164+pni & aci, no pni session") {
test("merge, e164+pni & aci") {
given(E164_A, PNI_A, null)
given(null, null, ACI_A)
@@ -483,52 +419,6 @@ 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)
@@ -868,10 +758,9 @@ class RecipientTableTest_getAndPossiblyMerge {
return id
}
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)
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)
generatedIds += outputRecipientId
return outputRecipientId
}
fun expect(e164: String?, pni: PNI?, aci: ACI?) {

View File

@@ -0,0 +1,842 @@
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,6 +22,7 @@ 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
@@ -65,13 +66,15 @@ 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)
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
true
)
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

@@ -1,175 +0,0 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.failure
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
import org.whispersystems.util.Base64UrlSafe
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
@get:Rule
val harness = SignalActivityRule()
@After
fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers()
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
}
@Test
fun givenNoLocalUsername_whenICheckUsernameIsInSync_thenIExpectNoFailures() {
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
}
@Test
fun givenLocalUsernameDoesNotMatchServerUsername_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
val serverUsername = "hello.3232"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(serverUsername))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
@Test
fun givenLocalAndNoServer_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(WhoAmIResponse())
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
@Test
fun givenLocalAndServerMatch_whenICheckUsernameIsInSync_thenIExpectNoRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertFalse(didReserve)
assertFalse(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
@Test
fun givenMismatchAndReservationFails_whenICheckUsernameIsInSync_thenIExpectNoConfirm() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash("${username}23"))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().failure(418)
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertFalse(didConfirm)
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.multidex.MultiDexApplication;
import com.google.android.gms.security.ProviderInstaller;
@@ -62,7 +63,6 @@ import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.RefreshKbsCredentialsJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
@@ -75,6 +75,7 @@ import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -103,10 +104,11 @@ import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWra
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.security.Security;
import java.util.HashMap;
import java.util.Map;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.core.CompletableObserver;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
@@ -162,6 +164,7 @@ 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)
@@ -174,7 +177,6 @@ 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)
@@ -198,7 +200,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(this::initializeExpiringMessageManager)
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
.addPostRender(this::initializeTrimThreadsByDateManager)
.addPostRender(RefreshKbsCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
@@ -211,7 +212,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
.addPostRender(() -> SignalDatabase.groupCallRings().removeOldRings())
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -416,11 +416,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeRingRtc() {
try {
Map<String, String> fieldTrials = new HashMap<>();
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
}
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
CallManager.initialize(this, new RingRtcLogger(), Collections.emptyMap());
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);
}

View File

@@ -10,11 +10,10 @@ 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;
@@ -24,7 +23,6 @@ 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;
@@ -115,7 +113,5 @@ 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,14 +25,6 @@ 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 {
@@ -43,7 +35,7 @@ class BiometricDeviceAuthentication(
return false
}
return if (!DISALLOWED_BIOMETRIC_VERSIONS.contains(Build.VERSION.SDK_INT) && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
return if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
if (force) {
Log.i(TAG, "Listening for biometric authentication...")
biometricPrompt.authenticate(biometricPromptInfo)

View File

@@ -26,7 +26,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -71,7 +71,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
protected void onCreate(Bundle icicle, boolean ready) {
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
int displayMode = includeSms ? ContactSelectionDisplayMode.FLAG_ALL : ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS | ContactSelectionDisplayMode.FLAG_SELF;
int displayMode = includeSms ? DisplayMode.FLAG_ALL : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
}

View File

@@ -1,88 +0,0 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.view.View
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
class ContactSelectionListAdapter(
context: Context,
fixedContacts: Set<ContactSearchKey>,
displayCheckBox: Boolean,
displaySmsTag: DisplaySmsTag,
displaySecondaryInformation: DisplaySecondaryInformation,
onClickCallbacks: OnContactSelectionClick,
longClickCallbacks: LongClickCallbacks,
storyContextMenuCallbacks: StoryContextMenuCallbacks
) : ContactSearchAdapter(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, onClickCallbacks, longClickCallbacks, storyContextMenuCallbacks) {
init {
registerFactory(NewGroupModel::class.java, LayoutFactory({ NewGroupViewHolder(it, onClickCallbacks::onNewGroupClicked) }, R.layout.contact_selection_new_group_item))
registerFactory(InviteToSignalModel::class.java, LayoutFactory({ InviteToSignalViewHolder(it, onClickCallbacks::onInviteToSignalClicked) }, R.layout.contact_selection_invite_action_item))
}
class NewGroupModel : MappingModel<NewGroupModel> {
override fun areItemsTheSame(newItem: NewGroupModel): Boolean = true
override fun areContentsTheSame(newItem: NewGroupModel): Boolean = true
}
class InviteToSignalModel : MappingModel<InviteToSignalModel> {
override fun areItemsTheSame(newItem: InviteToSignalModel): Boolean = true
override fun areContentsTheSame(newItem: InviteToSignalModel): Boolean = true
}
private class InviteToSignalViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<InviteToSignalModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: InviteToSignalModel) = Unit
}
private class NewGroupViewHolder(itemView: View, onClickListener: () -> Unit) : MappingViewHolder<NewGroupModel>(itemView) {
init {
itemView.setOnClickListener { onClickListener() }
}
override fun bind(model: NewGroupModel) = Unit
}
class ArbitraryRepository : org.thoughtcrime.securesms.contacts.paged.ArbitraryRepository {
enum class ArbitraryRow(val code: String) {
NEW_GROUP("new-group"),
INVITE_TO_SIGNAL("invite-to-signal");
companion object {
fun fromCode(code: String) = values().first { it.code == code }
}
}
override fun getSize(section: ContactSearchConfiguration.Section.Arbitrary, query: String?): Int {
return if (query.isNullOrEmpty()) section.types.size else 0
}
override fun getData(section: ContactSearchConfiguration.Section.Arbitrary, query: String?, startIndex: Int, endIndex: Int, totalSearchSize: Int): List<ContactSearchData.Arbitrary> {
check(section.types.size == 1)
return listOf(ContactSearchData.Arbitrary(section.types.first()))
}
override fun getMappingModel(arbitrary: ContactSearchData.Arbitrary): MappingModel<*> {
val code = ArbitraryRow.fromCode(arbitrary.type)
return when (code) {
ArbitraryRow.NEW_GROUP -> NewGroupModel()
ArbitraryRow.INVITE_TO_SIGNAL -> InviteToSignalModel()
}
}
}
interface OnContactSelectionClick : ClickCallbacks {
fun onNewGroupClicked()
fun onInviteToSignalClicked()
}
}

View File

@@ -21,6 +21,7 @@ import android.Manifest;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Rect;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -39,7 +40,10 @@ import androidx.annotation.Px;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -55,36 +59,38 @@ import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.HeaderAction;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.SelectedContacts;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchSortOrder;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sharing.ShareContact;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -99,6 +105,7 @@ import kotlin.Unit;
* @author Moxie Marlinspike
*/
public final class ContactSelectionListFragment extends LoggingFragment
implements LoaderManager.LoaderCallbacks<Cursor>
{
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
@@ -130,23 +137,27 @@ public final class ContactSelectionListFragment extends LoggingFragment
private String cursorFilter;
private RecyclerView recyclerView;
private RecyclerViewFastScroller fastScroller;
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private RecyclerView chipRecycler;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private MappingAdapter contactChipAdapter;
private ContactChipViewModel contactChipViewModel;
private LifecycleDisposable lifecycleDisposable;
private HeaderActionProvider headerActionProvider;
private TextView headerActionView;
private ContactSearchMediator contactSearchMediator;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
@Nullable private OnItemLongClickListener onItemLongClickListener;
private GlideRequests glideRequests;
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean hideCount;
private boolean canSelectSelf;
private ListClickListener listClickListener = new ListClickListener();
@Override
public void onAttach(@NonNull Context context) {
@@ -180,6 +191,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
}
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
}
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
}
if (context instanceof HeaderActionProvider) {
headerActionProvider = (HeaderActionProvider) context;
}
@@ -215,14 +234,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (!TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
handleContactPermissionGranted();
} else {
contactSearchMediator.refresh();
LoaderManager.getInstance(this).initLoader(0, null, this);
}
})
.onAnyDenied(() -> {
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
FragmentActivity activity = requireActivity();
if (safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false))) {
contactSearchMediator.refresh();
activity.getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
if (safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false))) {
LoaderManager.getInstance(this).initLoader(0, null, ContactSelectionListFragment.this);
} else {
initializeNoContactsPermission();
}
@@ -284,6 +305,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
swipeRefresh.setEnabled(isRefreshable);
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
if (selectionLimit == null) {
selectionLimit = intent.getParcelableExtra(SELECTION_LIMITS);
@@ -331,66 +353,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
headerActionView.setEnabled(false);
}
contactSearchMediator = new ContactSearchMediator(
this,
currentSelection.stream()
.map(r -> new ContactSearchKey.RecipientSearchKey(r, false))
.collect(java.util.stream.Collectors.toSet()),
selectionLimit,
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
this::mapStateToConfiguration,
new ContactSearchMediator.SimpleCallbacks() {
@Override
public void onAdapterListCommitted(int size) {
onLoadFinished(size);
}
},
false,
(context, fixedContacts, displayCheckBox, displaySmsTag, displaySecondaryInformation, callbacks, longClickCallbacks, storyContextMenuCallbacks) -> new ContactSelectionListAdapter(
context,
fixedContacts,
displayCheckBox,
displaySmsTag,
displaySecondaryInformation,
new ContactSelectionListAdapter.OnContactSelectionClick() {
@Override
public void onNewGroupClicked() {
listCallback.onNewGroup(false);
}
@Override
public void onInviteToSignalClicked() {
listCallback.onInvite();
}
@Override
public void onStoryClicked(@NonNull View view1, @NonNull ContactSearchData.Story story, boolean isSelected) {
throw new UnsupportedOperationException();
}
@Override
public void onKnownRecipientClicked(@NonNull View view1, @NonNull ContactSearchData.KnownRecipient knownRecipient, boolean isSelected) {
listClickListener.onItemClick(knownRecipient.getContactSearchKey());
}
@Override
public void onExpandClicked(@NonNull ContactSearchData.Expand expand) {
callbacks.onExpandClicked(expand);
}
@Override
public void onUnknownRecipientClicked(@NonNull View view, @NonNull ContactSearchData.UnknownRecipient unknownRecipient, boolean isSelected) {
listClickListener.onItemClick(unknownRecipient.getContactSearchKey());
}
},
(anchorView, data) -> listClickListener.onItemLongClick(anchorView, data.getContactSearchKey()),
storyContextMenuCallbacks
),
new ContactSelectionListAdapter.ArbitraryRepository()
);
return view;
}
@@ -410,30 +372,27 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public @NonNull List<SelectedContact> getSelectedContacts() {
if (contactSearchMediator == null) {
if (cursorRecyclerViewAdapter == null) {
return Collections.emptyList();
}
return contactSearchMediator.getSelectedContacts()
.stream()
.map(ContactSearchKey::requireSelectedContact)
.collect(java.util.stream.Collectors.toList());
return cursorRecyclerViewAdapter.getSelectedContacts();
}
public int getSelectedContactsCount() {
if (contactSearchMediator == null) {
if (cursorRecyclerViewAdapter == null) {
return 0;
}
return contactSearchMediator.getSelectedContacts().size();
return cursorRecyclerViewAdapter.getSelectedContactsCount();
}
public int getTotalMemberCount() {
if (contactSearchMediator == null) {
if (cursorRecyclerViewAdapter == null) {
return 0;
}
return getSelectedContactsCount() + contactSearchMediator.getFixedContactsSize();
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
}
private Set<RecipientId> getCurrentSelection() {
@@ -443,7 +402,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
return currentSelection == null ? Collections.emptySet()
: Collections.unmodifiableSet(new HashSet<>(currentSelection));
: Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
}
public boolean isMulti() {
@@ -451,8 +410,34 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void initializeCursor() {
glideRequests = GlideApp.with(this);
cursorRecyclerViewAdapter = new ContactSelectionListAdapter(requireContext(),
glideRequests,
null,
new ListClickListener(),
isMulti,
currentSelection,
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
if (listCallback != null) {
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
headerAdapter.hide();
concatenateAdapter.addAdapter(headerAdapter);
}
concatenateAdapter.addAdapter(cursorRecyclerViewAdapter);
if (listCallback != null) {
footerAdapter = new FixedViewsAdapter(createInviteActionView(listCallback));
footerAdapter.hide();
concatenateAdapter.addAdapter(footerAdapter);
}
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(contactSearchMediator.getAdapter());
recyclerView.setAdapter(concatenateAdapter);
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
@@ -473,6 +458,20 @@ public final class ContactSelectionListFragment extends LoggingFragment
return hasQueryFilter() || shouldDisplayRecents();
}
private View createInviteActionView(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_invite_action_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onInvite());
return view;
}
private View createNewGroupItem(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onNewGroup(false));
return view;
}
private void initializeNoContactsPermission() {
swipeRefresh.setVisibility(View.GONE);
@@ -497,7 +496,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
public void setQueryFilter(String filter) {
this.cursorFilter = filter;
contactSearchMediator.onFilterChanged(filter);
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
public void resetQueryFilter() {
@@ -514,21 +513,51 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public void reset() {
contactSearchMediator.clearSelection();
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
cursorRecyclerViewAdapter.clearSelectedContacts();
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
}
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
}
private void onLoadFinished(int count) {
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
boolean displayRecents = shouldDisplayRecents();
if (cursorFactoryProvider != null) {
return cursorFactoryProvider.get().create();
} else {
return new ContactsCursorLoader.Factory(activity, displayMode, cursorFilter, displayRecents).create();
}
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, @Nullable Cursor data) {
swipeRefresh.setVisibility(View.VISIBLE);
showContactsLayout.setVisibility(View.GONE);
cursorRecyclerViewAdapter.changeCursor(data);
if (footerAdapter != null) {
footerAdapter.show();
}
if (headerAdapter != null) {
if (TextUtils.isEmpty(cursorFilter)) {
headerAdapter.show();
} else {
headerAdapter.hide();
}
}
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
boolean useFastScroller = count > 20;
boolean useFastScroller = data != null && data.getCount() > 20;
recyclerView.setVerticalScrollBarEnabled(!useFastScroller);
if (useFastScroller) {
fastScroller.setVisibility(View.VISIBLE);
@@ -545,6 +574,13 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
cursorRecyclerViewAdapter.changeCursor(null);
fastScroller.setVisibility(View.GONE);
headerActionView.setVisibility(View.GONE);
}
private boolean shouldDisplayRecents() {
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
}
@@ -598,15 +634,20 @@ public final class ContactSelectionListFragment extends LoggingFragment
*
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
*/
public void markSelected(@NonNull Set<RecipientId> contacts) {
public void markSelected(@NonNull Set<ShareContact> contacts) {
if (contacts.isEmpty()) {
return;
}
Set<SelectedContact> toMarkSelected = contacts.stream()
.filter(r -> !contactSearchMediator.getSelectedContacts()
.contains(new ContactSearchKey.RecipientSearchKey(r, false)))
.map(SelectedContact::forRecipientId)
.map(contact -> {
if (contact.getRecipientId().isPresent()) {
return SelectedContact.forRecipientId(contact.getRecipientId().get());
} else {
return SelectedContact.forPhone(null, contact.getNumber());
}
})
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
.collect(java.util.stream.Collectors.toSet());
if (toMarkSelected.isEmpty()) {
@@ -616,18 +657,22 @@ public final class ContactSelectionListFragment extends LoggingFragment
for (final SelectedContact selectedContact : toMarkSelected) {
markContactSelected(selectedContact);
}
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
}
private class ListClickListener {
public void onItemClick(ContactSearchKey contact) {
SelectedContact selectedContact = contact.requireSelectedContact();
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
@Override
public void onItemClick(ContactSelectionListItem contact) {
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
return;
}
if (!isMulti || !contactSearchMediator.getSelectedContacts().contains(selectedContact.toContactSearchKey())) {
if (!isMulti || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
if (selectionHardLimitReached()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onHardLimitReached(selectionLimit.getHardLimit());
@@ -637,58 +682,63 @@ public final class ContactSelectionListFragment extends LoggingFragment
return;
}
if (contact instanceof ContactSearchKey.UnknownRecipientKey && ((ContactSearchKey.UnknownRecipientKey) contact).getSectionKey() == ContactSearchConfiguration.SectionKey.USERNAME) {
String username = ((ContactSearchKey.UnknownRecipientKey) contact).getQuery();
if (contact.isUsernameType()) {
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(username);
return UsernameUtil.fetchAciForUsername(contact.getNumber());
}, uuid -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
if (allowed) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, username))
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.show();
}
});
} else {
if (onContactSelectedListener != null) {
onContactSelectedListener.onBeforeContactSelected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber(), allowed -> {
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
if (allowed) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
});
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
}
} else {
markContactUnselected(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.ofNullable(selectedContact.getRecipientId()), selectedContact.getNumber());
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
}
}
}
public boolean onItemLongClick(View anchorView, ContactSearchKey item) {
@Override
public boolean onItemLongClick(ContactSelectionListItem item) {
if (onItemLongClickListener != null) {
return onItemLongClickListener.onLongClick(anchorView, item, recyclerView);
return onItemLongClickListener.onLongClick(item, recyclerView);
} else {
return false;
}
@@ -708,7 +758,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void markContactSelected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysSelected(Collections.singleton(selectedContact.toContactSearchKey()));
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
@@ -718,7 +768,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
contactSearchMediator.setKeysNotSelected(Collections.singleton(selectedContact.toContactSearchKey()));
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
contactChipViewModel.remove(selectedContact);
if (onContactSelectedListener != null) {
@@ -791,116 +842,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
chipRecycler.smoothScrollBy(x, 0);
}
private @NonNull ContactSearchConfiguration mapStateToConfiguration(@NonNull ContactSearchState contactSearchState) {
int displayMode = safeArguments().getInt(DISPLAY_MODE, requireActivity().getIntent().getIntExtra(DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_ALL));
boolean includeRecents = safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
boolean includePushContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_PUSH);
boolean includeSmsContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SMS);
boolean includeActiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS);
boolean includeInactiveGroups = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS);
boolean includeSelf = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_SELF);
boolean includeV1Groups = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_GROUPS_V1);
boolean includeNew = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_NEW);
boolean includeRecentsHeader = !flagSet(displayMode, ContactSelectionDisplayMode.FLAG_HIDE_RECENT_HEADER);
boolean includeGroupsAfterContacts = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_GROUPS_AFTER_CONTACTS);
boolean blocked = flagSet(displayMode, ContactSelectionDisplayMode.FLAG_BLOCK);
ContactSearchConfiguration.TransportType transportType = resolveTransportType(includePushContacts, includeSmsContacts);
ContactSearchConfiguration.Section.Recents.Mode mode = resolveRecentsMode(transportType, includeActiveGroups);
ContactSearchConfiguration.NewRowMode newRowMode = resolveNewRowMode(blocked, includeActiveGroups);
return ContactSearchConfiguration.build(builder -> {
builder.setQuery(contactSearchState.getQuery());
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.NEW_GROUP.getCode());
}
if (transportType != null) {
if (TextUtils.isEmpty(contactSearchState.getQuery()) && includeRecents) {
builder.addSection(new ContactSearchConfiguration.Section.Recents(
25,
mode,
includeInactiveGroups,
includeV1Groups,
includeSmsContacts,
includeSelf,
includeRecentsHeader,
null
));
}
builder.addSection(new ContactSearchConfiguration.Section.Individuals(
includeSelf,
transportType,
true,
null,
!hideLetterHeaders()
));
}
if ((includeGroupsAfterContacts || !TextUtils.isEmpty(contactSearchState.getQuery())) && includeActiveGroups) {
builder.addSection(new ContactSearchConfiguration.Section.Groups(
includeSmsContacts,
includeV1Groups,
includeInactiveGroups,
false,
ContactSearchSortOrder.NATURAL,
false,
true,
null
));
}
if (listCallback != null) {
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.INVITE_TO_SIGNAL.getCode());
}
if (includeNew) {
builder.phone(newRowMode);
builder.username(newRowMode);
}
return Unit.INSTANCE;
});
}
private static @Nullable ContactSearchConfiguration.TransportType resolveTransportType(boolean includePushContacts, boolean includeSmsContacts) {
if (includePushContacts && includeSmsContacts) {
return ContactSearchConfiguration.TransportType.ALL;
} else if (includePushContacts) {
return ContactSearchConfiguration.TransportType.PUSH;
} else if (includeSmsContacts) {
return ContactSearchConfiguration.TransportType.SMS;
} else {
return null;
}
}
private static @NonNull ContactSearchConfiguration.Section.Recents.Mode resolveRecentsMode(ContactSearchConfiguration.TransportType transportType, boolean includeGroupContacts) {
if (transportType != null && includeGroupContacts) {
return ContactSearchConfiguration.Section.Recents.Mode.ALL;
} else if (includeGroupContacts) {
return ContactSearchConfiguration.Section.Recents.Mode.GROUPS;
} else {
return ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS;
}
}
private static @NonNull ContactSearchConfiguration.NewRowMode resolveNewRowMode(boolean isBlocked, boolean isActiveGroups) {
if (isBlocked) {
return ContactSearchConfiguration.NewRowMode.BLOCK;
} else if (isActiveGroups) {
return ContactSearchConfiguration.NewRowMode.NEW_CONVERSATION;
} else {
return ContactSearchConfiguration.NewRowMode.ADD_TO_GROUP;
}
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}
public interface OnContactSelectedListener {
/**
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
@@ -933,6 +874,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public interface OnItemLongClickListener {
boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView);
boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView);
}
public interface AbstractContactsCursorLoaderFactoryProvider {
@NonNull AbstractContactsCursorLoader.Factory get();
}
}

View File

@@ -23,7 +23,7 @@ import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@@ -62,7 +62,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_SMS);
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, DisplayMode.FLAG_SMS);
getIntent().putExtra(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS);
getIntent().putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
getIntent().putExtra(ContactSelectionListFragment.REFRESHABLE, false);

View File

@@ -20,7 +20,6 @@ import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.result.ActivityResultLauncher;
@@ -40,22 +39,26 @@ 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.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;
@@ -230,14 +233,18 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public boolean onLongClick(View anchorView, ContactSearchKey contactSearchKey, RecyclerView recyclerView) {
RecipientId recipientId = contactSearchKey.requireRecipientSearchKey().getRecipientId();
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView) {
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null);
if (recipientId == null) {
return false;
}
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
if (actions.isEmpty()) {
return false;
}
new SignalContextMenu.Builder(anchorView, (ViewGroup) anchorView.getRootView())
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView())
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
.offsetX((int) DimensionUnit.DP.toPixels(12))

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) {
@@ -47,7 +47,6 @@ class SignalBackupAgent : BackupAgent() {
}
override fun onRestore(dataInput: BackupDataInput, appVersionCode: Int, newState: ParcelFileDescriptor) {
Log.i(TAG, "Restoring from Android Backup Service.")
while (dataInput.readNextHeader()) {
val buffer = ByteArray(dataInput.dataSize)
dataInput.readEntityData(buffer, 0, dataInput.dataSize)

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.absbackup.backupables
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.absbackup.AndroidBackupItem
import org.thoughtcrime.securesms.absbackup.protos.KbsAuthToken
import org.thoughtcrime.securesms.absbackup.ExternalBackupProtos
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
@@ -17,8 +17,11 @@ object KbsAuthTokens : AndroidBackupItem {
}
override fun getDataForBackup(): ByteArray {
val proto = KbsAuthToken(tokens = SignalStore.kbsValues().kbsAuthTokenList)
return proto.encode()
val registrationRecoveryTokenList = SignalStore.kbsValues().kbsAuthTokenList
val proto = ExternalBackupProtos.KbsAuthToken.newBuilder()
.addAllToken(registrationRecoveryTokenList)
.build()
return proto.toByteArray()
}
override fun restoreData(data: ByteArray) {
@@ -27,9 +30,9 @@ object KbsAuthTokens : AndroidBackupItem {
}
try {
val proto = KbsAuthToken.ADAPTER.decode(data)
val proto = ExternalBackupProtos.KbsAuthToken.parseFrom(data)
SignalStore.kbsValues().putAuthTokenList(proto.tokens)
SignalStore.kbsValues().putAuthTokenList(proto.tokenList)
} catch (e: InvalidProtocolBufferException) {
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
}

View File

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

View File

@@ -1,9 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -7,7 +7,6 @@ import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
@@ -28,7 +27,6 @@ public class AudioRecorder {
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
private final Context context;
private final AudioRecordingHandler uiHandler;
private final AudioRecorderFocusManager audioFocusManager;
private Recorder recorder;
@@ -36,28 +34,12 @@ public class AudioRecorder {
private SingleSubject<VoiceNoteDraft> recordingSubject;
public AudioRecorder(@NonNull Context context, @Nullable AudioRecordingHandler uiHandler) {
this.context = context;
this.uiHandler = uiHandler;
AudioManager.OnAudioFocusChangeListener onAudioFocusChangeListener;
if (this.uiHandler != null) {
onAudioFocusChangeListener = focusChange -> {
if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
this.uiHandler.onRecordCanceled(false);
}
};
} else {
onAudioFocusChangeListener = focusChange -> {
if (focusChange == AudioManager.AUDIOFOCUS_LOSS) {
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
stopRecording();
}
};
}
audioFocusManager = AudioRecorderFocusManager.create(context, onAudioFocusChangeListener);
public AudioRecorder(@NonNull Context context) {
this.context = context;
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> {
Log.i(TAG, "Audio focus change " + focusChange + " stopping recording");
stopRecording();
});
}
public @NonNull Single<VoiceNoteDraft> startRecording() {
@@ -77,7 +59,7 @@ public class AudioRecorder {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
int focusResult = audioFocusManager.requestAudioFocus();
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);

View File

@@ -1,10 +0,0 @@
package org.thoughtcrime.securesms.audio
interface AudioRecordingHandler {
fun onRecordPressed()
fun onRecordReleased()
fun onRecordCanceled(byUser: Boolean)
fun onRecordLocked()
fun onRecordMoved(offsetX: Float, absoluteX: Float)
fun onRecordPermissionRequired()
}

View File

@@ -0,0 +1,325 @@
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

@@ -1,168 +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 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

@@ -1,152 +0,0 @@
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,7 +6,6 @@ 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
@@ -29,13 +28,8 @@ object AvatarRenderer {
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
private var typeface: Typeface? = null
@MainThread
fun getTypeface(context: Context): Typeface {
val interMedium = typeface ?: Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
typeface = interMedium
return interMedium
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
}
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,9 +87,8 @@ 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,8 +6,6 @@ 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;
@@ -47,21 +45,21 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
byte[] headerFrame = new byte[headerLength];
StreamUtil.readFully(in, headerFrame);
BackupFrame frame = BackupFrame.ADAPTER.decode(headerFrame);
BackupProtos.BackupFrame frame = BackupProtos.BackupFrame.parseFrom(headerFrame);
if (frame.header_ == null) {
if (!frame.hasHeader()) {
throw new IOException("Backup stream does not start with header!");
}
Header header = frame.header_;
BackupProtos.Header header = frame.getHeader();
this.iv = header.iv.toByteArray();
this.iv = header.getIv().toByteArray();
if (iv.length != 16) {
throw new IOException("Invalid IV length!");
}
byte[] key = getBackupKey(passphrase, header.salt != null ? header.salt.toByteArray() : null);
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64);
byte[][] split = ByteUtil.split(derived, 32, 32);
@@ -78,7 +76,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
BackupFrame readFrame() throws IOException {
BackupProtos.BackupFrame readFrame() throws IOException {
return readFrame(in);
}
@@ -130,7 +128,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
}
}
private BackupFrame readFrame(InputStream in) throws IOException {
private BackupProtos.BackupFrame readFrame(InputStream in) throws IOException {
try {
byte[] length = new byte[4];
StreamUtil.readFully(in, length);
@@ -153,7 +151,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
return BackupFrame.ADAPTER.decode(plaintext);
return BackupProtos.BackupFrame.parseFrom(plaintext);
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
throw new AssertionError(e);
}

View File

@@ -2,10 +2,7 @@ package org.thoughtcrime.securesms.backup
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log
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 org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
@@ -18,42 +15,38 @@ object BackupVerifier {
private val TAG = Log.tag(BackupVerifier::class.java)
@JvmStatic
@Throws(IOException::class, FullBackupExporter.BackupCanceledException::class)
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long, cancellationSignal: FullBackupExporter.BackupCancellationSignal): Boolean {
@Throws(IOException::class)
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long): Boolean {
val inputStream = BackupRecordInputStream(cipherStream, passphrase)
var count = 0L
var frame: BackupFrame = inputStream.readFrame()
cipherStream.use {
while (frame.end != true && !cancellationSignal.isCanceled) {
val verified = when {
frame.attachment != null -> verifyAttachment(frame.attachment!!, inputStream)
frame.sticker != null -> verifySticker(frame.sticker!!, inputStream)
frame.avatar != null -> verifyAvatar(frame.avatar!!, inputStream)
else -> true
}
if (!verified) {
return false
}
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
frame = inputStream.readFrame()
while (!frame.end) {
val verified = when {
frame.hasAttachment() -> verifyAttachment(frame.attachment, inputStream)
frame.hasSticker() -> verifySticker(frame.sticker, inputStream)
frame.hasAvatar() -> verifyAvatar(frame.avatar, inputStream)
else -> true
}
if (!verified) {
return false
}
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
frame = inputStream.readFrame()
}
if (cancellationSignal.isCanceled) {
throw FullBackupExporter.BackupCanceledException()
}
cipherStream.close()
return true
}
private fun verifyAttachment(attachment: Attachment, inputStream: BackupRecordInputStream): Boolean {
private fun verifyAttachment(attachment: BackupProtos.Attachment, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, attachment.length ?: 0)
inputStream.readAttachmentTo(NullOutputStream, attachment.length)
} catch (e: IOException) {
Log.w(TAG, "Bad attachment id: ${attachment.attachmentId} len: ${attachment.length}", e)
return false
@@ -62,9 +55,9 @@ object BackupVerifier {
return true
}
private fun verifySticker(sticker: Sticker, inputStream: BackupRecordInputStream): Boolean {
private fun verifySticker(sticker: BackupProtos.Sticker, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, sticker.length ?: 0)
inputStream.readAttachmentTo(NullOutputStream, sticker.length)
} catch (e: IOException) {
Log.w(TAG, "Bad sticker id: ${sticker.rowId} len: ${sticker.length}", e)
return false
@@ -72,9 +65,9 @@ object BackupVerifier {
return true
}
private fun verifyAvatar(avatar: Avatar, inputStream: BackupRecordInputStream): Boolean {
private fun verifyAvatar(avatar: BackupProtos.Avatar, inputStream: BackupRecordInputStream): Boolean {
try {
inputStream.readAttachmentTo(NullOutputStream, avatar.length ?: 0)
inputStream.readAttachmentTo(NullOutputStream, avatar.length)
} catch (e: IOException) {
Log.w(TAG, "Bad avatar id: ${avatar.recipientId} len: ${avatar.length}", e)
return false

View File

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

View File

@@ -11,20 +11,17 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import net.zetetic.database.sqlcipher.SQLiteConstraintException;
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.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.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.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.AttachmentTable;
@@ -89,17 +86,17 @@ public class FullBackupImporter extends FullBackupBase {
BackupFrame frame;
while ((frame = inputStream.readFrame()).end != Boolean.TRUE) {
while (!(frame = inputStream.readFrame()).getEnd()) {
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
count++;
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);
if (frame.hasVersion()) processVersion(db, frame.getVersion());
else if (frame.hasStatement()) processStatement(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());
else count--;
}
@@ -122,65 +119,35 @@ public class FullBackupImporter extends FullBackupBase {
}
private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException {
if (version.version == null || version.version > db.getVersion()) {
throw new DatabaseDowngradeException(db.getVersion(), version.version != null ? version.version : -1);
if (version.getVersion() > db.getVersion()) {
throw new DatabaseDowngradeException(db.getVersion(), version.getVersion());
}
db.setVersion(version.version);
}
private static void tryProcessStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
try {
processStatement(db, statement);
} catch (SQLiteConstraintException e) {
String tableName = "?";
String statementString = statement.statement;
if (statementString != null && statementString.startsWith("INSERT INTO ")) {
int nameStart = "INSERT INTO ".length();
int nameEnd = statementString.indexOf(" ", "INSERT INTO ".length());
if (nameStart < statementString.length() && nameEnd > nameStart) {
tableName = statementString.substring(nameStart, nameEnd);
}
}
if (tableName.startsWith("msl_")) {
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Ignoring.");
} else {
Log.w(TAG, "Constraint failed when inserting into " + tableName + ". Throwing!");
throw e;
}
}
db.setVersion(version.getVersion());
}
private static void processStatement(@NonNull SQLiteDatabase db, SqlStatement statement) {
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_");
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 (isForMmsFtsSecretTable || isForEmojiSecretTable || isForSqliteSecretTable) {
Log.i(TAG, "Ignoring import for statement: " + statement.statement);
Log.i(TAG, "Ignoring import for statement: " + statement.getStatement());
return;
}
List<Object> parameters = new LinkedList<>();
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);
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);
}
if (parameters.size() > 0) db.execSQL(statement.statement, parameters.toArray());
else db.execSQL(statement.statement);
if (parameters.size() > 0) db.execSQL(statement.getStatement(), parameters.toArray());
else db.execSQL(statement.getStatement());
}
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
@@ -192,12 +159,12 @@ public class FullBackupImporter extends FullBackupBase {
ContentValues contentValues = new ContentValues();
try {
inputStream.readAttachmentTo(output.second, attachment.length);
inputStream.readAttachmentTo(output.second, attachment.getLength());
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.attachmentId + "! Can't restore it.", e);
Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e);
dataFile.delete();
contentValues.put(AttachmentTable.DATA, (String) null);
contentValues.put(AttachmentTable.DATA_RANDOM, (String) null);
@@ -205,7 +172,7 @@ public class FullBackupImporter extends FullBackupBase {
db.update(AttachmentTable.TABLE_NAME, contentValues,
AttachmentTable.ROW_ID + " = ? AND " + AttachmentTable.UNIQUE_ID + " = ?",
new String[] {String.valueOf(attachment.rowId), String.valueOf(attachment.attachmentId)});
new String[] {String.valueOf(attachment.getRowId()), String.valueOf(attachment.getAttachmentId())});
}
private static void processSticker(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Sticker sticker, BackupRecordInputStream inputStream)
@@ -216,57 +183,52 @@ public class FullBackupImporter extends FullBackupBase {
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
inputStream.readAttachmentTo(output.second, sticker.length);
inputStream.readAttachmentTo(output.second, sticker.getLength());
ContentValues contentValues = new ContentValues();
contentValues.put(StickerTable.FILE_PATH, dataFile.getAbsolutePath());
contentValues.put(StickerTable.FILE_LENGTH, sticker.length);
contentValues.put(StickerTable.FILE_LENGTH, sticker.getLength());
contentValues.put(StickerTable.FILE_RANDOM, output.first);
db.update(StickerTable.TABLE_NAME, contentValues,
StickerTable._ID + " = ?",
new String[] {String.valueOf(sticker.rowId)});
new String[] {String.valueOf(sticker.getRowId())});
}
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);
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());
} else {
if (avatar.name != null && SqlUtil.tableExists(db, "recipient_preferences")) {
if (avatar.hasName() && 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.name });
} else if (avatar.name != null && SqlUtil.tableExists(db, "recipient")) {
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")) {
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.name });
db.execSQL("UPDATE recipient SET signal_profile_avatar = NULL WHERE phone = ?", new String[] { avatar.getName() });
} else {
Log.w(TAG, "Avatar is missing a recipientId. Skipping avatar restore.");
}
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.length);
inputStream.readAttachmentTo(new ByteArrayOutputStream(), avatar.getLength());
}
}
private static void processKeyValue(KeyValue keyValue) {
private static void processKeyValue(BackupProtos.KeyValue keyValue) {
KeyValueDataSet dataSet = new KeyValueDataSet();
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);
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());
} else {
Log.i(TAG, "Unknown KeyValue backup value, skipping");
return;
@@ -277,25 +239,25 @@ public class FullBackupImporter extends FullBackupBase {
@SuppressLint("ApplySharedPref")
private static void processPreference(@NonNull Context context, SharedPreference preference) {
SharedPreferences preferences = context.getSharedPreferences(preference.file_, 0);
SharedPreferences preferences = context.getSharedPreferences(preference.getFile(), 0);
// Identity keys were moved from shared prefs into SignalStore. Need to handle importing backups made before the migration.
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_);
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());
}
return;
}
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();
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();
}
}

View File

@@ -31,8 +31,7 @@ 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,8 +123,7 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
}
errorDialog = DonationErrorDialogs.show(
requireContext(),
throwable,
requireContext(), throwable,
object : DonationErrorDialogs.DialogCallback() {
override fun onDialogDismissed() {
findNavController().popBackStack()
@@ -159,8 +158,7 @@ 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,8 +66,7 @@ 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

@@ -19,7 +19,7 @@ import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
@@ -143,11 +143,11 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
intent.putExtra(ContactSelectionListFragment.SELECTION_LIMITS, 1);
intent.putExtra(ContactSelectionListFragment.HIDE_COUNT, true);
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE,
ContactSelectionDisplayMode.FLAG_PUSH |
ContactSelectionDisplayMode.FLAG_SMS |
ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS |
ContactSelectionDisplayMode.FLAG_INACTIVE_GROUPS |
ContactSelectionDisplayMode.FLAG_BLOCK);
ContactsCursorLoader.DisplayMode.FLAG_PUSH |
ContactsCursorLoader.DisplayMode.FLAG_SMS |
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_INACTIVE_GROUPS |
ContactsCursorLoader.DisplayMode.FLAG_BLOCK);
getSupportFragmentManager().beginTransaction()
.replace(R.id.fragment_container, fragment, CONTACT_SELECTION_FRAGMENT)

View File

@@ -27,15 +27,13 @@ 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();
@@ -84,7 +82,6 @@ public class AlbumThumbnailView extends FrameLayout {
}
showSlides(glideRequests, slides);
applyCorners();
}
public void setCellBackgroundColor(@ColorInt int color) {
@@ -105,15 +102,6 @@ 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();
@@ -136,83 +124,6 @@ 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 && current.getVisibility() == View.VISIBLE) return;
if (view == current) 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 && current.getVisibility() == View.VISIBLE) return;
if (view == current) 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.AudioWaveForms;
import org.thoughtcrime.securesms.audio.AudioWaveForm;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.events.PartProgressEvent;
@@ -43,9 +43,6 @@ 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);
@@ -80,8 +77,6 @@ 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) {
@@ -160,7 +155,6 @@ public final class AudioView extends FrameLayout {
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
EventBus.getDefault().unregister(this);
disposable.dispose();
}
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
@@ -176,7 +170,6 @@ public final class AudioView extends FrameLayout {
final boolean showControls,
final boolean forceHideDuration)
{
this.disposable.dispose();
this.callbacks = callbacks;
if (duration != null) {
@@ -219,19 +212,16 @@ public final class AudioView extends FrameLayout {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor, waveFormThumbTint);
if (android.os.Build.VERSION.SDK_INT >= 23) {
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)
);
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));
} else {
waveFormView.setWaveMode(false);
if (duration != null) {

View File

@@ -4,7 +4,6 @@ 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;
@@ -59,10 +58,10 @@ public class BorderlessImageView extends FrameLayout {
boolean showControls = slide.asAttachment().getUri() == null;
if (slide.hasSticker()) {
image.setScaleType(ImageView.ScaleType.FIT_CENTER);
image.setFit(new CenterInside());
image.setImageResource(glideRequests, slide, showControls, false);
} else {
image.setScaleType(ImageView.ScaleType.CENTER_CROP);
image.setFit(new CenterCrop());
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
}

View File

@@ -0,0 +1,221 @@
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

@@ -1,274 +0,0 @@
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

@@ -1,101 +0,0 @@
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,8 +33,6 @@ 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;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
@@ -65,7 +63,7 @@ import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class InputPanel extends LinearLayout
implements AudioRecordingHandler,
implements MicrophoneRecorderView.Listener,
KeyboardAwareLinearLayout.OnKeyboardShownListener,
EmojiEventListener,
ConversationStickerSuggestionAdapter.EventListener
@@ -139,7 +137,7 @@ public class InputPanel extends LinearLayout
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setHandler(this);
this.microphoneRecorderView.setListener(this);
this.recordTime = new RecordTime(findViewById(R.id.record_time),
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
@@ -574,39 +572,11 @@ public class InputPanel extends LinearLayout
}
private void fadeIn(@NonNull View v) {
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();
v.animate().alpha(1).setDuration(FADE_TIME).start();
}
private void fadeOut(@NonNull View v) {
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();
v.animate().alpha(0).setDuration(FADE_TIME).start();
}
private void updateVisibility() {

View File

@@ -4,8 +4,6 @@ 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;
@@ -24,7 +22,6 @@ 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;
@@ -37,27 +34,23 @@ 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 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 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 int type;
private int defaultRadius;
private CornerMask cornerMask;
private CloseClickedListener closeClickedListener;
private LinkPreviewViewThumbnailState thumbnailState = new LinkPreviewViewThumbnailState();
private int type;
private int defaultRadius;
private CornerMask cornerMask;
private CloseClickedListener closeClickedListener;
public LinkPreviewView(Context context) {
super(context);
@@ -73,7 +66,7 @@ public class LinkPreviewView extends FrameLayout {
inflate(getContext(), R.layout.link_preview, this);
container = findViewById(R.id.linkpreview_container);
thumbnail = new Stub<>(findViewById(R.id.linkpreview_thumbnail));
thumbnail = findViewById(R.id.linkpreview_thumbnail);
title = findViewById(R.id.linkpreview_title);
description = findViewById(R.id.linkpreview_description);
site = findViewById(R.id.linkpreview_site);
@@ -108,30 +101,6 @@ 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);
@@ -204,9 +173,8 @@ public class LinkPreviewView extends FrameLayout {
if (showThumbnail && linkPreview.getThumbnail().isPresent()) {
thumbnail.setVisibility(VISIBLE);
thumbnailState.applyState(thumbnail);
thumbnail.get().setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.get().showDownloadText(false);
thumbnail.setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false);
thumbnail.showDownloadText(false);
} else {
thumbnail.setVisibility(GONE);
}
@@ -215,24 +183,10 @@ public class LinkPreviewView extends FrameLayout {
public void setCorners(int topStart, int topEnd) {
if (ViewUtil.isRtl(this)) {
cornerMask.setRadii(topEnd, topStart, 0, 0);
thumbnailState = thumbnailState.copy(
defaultRadius,
topEnd,
defaultRadius,
defaultRadius,
thumbnailState.getDownloadListener()
);
thumbnailState.applyState(thumbnail);
thumbnail.setCorners(defaultRadius, topEnd, defaultRadius, defaultRadius);
} else {
cornerMask.setRadii(topStart, topEnd, 0, 0);
thumbnailState.copy(
topStart,
defaultRadius,
defaultRadius,
defaultRadius,
thumbnailState.getDownloadListener()
);
thumbnailState.applyState(thumbnail);
thumbnail.setCorners(topStart, defaultRadius, defaultRadius, defaultRadius);
}
postInvalidate();
}
@@ -242,8 +196,7 @@ public class LinkPreviewView extends FrameLayout {
}
public void setDownloadClickedListener(SlidesClickedListener listener) {
thumbnailState = thumbnailState.withDownloadListener(listener);
thumbnailState.applyState(thumbnail);
thumbnail.setDownloadClickListener(listener);
}
private @StringRes static int getLinkPreviewErrorString(@Nullable LinkPreviewRepository.Error customError) {

View File

@@ -1,28 +0,0 @@
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

@@ -21,7 +21,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.audio.AudioRecordingHandler;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -35,10 +34,10 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
public static final int ANIMATION_DURATION = 200;
private FloatingRecordButton floatingRecordButton;
private LockDropTarget lockDropTarget;
private @Nullable AudioRecordingHandler handler;
private @NonNull State state = State.NOT_RUNNING;
private FloatingRecordButton floatingRecordButton;
private LockDropTarget lockDropTarget;
private @Nullable Listener listener;
private @NonNull State state = State.NOT_RUNNING;
public MicrophoneRecorderView(Context context) {
super(context);
@@ -64,8 +63,8 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
state = State.NOT_RUNNING;
hideUi();
if (handler != null) {
handler.onRecordCanceled(byUser);
if (listener != null) {
listener.onRecordCanceled(byUser);
}
}
}
@@ -79,7 +78,7 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
state = State.RUNNING_LOCKED;
hideUi();
if (handler != null) handler.onRecordLocked();
if (listener != null) listener.onRecordLocked();
}
}
@@ -88,7 +87,7 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
state = State.NOT_RUNNING;
hideUi();
if (handler != null) handler.onRecordReleased();
if (listener != null) listener.onRecordReleased();
}
}
@@ -102,12 +101,12 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
if (!Permissions.hasAll(getContext(), Manifest.permission.RECORD_AUDIO)) {
if (handler != null) handler.onRecordPermissionRequired();
if (listener != null) listener.onRecordPermissionRequired();
} else if (state == State.NOT_RUNNING) {
state = State.RUNNING_HELD;
floatingRecordButton.display(event.getX(), event.getY());
lockDropTarget.display();
if (handler != null) handler.onRecordPressed();
if (listener != null) listener.onRecordPressed();
}
break;
case MotionEvent.ACTION_CANCEL:
@@ -115,13 +114,13 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
if (this.state == State.RUNNING_HELD) {
state = State.NOT_RUNNING;
hideUi();
if (handler != null) handler.onRecordReleased();
if (listener != null) listener.onRecordReleased();
}
break;
case MotionEvent.ACTION_MOVE:
if (this.state == State.RUNNING_HELD) {
this.floatingRecordButton.moveTo(event.getX(), event.getY());
if (handler != null) handler.onRecordMoved(floatingRecordButton.lastOffsetX, event.getRawX());
if (listener != null) listener.onRecordMoved(floatingRecordButton.lastOffsetX, event.getRawX());
int dimensionPixelSize = getResources().getDimensionPixelSize(R.dimen.recording_voice_lock_target);
if (floatingRecordButton.lastOffsetY <= dimensionPixelSize) {
@@ -134,8 +133,17 @@ public final class MicrophoneRecorderView extends FrameLayout implements View.On
return false;
}
public void setHandler(@Nullable AudioRecordingHandler handler) {
this.handler = handler;
public void setListener(@Nullable Listener listener) {
this.listener = listener;
}
public interface Listener {
void onRecordPressed();
void onRecordReleased();
void onRecordCanceled(boolean byUser);
void onRecordLocked();
void onRecordMoved(float offsetX, float absoluteX);
void onRecordPermissionRequired();
}
private static class FloatingRecordButton {

View File

@@ -204,7 +204,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}.toMutableList()
if (allowScheduling && listener?.canSchedule() == true) {
items += ActionItem(
iconRes = R.drawable.symbol_calendar_24,
iconRes = R.drawable.ic_calendar_24,
title = context.getString(R.string.conversation_activity__option_schedule_message),
action = { listener.onSendScheduled() }
)

View File

@@ -0,0 +1,35 @@
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,7 +19,6 @@ 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;
@@ -98,8 +97,7 @@ public class ThreadPhotoRailView extends FrameLayout {
}
imageView.setOnClickListener(v -> {
MediaPreviewCache.INSTANCE.setDrawable(imageView.getImageDrawable());
if (clickedListener != null) clickedListener.onItemClicked(imageView, mediaRecord);
if (clickedListener != null) clickedListener.onItemClicked(mediaRecord);
});
}
@@ -120,6 +118,6 @@ public class ThreadPhotoRailView extends FrameLayout {
}
public interface OnItemClickedListener {
void onItemClicked(View itemView, MediaTable.MediaRecord mediaRecord);
void onItemClicked(MediaTable.MediaRecord mediaRecord);
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -23,6 +22,10 @@ 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;
@@ -42,13 +45,14 @@ 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;
@@ -64,25 +68,24 @@ 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 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 Optional<TransferControlView> transferControls = Optional.empty();
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
private BitmapTransformation fit = new CenterCrop();
private int radius;
public ThumbnailView(Context context) {
this(context, null);
@@ -97,13 +100,11 @@ 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.cornerMask = new CornerMask(this);
this.transferControlViewStub = new Stub<>(findViewById(R.id.transfer_controls_stub));
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);
super.setOnClickListener(new ThumbnailClickDispatcher());
@@ -113,9 +114,8 @@ 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);
float radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
cornerMask.setRadius((int) radius);
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();
int transparentOverlayColor = typedArray.getColor(R.styleable.ThumbnailView_transparent_overlay_color, -1);
if (transparentOverlayColor > 0) {
@@ -126,8 +126,7 @@ public class ThumbnailView extends FrameLayout {
typedArray.recycle();
} else {
float radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
cornerMask.setRadius((int) radius);
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
image.setColorFilter(null);
}
}
@@ -147,7 +146,6 @@ 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);
@@ -158,7 +156,7 @@ public class ThumbnailView extends FrameLayout {
if (playOverlayWidth * 2 > getWidth()) {
playOverlayScale /= 2;
captionIconScale = 0;
captionIconScale = 0;
}
playOverlay.setScaleX(playOverlayScale);
@@ -168,13 +166,6 @@ 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();
@@ -196,7 +187,7 @@ public class ThumbnailView extends FrameLayout {
}
if (dimensAreInvalid || dimensFilledCount == 0 || boundsFilledCount == 0) {
targetDimens[WIDTH] = 0;
targetDimens[WIDTH] = 0;
targetDimens[HEIGHT] = 0;
return;
}
@@ -228,10 +219,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;
}
@@ -240,10 +231,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;
}
@@ -256,9 +247,9 @@ public class ThumbnailView extends FrameLayout {
targetDimens[HEIGHT] = (int) measuredHeight;
}
private int getNonZeroCount(int[] values) {
private int getNonZeroCount(int[] vals) {
int count = 0;
for (int val : values) {
for (int val : vals) {
if (val > 0) {
count++;
}
@@ -274,19 +265,20 @@ public class ThumbnailView extends FrameLayout {
@Override
public void setFocusable(boolean focusable) {
super.setFocusable(focusable);
transferControlsState = transferControlsState.withFocusable(focusable);
transferControlsState.applyState(transferControlViewStub);
if (transferControls.isPresent()) transferControls.get().setFocusable(focusable);
}
@Override
public void setClickable(boolean clickable) {
super.setClickable(clickable);
transferControlsState = transferControlsState.withClickable(clickable);
transferControlsState.applyState(transferControlViewStub);
if (transferControls.isPresent()) transferControls.get().setClickable(clickable);
}
public @Nullable Drawable getImageDrawable() {
return image.getDrawable();
private TransferControlView getTransferControls() {
if (!transferControls.isPresent()) {
transferControls = Optional.of(ViewUtil.inflateStub(this, R.id.transfer_controls_stub));
}
return transferControls.get();
}
public void setBounds(int minWidth, int maxWidth, int minHeight, int maxHeight) {
@@ -300,10 +292,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
@@ -321,11 +313,11 @@ public class ThumbnailView extends FrameLayout {
if (slide.asAttachment().isPermanentlyFailed()) {
this.slide = slide;
transferControlViewStub.setVisibility(View.GONE);
transferControls.ifPresent(c -> c.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);
@@ -347,18 +339,10 @@ public class ThumbnailView extends FrameLayout {
}
if (showControls) {
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);
getTransferControls().setSlide(slide);
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
} else if (transferControls.isPresent()) {
getTransferControls().setVisibility(View.GONE);
}
if (slide.getUri() != null && slide.hasPlayOverlay() &&
@@ -374,7 +358,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()))
{
@@ -387,7 +371,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;
@@ -401,19 +385,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));
@@ -442,7 +426,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<>();
transferControlViewStub.setVisibility(View.GONE);
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
GlideRequest<Drawable> request = glideRequests.load(new DecryptableUri(uri))
.diskCacheStrategy(DiskCacheStrategy.NONE)
@@ -456,9 +440,15 @@ public class ThumbnailView extends FrameLayout {
request = request.override(width, height);
}
GlideDrawableListeningTarget target = new GlideDrawableListeningTarget(image, future);
Request previousRequest = target.getRequest();
boolean previousRequestRunning = previousRequest != null && previousRequest.isRunning();
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();
request.into(target);
if (listener != null) {
listener.onLoadScheduled();
@@ -467,7 +457,7 @@ public class ThumbnailView extends FrameLayout {
}
}
blurHash.setImageDrawable(null);
blurhash.setImageDrawable(null);
return future;
}
@@ -475,19 +465,25 @@ public class ThumbnailView extends FrameLayout {
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull StoryTextPostModel model, int width, int height) {
SettableFuture<Boolean> future = new SettableFuture<>();
transferControlViewStub.setVisibility(View.GONE);
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
GlideRequest<Drawable> request = glideRequests.load(model)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.placeholder(model.getPlaceholder())
.transition(withCrossFade());
GlideRequest 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;
}
@@ -504,54 +500,44 @@ public class ThumbnailView extends FrameLayout {
glideRequests.clear(image);
image.setImageDrawable(null);
if (transferControlViewStub.resolved()) {
transferControlViewStub.get().clear();
if (transferControls.isPresent()) {
getTransferControls().clear();
}
glideRequests.clear(blurHash);
blurHash.setImageDrawable(null);
glideRequests.clear(blurhash);
blurhash.setImageDrawable(null);
slide = null;
}
public void showDownloadText(boolean showDownloadText) {
transferControlsState = transferControlsState.withDownloadText(showDownloadText);
transferControlsState.applyState(transferControlViewStub);
getTransferControls().setShowDownloadText(showDownloadText);
}
public void showProgressSpinner() {
transferControlViewStub.get().showProgressSpinner();
getTransferControls().showProgressSpinner();
}
public void setScaleType(@NonNull ImageView.ScaleType scaleType) {
image.setScaleType(scaleType);
public void setFit(@NonNull BitmapTransformation fit) {
this.fit = fit;
}
protected void setRadius(int radius) {
cornerMask.setRadius(radius);
invalidate();
this.radius = radius;
}
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()));
private GlideRequest buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest request = applySizing(glideRequests.load(new DecryptableUri(slide.getUri()))
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.transition(withCrossFade()), fit);
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<Bitmap> buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
private RequestBuilder buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
GlideRequest<Bitmap> bitmap = glideRequests.asBitmap();
BlurHash placeholderBlur = slide.getPlaceholderBlur();
@@ -561,10 +547,10 @@ public class ThumbnailView extends FrameLayout {
bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme()));
}
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE), new CenterCrop());
}
private <TranscodeType> GlideRequest<TranscodeType> applySizing(@NonNull GlideRequest<TranscodeType> request) {
private GlideRequest applySizing(@NonNull GlideRequest request, @NonNull BitmapTransformation fitting) {
int[] size = new int[2];
fillTargetDimensions(size, dimens, bounds);
if (size[WIDTH] == 0 && size[HEIGHT] == 0) {
@@ -572,7 +558,13 @@ public class ThumbnailView extends FrameLayout {
size[HEIGHT] = getDefaultHeight();
}
return request.override(size[WIDTH], size[HEIGHT]);
request = request.override(size[WIDTH], size[HEIGHT]);
if (radius > 0) {
return request.transforms(fitting, new RoundedCorners(radius));
} else {
return request.transforms(fitting);
}
}
private int getDefaultWidth() {
@@ -593,7 +585,6 @@ public class ThumbnailView extends FrameLayout {
public interface ThumbnailRequestListener extends RequestListener<Drawable> {
void onLoadCanceled();
void onLoadScheduled();
}
@@ -621,31 +612,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: " + slide + " downloadClickListener: " + downloadClickListener);
Log.w(TAG, "Received a download button click, but unable to execute it. slide: " + String.valueOf(slide) + " downloadClickListener: " + String.valueOf(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

@@ -1,35 +0,0 @@
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

@@ -1,101 +0,0 @@
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;
}
static int getTransferState(@NonNull List<Slide> slides) {
private int getTransferState(@NonNull List<Slide> slides) {
int transferState = AttachmentTable.TRANSFER_PROGRESS_DONE;
boolean allFailed = true;

View File

@@ -329,13 +329,9 @@ public class EmojiTextView extends AppCompatTextView {
.append(Optional.ofNullable(overflowText).orElse(""));
EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent);
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) {
super.setText(newContent, BufferType.SPANNABLE);
} else {
CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
super.setText(emojified, BufferType.SPANNABLE);
}
super.setText(emojified, BufferType.SPANNABLE);
} else if (maxLength > 0) {
ellipsizeAnyTextForMaxLength();
}

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

@@ -2,8 +2,8 @@ package org.thoughtcrime.securesms.components.registration
import android.content.Context
import android.util.AttributeSet
import androidx.annotation.StringRes
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
class ActionCountDownButton @JvmOverloads constructor(
@@ -11,12 +11,6 @@ class ActionCountDownButton @JvmOverloads constructor(
attrs: AttributeSet? = null,
defStyle: Int = 0
) : MaterialButton(context, attrs, defStyle) {
@StringRes
private var enabledText = 0
@StringRes
private var disabledText = 0
private var countDownToTime: Long = 0
private var listener: Listener? = null
@@ -30,8 +24,8 @@ class ActionCountDownButton @JvmOverloads constructor(
}
}
private fun setActionEnabled() {
setText(enabledText)
fun setCallEnabled() {
setText(R.string.RegistrationActivity_call)
isEnabled = true
alpha = 1.0f
}
@@ -44,11 +38,11 @@ class ActionCountDownButton @JvmOverloads constructor(
val totalRemainingSeconds = TimeUnit.MILLISECONDS.toSeconds(remainingMillis).toInt()
val minutesRemaining = totalRemainingSeconds / 60
val secondsRemaining = totalRemainingSeconds % 60
text = resources.getString(disabledText, minutesRemaining, secondsRemaining)
text = resources.getString(R.string.RegistrationActivity_call_me_instead_available_in, minutesRemaining, secondsRemaining)
listener?.onRemaining(this, totalRemainingSeconds)
postDelayed({ updateCountDown() }, 250)
} else {
setActionEnabled()
setCallEnabled()
}
}
@@ -56,11 +50,6 @@ class ActionCountDownButton @JvmOverloads constructor(
this.listener = listener
}
fun setTextResources(@StringRes enabled: Int, @StringRes disabled: Int) {
enabledText = enabled
disabledText = disabled
}
interface Listener {
fun onRemaining(view: ActionCountDownButton, secondsRemaining: Int)
}

View File

@@ -43,14 +43,12 @@ 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

@@ -1,36 +0,0 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
/**
* Displays a reminder message when the local username gets out of sync with
* what the server thinks our username is.
*/
class UsernameOutOfSyncReminder(context: Context) : Reminder(
null,
context.getString(R.string.UsernameOutOfSyncReminder__something_went_wrong)
) {
init {
addAction(
Action(
context.getString(R.string.UsernameOutOfSyncReminder__fix_now),
R.id.reminder_action_fix_username
)
)
}
override fun isDismissable(): Boolean {
return false
}
companion object {
@JvmStatic
fun isEligible(): Boolean {
return FeatureFlags.usernames() && SignalStore.phoneNumberPrivacy().isUsernameOutOfSync
}
}
}

View File

@@ -211,7 +211,6 @@ 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()
@@ -228,7 +227,6 @@ 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]
@@ -327,14 +325,12 @@ 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 (index > nextSegmentIndex - 1) segment.animationState =
Segment.AnimationState.IDLE
} else if (offset == 0) {
if (index == nextSegmentIndex) segment.animationState = Segment.AnimationState.IDLE
}
}

View File

@@ -38,6 +38,7 @@ 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,7 +2,6 @@ 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
@@ -11,7 +10,6 @@ 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,7 +10,6 @@ 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
@@ -28,7 +27,6 @@ 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,7 +7,6 @@ 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
@@ -16,7 +15,6 @@ 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,7 +6,6 @@ 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
@@ -21,7 +20,6 @@ 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,
@@ -89,9 +87,8 @@ abstract class DSLSettingsFragment(
}
override fun onDestroyView() {
recyclerView = null
toolbar = null
super.onDestroyView()
recyclerView = null
}
fun setTitle(@StringRes resId: Int) {

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,6 +46,7 @@ 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,6 +59,7 @@ 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

@@ -45,7 +45,7 @@ class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_num
task.addOnSuccessListener {
Log.i(TAG, "Successfully registered SMS listener.")
navigateToVerify(smsListenerEnabled = true)
navigateToVerify()
}
task.addOnFailureListener { e ->
@@ -57,8 +57,8 @@ class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_num
}
}
private fun navigateToVerify(smsListenerEnabled: Boolean = false) {
findNavController().safeNavigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment, ChangeNumberVerifyFragmentArgs.Builder().setSmsListenerEnabled(smsListenerEnabled).build().toBundle())
private fun navigateToVerify() {
findNavController().safeNavigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment)
}
companion object {

View File

@@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.LabeledEditText
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel.ContinueStatus
import org.thoughtcrime.securesms.databinding.FragmentChangeNumberEnterPhoneNumberBinding
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
import org.thoughtcrime.securesms.registration.util.ChangeNumberInputController
@@ -26,30 +25,19 @@ private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number) {
private var binding: FragmentChangeNumberEnterPhoneNumberBinding? = null
private lateinit var scrollView: ScrollView
private val scrollView: ScrollView
get() = binding!!.changeNumberEnterPhoneNumberScroll
private lateinit var oldNumberCountrySpinner: Spinner
private lateinit var oldNumberCountryCode: LabeledEditText
private lateinit var oldNumber: LabeledEditText
private val oldNumberCountrySpinner: Spinner
get() = binding!!.changeNumberEnterPhoneNumberOldNumberSpinner
private val oldNumberCountryCode: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberOldNumberCountryCode
private val oldNumber: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberOldNumberNumber
private val newNumberCountrySpinner: Spinner
get() = binding!!.changeNumberEnterPhoneNumberNewNumberSpinner
private val newNumberCountryCode: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberNewNumberCountryCode
private val newNumber: LabeledEditText
get() = binding!!.changeNumberEnterPhoneNumberNewNumberNumber
private lateinit var newNumberCountrySpinner: Spinner
private lateinit var newNumberCountryCode: LabeledEditText
private lateinit var newNumber: LabeledEditText
private lateinit var viewModel: ChangeNumberViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentChangeNumberEnterPhoneNumberBinding.bind(view)
viewModel = getViewModel(this)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
@@ -60,6 +48,12 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
onContinue()
}
scrollView = view.findViewById(R.id.change_number_enter_phone_number_scroll)
oldNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_old_number_spinner)
oldNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_old_number_country_code)
oldNumber = view.findViewById(R.id.change_number_enter_phone_number_old_number_number)
val oldController = ChangeNumberInputController(
requireContext(),
oldNumberCountryCode,
@@ -93,6 +87,10 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
}
)
newNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_new_number_spinner)
newNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_new_number_country_code)
newNumber = view.findViewById(R.id.change_number_enter_phone_number_new_number_number)
val newController = ChangeNumberInputController(
requireContext(),
newNumberCountryCode,
@@ -138,11 +136,6 @@ class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_c
viewModel.getLiveNewNumber().observe(viewLifecycleOwner, newController::updateNumber)
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
private fun onContinue() {
if (TextUtils.isEmpty(oldNumberCountryCode.text)) {
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_number_country_code), Toast.LENGTH_LONG).show()
@@ -168,9 +161,7 @@ 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

@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
import org.thoughtcrime.securesms.database.model.toProtoByteString
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
import org.thoughtcrime.securesms.keyvalue.CertificateType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.KbsRepository
@@ -38,7 +37,6 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
@@ -87,31 +85,16 @@ class ChangeNumberRepository(
fun ensureDecryptionsDrained(): Completable {
return Completable.create { emitter ->
val drainedListener = object : Runnable {
override fun run() {
emitter.onComplete()
ApplicationDependencies
.getIncomingMessageObserver()
.removeDecryptionDrainedListener(this)
}
}
emitter.setCancellable {
ApplicationDependencies
.getIncomingMessageObserver()
.removeDecryptionDrainedListener(drainedListener)
}
ApplicationDependencies
.getIncomingMessageObserver()
.addDecryptionDrainedListener(drainedListener)
.addDecryptionDrainedListener {
emitter.onComplete()
}
}.subscribeOn(Schedulers.single())
.timeout(15, TimeUnit.SECONDS)
}
fun changeNumber(sessionId: String? = null, recoveryPassword: String? = null, newE164: String, pniUpdateMode: Boolean = false): Single<ServiceResponse<VerifyResponse>> {
check((sessionId != null && recoveryPassword == null) || (sessionId == null && recoveryPassword != null))
fun changeNumber(code: String, newE164: String, pniUpdateMode: Boolean = false): Single<ServiceResponse<VerifyResponse>> {
return Single.fromCallable {
var completed = false
var attempts = 0
@@ -119,9 +102,9 @@ class ChangeNumberRepository(
while (!completed && attempts < 5) {
val (request: ChangePhoneNumberRequest, metadata: PendingChangeNumberMetadata) = createChangeNumberRequest(
sessionId = sessionId,
recoveryPassword = recoveryPassword,
code = code,
newE164 = newE164,
registrationLock = null,
pniUpdateMode = pniUpdateMode
)
@@ -144,7 +127,7 @@ class ChangeNumberRepository(
}
fun changeNumber(
sessionId: String,
code: String,
newE164: String,
pin: String,
tokenData: TokenData
@@ -170,9 +153,10 @@ class ChangeNumberRepository(
while (!completed && attempts < 5) {
val (request: ChangePhoneNumberRequest, metadata: PendingChangeNumberMetadata) = createChangeNumberRequest(
sessionId = sessionId,
code = code,
newE164 = newE164,
registrationLock = registrationLock
registrationLock = registrationLock,
pniUpdateMode = false
)
SignalStore.misc().setPendingChangeNumberMetadata(metadata)
@@ -269,8 +253,6 @@ class ChangeNumberRepository(
ApplicationDependencies.closeConnections()
ApplicationDependencies.getIncomingMessageObserver()
ApplicationDependencies.getJobManager().add(RefreshAttributesJob())
return rotateCertificates()
}
@@ -298,11 +280,10 @@ class ChangeNumberRepository(
@Suppress("UsePropertyAccessSyntax")
@WorkerThread
private fun createChangeNumberRequest(
sessionId: String? = null,
recoveryPassword: String? = null,
code: String,
newE164: String,
registrationLock: String? = null,
pniUpdateMode: Boolean = false
registrationLock: String?,
pniUpdateMode: Boolean
): ChangeNumberRequestData {
val selfIdentifier: String = SignalStore.account().requireAci().toString()
val aciProtocolStore: SignalProtocolStore = ApplicationDependencies.getProtocolStore().aci()
@@ -355,9 +336,8 @@ class ChangeNumberRepository(
}
val request = ChangePhoneNumberRequest(
sessionId,
recoveryPassword,
newE164,
code,
registrationLock,
pniIdentity.publicKey,
deviceMessages,
@@ -375,11 +355,5 @@ class ChangeNumberRepository(
return ChangeNumberRequestData(request, metadata)
}
fun verifyAccount(sessionId: String, code: String): Single<ServiceResponse<RegistrationSessionMetadataResponse>> {
return Single.fromCallable {
accountManager.verifyAccount(code, sessionId)
}.subscribeOn(Schedulers.io())
}
data class ChangeNumberRequestData(val changeNumberRequest: ChangePhoneNumberRequest, val pendingChangeNumberMetadata: PendingChangeNumberMetadata)
}

View File

@@ -7,17 +7,13 @@ import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import org.thoughtcrime.securesms.util.navigation.safeNavigate
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
@@ -52,31 +48,17 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
private fun requestCode() {
val mode = if (ChangeNumberVerifyFragmentArgs.fromBundle(requireArguments()).smsListenerEnabled) VerifyAccountRepository.Mode.SMS_WITH_LISTENER else VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER
val mccMncProducer = MccMncProducer(requireContext())
lifecycleDisposable += viewModel
.ensureDecryptionsDrained()
.onErrorComplete()
.andThen(viewModel.changeNumberWithRecoveryPassword())
.flatMap { changed ->
if (changed) {
Single.just(RequestCodeResult.RecoveryPasswordWorked)
} else {
viewModel.requestVerificationCode(mode, mccMncProducer.mcc, mccMncProducer.mnc)
.map { p -> RequestCodeResult.RequestedVerificationCode(p) }
}
}
.andThen(viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER))
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
if (result is RequestCodeResult.RecoveryPasswordWorked) {
changeNumberSuccess()
return@subscribe
}
val processor = (result as RequestCodeResult.RequestedVerificationCode).processor
.subscribe { processor ->
if (processor.hasResult()) {
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.localRateLimit()) {
Log.i(TAG, "Unable to request sms code due to local rate limit")
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.captchaRequired()) {
Log.i(TAG, "Unable to request sms code due to captcha required")
findNavController().safeNavigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
@@ -92,9 +74,4 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
}
private sealed interface RequestCodeResult {
object RecoveryPasswordWorked : RequestCodeResult
class RequestedVerificationCode(val processor: RegistrationSessionProcessor) : RequestCodeResult
}
}

View File

@@ -9,16 +9,13 @@ import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.KbsRepository
import org.thoughtcrime.securesms.pin.TokenData
import org.thoughtcrime.securesms.registration.RegistrationSessionProcessor
import org.thoughtcrime.securesms.registration.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.VerifyResponse
@@ -29,7 +26,6 @@ import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewMod
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.exceptions.IncorrectCodeException
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Objects
@@ -156,32 +152,11 @@ class ChangeNumberViewModel(
}
override fun verifyAccountWithoutRegistrationLock(): Single<ServiceResponse<VerifyResponse>> {
val sessionId = sessionId ?: throw IllegalStateException("No valid registration session")
return changeNumberRepository.verifyAccount(sessionId, textCodeEntered)
.map { RegistrationSessionProcessor.RegistrationSessionProcessorForVerification(it) }
.observeOn(AndroidSchedulers.mainThread())
.doOnSuccess {
if (it.hasResult()) {
setCanSmsAtTime(it.getNextCodeViaSmsAttempt())
setCanCallAtTime(it.getNextCodeViaCallAttempt())
}
}
.observeOn(Schedulers.io())
.flatMap { processor ->
if (processor.isAlreadyVerified() || processor.hasResult() && processor.isVerified()) {
changeNumberRepository.changeNumber(sessionId = sessionId, newE164 = number.e164Number)
} else if (processor.error == null) {
Single.just<ServiceResponse<VerifyResponse>>(ServiceResponse.forApplicationError(IncorrectCodeException(), 403, null))
} else {
Single.just<ServiceResponse<VerifyResponse>>(ServiceResponse.coerceError(processor.response))
}
}
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number)
}
override fun verifyAccountWithRegistrationLock(pin: String, kbsTokenData: TokenData): Single<ServiceResponse<VerifyResponse>> {
val sessionId = sessionId ?: throw IllegalStateException("No valid registration session")
return changeNumberRepository.changeNumber(sessionId, number.e164Number, pin, kbsTokenData)
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number, pin, kbsTokenData)
}
@WorkerThread
@@ -203,24 +178,6 @@ class ChangeNumberViewModel(
}
}
fun changeNumberWithRecoveryPassword(): Single<Boolean> {
val recoveryPassword = SignalStore.kbsValues().recoveryPassword
return if (SignalStore.kbsValues().hasPin() && recoveryPassword != null) {
changeNumberRepository.changeNumber(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
.map { r -> VerifyResponseWithoutKbs(r) }
.flatMap { p ->
if (p.hasResult()) {
onVerifySuccess(p).map { true }
} else {
Single.just(false)
}
}
} else {
Single.just(false)
}
}
class Factory(owner: SavedStateRegistryOwner) : AbstractSavedStateViewModelFactory(owner, null) {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {

View File

@@ -44,6 +44,7 @@ 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,6 +60,7 @@ 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.getStorageBreakdown()
val breakdown = SignalDatabase.media.storageBreakdown
consumer(listOf(breakdown.audioSize, breakdown.documentSize, breakdown.photoSize, breakdown.videoSize).sum())
}

View File

@@ -578,13 +578,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Clear Username education ui hint"),
onClick = {
SignalStore.uiHints().clearHasSeenUsernameEducation()
}
)
if (FeatureFlags.chatFilters()) {
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Chat Filters"))

View File

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

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