Compare commits

...

75 Commits

Author SHA1 Message Date
Alex Hart 4f0f0938d8 Bump version to 8.14.2 2026-06-05 16:38:39 -03:00
Alex Hart 0136971963 Update translations and other static files. 2026-06-05 16:25:51 -03:00
Michelle Tang f810d731dd Turn off KT. 2026-06-05 14:32:07 -04:00
Cody Henthorne 7c7c364fef Fix sending quoted voice notes in 1:1 chats via IndividualSendJobV2.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-05 14:28:16 -04:00
Alex Hart aa9591211b Bump version to 8.14.1 2026-06-04 16:21:46 -03:00
Alex Hart bbd48547e5 Update translations and other static files. 2026-06-04 16:08:30 -03:00
Cody Henthorne 757b521744 Add additional logging around message request interactions. 2026-06-04 13:59:47 -04:00
Cody Henthorne a6311c87c1 Cleanup bad notified state in background instead of during db migration. 2026-06-04 13:55:38 -04:00
Cody Henthorne 045bd9287b Fix incorrect quote and link preview compose layout. 2026-06-04 11:30:37 -04:00
Cody Henthorne f1a72dd01a Use CDN number instead of parsing identifier for attachment remote id. 2026-06-04 11:22:14 -04:00
Cody Henthorne af4d0a0ef0 Fix illegal session state crashes in receipt send flows. 2026-06-04 11:21:46 -04:00
Greyson Parrelli 7dcaa933f2 Rotate IndividuaSendJobV2 remote config. 2026-06-04 00:36:04 -04:00
Greyson Parrelli 2c88945e6b Fix sending messages to self when your session is deleted. 2026-06-04 00:35:40 -04:00
Greyson Parrelli f9b9ce6c14 Fix character swapping during backup restore. 2026-06-03 23:57:04 -04:00
Michelle Tang 1d8fbad17e Add additional unauthorized KT check. 2026-06-03 17:14:40 -04:00
Alex Hart 6872a14378 Bump version to 8.14.0 2026-06-03 15:18:58 -03:00
Alex Hart 3a1eb4bd88 Update translations and other static files. 2026-06-03 15:11:03 -03:00
Michelle Tang d9f93294e4 Turn on KT. 2026-06-03 15:06:31 -03:00
Greyson Parrelli f063c43b52 Fix session initialization in MessageService. 2026-06-03 15:06:31 -03:00
Michelle Tang a7ed672634 Stop unregistered KT failures. 2026-06-03 15:06:31 -03:00
Michelle Tang 1371663163 Add capability for KT username syncs. 2026-06-03 15:06:31 -03:00
Alex Hart 1f0c24a5d5 Add bank transfer fix for pill size and state readout. 2026-06-03 15:06:31 -03:00
Cody Henthorne b732cbe00b Disable video mirroring for screen share preview in calls. 2026-06-03 15:06:31 -03:00
dependabot[bot] 85d60dd0da Bump GH actions versions for checkout and stale. 2026-06-03 15:06:31 -03:00
andrew-signal c020bfeb7a Bump to libsignal v0.94.4 2026-06-03 15:06:31 -03:00
Greyson Parrelli 3ad446c6c9 Render legacy encryption error messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli bc9c560f96 Add support for optional remote build cache. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 2b54dc4715 Ensure we uppercase AEP entry, add tests. 2026-06-03 15:06:31 -03:00
Michelle Tang 1443457eca Fix pluralization of pin attempt string. 2026-06-03 15:06:31 -03:00
Cody Henthorne ffbc4465bb Improve attachment and delete database operations. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4e5ddad78f Fixed linkifying URLs with commas. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 47a69d667c Hide video overlay stub when binding text-only quoted messages. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 9d3a51def2 Check battery optimization status before showing general battery saver prompt. 2026-06-03 15:06:31 -03:00
Greyson Parrelli b8c964846c Add gradle cache support to CI. 2026-06-03 15:06:31 -03:00
jeffrey-signal b02210c166 Fix medium sized devices using bar navigation instead of rail navigation. 2026-06-03 15:06:31 -03:00
Greyson Parrelli c2f8261419 Use better index for story query. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 089d47936b Log query analysis of slow queries for internal users. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 48f55bba0a Add search to internal preferences. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 348387f2d0 Fix v2 message sync sends over gRPC. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 30c0ef255a Upgrade wire to 6.4.0 2026-06-03 15:06:31 -03:00
Greyson Parrelli 64d3ba9e5b Add android tools checksum to Dockerfile. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 930a2f052a Remove SDK guard on voice note comparison. 2026-06-03 15:06:31 -03:00
Greyson Parrelli efec070728 Force screen security flag on PIN screens. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 99aa8a602b Force a WAL checkpoint after message deletions. 2026-06-03 15:06:31 -03:00
Greyson Parrelli 4a68e0c469 Fix backup progress being stuck. 2026-06-03 15:06:31 -03:00
Michelle Tang be80619a3b Disappear 1:1 calls. 2026-06-03 15:06:31 -03:00
fethij a0d605d1b1 Fix integer division in failed-service-start heuristic 2026-06-03 15:06:31 -03:00
Alex Hart 64f30bff47 Use scrollable tab mode for donation receipt list to prevent text truncation.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 15:06:31 -03:00
Greyson Parrelli 843b656fb6 Use specific remote config for slow database notification. 2026-06-03 15:06:31 -03:00
Cody Henthorne f5f5bf0a67 Use view stubs for heavy, low use compose drafting views. 2026-06-03 15:06:31 -03:00
Cody Henthorne bf73954f42 Add indexes for slow database queries and optimize view-once query. 2026-06-03 13:55:36 -04:00
Cody Henthorne 1fd651ee50 Fix answered ringing group calls getting marked as missed. 2026-06-03 13:55:36 -04:00
Greyson Parrelli f76292769a Update MessageApiV2 to use libsignal-net. 2026-06-03 13:55:36 -04:00
Cody Henthorne 51c4afe5f5 Add missing unidentified status data to individual send v2 sync message. 2026-06-03 13:55:36 -04:00
Cody Henthorne fe435433fd Add missing handler for group request join/cancel update.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-06-03 13:55:36 -04:00
andrew-signal fa8098a9aa Bump to libsignal v0.94.2. 2026-06-03 13:55:36 -04:00
BarbossHack 81e09e65cb Fix resources.arsc comparison in reproducible script.
Resolves signalapp/Signal-Android#14817
2026-06-03 13:55:36 -04:00
jeffrey-signal 1059fcafba Fix minimum length hint on RegV5 PIN creation screen. 2026-06-03 13:55:36 -04:00
Cody Henthorne 91d3fa8ad5 Fix bugs around stalled media restore and add recovery logic. 2026-06-03 13:55:36 -04:00
Cody Henthorne 89bffe39ae Improve cold start performance. 2026-06-03 13:55:36 -04:00
Cody Henthorne de2a5ea440 Add internal slow database transaction logging and alerting. 2026-06-03 13:55:36 -04:00
Cody Henthorne a54d62c09d Fix db access on main thread during conversation open. 2026-06-03 13:55:36 -04:00
jeffrey-signal e8785218a5 Add RegV5 confirm PIN UI. 2026-06-03 13:55:36 -04:00
Cody Henthorne e6e6075c9b Fix potential Recipient.resolve on main thread for group thread list item. 2026-06-03 13:55:36 -04:00
Alex Hart 95b69faa58 Cap persisted image-editor undo history to avoid crash. 2026-06-03 13:55:36 -04:00
Cody Henthorne b7f09ef923 Retry delivery receipt send on generic IO failures. 2026-06-03 13:55:36 -04:00
Alex Hart 50884e144e Add linked device registration skeleton UI. 2026-06-03 13:55:35 -04:00
Alex Hart 274feb168e Fix story preview thumbnails not rendering in contact picker. 2026-06-03 13:55:35 -04:00
jeffrey-signal 5b1f5a2a20 Ensure consistent regV5 footer padding and elevation. 2026-06-03 13:55:35 -04:00
dependabot[bot] 2cb9685024 Bump actions/stale from 10.2.0 to 10.3.0 in the actions group.
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 13:55:35 -04:00
Greyson Parrelli 0f4fc74829 Reduce unit test parallelism. 2026-06-03 13:55:35 -04:00
Cody Henthorne f5e8e15785 Add index for notification state query. 2026-06-03 13:55:35 -04:00
jeffrey-signal 3ff1501090 Adaptive PIN entry screen. 2026-06-03 13:55:35 -04:00
Greyson Parrelli 0907898105 Have QA task use debug variants. 2026-06-03 13:55:35 -04:00
Cody Henthorne 5fd8101180 Fix spinner build. 2026-06-03 13:55:35 -04:00
337 changed files with 11452 additions and 3297 deletions
+16 -3
View File
@@ -5,7 +5,7 @@ on:
push:
branches:
- 'main'
- '7.**'
- '8.**'
permissions:
contents: read # to fetch code (actions/checkout)
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
@@ -27,13 +27,26 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Validate Gradle Wrapper
uses: gradle/actions/wrapper-validation@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
- name: Set up Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# Only 8.** branch builds write to the cache; everything else (PRs, etc.) reads only.
cache-read-only: ${{ !startsWith(github.ref, 'refs/heads/8.') }}
# Required to persist the Gradle configuration cache across runs.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build with Gradle
env:
SIGNAL_BUILD_CACHE_URL: ${{ secrets.SIGNAL_BUILD_CACHE_URL }}
SIGNAL_BUILD_CACHE_USER: ${{ secrets.SIGNAL_BUILD_CACHE_USER }}
SIGNAL_BUILD_CACHE_PASSWORD: ${{ secrets.SIGNAL_BUILD_CACHE_PASSWORD }}
SIGNAL_BUILD_CACHE_PUSH: ${{ startsWith(github.ref, 'refs/heads/8.') }}
run: ./gradlew qa
- name: Archive reports for failed build
+11 -3
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest-8-cores
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
@@ -28,7 +28,15 @@ jobs:
with:
distribution: temurin
java-version: 17
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6
# gh api repos/gradle/actions/commits/v6 --jq '.sha'
with:
# PR-only workflow: always read from the cache, never write.
cache-read-only: true
# Required to read the Gradle configuration cache persisted by 8.** builds.
cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Install NDK
run: echo "y" | ${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager --install "ndk;${{ env.NDK_VERSION }}"
@@ -53,7 +61,7 @@ jobs:
if: steps.cache-base.outputs.cache-hit != 'true'
run: mv app/build/outputs/apk/playProd/release/*arm64*.apk diffuse-base.apk
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
with:
submodules: true
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# gh api repos/actions/checkout/commits/v6 --jq '.sha'
- name: Build image
run: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
actions: write
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10
# gh api repos/actions/stale/commits/v10 --jq '.sha'
with:
days-before-stale: 60
+2 -2
View File
@@ -27,8 +27,8 @@ plugins {
val staticIps = Properties().apply { file("static-ips.properties").reader().use { load(it) } }
staticIps.stringPropertyNames().forEach { rootProject.extra[it] = staticIps.getProperty(it) }
val canonicalVersionCode = 1699
val canonicalVersionName = "8.13.1"
val canonicalVersionCode = 1702
val canonicalVersionName = "8.14.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -138,7 +138,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.CDN_3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -139,7 +139,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -146,7 +146,7 @@ object TestMessages {
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"image/webp",
null,
Optional.empty(),
@@ -170,7 +170,7 @@ object TestMessages {
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
SignalServiceAttachmentRemoteId.from("", Cdn.S3.cdnNumber),
"audio/aac",
null,
Optional.empty(),
@@ -133,7 +133,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyPair.generate()
@@ -157,7 +157,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
@@ -13,7 +13,8 @@ object AppCapabilities {
storage = storageCapable,
versionedExpirationTimer = true,
attachmentBackfill = true,
spqr = true
spqr = true,
usernameChangeSyncMessage = false // TODO(michelle): Turn on once all clients support it and add a migration
)
}
}
@@ -33,8 +33,10 @@ import net.zetetic.database.Logger;
import org.conscrypt.ConscryptSignal;
import org.greenrobot.eventbus.EventBus;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.AppForegroundObserver;
import org.signal.core.util.DiskUtil;
import org.signal.core.util.MemoryTracker;
import org.signal.core.util.Util;
import org.signal.core.util.concurrent.AnrDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
@@ -51,6 +53,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
import org.thoughtcrime.securesms.database.LogDatabase;
import org.thoughtcrime.securesms.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -59,6 +62,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
import org.thoughtcrime.securesms.glide.SignalGlideComponents;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
@@ -75,6 +79,7 @@ import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
import org.thoughtcrime.securesms.jobs.InAppPaymentAuthCheckJob;
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob;
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
import org.thoughtcrime.securesms.jobs.MessageSendLogCleanupJob;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
@@ -83,7 +88,6 @@ import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint;
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -107,10 +111,8 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.signal.core.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
@@ -121,7 +123,6 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.SqlCipherLogTarget;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.VersionTracker;
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
import org.whispersystems.signalservice.api.websocket.SignalWebSocket;
@@ -229,7 +230,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(MessageSendLogCleanupJob::enqueue)
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
@@ -420,6 +421,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
Environment.IS_LINK_AND_SYNC_AVAILABLE,
null,
context -> {
context.startActivity(new Intent(context, SubmitDebugLogActivity.class));
@@ -510,6 +512,8 @@ public class ApplicationContext extends Application implements AppForegroundObse
if (RemoteConfig.internalUser()) {
Tracer.getInstance().setMaxBufferSize(35_000);
}
SQLiteDatabase.setSlowWriteLoggingEnabled(RemoteConfig.slowDatabaseNotifications());
}
private void initializePeriodicTasks() {
@@ -31,7 +31,7 @@ fun Attachment.toAttachmentPointer(context: Context): AttachmentPointer? {
}
try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
var attachmentWidth = attachment.width
var attachmentHeight = attachment.height
@@ -38,8 +38,8 @@ object AvatarPickerStorage {
.getAllAvatars()
.filterIsInstance<Avatar.Photo>()
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.map { it.name }
val inDatabaseFileNames = photoAvatars.mapTo(mutableSetOf()) { PartAuthority.getAvatarPickerFilename(it.uri) }
val onDiskFileNames = avatarFiles.mapTo(mutableSetOf()) { it.name }
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
@@ -6,14 +6,18 @@
package org.thoughtcrime.securesms.backup
import androidx.annotation.WorkerThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.withContext
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
@@ -46,6 +50,8 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
@@ -61,7 +67,7 @@ object ArchiveUploadProgress {
/**
* Observe this to get updates on the current upload progress.
*/
val progress: Flow<ArchiveUploadProgressState> = _progress
val progress: SharedFlow<ArchiveUploadProgressState> = _progress
.throttleLatest(500.milliseconds) {
uploadProgress.state == ArchiveUploadProgressState.State.None ||
(uploadProgress.state == ArchiveUploadProgressState.State.UploadBackupFile && uploadProgress.backupFileUploadedBytes == 0L) ||
@@ -114,6 +120,11 @@ object ArchiveUploadProgress {
}
.onStart { emit(uploadProgress) }
.flowOn(Dispatchers.IO)
.shareIn(scope, SharingStarted.Eagerly, replay = 1)
init {
_progress.tryEmit(Unit)
}
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
@@ -31,6 +31,9 @@ import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.DiskSpaceNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
@@ -157,6 +160,22 @@ object ArchiveRestoreProgress {
update()
}
/**
* Self-heal hook for restores that appear active (banner showing, media still remaining) but have no jobs left actually working on them.
*/
fun checkForStalledRestore() {
SignalExecutors.BOUNDED.execute {
val stalled = SignalStore.backup.restoreState.isMediaRestoreOperation &&
SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L &&
AppDependencies.jobManager.areFactoriesEmpty(setOf(RestoreAttachmentJob.KEY, RestoreLocalAttachmentJob.KEY, CheckRestoreMediaLeftJob.KEY))
if (stalled) {
Log.w(TAG, "Detected a stalled media restore with no active jobs. Enqueueing a check job to recover.")
CheckRestoreMediaLeftJob.enqueueStalledRecoveryCheck()
}
}
}
fun clearLocalRestoreDirectoryError() {
SignalStore.backup.localRestoreDirectoryError = false
update()
@@ -1465,6 +1465,7 @@ object BackupRepository {
}
SignalDatabase.remappedRecords.clearCache()
SignalDatabase.remappedRecords.trimStaleMappings()
AppDependencies.recipientCache.clear()
AppDependencies.recipientCache.warmUp()
SignalDatabase.threads.clearCache()
@@ -123,7 +123,7 @@ fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): S
throw InvalidAttachmentException("empty content id")
}
SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber
SignalServiceAttachmentRemoteId.from(remoteLocation, cdn.cdnNumber) to cdn.cdnNumber
}
val key = Base64.decode(remoteKey)
@@ -87,7 +87,7 @@ fun FilePointer?.toLocalAttachment(
AttachmentType.TRANSIT -> {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
cdnNumber = locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!),
remoteId = SignalServiceAttachmentRemoteId.from(locatorInfo.transitCdnKey!!, locatorInfo.transitCdnNumber ?: Cdn.CDN_0.cdnNumber),
contentType = contentType,
key = locatorInfo.key.toByteArray(),
size = Optional.ofNullable(locatorInfo.size),
@@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
@@ -25,6 +26,7 @@ class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerList
override val dataFlow: Flow<ArchiveRestoreProgressState> by lazy {
ArchiveRestoreProgress
.stateFlow
.onStart { ArchiveRestoreProgress.checkForStalledRestore() }
.filter {
it.restoreStatus != RestoreStatus.NONE && (it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED)
}
@@ -38,10 +38,12 @@ import com.bumptech.glide.RequestManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.ui.view.Stub;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SettableFuture;
import org.signal.core.util.logging.Log;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.animation.AnimationStartListener;
@@ -63,7 +65,6 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
import org.signal.glide.decryptableuri.DecryptableUri;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
@@ -91,32 +92,33 @@ public class InputPanel extends ConstraintLayout
private static final long QUOTE_REVEAL_DURATION_MILLIS = 150;
private static final int FADE_TIME = 150;
private RecyclerView stickerSuggestion;
private QuoteView quoteView;
private LinkPreviewView linkPreview;
private EmojiToggle mediaKeyboard;
private ComposeText composeText;
private ImageButton quickCameraToggle;
private ImageButton quickAudioToggle;
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private View recordingContainer;
private View recordLockCancel;
private View composeContainer;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageTitle;
private FrameLayout composeTextContainer;
private RecyclerView stickerSuggestion;
private Stub<QuoteView> quoteViewStub;
private Stub<LinkPreviewView> linkPreviewStub;
private EmojiToggle mediaKeyboard;
private ComposeText composeText;
private ImageButton quickCameraToggle;
private ImageButton quickAudioToggle;
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private View recordingContainer;
private View recordLockCancel;
private View composeContainer;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageTitle;
private FrameLayout composeTextContainer;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator quoteAnimator;
private ValueAnimator editMessageAnimator;
private VoiceNoteDraftView voiceNoteDraftView;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator quoteAnimator;
private ValueAnimator editMessageAnimator;
private Stub<VoiceNoteDraftView> voiceNoteDraftViewStub;
private @Nullable Listener listener;
private boolean emojiVisible;
private boolean wallpaperEnabled;
private boolean hideForMessageRequestState;
private boolean hideForGroupState;
@@ -127,6 +129,12 @@ public class InputPanel extends ConstraintLayout
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
private MessageRecord messageToEdit;
private final Observer<VoiceNotePlaybackState> playbackStateObserverProxy = state -> {
if (voiceNoteDraftViewStub.resolved()) {
voiceNoteDraftViewStub.get().getPlaybackStateObserver().onChanged(state);
}
};
public InputPanel(Context context) {
super(context);
}
@@ -143,12 +151,10 @@ public class InputPanel extends ConstraintLayout
public void onFinishInflate() {
super.onFinishInflate();
View quoteDismiss = findViewById(R.id.quote_dismiss_stub);
this.composeContainer = findViewById(R.id.compose_bubble);
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);
this.quoteView = findViewById(R.id.quote_view);
this.linkPreview = findViewById(R.id.link_preview);
this.quoteViewStub = new Stub<>(findViewById(R.id.quote_view));
this.linkPreviewStub = new Stub<>(findViewById(R.id.link_preview));
this.mediaKeyboard = findViewById(R.id.emoji_toggle);
this.composeText = findViewById(R.id.embedded_text_editor);
this.composeTextContainer = findViewById(R.id.embedded_text_editor_container);
@@ -158,7 +164,7 @@ public class InputPanel extends ConstraintLayout
this.sendButton = findViewById(R.id.send_button);
this.recordingContainer = findViewById(R.id.recording_container);
this.recordLockCancel = findViewById(R.id.record_cancel);
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
this.voiceNoteDraftViewStub = new Stub<>(findViewById(R.id.voice_note_draft_view_stub));
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setHandler(this);
@@ -175,14 +181,6 @@ public class InputPanel extends ConstraintLayout
mediaKeyboard.setVisibility(View.VISIBLE);
emojiVisible = true;
quoteDismiss.setOnClickListener(v -> clearQuote());
linkPreview.setCloseClickedListener(() -> {
if (listener != null) {
listener.onLinkPreviewCanceled();
}
});
stickerSuggestionAdapter = new ConversationStickerSuggestionAdapter(Glide.with(this), this);
stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
@@ -197,7 +195,6 @@ public class InputPanel extends ConstraintLayout
this.listener = listener;
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
voiceNoteDraftView.setListener(listener);
if (Camera.getNumberOfCameras() > 0) {
quickCameraToggle.setOnClickListener(v -> listener.onQuickCameraToggleClicked());
@@ -214,34 +211,35 @@ public class InputPanel extends ConstraintLayout
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
this.quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true, null);
QuoteView quoteView = requireQuoteView();
quoteView.setQuote(requestManager, id, author, body, false, attachments, null, quoteType, true, null);
if (listener != null) {
this.quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
quoteView.setOnClickListener(v -> listener.onQuoteClicked(id, author.getId()));
}
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
: 0;
int originalHeight = quoteView.getVisibility() == VISIBLE ? quoteView.getMeasuredHeight() : 0;
this.quoteView.setVisibility(VISIBLE);
quoteView.setVisibility(VISIBLE);
int maxWidth = composeContainer.getWidth();
if (quoteView.getLayoutParams() instanceof MarginLayoutParams) {
MarginLayoutParams layoutParams = (MarginLayoutParams) quoteView.getLayoutParams();
maxWidth -= layoutParams.leftMargin + layoutParams.rightMargin;
}
this.quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
quoteView.measure(MeasureSpec.makeMeasureSpec(maxWidth, MeasureSpec.AT_MOST), 0);
if (quoteAnimator != null) {
quoteAnimator.cancel();
}
quoteAnimator = createHeightAnimator(quoteView, originalHeight, this.quoteView.getMeasuredHeight(), null);
quoteAnimator = createHeightAnimator(quoteView, originalHeight, quoteView.getMeasuredHeight(), null);
quoteAnimator.start();
if (this.linkPreview.getVisibility() == View.VISIBLE) {
if (linkPreviewStub.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
this.linkPreview.setCorners(cornerRadius, cornerRadius);
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
}
if (listener != null) {
@@ -250,6 +248,12 @@ public class InputPanel extends ConstraintLayout
}
public void clearQuote() {
if (!quoteViewStub.resolved()) {
return;
}
QuoteView quoteView = quoteViewStub.get();
if (quoteAnimator != null) {
quoteAnimator.cancel();
}
@@ -259,9 +263,9 @@ public class InputPanel extends ConstraintLayout
public void onAnimationEnd(Animator animation) {
quoteView.dismiss();
if (linkPreview.getVisibility() == View.VISIBLE) {
if (linkPreviewStub.getVisibility() == View.VISIBLE) {
int cornerRadius = readDimen(R.dimen.message_corner_radius);
linkPreview.setCorners(cornerRadius, cornerRadius);
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
}
}
});
@@ -273,6 +277,20 @@ public class InputPanel extends ConstraintLayout
}
}
private @NonNull QuoteView requireQuoteView() {
boolean wasResolved = quoteViewStub.resolved();
QuoteView quoteView = quoteViewStub.get();
if (!wasResolved) {
quoteView.setWallpaperEnabled(wallpaperEnabled);
View quoteDismiss = quoteView.findViewById(R.id.quote_dismiss_stub);
if (quoteDismiss != null) {
quoteDismiss.setOnClickListener(v -> clearQuote());
}
}
return quoteView;
}
private static ValueAnimator createHeightAnimator(@NonNull View view,
int originalHeight,
int finalHeight,
@@ -294,11 +312,12 @@ public class InputPanel extends ConstraintLayout
return animator;
}
public boolean hasSaveableContent() {
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
}
public Optional<QuoteModel> getQuote() {
if (!quoteViewStub.resolved()) {
return Optional.empty();
}
QuoteView quoteView = quoteViewStub.get();
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(),
quoteView.getAuthor().getId(),
@@ -314,41 +333,53 @@ public class InputPanel extends ConstraintLayout
}
public boolean hasLinkPreview() {
return linkPreview.getVisibility() == View.VISIBLE;
return linkPreviewStub.getVisibility() == View.VISIBLE;
}
public void setLinkPreviewLoading() {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLoading();
LinkPreviewView linkPreview = requireLinkPreview();
linkPreview.setVisibility(View.VISIBLE);
linkPreview.setLoading();
}
public void setLinkPreviewNoPreview(@Nullable LinkPreviewRepository.Error customError) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setNoPreview(customError);
LinkPreviewView linkPreview = requireLinkPreview();
linkPreview.setVisibility(View.VISIBLE);
linkPreview.setNoPreview(customError);
}
public void setLinkPreview(@NonNull RequestManager requestManager, @NonNull Optional<LinkPreview> preview) {
if (preview.isPresent()) {
this.linkPreview.setVisibility(View.VISIBLE);
this.linkPreview.setLinkPreview(requestManager, preview.get(), true);
LinkPreviewView linkPreview = requireLinkPreview();
linkPreview.setVisibility(View.VISIBLE);
linkPreview.setLinkPreview(requestManager, preview.get(), true);
} else {
this.linkPreview.setVisibility(View.GONE);
linkPreviewStub.setVisibility(View.GONE);
}
int cornerRadius = quoteView.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius)
: readDimen(R.dimen.message_corner_radius);
if (linkPreviewStub.resolved()) {
int cornerRadius = quoteViewStub.getVisibility() == VISIBLE ? readDimen(R.dimen.message_corner_collapse_radius) : readDimen(R.dimen.message_corner_radius);
linkPreviewStub.get().setCorners(cornerRadius, cornerRadius);
}
}
this.linkPreview.setCorners(cornerRadius, cornerRadius);
private @NonNull LinkPreviewView requireLinkPreview() {
boolean wasResolved = linkPreviewStub.resolved();
LinkPreviewView view = linkPreviewStub.get();
if (!wasResolved) {
view.setCloseClickedListener(() -> {
if (listener != null) listener.onLinkPreviewCanceled();
});
}
return view;
}
public void clickOnComposeInput() {
composeText.performClick();
}
public void setMediaKeyboard(@NonNull MediaKeyboard mediaKeyboard) {
this.mediaKeyboard.attach(mediaKeyboard);
}
public void setStickerSuggestions(@NonNull List<StickerRecord> stickers) {
stickerSuggestion.setVisibility(stickers.isEmpty() ? View.GONE : View.VISIBLE);
stickerSuggestionAdapter.setStickers(stickers);
@@ -403,7 +434,10 @@ public class InputPanel extends ConstraintLayout
quickCameraToggle.setColorFilter(iconTint);
composeText.setTextColor(textColor);
composeText.setHintTextColor(textHintColor);
quoteView.setWallpaperEnabled(enabled);
wallpaperEnabled = enabled;
if (quoteViewStub.resolved()) {
quoteViewStub.get().setWallpaperEnabled(enabled);
}
}
public void enterEditModeIfPossible(@NonNull RequestManager requestManager, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft, boolean clearQuote) {
@@ -493,7 +527,9 @@ public class InputPanel extends ConstraintLayout
if (messageToEdit != null) {
composeText.setText("");
messageToEdit = null;
quoteView.setMessageType(QuoteView.MessageType.PREVIEW);
if (quoteViewStub.resolved()) {
quoteViewStub.get().setMessageType(QuoteView.MessageType.PREVIEW);
}
clearQuote();
}
updateEditModeUi();
@@ -647,7 +683,7 @@ public class InputPanel extends ConstraintLayout
}
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
return voiceNoteDraftView.getPlaybackStateObserver();
return playbackStateObserverProxy;
}
public void setEnabled(boolean enabled) {
@@ -666,7 +702,7 @@ public class InputPanel extends ConstraintLayout
future.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
if (voiceNoteDraftView.getDraft() == null) {
if (!voiceNoteDraftViewStub.resolved() || voiceNoteDraftViewStub.get().getDraft() == null) {
fadeInNormalComposeViews();
}
}
@@ -680,10 +716,6 @@ public class InputPanel extends ConstraintLayout
mediaKeyboard.setToMedia();
}
public void setToIme() {
mediaKeyboard.setToIme();
}
@Override
public void onKeyEvent(KeyEvent keyEvent) {
composeText.dispatchKeyEvent(keyEvent);
@@ -715,20 +747,35 @@ public class InputPanel extends ConstraintLayout
public void setVoiceNoteDraft(@Nullable DraftTable.Draft voiceNoteDraft) {
if (voiceNoteDraft != null) {
VoiceNoteDraftView voiceNoteDraftView = requireVoiceNoteDraft();
voiceNoteDraftView.setDraft(voiceNoteDraft);
voiceNoteDraftView.setVisibility(VISIBLE);
hideNormalComposeViews();
fadeIn(buttonToggle);
buttonToggle.displayQuick(sendButton);
} else {
voiceNoteDraftView.clearDraft();
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
if (voiceNoteDraftViewStub.resolved()) {
VoiceNoteDraftView voiceNoteDraftView = voiceNoteDraftViewStub.get();
voiceNoteDraftView.clearDraft();
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
}
fadeInNormalComposeViews();
}
}
public @Nullable DraftTable.Draft getVoiceNoteDraft() {
return voiceNoteDraftView.getDraft();
if (!voiceNoteDraftViewStub.resolved()) return null;
return voiceNoteDraftViewStub.get().getDraft();
}
private @NonNull VoiceNoteDraftView requireVoiceNoteDraft() {
boolean wasResolved = voiceNoteDraftViewStub.resolved();
VoiceNoteDraftView voiceNoteDraftView = voiceNoteDraftViewStub.get();
if (!wasResolved) {
voiceNoteDraftView.setListener(listener);
}
return voiceNoteDraftView;
}
private void hideNormalComposeViews() {
@@ -434,6 +434,7 @@ public class QuoteView extends ConstraintLayout implements RecipientForeverObser
if (TextUtils.isEmpty(quoteTargetContentType)) {
thumbnailView.setVisibility(GONE);
attachmentVideoOVerlayStub.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
@@ -81,6 +81,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.core.ui.R as CoreUiR
class AppSettingsFragment : ComposeFragment(), Callbacks {
@@ -295,7 +296,7 @@ private fun AppSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.AccountSettingsFragment__account),
icon = painterResource(R.drawable.symbol_person_circle_24),
icon = painterResource(CoreUiR.drawable.symbol_person_circle_24),
onClick = {
callbacks.navigate(AppSettingsRoute.AccountRoute.Account)
}
@@ -305,7 +306,7 @@ private fun AppSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.preferences__linked_devices),
icon = painterResource(R.drawable.symbol_devices_24),
icon = painterResource(CoreUiR.drawable.symbol_devices_24),
onClick = {
callbacks.navigate(AppSettingsRoute.LinkDeviceRoute.LinkDevice)
},
@@ -242,7 +242,7 @@ private fun BackupsSettingsContent(
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups),
icon = ImageVector.vectorResource(R.drawable.symbol_device_phone_24),
icon = ImageVector.vectorResource(CoreUiR.drawable.symbol_device_phone_24),
label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to),
onClick = onOnDeviceBackupsRowClick
)
@@ -6,9 +6,12 @@ import android.content.Context
import android.content.DialogInterface
import android.os.Build
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.widget.SearchView
import androidx.appcompat.widget.Toolbar
import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
@@ -74,8 +77,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
import java.util.UUID
@@ -84,13 +89,14 @@ import kotlin.math.max
import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences, R.menu.internal_settings) {
companion object {
private val TAG = Log.tag(InternalSettingsFragment::class.java)
}
private lateinit var viewModel: InternalSettingsViewModel
private var searchMenuItem: MenuItem? = null
private var scrollToPosition: Int = 0
private val layoutManager: LinearLayoutManager?
@@ -107,6 +113,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
scrollToPosition = SignalStore.internal.lastScrollPosition
initializeSearch(view)
setFragmentResultListener(CallQualityBottomSheetFragment.REQUEST_KEY) { _, bundle ->
if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) {
@@ -125,8 +132,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel = ViewModelProvider(this, factory)[InternalSettingsViewModel::class.java]
viewModel.state.observe(viewLifecycleOwner) {
adapter.submitList(getConfiguration(it).toMappingModelList()) {
if (scrollToPosition != 0) {
val mappingModelList = getConfiguration(it).toMappingModelList()
val filteredList = viewModel.filterPreferences(requireContext(), mappingModelList, it.searchQuery)
adapter.submitList(filteredList) {
if (scrollToPosition != 0 && it.searchQuery.isBlank()) {
layoutManager?.scrollToPositionWithOffset(scrollToPosition, 0)
scrollToPosition = 0
}
@@ -134,6 +144,56 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
}
override fun onToolbarNavigationClicked() {
if (searchMenuItem?.isActionViewExpanded == true) {
searchMenuItem?.collapseActionView()
} else {
super.onToolbarNavigationClicked()
}
}
private fun initializeSearch(view: View) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
searchMenuItem = toolbar.menu.findItem(R.id.menu_search)
val searchView: SearchView = searchMenuItem?.actionView as? SearchView ?: return
val queryListener = object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
searchView.clearFocus()
viewModel.setSearchQuery(query.orEmpty())
return true
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.setSearchQuery(newText.orEmpty())
return true
}
}
searchView.maxWidth = Integer.MAX_VALUE
searchView.queryHint = getString(R.string.CameraContacts__menu_search)
searchMenuItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
searchView.setIncognitoKeyboardEnabled(TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()))
searchView.setOnQueryTextListener(queryListener)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
searchView.setOnQueryTextListener(null)
searchView.setQuery("", false)
viewModel.setSearchQuery("")
return true
}
})
val currentQuery = viewModel.state.value?.searchQuery.orEmpty()
if (currentQuery.isNotBlank() && searchMenuItem?.expandActionView() == true) {
searchView.setQuery(currentQuery, false)
}
}
private fun getConfiguration(state: InternalSettingsState): DSLConfiguration {
return configure {
sectionHeaderPref(DSLSettingsText.from("Account"))
@@ -32,5 +32,6 @@ data class InternalSettingsState(
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val useNewMediaActivity: Boolean,
val disableInternalUser: Boolean
val disableInternalUser: Boolean,
val searchQuery: String = ""
)
@@ -1,10 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.internal
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.components.settings.DividerPreference
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.SectionHeaderPreference
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.keyvalue.InternalValues
@@ -12,7 +16,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Locale
class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() {
private val preferenceDataStore = SignalStore.getPreferenceDataStore()
@@ -167,7 +174,47 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
}
fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
store.update { getState().copy(emojiVersion = it.emojiVersion, searchQuery = it.searchQuery) }
}
fun setSearchQuery(query: String) {
store.update {
if (it.searchQuery == query) {
it
} else {
it.copy(searchQuery = query)
}
}
}
fun filterPreferences(context: Context, items: MappingModelList, query: String): MappingModelList {
val normalizedQuery = query.trim().lowercase(Locale.getDefault())
if (normalizedQuery.isBlank()) {
return items
}
val groups = buildSearchGroups(items)
val filtered = MappingModelList()
groups.forEach { group ->
val headerMatches = group.header?.searchableText(context)?.contains(normalizedQuery) == true
val matchingItems = if (headerMatches) {
group.items
} else {
group.items.filter { it.searchableText(context)?.contains(normalizedQuery) == true }
}
if (headerMatches || matchingItems.isNotEmpty()) {
if (filtered.isNotEmpty() && group.divider != null) {
filtered.add(group.divider)
}
group.header?.let { filtered.add(it) }
filtered.addAll(matchingItems)
}
}
return filtered
}
private fun getState() = InternalSettingsState(
@@ -225,6 +272,57 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
private fun buildSearchGroups(items: MappingModelList): List<SearchGroup> {
val groups = mutableListOf<SearchGroup>()
var divider: DividerPreference? = null
var header: SectionHeaderPreference? = null
var groupItems = mutableListOf<MappingModel<*>>()
fun flush() {
if (header != null || groupItems.isNotEmpty()) {
groups.add(SearchGroup(divider, header, groupItems))
}
divider = null
header = null
groupItems = mutableListOf()
}
items.forEach { item ->
when (item) {
is DividerPreference -> {
flush()
divider = item
}
is SectionHeaderPreference -> {
flush()
header = item
}
else -> groupItems.add(item)
}
}
flush()
return groups
}
private fun MappingModel<*>.searchableText(context: Context): String? {
return if (this is PreferenceModel<*>) {
listOfNotNull(title, summary)
.joinToString(separator = " ") { it.resolve(context).toString() }
.lowercase(Locale.getDefault())
} else {
null
}
}
private data class SearchGroup(
val divider: DividerPreference?,
val header: SectionHeaderPreference?,
val items: List<MappingModel<*>>
)
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository)))
@@ -162,11 +162,18 @@ class ManageDonationsViewModel : ViewModel() {
private fun deriveRedemptionState(status: DonationRedemptionJobStatus, latestPayment: InAppPaymentTable.InAppPayment?): ManageDonationsState.RedemptionState {
return when (status) {
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
DonationRedemptionJobStatus.PendingKeepAlive -> ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
DonationRedemptionJobStatus.PendingKeepAlive -> {
if (latestPayment.isPendingBankTransfer()) {
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER
} else {
ManageDonationsState.RedemptionState.SUBSCRIPTION_REFRESH
}
}
is DonationRedemptionJobStatus.PendingExternalVerification -> {
if (latestPayment != null && (latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT || latestPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL)) {
if (latestPayment.isPendingBankTransfer()) {
ManageDonationsState.RedemptionState.IS_PENDING_BANK_TRANSFER
} else {
ManageDonationsState.RedemptionState.IN_PROGRESS
@@ -178,6 +185,10 @@ class ManageDonationsViewModel : ViewModel() {
}
}
private fun InAppPaymentTable.InAppPayment?.isPendingBankTransfer(): Boolean {
return this != null && (data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT || data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL)
}
private fun InAppPaymentTable.InAppPayment.toPendingOneTimeDonation(): PendingOneTimeDonation? {
if (type.recurring || data.amount == null || data.badge == null) {
return null
@@ -7,7 +7,12 @@ package org.thoughtcrime.securesms.components.settings.conversation
import androidx.annotation.WorkerThread
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
@@ -98,18 +103,18 @@ data class InternalConversationSettingsState(
val capabilities: RecipientRecord.Capabilities? = SignalDatabase.recipients.getCapabilities(recipient.id)
if (capabilities != null) {
AnnotatedString("No capabilities right now.")
// Left as an example in case we add one in the future
// val style: SpanStyle = when (capabilities.storageServiceEncryptionV2) {
// Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
// Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
// Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
// }
//
// buildAnnotatedString {
// withStyle(style = style) {
// append("SSREv2")
// }
// }
// Always leave one as an example in case we add one in the future
val style: SpanStyle = when (capabilities.usernameSyncMessages) {
Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
}
buildAnnotatedString {
withStyle(style = style) {
append("usernameSyncMessages")
}
}
} else {
AnnotatedString("Recipient not found!")
}
@@ -7,11 +7,13 @@ import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.databinding.ConversationSettingsCallPreferenceItemBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.visible
/**
* Renders a single call preference row when displaying call info.
@@ -41,6 +43,25 @@ object CallPreference {
binding.callIcon.setImageResource(getCallIcon(model.call))
binding.callType.text = getCallType(model.call)
binding.callTime.text = getCallTime(model.record)
presentTimer(model.record)
}
private fun presentTimer(messageRecord: MessageRecord) {
if (messageRecord.expiresIn > 0) {
binding.callTimer.visible = true
binding.callTimer.setPercentComplete(0f)
if (messageRecord.expireStarted > 0) {
binding.callTimer.setExpirationTime(messageRecord.expireStarted, messageRecord.expiresIn)
binding.callTimer.startAnimation()
if (messageRecord.expireStarted + messageRecord.expiresIn <= System.currentTimeMillis()) {
AppDependencies.expiringMessageManager.checkSchedule()
}
}
} else {
binding.callTimer.visible = false
}
}
@DrawableRes
@@ -105,7 +105,7 @@ object RecipientPreference {
} else {
if (recipient.isSystemContact) {
SpannableStringBuilder(recipient.getDisplayName(context)).apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
val drawable = context.requireDrawable(CoreUiR.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
@@ -7,7 +7,6 @@ import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Process;
@@ -116,7 +115,7 @@ public class VoiceNotePlaybackService extends MediaSessionService {
@Nullable
@Override
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
if (Build.VERSION.SDK_INT >= 28 && controllerInfo.getUid() != Process.myUid()) {
if (controllerInfo.getUid() != Process.myUid()) {
Log.w(TAG, "Denying session to external caller: " + controllerInfo.getPackageName());
return null;
}
@@ -221,7 +221,7 @@ private fun Title(
style = MaterialTheme.typography.headlineMedium
)
Icon(
painter = painterResource(id = R.drawable.symbol_person_circle_24),
painter = painterResource(id = CoreUiR.drawable.symbol_person_circle_24),
contentDescription = null,
modifier = Modifier
.padding(start = 6.dp)
@@ -190,7 +190,7 @@ fun SelfPipContent(
Box(modifier = modifier) {
VideoRenderer(
participant = participant,
mirror = participant.cameraDirection == CameraState.Direction.FRONT,
mirror = !participant.isScreenSharing && participant.cameraDirection == CameraState.Direction.FRONT,
modifier = Modifier.fillMaxSize()
)
@@ -543,7 +543,7 @@ private fun LargeLocalVideoRenderer(
participant = localParticipant,
renderInPip = false,
raiseHandAllowed = false,
mirrorVideo = localParticipant.cameraDirection == CameraState.Direction.FRONT,
mirrorVideo = !localParticipant.isScreenSharing && localParticipant.cameraDirection == CameraState.Direction.FRONT,
showAudioIndicator = false,
onInfoMoreInfoClick = null,
modifier = modifier
@@ -49,6 +49,7 @@ import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
import org.thoughtcrime.securesms.events.CallParticipant
import org.signal.core.ui.R as CoreUiR
/**
* Small moveable local video renderer that displays the user's video in a draggable and expandable view.
@@ -160,7 +161,7 @@ fun MoveableLocalVideoRenderer(
) {
Icon(
imageVector = ImageVector.vectorResource(
if (isFocused) R.drawable.symbol_minimize_24 else R.drawable.symbol_maximize_24
if (isFocused) R.drawable.symbol_minimize_24 else CoreUiR.drawable.symbol_maximize_24
),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
contentDescription = stringResource(
@@ -75,7 +75,7 @@ fun PictureInPictureCallScreen(
renderInPip = true,
raiseHandAllowed = false,
onInfoMoreInfoClick = null,
mirrorVideo = isFullScreenLocalParticipant,
mirrorVideo = isFullScreenLocalParticipant && !fullScreenParticipant.isScreenSharing,
modifier = Modifier.fillMaxSize()
)
@@ -643,7 +643,7 @@ object ContactSearchModels {
val recipient = getRecipient(model)
val suffix: CharSequence? = if (recipient.isSystemContact && !recipient.showVerified) {
SpannableStringBuilder().apply {
val drawable = context.requireDrawable(R.drawable.symbol_person_circle_24).apply {
val drawable = context.requireDrawable(CoreUiR.drawable.symbol_person_circle_24).apply {
setTint(ContextCompat.getColor(context, CoreUiR.color.signal_colorOnSurface))
}
SpanUtil.appendCenteredImageSpan(this, drawable, 16, 16)
@@ -10,7 +10,7 @@ public enum AttachmentKeyboardButton {
GALLERY(R.string.AttachmentKeyboard_gallery, R.drawable.symbol_album_tilt_24),
FILE(R.string.AttachmentKeyboard_file, R.drawable.symbol_file_24),
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.symbol_payment_24),
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.symbol_person_circle_24),
CONTACT(R.string.AttachmentKeyboard_contact, org.signal.core.ui.R.drawable.symbol_person_circle_24),
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24),
POLL(R.string.AttachmentKeyboard_poll, R.drawable.symbol_poll_24);
@@ -19,7 +19,6 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
@@ -104,6 +103,7 @@ public final class ConversationUpdateItem extends FrameLayout
private EventListener eventListener;
private Button collapsedButton;
private float lastYDownRelativeToThis;
private int tint;
private final UpdateObserver updateObserver = new UpdateObserver();
@@ -221,6 +221,8 @@ public final class ConversationUpdateItem extends FrameLayout
observeDisplayBody(lifecycleOwner, spannableMessage);
observeDisplayBodyWithTimer(lifecycleOwner);
this.tint = updateDescription.getTint(getContext());
boolean donationRequest = conversationMessage.getMessageRecord().isReleaseChannelDonationRequest();
present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted);
@@ -487,8 +489,9 @@ public final class ConversationUpdateItem extends FrameLayout
SpannableStringBuilder builder = new SpannableStringBuilder(displayBody);
int color = tint != 0 ? tint : ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary);
if (latestFrame != 0) {
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary));
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), color);
SpanUtil.appendCenteredImageSpan(builder, drawable, 12, 12);
}
@@ -784,7 +787,7 @@ public final class ConversationUpdateItem extends FrameLayout
return false;
}
return (messageRecord.isCollapsedGroupV2JoinUpdate() && !nextMessageRecord.map(m -> m.isGroupV2JoinRequest(toBlock.requireServiceId())).orElse(false)) ||
return (messageRecord.isCollapsedGroupV2JoinUpdate(toBlock.requireServiceId()) && !nextMessageRecord.map(m -> m.isGroupV2JoinRequest(toBlock.requireServiceId())).orElse(false)) ||
(messageRecord.isGroupV2JoinRequest(toBlock.requireServiceId()) && previousMessageRecord.map(m -> m.isCollapsedGroupV2JoinUpdate(toBlock.requireServiceId())).orElse(false));
}
@@ -41,6 +41,10 @@ class ExpirationTimer(
}
fun calculateProgress(): Float {
if (startedAt == 0L) {
return 0f
}
val progressed = System.currentTimeMillis() - startedAt
val percentComplete = progressed.toFloat() / expiresIn.toFloat()
@@ -2882,10 +2882,11 @@ class ConversationFragment :
requireContext(),
recipient,
{
val disabledInput = binding.conversationDisabledInput
messageRequestViewModel
.onReportSpam()
.doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.doOnSubscribe { disabledInput.showBusy() }
.doOnTerminate { disabledInput.hideBusy() }
.subscribeBy {
Log.d(TAG, "report spam complete")
toast(R.string.ConversationFragment_reported_as_spam)
@@ -2895,10 +2896,11 @@ class ConversationFragment :
null
} else {
Runnable {
val disabledInput = binding.conversationDisabledInput
messageRequestViewModel
.onBlockAndReportSpam()
.doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
.doOnSubscribe { disabledInput.showBusy() }
.doOnTerminate { disabledInput.hideBusy() }
.subscribeBy { result ->
when (result) {
is Result.Success -> {
@@ -2957,7 +2959,6 @@ class ConversationFragment :
messageRequestViewModel
.onAccept()
.subscribeWithShowProgress("accept message request")
.addTo(disposables)
}
private fun onDeleteConversation() {
@@ -2976,8 +2977,9 @@ class ConversationFragment :
}
private fun Single<Result<Unit, GroupChangeFailureReason>>.subscribeWithShowProgress(logMessage: String): Disposable {
return doOnSubscribe { binding.conversationDisabledInput.showBusy() }
.doOnTerminate { binding.conversationDisabledInput.hideBusy() }
val disabledInput = binding.conversationDisabledInput
return doOnSubscribe { disabledInput.showBusy() }
.doOnTerminate { disabledInput.hideBusy() }
.subscribeBy { result ->
when (result) {
is Result.Success -> Log.d(TAG, "$logMessage complete")
@@ -246,6 +246,7 @@ class ConversationViewModel(
disposables += chatColors.update(chatColorsDataObservable.toFlowable(BackpressureStrategy.LATEST)) { c, _ -> c }
disposables += repository.getConversationThreadState(threadId, requestedStartingPosition)
.subscribeOn(Schedulers.io())
.subscribeBy(onSuccess = {
pagingController.set(it.items.controller)
_conversationThreadState.onNext(it)
@@ -16,6 +16,7 @@ import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView
@@ -39,6 +40,10 @@ class DisabledInputView @JvmOverloads constructor(
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
companion object {
private val TAG = Log.tag(DisabledInputView::class.java)
}
private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) }
private var expiredOrUnauthorized: View? = null
@@ -93,30 +98,51 @@ class DisabledInputView @JvmOverloads constructor(
setWallpaperEnabled(recipient.hasWallpaper)
setAcceptOnClickListener {
Log.i(TAG, "[message-request] Accept tapped. isIndividual: ${messageRequestState.isIndividual}, isGroupV2Add: ${messageRequestState.isGroupV2Add}, listener present: ${listener != null}")
if (messageRequestState.isIndividual) {
val signalWillNever = context.getString(R.string.MessageRequestBottomView_signal_will_never)
val body = context.getString(R.string.MessageRequestBottomView_accept_request_body, signalWillNever)
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MessageRequestBottomView_accept_request)
.setMessage(SpanUtil.boldSubstring(body, signalWillNever))
.setPositiveButton(R.string.MessageRequestBottomView_accept) { _, _ -> listener?.onAcceptMessageRequestClicked() }
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(false)
.setPositiveButton(R.string.MessageRequestBottomView_accept) { _, _ ->
Log.i(TAG, "[message-request] Individual request confirmed. listener present: ${listener != null}")
listener?.onAcceptMessageRequestClicked()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> Log.i(TAG, "[message-request] Individual request canceled.") }
.show()
} else if (messageRequestState.isGroupV2Add) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.MessageRequestBottomView_join_group)
.setMessage(R.string.MessageRequestBottomView_review_requests_carefully_groups)
.setPositiveButton(R.string.MessageRequestBottomView_join) { _, _ -> listener?.onAcceptMessageRequestClicked() }
.setNegativeButton(android.R.string.cancel, null)
.setCancelable(false)
.setPositiveButton(R.string.MessageRequestBottomView_join) { _, _ ->
Log.i(TAG, "[message-request] Group join confirmed. listener present: ${listener != null}")
listener?.onAcceptMessageRequestClicked()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> Log.i(TAG, "[message-request] Group join canceled.") }
.show()
} else {
listener?.onAcceptMessageRequestClicked()
}
}
setDeleteOnClickListener { listener?.onDeleteClicked() }
setBlockOnClickListener { listener?.onBlockClicked() }
setUnblockOnClickListener { listener?.onUnblockClicked() }
setReportOnClickListener { listener?.onReportSpamClicked() }
setDeleteOnClickListener {
Log.i(TAG, "[message-request] Delete tapped. listener present: ${listener != null}")
listener?.onDeleteClicked()
}
setBlockOnClickListener {
Log.i(TAG, "[message-request] Block tapped. listener present: ${listener != null}")
listener?.onBlockClicked()
}
setUnblockOnClickListener {
Log.i(TAG, "[message-request] Unblock tapped. listener present: ${listener != null}")
listener?.onUnblockClicked()
}
setReportOnClickListener {
Log.i(TAG, "[message-request] Report tapped. listener present: ${listener != null}")
listener?.onReportSpamClicked()
}
}
)
}
@@ -167,6 +167,7 @@ import org.signal.core.util.ServiceUtil;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
@@ -435,7 +436,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
onSearchQueryUpdated(query);
}
if (SignalStore.settings().getAutomaticVerificationEnabled() &&
if (SignalStore.account().isRegistered() &&
!TextSecurePreferences.isUnauthorizedReceived(requireContext()) &&
SignalStore.settings().getAutomaticVerificationEnabled() &&
SignalStore.misc().getHasKeyTransparencyFailure() &&
!SignalStore.misc().getHasSeenKeyTransparencyFailure()) {
SelfVerificationFailureSheet.show(getParentFragmentManager());
@@ -245,7 +245,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
SpannableStringBuilder suffix = null;
if (appendSystemContactIcon && recipient.get().isSystemContact() && !recipient.get().getShowVerified()) {
suffix = new SpannableStringBuilder();
Drawable drawable = ContextUtil.requireDrawable(getContext(), R.drawable.symbol_person_circle_24);
Drawable drawable = ContextUtil.requireDrawable(getContext(), org.signal.core.ui.R.drawable.symbol_person_circle_24);
drawable.setTint(ContextCompat.getColor(getContext(), org.signal.core.ui.R.color.signal_colorOnSurface));
SpanUtil.appendCenteredImageSpan(suffix, drawable, 16, 16);
}
@@ -629,12 +629,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
return emphasisAdded(context, context.getString(R.string.ConversationListItem_key_exchange_message), defaultTint);
} else if (MessageTypes.isChatSessionRefresh(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.ThreadRecord_chat_session_refreshed), Glyph.REFRESH, defaultTint);
} else if (MessageTypes.isNoRemoteSessionType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session), defaultTint);
} else if (MessageTypes.isEndSessionType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.ThreadRecord_secure_session_reset), defaultTint);
} else if (MessageTypes.isLegacyType(thread.getType())) {
return emphasisAdded(context, context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported), defaultTint);
} else if (thread.isScheduledMessage()) {
return emphasisAdded(context, context.getString(R.string.ThreadRecord_scheduled_message), Glyph.CALENDAR, defaultTint);
} else if (MessageTypes.isDraftMessageType(thread.getType())) {
@@ -716,15 +712,15 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
MessageStyler.style(thread.getDate(), thread.getBodyRanges(), sourceBody);
CharSequence body = StringUtil.replace(sourceBody, '\n', " ");
LiveData<SpannableString> finalBody = Transformations.map(createFinalBodyWithMediaIcon(context, body, thread, requestManager, thumbSize, thumbTarget), updatedBody -> {
LiveData<SpannableString> finalBody = Transformations.switchMap(createFinalBodyWithMediaIcon(context, body, thread, requestManager, thumbSize, thumbTarget), updatedBody -> {
if (thread.getRecipient().isGroup()) {
RecipientId groupMessageSender = thread.getGroupMessageSender();
if (!groupMessageSender.isUnknown()) {
return createGroupMessageUpdateString(context, updatedBody, Recipient.resolved(groupMessageSender));
return Transformations.map(Recipient.live(groupMessageSender).getLiveDataResolved(), recipient -> createGroupMessageUpdateString(context, updatedBody, recipient));
}
}
return new SpannableString(updatedBody);
return LiveDataUtil.just(new SpannableString(updatedBody));
});
return whileLoadingShow(sourceBody, finalBody);
@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.database.Cursor
import org.signal.core.util.SqlUtil
import org.signal.core.util.Util
import org.signal.core.util.delete
import org.signal.core.util.insertInto
@@ -84,6 +85,23 @@ class AttachmentMetadataTable(context: Context, databaseHelper: SignalDatabase)
.run()
}
fun cleanupById(ids: Collection<Long>) {
if (ids.isEmpty()) {
return
}
val idClause = SqlUtil.buildSingleCollectionQuery(ID, ids)
val attachmentReferenceClause = SqlUtil.buildSingleCollectionQuery(AttachmentTable.METADATA_ID, ids)
writableDatabase
.delete(TABLE_NAME)
.where(
"${idClause.where} AND $ID NOT IN (SELECT ${AttachmentTable.METADATA_ID} FROM ${AttachmentTable.TABLE_NAME} WHERE ${attachmentReferenceClause.where})",
idClause.whereArgs + attachmentReferenceClause.whereArgs
)
.run()
}
fun insertNewKeysForExistingAttachments() {
writableDatabase.withinTransaction {
do {
@@ -298,7 +298,10 @@ class AttachmentTable(
"CREATE INDEX IF NOT EXISTS attachment_archive_transfer_state ON $TABLE_NAME ($ARCHIVE_TRANSFER_STATE);",
"CREATE INDEX IF NOT EXISTS attachment_remote_digest_index ON $TABLE_NAME ($REMOTE_DIGEST);",
"CREATE INDEX IF NOT EXISTS attachment_metadata_id ON $TABLE_NAME ($METADATA_ID);",
"CREATE INDEX IF NOT EXISTS attachment_media_overview_size ON $TABLE_NAME ($DATA_SIZE DESC, $DISPLAY_ORDER DESC) WHERE $QUOTE = 0 AND $STICKER_PACK_ID IS NULL AND $DATA_FILE IS NOT NULL"
"CREATE INDEX IF NOT EXISTS attachment_media_overview_size ON $TABLE_NAME ($DATA_SIZE DESC, $DISPLAY_ORDER DESC) WHERE $QUOTE = 0 AND $STICKER_PACK_ID IS NULL AND $DATA_FILE IS NOT NULL",
"CREATE INDEX IF NOT EXISTS attachment_archive_thumbnail_transfer_state ON $TABLE_NAME ($ARCHIVE_THUMBNAIL_TRANSFER_STATE)",
"CREATE INDEX IF NOT EXISTS attachment_thumbnail_file_index ON $TABLE_NAME ($THUMBNAIL_FILE) WHERE $THUMBNAIL_FILE IS NOT NULL",
"CREATE INDEX IF NOT EXISTS attachment_uuid_index ON $TABLE_NAME ($ATTACHMENT_UUID) WHERE $ATTACHMENT_UUID IS NOT NULL"
)
private val DATA_FILE_INFO_PROJECTION = arrayOf(
@@ -372,7 +375,7 @@ class AttachmentTable(
val hashEnd = Base64.encodeWithPadding(hash)
val (existingFile: String?, existingSize: Long?, existingRandom: ByteArray?) = db.select(DATA_FILE, DATA_SIZE, DATA_RANDOM)
.from(TABLE_NAME)
.from("$TABLE_NAME INDEXED BY $DATA_HASH_REMOTE_KEY_INDEX")
.where("$DATA_HASH_END = ? AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL AND $DATA_FILE != ?", hashEnd, file.absolutePath)
.limit(1)
.run()
@@ -1301,7 +1304,9 @@ class AttachmentTable(
val contentTypesToDelete: MutableSet<String> = mutableSetOf()
val deleteCount = writableDatabase.withinTransaction { db ->
db.select(DATA_FILE, CONTENT_TYPE, ID)
val metadataIdsToCleanup: MutableSet<Long> = mutableSetOf()
db.select(DATA_FILE, CONTENT_TYPE, ID, METADATA_ID)
.from(TABLE_NAME)
.where("$MESSAGE_ID = ?", mmsId)
.run()
@@ -1313,6 +1318,8 @@ class AttachmentTable(
val filePath = cursor.requireString(DATA_FILE)
val contentType = cursor.requireString(CONTENT_TYPE)
cursor.requireLongOrNull(METADATA_ID)?.let { metadataIdsToCleanup += it }
if (filePath != null && isSafeToDeleteDataFile(filePath, attachmentId)) {
filePathsToDelete += filePath
contentType?.let { contentTypesToDelete += it }
@@ -1323,7 +1330,7 @@ class AttachmentTable(
.where("$MESSAGE_ID = ?", mmsId)
.run()
SignalDatabase.attachmentMetadata.cleanup()
SignalDatabase.attachmentMetadata.cleanupById(metadataIdsToCleanup)
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
@@ -1367,7 +1374,9 @@ class AttachmentTable(
var threadId: Long = -1
writableDatabase.withinTransaction { db ->
db.select(DATA_FILE, CONTENT_TYPE, ID)
val metadataIdsToCleanup: MutableSet<Long> = mutableSetOf()
db.select(DATA_FILE, CONTENT_TYPE, ID, METADATA_ID)
.from(TABLE_NAME)
.where("$MESSAGE_ID = ?", messageId)
.run()
@@ -1375,6 +1384,7 @@ class AttachmentTable(
val filePath = cursor.requireString(DATA_FILE)
val contentType = cursor.requireString(CONTENT_TYPE)
val id = AttachmentId(cursor.requireLong(ID))
cursor.requireLongOrNull(METADATA_ID)?.let { metadataIdsToCleanup += it }
if (filePath != null && isSafeToDeleteDataFile(filePath, id)) {
filePathsToDelete += filePath
@@ -1408,7 +1418,7 @@ class AttachmentTable(
.where("$MESSAGE_ID = ?", messageId)
.run()
SignalDatabase.attachmentMetadata.cleanup()
SignalDatabase.attachmentMetadata.cleanupById(metadataIdsToCleanup)
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
@@ -1433,7 +1443,7 @@ class AttachmentTable(
var deletedMessageId: Long? = null
writableDatabase.withinTransaction { db ->
db.select(DATA_FILE, CONTENT_TYPE, MESSAGE_ID)
db.select(DATA_FILE, CONTENT_TYPE, MESSAGE_ID, METADATA_ID)
.from(TABLE_NAME)
.where("$ID = ?", id.id)
.run()
@@ -1446,12 +1456,13 @@ class AttachmentTable(
val filePath = cursor.requireString(DATA_FILE)
val contentType = cursor.requireString(CONTENT_TYPE)
deletedMessageId = cursor.requireLong(MESSAGE_ID)
val metadataId = cursor.requireLongOrNull(METADATA_ID)
db.delete(TABLE_NAME)
.where("$ID = ?", id.id)
.run()
SignalDatabase.attachmentMetadata.cleanup()
SignalDatabase.attachmentMetadata.cleanupById(listOfNotNull(metadataId))
if (filePath != null && isSafeToDeleteDataFile(filePath, id)) {
filePathsToDelete += filePath
@@ -1665,6 +1676,24 @@ class AttachmentTable(
}
}
/**
* Marks any restorable attachment whose [MESSAGE_ID] does not point to an existing message as failed.
* @return the number of rows updated
*/
fun markRestorableAttachmentsWithoutMessageAsFailed(): Int {
return writableDatabase
.update(TABLE_NAME)
.values(TRANSFER_STATE to TRANSFER_PROGRESS_FAILED)
.where(
"""
($TRANSFER_STATE = $TRANSFER_NEEDS_RESTORE OR $TRANSFER_STATE = $TRANSFER_RESTORE_IN_PROGRESS) AND
$MESSAGE_ID > 0 AND
$MESSAGE_ID NOT IN (SELECT ${MessageTable.ID} FROM ${MessageTable.TABLE_NAME})
"""
)
.run()
}
fun setRestoreTransferState(attachmentId: AttachmentId, state: Int) {
setRestoreTransferState(listOf(attachmentId), state)
}
@@ -1730,7 +1759,7 @@ class AttachmentTable(
// the quality of the attachment we received.
val hashMatch: DataFileInfo? = readableDatabase
.select(*DATA_FILE_INFO_PROJECTION)
.from(TABLE_NAME)
.from("$TABLE_NAME INDEXED BY $DATA_HASH_REMOTE_KEY_INDEX")
.where("$DATA_HASH_END = ? AND $DATA_HASH_END NOT NULL AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $DATA_FILE NOT NULL", fileWriteResult.hash)
.run()
.readToList { it.readDataFileInfo() }
@@ -30,6 +30,7 @@ import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.signal.ringrtc.CallId
import org.signal.ringrtc.CallManager.RingUpdate
import org.thoughtcrime.securesms.database.CallTable.Companion.TIMESTAMP
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
@@ -109,24 +110,66 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
)
}
fun markAllCallEventsRead(timestamp: Long = Long.MAX_VALUE) {
val now = System.currentTimeMillis()
val allUnreadMissedCalls = readableDatabase
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
}
val updateCount = writableDatabase
.update(TABLE_NAME)
.values(READ to ReadState.serialize(ReadState.READ))
.where("$TIMESTAMP <= ? AND $READ != ?", timestamp, ReadState.serialize(ReadState.READ))
.run()
val expiringCalls = SignalDatabase.messages.getUnstartedExpirations(allUnreadMissedCalls)
if (expiringCalls.isNotEmpty()) {
Log.i(TAG, "Found ${expiringCalls.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiringCalls.map { it.key to now })
for ((messageId, expiresIn) in expiringCalls) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, now, expiresIn)
}
}
if (updateCount > 0) {
notifyConversationListListeners()
}
}
fun markAllCallEventsWithPeerBeforeTimestampRead(peer: RecipientId, timestamp: Long): Call? {
val now = System.currentTimeMillis()
val latestCallAsOfTimestamp = writableDatabase.withinTransaction { db ->
val unreadMissedCalls = db
.select(MESSAGE_ID)
.from(TABLE_NAME)
.where("$PEER = ? AND $TIMESTAMP <= ? AND $READ != ? AND $EVENT = ?", peer.toLong(), timestamp, ReadState.serialize(ReadState.READ), Event.serialize(Event.MISSED))
.run()
.readToList { cursor ->
cursor.requireLong(MESSAGE_ID)
}
val updated = db.update(TABLE_NAME)
.values(READ to ReadState.serialize(ReadState.READ))
.where("$PEER = ? AND $TIMESTAMP <= ?", peer.toLong(), timestamp)
.run()
val expiring = SignalDatabase.messages.getUnstartedExpirations(unreadMissedCalls)
if (expiring.isNotEmpty()) {
Log.i(TAG, "Found ${expiring.size} calls that needs expiring.")
SignalDatabase.messages.markExpireStarted(expiring.map { it.key to now })
for ((messageId, expiresIn) in expiring) {
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, now, expiresIn)
}
}
if (updated == 0) {
null
} else {
@@ -157,7 +200,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
val messageType: Long = Call.getMessageType(type, direction, event)
writableDatabase.withinTransaction {
val result = SignalDatabase.messages.insertCallLog(peer, messageType, timestamp, direction == Direction.OUTGOING)
val result = SignalDatabase.messages.insertOneToOneCallLog(peer, messageType, timestamp, direction == Direction.OUTGOING)
val values = contentValuesOf(
CALL_ID to callId,
MESSAGE_ID to result.messageId,
@@ -202,7 +245,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
if (call.messageId == null) {
Log.w(TAG, "Call does not have an associated message id! No message to update.")
} else {
SignalDatabase.messages.updateCallLog(call.messageId, call.messageType)
SignalDatabase.messages.updateOneToOneCallLog(call.messageId, call.messageType)
}
AppDependencies.messageNotifier.updateNotification(context)
@@ -892,7 +935,7 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
Log.d(TAG, "Updating group call state: localJoined: $localJoined, isGroupCallActive: $isGroupCallActive")
return writableDatabase.update(TABLE_NAME)
val changed = writableDatabase.update(TABLE_NAME)
.values(
LOCAL_JOINED to localJoined,
GROUP_CALL_ACTIVE to isGroupCallActive
@@ -905,6 +948,16 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
isGroupCallActive.toInt()
)
.run() > 0
if (hasLocalUserJoined && !call.didLocalUserJoin && call.event == Event.RINGING) {
writableDatabase.update(TABLE_NAME)
.values(EVENT to Event.serialize(Event.ACCEPTED))
.where("$CALL_ID = ?", call.callId)
.run()
Log.d(TAG, "[updateGroupCallState] Transitioned group call ${call.callId} from RINGING to ACCEPTED on local join")
}
return changed
}
private fun handleGroupRingState(
@@ -936,7 +989,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
RingUpdate.EXPIRED_REQUEST, RingUpdate.CANCELLED_BY_RINGER -> {
when (call.event) {
Event.GENERIC_GROUP_CALL, Event.RINGING -> updateEventFromRingState(ringId, if (dueToNotificationProfile) Event.MISSED_NOTIFICATION_PROFILE else Event.MISSED, ringerRecipient)
Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, if (dueToNotificationProfile) Event.MISSED_NOTIFICATION_PROFILE else Event.MISSED, ringerRecipient)
Event.RINGING -> {
if (call.didLocalUserJoin) {
updateEventFromRingState(ringId, Event.ACCEPTED, ringerRecipient)
} else {
updateEventFromRingState(ringId, if (dueToNotificationProfile) Event.MISSED_NOTIFICATION_PROFILE else Event.MISSED, ringerRecipient)
}
}
Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED, ringerRecipient)
Event.OUTGOING_RING -> Log.w(TAG, "Received an expiration or cancellation while in OUTGOING_RING state. Ignoring.")
else -> Unit
@@ -946,7 +1006,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
RingUpdate.BUSY_LOCALLY -> {
when (call.event) {
Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED)
Event.GENERIC_GROUP_CALL, Event.RINGING -> updateEventFromRingState(ringId, Event.MISSED)
Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.MISSED)
Event.RINGING -> {
if (call.didLocalUserJoin) {
updateEventFromRingState(ringId, Event.ACCEPTED)
} else {
updateEventFromRingState(ringId, Event.MISSED)
}
}
else -> {
updateEventFromRingState(ringId, call.event, ringerRecipient)
Log.w(TAG, "Received a busy event we can't process. Updating ringer only.")
@@ -957,7 +1024,14 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
RingUpdate.BUSY_ON_ANOTHER_DEVICE -> {
when (call.event) {
Event.JOINED -> updateEventFromRingState(ringId, Event.ACCEPTED)
Event.GENERIC_GROUP_CALL, Event.RINGING -> updateEventFromRingState(ringId, Event.MISSED)
Event.GENERIC_GROUP_CALL -> updateEventFromRingState(ringId, Event.MISSED)
Event.RINGING -> {
if (call.didLocalUserJoin) {
updateEventFromRingState(ringId, Event.ACCEPTED)
} else {
updateEventFromRingState(ringId, Event.MISSED)
}
}
else -> Log.w(TAG, "Received a busy event we can't process. Ignoring.")
}
}
@@ -138,7 +138,6 @@ import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo
import org.thoughtcrime.securesms.revealable.ViewOnceUtil
import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil
import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled
import org.thoughtcrime.securesms.util.DateUtils
@@ -316,8 +315,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
private const val INDEX_THREAD_COUNT = "message_thread_count_index"
private const val INDEX_THREAD_UNREAD_COUNT = "message_thread_unread_count_index"
private const val INDEX_STORY_TYPE = "message_story_type_index"
private const val INDEX_PARENT_STORY_ID = "message_parent_story_id_index"
private const val INDEX_ARCHIVED_STORY = "message_story_archived_index"
private const val INDEX_STARRED = "message_starred_index"
private const val INDEX_NOTIFICATION_STATE = "message_notification_state_index"
private const val INDEX_RATE_LIMITED = "message_rate_limited_index"
@JvmField
val CREATE_INDEXS = arrayOf(
@@ -350,7 +352,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS $INDEX_ARCHIVED_STORY ON $TABLE_NAME ($STORY_ARCHIVED, $STORY_TYPE, $DATE_SENT) WHERE $STORY_TYPE > 0 AND $STORY_ARCHIVED > 0",
"CREATE INDEX IF NOT EXISTS $INDEX_STARRED ON $TABLE_NAME ($STARRED) WHERE $STARRED > 0",
"CREATE INDEX IF NOT EXISTS message_collapsed_state_index ON $TABLE_NAME ($COLLAPSED_STATE)",
"CREATE INDEX IF NOT EXISTS message_collapsed_head_id_index ON $TABLE_NAME ($COLLAPSED_HEAD_ID)"
"CREATE INDEX IF NOT EXISTS message_collapsed_head_id_index ON $TABLE_NAME ($COLLAPSED_HEAD_ID)",
"CREATE INDEX IF NOT EXISTS $INDEX_NOTIFICATION_STATE ON $TABLE_NAME ($DATE_RECEIVED) WHERE $NOTIFIED = 0 AND $STORY_TYPE = 0 AND $LATEST_REVISION_ID IS NULL",
"CREATE INDEX IF NOT EXISTS message_expire_started_index ON $TABLE_NAME ($EXPIRE_STARTED) WHERE $EXPIRE_STARTED > 0",
"CREATE INDEX IF NOT EXISTS message_view_once_index ON $TABLE_NAME ($VIEW_ONCE) WHERE $VIEW_ONCE > 0",
"CREATE INDEX IF NOT EXISTS $INDEX_RATE_LIMITED ON $TABLE_NAME ($ID) WHERE ($TYPE & ${MessageTypes.MESSAGE_RATE_LIMITED_BIT}) != 0"
)
private val MMS_PROJECTION_BASE = arrayOf(
@@ -652,6 +658,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return queryMessages(where, null)
}
/**
* Given a list of ids, will return a list of the ids that have an expiration and what that expiration is.
*/
fun getUnstartedExpirations(messageIds: List<Long>): Map<Long, Long> {
val expirations: MutableMap<Long, Long> = hashMapOf()
SqlUtil.buildCollectionQuery(
column = ID,
values = messageIds,
prefix = "$EXPIRES_IN != 0 AND $EXPIRE_STARTED = 0 AND"
).forEach { query ->
readableDatabase
.select(ID, EXPIRES_IN)
.from(TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.use { cursor ->
while (cursor.moveToNext()) {
expirations[cursor.requireLong(ID)] = cursor.requireLong(EXPIRES_IN)
}
}
}
return expirations
}
/**
* Returns true iff
* - the message will expire within [ChatItemArchiveExporter.EXPIRATION_CUTOFF] once viewed
@@ -873,11 +905,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return results
}
fun insertCallLog(recipientId: RecipientId, type: Long, timestamp: Long, outgoing: Boolean): InsertResult {
fun insertOneToOneCallLog(recipientId: RecipientId, type: Long, timestamp: Long, outgoing: Boolean): InsertResult {
val recipient = Recipient.resolved(recipientId)
val threadIdResult = threads.getOrCreateThreadIdResultFor(recipient.id, recipient.isGroup)
val threadId = threadIdResult.threadId
val dateReceived = System.currentTimeMillis()
val expiresIn = if (RemoteConfig.disappearMore) threads.getExpiresIn(threadId) else 0
val values = contentValuesOf(
FROM_RECIPIENT_ID to if (outgoing) Recipient.self().id.serialize() else recipientId.serialize(),
@@ -886,8 +919,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to dateReceived,
DATE_SENT to timestamp,
READ to 1,
NOTIFIED to 1,
TYPE to type,
THREAD_ID to threadId
THREAD_ID to threadId,
EXPIRES_IN to expiresIn
)
val messageId = writableDatabase.insert(TABLE_NAME, null, values)
@@ -906,13 +941,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
)
}
fun updateCallLog(messageId: Long, type: Long) {
fun updateOneToOneCallLog(messageId: Long, type: Long) {
val message = getMessageRecordOrNull(messageId = messageId)
writableDatabase
.update(TABLE_NAME)
.values(
TYPE to type,
READ to 1
READ to 1,
NOTIFIED to 1
)
.where("$ID = ?", messageId)
.run()
@@ -925,6 +961,13 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
maybeCollapseMessage(db = writableDatabase, messageId = messageId, threadId = threadId, dateReceived = message.dateReceived, messageExtras = message.messageExtras, messageType = type)
}
// Start disappearing timer when a call is answered or declined (e.g. not missed)
if (message?.expiresIn != null && message.expiresIn != 0L && !MessageTypes.isMissedVideoCall(type) && !MessageTypes.isMissedAudioCall(type)) {
val now = System.currentTimeMillis()
markExpireStarted(messageId, now)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, message.isMms, now, message.expiresIn)
}
notifyConversationListeners(threadId)
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
@@ -960,6 +1003,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to timestamp,
DATE_SENT to timestamp,
READ to if (markRead) 1 else 0,
NOTIFIED to if (markRead) 1 else 0,
BODY to Base64.encodeWithPadding(updateDetails),
TYPE to MessageTypes.GROUP_CALL_TYPE,
THREAD_ID to threadId
@@ -1059,7 +1103,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val updateDetail = GroupCallUpdateDetailsUtil.parse(message.body)
val containsSelf = joinedUuids.contains(SignalStore.account.requireAci().rawUuid)
val sameEraId = updateDetail.eraId == eraId && !Util.isEmpty(eraId)
// Treat empty eraId from ring requests as matching for updating
val sameEraId = (updateDetail.eraId == eraId || updateDetail.eraId.isEmpty()) && !Util.isEmpty(eraId)
val inCallUuids = if (sameEraId) joinedUuids.map { it.toString() } else emptyList()
val body = GroupCallUpdateDetailsUtil.createUpdatedBody(updateDetail, inCallUuids, isCallFull, isRingingOnLocalDevice)
val contentValues = contentValuesOf(
@@ -1068,6 +1113,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (sameEraId && (containsSelf || updateDetail.localUserJoined)) {
contentValues.put(READ, 1)
contentValues.put(NOTIFIED, 1)
}
val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(messageId), contentValues)
@@ -1105,7 +1151,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val record = reader.getNext() ?: return@withinTransaction false
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body)
val containsSelf = peekJoinedUuids.contains(SignalStore.account.requireAci().rawUuid)
val sameEraId = groupCallUpdateDetails.eraId == peekGroupCallEraId && !Util.isEmpty(peekGroupCallEraId)
// Treat empty eraId from ring requests as matching for updating
val sameEraId = (groupCallUpdateDetails.eraId == peekGroupCallEraId || groupCallUpdateDetails.eraId.isEmpty()) && !Util.isEmpty(peekGroupCallEraId)
val inCallUuids = if (sameEraId) {
peekJoinedUuids.map { it.toString() }.toList()
@@ -1119,6 +1166,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (sameEraId && (containsSelf || groupCallUpdateDetails.localUserJoined)) {
contentValues.put(READ, 1)
contentValues.put(NOTIFIED, 1)
}
val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(record.id), contentValues)
@@ -1187,6 +1235,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to now,
DATE_SENT to now,
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.PROFILE_CHANGE_TYPE,
THREAD_ID to threadId,
MESSAGE_EXTRAS to extras.encode()
@@ -1226,6 +1275,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to now,
DATE_SENT to now,
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.PROFILE_CHANGE_TYPE,
THREAD_ID to threadId,
MESSAGE_EXTRAS to extras.encode()
@@ -1259,6 +1309,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to System.currentTimeMillis(),
DATE_SENT to System.currentTimeMillis(),
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.GV1_MIGRATION_TYPE,
THREAD_ID to threadId
)
@@ -1293,6 +1344,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to System.currentTimeMillis(),
DATE_SENT to System.currentTimeMillis(),
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.CHANGE_NUMBER_TYPE,
THREAD_ID to threadId,
BODY to null
@@ -1317,6 +1369,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to System.currentTimeMillis(),
DATE_SENT to System.currentTimeMillis(),
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.RELEASE_CHANNEL_DONATION_REQUEST_TYPE,
THREAD_ID to threadId,
BODY to null
@@ -1334,6 +1387,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to System.currentTimeMillis(),
DATE_SENT to System.currentTimeMillis(),
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.THREAD_MERGE_TYPE,
THREAD_ID to threadId,
BODY to Base64.encodeWithPadding(event.encode())
@@ -1352,6 +1406,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to System.currentTimeMillis(),
DATE_SENT to System.currentTimeMillis(),
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.SESSION_SWITCHOVER_TYPE,
THREAD_ID to threadId,
BODY to Base64.encodeWithPadding(event.encode())
@@ -1373,6 +1428,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
DATE_RECEIVED to System.currentTimeMillis(),
DATE_SENT to System.currentTimeMillis(),
READ to 1,
NOTIFIED to 1,
TYPE to MessageTypes.SMS_EXPORT_TYPE,
THREAD_ID to threadId,
BODY to null
@@ -1650,7 +1706,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select(DATE_SENT)
.from(TABLE_NAME)
.from("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where("$IS_STORY_CLAUSE AND $THREAD_ID != ?", releaseChannelThreadId)
.limit(1)
.orderBy("$DATE_SENT ASC")
@@ -1670,76 +1726,45 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return writableDatabase.withinTransaction { db ->
val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories)
val storiesBeforeTimestampWhere = "$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ?"
val sharedArgs = buildArgs(timestamp, releaseChannelThreadId)
data class ExpiredStory(val id: Long, val fromRecipientId: Long)
val deleteStoryRepliesQuery = """
DELETE FROM $TABLE_NAME INDEXED BY $INDEX_STORY_TYPE
WHERE
$PARENT_STORY_ID > 0 AND
$PARENT_STORY_ID IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storiesBeforeTimestampWhere
)
"""
val expiredStories = db
.select(ID, FROM_RECIPIENT_ID)
.from("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where("$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ?", buildArgs(timestamp, releaseChannelThreadId))
.run()
.readToList { ExpiredStory(it.requireLong(ID), it.requireLong(FROM_RECIPIENT_ID)) }
val disassociateQuoteQuery = """
UPDATE $TABLE_NAME
SET
$QUOTE_MISSING = 1,
$QUOTE_BODY = ''
WHERE
$PARENT_STORY_ID < 0 AND
ABS($PARENT_STORY_ID) IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storiesBeforeTimestampWhere
)
"""
val storyRepliesQuery = """
SELECT $ID FROM $TABLE_NAME
WHERE
$PARENT_STORY_ID < 0 AND
ABS($PARENT_STORY_ID) IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storiesBeforeTimestampWhere
)
"""
db.execSQL(deleteStoryRepliesQuery, sharedArgs)
db.execSQL(disassociateQuoteQuery, sharedArgs)
db.rawQuery(storyRepliesQuery, sharedArgs).forEach { cursor: Cursor ->
val mmsId = cursor.requireLong(ID)
attachments.deleteAttachmentsForMessage(mmsId)
if (expiredStories.isEmpty()) {
return@withinTransaction 0
}
db.select(FROM_RECIPIENT_ID)
.from(TABLE_NAME)
.where(storiesBeforeTimestampWhere, sharedArgs)
val storyIds = expiredStories.map { it.id }
val directReplyClause = buildSingleCollectionQuery(PARENT_STORY_ID, storyIds)
val quotedReplyClause = buildSingleCollectionQuery(PARENT_STORY_ID, storyIds.map { -it })
db.delete("$TABLE_NAME INDEXED BY $INDEX_PARENT_STORY_ID")
.where(directReplyClause.where, directReplyClause.whereArgs)
.run()
.readToList { RecipientId.from(it.requireLong(FROM_RECIPIENT_ID)) }
.forEach { id -> AppDependencies.databaseObserver.notifyStoryObservers(id) }
val deletedStoryCount = db.select(ID)
.from(TABLE_NAME)
.where(storiesBeforeTimestampWhere, sharedArgs)
db.update("$TABLE_NAME INDEXED BY $INDEX_PARENT_STORY_ID")
.values(QUOTE_MISSING to 1, QUOTE_BODY to "")
.where(quotedReplyClause.where, quotedReplyClause.whereArgs)
.run()
.use { cursor ->
while (cursor.moveToNext()) {
deleteMessage(cursor.requireLong(ID))
}
cursor.count
}
db.select(ID)
.from("$TABLE_NAME INDEXED BY $INDEX_PARENT_STORY_ID")
.where(quotedReplyClause.where, quotedReplyClause.whereArgs)
.run()
.forEach { cursor -> attachments.deleteAttachmentsForMessage(cursor.requireLong(ID)) }
if (deletedStoryCount > 0) {
OptimizeMessageSearchIndexJob.enqueue()
}
expiredStories.forEach { AppDependencies.databaseObserver.notifyStoryObservers(RecipientId.from(it.fromRecipientId)) }
deletedStoryCount
storyIds.forEach { deleteMessage(it) }
OptimizeMessageSearchIndexJob.enqueue()
storyIds.size
}
}
@@ -1810,71 +1835,40 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun deleteStoriesForRecipient(recipientId: RecipientId): Int {
return writableDatabase.withinTransaction { db ->
val threadId = threads.getThreadIdFor(recipientId) ?: return@withinTransaction 0
val storiesInRecipientThread = "$IS_STORY_CLAUSE AND $THREAD_ID = ?"
val sharedArgs = buildArgs(threadId)
val deleteStoryRepliesQuery = """
DELETE FROM $TABLE_NAME INDEXED BY $INDEX_STORY_TYPE
WHERE
$PARENT_STORY_ID > 0 AND
$PARENT_STORY_ID IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storiesInRecipientThread
)
"""
val storyIds = db.select(ID)
.from("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where("$IS_STORY_CLAUSE AND $THREAD_ID = ?", threadId)
.run()
.readToList { it.requireLong(ID) }
val disassociateQuoteQuery = """
UPDATE $TABLE_NAME
SET
$QUOTE_MISSING = 1,
$QUOTE_BODY = ''
WHERE
$PARENT_STORY_ID < 0 AND
ABS($PARENT_STORY_ID) IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storiesInRecipientThread
)
"""
if (storyIds.isEmpty()) return@withinTransaction 0
val storyRepliesQuery = """
SELECT $ID FROM $TABLE_NAME
WHERE
$PARENT_STORY_ID < 0 AND
ABS($PARENT_STORY_ID) IN (
SELECT $ID
FROM $TABLE_NAME
WHERE $storiesInRecipientThread
)
"""
val directReplyClause = buildSingleCollectionQuery(PARENT_STORY_ID, storyIds)
val quotedReplyClause = buildSingleCollectionQuery(PARENT_STORY_ID, storyIds.map { -it })
db.execSQL(deleteStoryRepliesQuery, sharedArgs)
db.execSQL(disassociateQuoteQuery, sharedArgs)
db.rawQuery(storyRepliesQuery, sharedArgs).forEach { cursor: Cursor ->
val mmsId = cursor.requireLong(ID)
attachments.deleteAttachmentsForMessage(mmsId)
}
db.delete("$TABLE_NAME INDEXED BY $INDEX_PARENT_STORY_ID")
.where(directReplyClause.where, directReplyClause.whereArgs)
.run()
db.update("$TABLE_NAME INDEXED BY $INDEX_PARENT_STORY_ID")
.values(QUOTE_MISSING to 1, QUOTE_BODY to "")
.where(quotedReplyClause.where, quotedReplyClause.whereArgs)
.run()
db.select(ID)
.from("$TABLE_NAME INDEXED BY $INDEX_PARENT_STORY_ID")
.where(quotedReplyClause.where, quotedReplyClause.whereArgs)
.run()
.forEach { cursor -> attachments.deleteAttachmentsForMessage(cursor.requireLong(ID)) }
AppDependencies.databaseObserver.notifyStoryObservers(recipientId)
val deletedStoryCount = db.select(ID)
.from("$TABLE_NAME INDEXED BY $INDEX_STORY_TYPE")
.where(storiesInRecipientThread, sharedArgs)
.run()
.use { cursor ->
while (cursor.moveToNext()) {
deleteMessage(cursor.requireLong(ID))
}
storyIds.forEach { deleteMessage(it) }
cursor.count
}
OptimizeMessageSearchIndexJob.enqueue()
if (deletedStoryCount > 0) {
OptimizeMessageSearchIndexJob.enqueue()
}
deletedStoryCount
storyIds.size
}
}
@@ -2630,7 +2624,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val results = writableDatabase.rawQuery(
"""
UPDATE $TABLE_NAME INDEXED BY $index
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}, $VOTES_UNREAD = 0, $VOTES_LAST_SEEN = ${System.currentTimeMillis()}
SET $READ = 1, $NOTIFIED = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}, $VOTES_UNREAD = 0, $VOTES_LAST_SEEN = ${System.currentTimeMillis()}
WHERE $where
RETURNING $ID, $FROM_RECIPIENT_ID, $DATE_SENT, $DATE_RECEIVED, $TYPE, $EXPIRES_IN, $EXPIRE_STARTED, $THREAD_ID, $STORY_TYPE
""",
@@ -3430,6 +3424,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues.put(TYPE, type)
contentValues.put(THREAD_ID, threadId)
contentValues.put(READ, 1)
contentValues.put(NOTIFIED, 1)
contentValues.put(DATE_RECEIVED, dateReceived)
contentValues.put(SMS_SUBSCRIPTION_ID, message.subscriptionId)
contentValues.put(EXPIRES_IN, editedMessage?.expiresIn ?: message.expiresIn)
@@ -4231,15 +4226,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
fun getAllRateLimitedMessageIds(): Set<Long> {
val db = databaseHelper.signalReadableDatabase
val where = "(" + TYPE + " & " + MessageTypes.TOTAL_MASK + " & " + MessageTypes.MESSAGE_RATE_LIMITED_BIT + ") > 0"
val ids: MutableSet<Long> = HashSet()
db.query(TABLE_NAME, arrayOf(ID), where, null, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
ids.add(CursorUtil.requireLong(cursor, ID))
}
}
return ids
return readableDatabase
.select(ID)
.from("$TABLE_NAME INDEXED BY $INDEX_RATE_LIMITED")
.where("($TYPE & ${MessageTypes.MESSAGE_RATE_LIMITED_BIT}) != 0")
.run()
.readToSet { it.requireLong(ID) }
}
fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long, inclusive: Boolean): Int {
@@ -4443,34 +4435,25 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getNearestExpiringViewOnceMessage(): ViewOnceExpirationInfo? {
val query = """
SELECT
$TABLE_NAME.$ID,
$VIEW_ONCE,
$DATE_RECEIVED
FROM
$TABLE_NAME INNER JOIN ${AttachmentTable.TABLE_NAME} ON $TABLE_NAME.$ID = ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID}
WHERE
$VIEW_ONCE > 0 AND
SELECT
$TABLE_NAME.$ID,
$VIEW_ONCE,
$DATE_RECEIVED
FROM
$TABLE_NAME INNER JOIN ${AttachmentTable.TABLE_NAME} ON $TABLE_NAME.$ID = ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID}
WHERE
$VIEW_ONCE > 0 AND
(${AttachmentTable.DATA_FILE} NOT NULL OR ${AttachmentTable.TRANSFER_STATE} != ?)
ORDER BY $DATE_RECEIVED ASC
LIMIT 1
"""
val args = buildArgs(AttachmentTable.TRANSFER_PROGRESS_DONE)
var info: ViewOnceExpirationInfo? = null
var nearestExpiration = Long.MAX_VALUE
readableDatabase.rawQuery(query, args).forEach { cursor ->
val id = cursor.requireLong(ID)
val dateReceived = cursor.requireLong(DATE_RECEIVED)
val expiresAt = dateReceived + ViewOnceUtil.MAX_LIFESPAN
if (info == null || expiresAt < nearestExpiration) {
info = ViewOnceExpirationInfo(id, dateReceived)
nearestExpiration = expiresAt
return readableDatabase.rawQuery(query, args)
.readToSingleObject { cursor ->
ViewOnceExpirationInfo(cursor.requireLong(ID), cursor.requireLong(DATE_RECEIVED))
}
}
return info
}
/**
@@ -5613,6 +5596,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val values = contentValuesOf(
READ to 1,
NOTIFIED to 1,
REACTIONS_UNREAD to 0,
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
VOTES_UNREAD to 0,
@@ -5829,12 +5813,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.from("$TABLE_NAME INDEXED BY $INDEX_NOTIFICATION_STATE")
.where(
"""
$NOTIFIED = 0
AND $STORY_TYPE = 0
AND $LATEST_REVISION_ID IS NULL
$NOTIFIED = 0
AND $STORY_TYPE = 0
AND $LATEST_REVISION_ID IS NULL
AND (
($READ = 0 AND ($ORIGINAL_MESSAGE_ID IS NULL OR EXISTS (SELECT 1 FROM $TABLE_NAME AS m WHERE m.$ID = $TABLE_NAME.$ORIGINAL_MESSAGE_ID AND m.$READ = 0)))
OR $REACTIONS_UNREAD = 1
@@ -5991,7 +5975,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (!hasReactions) {
values.put(REACTIONS_UNREAD, 0)
} else if (!isRemoval) {
} else if (!isRemoval && isOutgoing) {
values.put(REACTIONS_UNREAD, 1)
}
@@ -6017,7 +6001,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (!hasVotes) {
values.put(VOTES_UNREAD, 0)
} else if (!isRemoval) {
} else if (!isRemoval && isOutgoing) {
values.put(VOTES_UNREAD, 1)
}
@@ -345,19 +345,11 @@ public interface MessageTypes {
}
static boolean isChatSessionRefresh(long type) {
return (type & ENCRYPTION_REMOTE_FAILED_BIT) != 0;
}
static boolean isDuplicateMessageType(long type) {
return (type & ENCRYPTION_REMOTE_DUPLICATE_BIT) != 0;
}
static boolean isNoRemoteSessionType(long type) {
return (type & ENCRYPTION_REMOTE_NO_SESSION_BIT) != 0;
}
static boolean isLegacyType(long type) {
return (type & ENCRYPTION_REMOTE_LEGACY_BIT) != 0 ||
return (type & ENCRYPTION_REMOTE_FAILED_BIT) != 0 ||
// These are legacy encryption error types that we just bundle into this type
(type & ENCRYPTION_REMOTE_NO_SESSION_BIT) != 0 ||
(type & ENCRYPTION_REMOTE_DUPLICATE_BIT) != 0 ||
(type & ENCRYPTION_REMOTE_LEGACY_BIT) != 0 ||
(type & ENCRYPTION_REMOTE_BIT) != 0;
}
@@ -424,6 +424,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
fun maskCapabilitiesToLong(capabilities: SignalServiceProfile.Capabilities): Long {
var value: Long = 0
value = Bitmask.update(value, Capabilities.STORAGE_SERVICE_ENCRYPTION_V2, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isStorageServiceEncryptionV2).serialize().toLong())
value = Bitmask.update(value, Capabilities.USERNAME_SYNC_MESSAGES, Capabilities.BIT_LENGTH, Recipient.Capability.fromBoolean(capabilities.isUsernameSyncMessages).serialize().toLong())
return value
}
}
@@ -4953,8 +4954,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
// const val DELETE_SYNC = 9
// const val VERSIONED_EXPIRATION_TIMER = 10
const val STORAGE_SERVICE_ENCRYPTION_V2 = 11
const val USERNAME_SYNC_MESSAGES = 12
// IMPORTANT: We cannot sore more than 32 capabilities in the bitmask.
// IMPORTANT: We cannot store more than 32 capabilities in the bitmask.
}
enum class VibrateState(val id: Int) {
@@ -10,6 +10,7 @@ import android.database.Cursor
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.Bitmask
import org.signal.core.util.Util
import org.signal.core.util.logging.Log
import org.signal.core.util.optionalBlob
@@ -174,7 +175,8 @@ object RecipientTableCursorUtil {
fun readCapabilities(cursor: Cursor): RecipientRecord.Capabilities {
val capabilities = cursor.requireLong(RecipientTable.CAPABILITIES)
return RecipientRecord.Capabilities(
rawBits = capabilities
rawBits = capabilities,
usernameSyncMessages = Recipient.Capability.deserialize(Bitmask.read(capabilities, RecipientTable.Capabilities.USERNAME_SYNC_MESSAGES, RecipientTable.Capabilities.BIT_LENGTH).toInt())
)
}
@@ -56,16 +56,11 @@ class RemappedRecordTables internal constructor(context: Context?, databaseHelpe
fun getAllRecipientMappings(): Map<RecipientId, RecipientId> {
val recipientMap: MutableMap<RecipientId, RecipientId> = HashMap()
readableDatabase.withinTransaction { db ->
trimInvalidRecipientEntries(db)
trimInvalidThreadEntries(db)
val mappings = getAllMappings(db, Recipients.TABLE_NAME)
for (mapping in mappings) {
val oldId = RecipientId.from(mapping.oldId)
val newId = RecipientId.from(mapping.newId)
recipientMap[oldId] = newId
}
val mappings = getAllMappings(readableDatabase, Recipients.TABLE_NAME)
for (mapping in mappings) {
val oldId = RecipientId.from(mapping.oldId)
val newId = RecipientId.from(mapping.newId)
recipientMap[oldId] = newId
}
return recipientMap
@@ -74,16 +69,21 @@ class RemappedRecordTables internal constructor(context: Context?, databaseHelpe
fun getAllThreadMappings(): Map<Long, Long> {
val threadMap: MutableMap<Long, Long> = HashMap()
readableDatabase.withinTransaction { db ->
val mappings = getAllMappings(db, Threads.TABLE_NAME)
for (mapping in mappings) {
threadMap[mapping.oldId] = mapping.newId
}
val mappings = getAllMappings(readableDatabase, Threads.TABLE_NAME)
for (mapping in mappings) {
threadMap[mapping.oldId] = mapping.newId
}
return threadMap
}
fun trimStaleMappings() {
writableDatabase.withinTransaction { db ->
trimInvalidRecipientEntries(db)
trimInvalidThreadEntries(db)
}
}
fun addRecipientMapping(oldId: RecipientId, newId: RecipientId) {
addMapping(Recipients.TABLE_NAME, Mapping(oldId.toLong(), newId.toLong()))
}
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.database;
import androidx.annotation.NonNull;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.signal.network.util.Preconditions;
@@ -11,6 +12,7 @@ import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Merging together recipients and threads is messy business. We can easily replace *almost* all of
@@ -30,8 +32,10 @@ class RemappedRecords {
private static final RemappedRecords INSTANCE = new RemappedRecords();
private Map<RecipientId, RecipientId> recipientMap;
private Map<Long, Long> threadMap;
private volatile Map<RecipientId, RecipientId> recipientMap;
private volatile Map<Long, Long> threadMap;
private final AtomicBoolean staleTrimScheduled = new AtomicBoolean(false);
private RemappedRecords() {}
@@ -106,13 +110,31 @@ class RemappedRecords {
private void ensureRecipientMapIsPopulated() {
if (recipientMap == null) {
recipientMap = SignalDatabase.remappedRecords().getAllRecipientMappings();
Map<RecipientId, RecipientId> loaded = SignalDatabase.remappedRecords().getAllRecipientMappings();
synchronized (this) {
if (recipientMap == null) {
recipientMap = loaded;
}
}
scheduleStaleTrimIfNeeded(loaded.isEmpty());
}
}
private void ensureThreadMapIsPopulated() {
if (threadMap == null) {
threadMap = SignalDatabase.remappedRecords().getAllThreadMappings();
Map<Long, Long> loaded = SignalDatabase.remappedRecords().getAllThreadMappings();
synchronized (this) {
if (threadMap == null) {
threadMap = loaded;
}
}
scheduleStaleTrimIfNeeded(loaded.isEmpty());
}
}
private void scheduleStaleTrimIfNeeded(boolean loadedMapWasEmpty) {
if (!loadedMapWasEmpty && staleTrimScheduled.compareAndSet(false, true)) {
SignalExecutors.BOUNDED.execute(() -> SignalDatabase.remappedRecords().trimStaleMappings());
}
}
@@ -12,8 +12,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.sqlite.db.SupportSQLiteQuery;
import net.zetetic.database.sqlcipher.SQLiteStatement;
import net.zetetic.database.sqlcipher.SQLiteQueryBuilder;
import net.zetetic.database.sqlcipher.SQLiteTransactionListener;
import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import java.io.IOException;
@@ -25,6 +27,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* This is a wrapper around {@link net.zetetic.database.sqlcipher.SQLiteDatabase}. There's difficulties
@@ -33,6 +36,14 @@ import java.util.Set;
*/
public class SQLiteDatabase implements SupportSQLiteDatabase {
private static final String TAG = Log.tag(SQLiteDatabase.class);
private static final long SLOW_WRITE_LOCK_WAIT_MS = TimeUnit.SECONDS.toMillis(3);
private static final long SLOW_TRANSACTION_HOLD_MS = 750;
private static final long SLOW_DIRECT_WRITE_MS = 250;
private static final long SLOW_DIRECT_DELETE_MS = TimeUnit.SECONDS.toMillis(1);
private static final long SLOW_QUERY_MS = TimeUnit.SECONDS.toMillis(1);
public static final int CONFLICT_ROLLBACK = 1;
public static final int CONFLICT_ABORT = 2;
public static final int CONFLICT_FAIL = 3;
@@ -48,6 +59,9 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
private final net.zetetic.database.sqlcipher.SQLiteDatabase wrapped;
private final Tracer tracer;
private static volatile boolean slowWriteLoggingEnabled = false;
private static final ThreadLocal<Long> TRANSACTION_HOLD_START_NS = new ThreadLocal<>();
private static final ThreadLocal<Set<Runnable>> PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS;
private static final ThreadLocal<Set<Runnable>> POST_SUCCESSFUL_TRANSACTION_TASKS;
@@ -58,6 +72,10 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
PENDING_POST_SUCCESSFUL_TRANSACTION_TASKS.set(new LinkedHashSet<>());
}
public static void setSlowWriteLoggingEnabled(boolean enabled) {
slowWriteLoggingEnabled = enabled;
}
public SQLiteDatabase(net.zetetic.database.sqlcipher.SQLiteDatabase wrapped) {
this.wrapped = wrapped;
this.tracer = Tracer.getInstance();
@@ -78,12 +96,20 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
}
private void traceSql(String methodName, String query, boolean locked, Runnable returnable) {
traceSql(methodName, query, locked, null, null, returnable);
}
private void traceSql(String methodName, String query, boolean locked, String queryPlanSql, Object[] queryPlanArgs, Runnable returnable) {
if (locked) {
traceLockStart();
}
tracer.start(methodName, KEY_QUERY, query);
long startNs = slowWriteLoggingEnabled && locked ? System.nanoTime() : 0L;
returnable.run();
if (slowWriteLoggingEnabled && locked) {
warnIfSlowDirectWrite(methodName, null, query, queryPlanSql, queryPlanArgs, startNs);
}
tracer.end(methodName);
if (locked) {
@@ -96,6 +122,10 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
}
private <E> E traceSql(String methodName, String table, String query, boolean locked, Returnable<E> returnable) {
return traceSql(methodName, table, query, locked, null, null, returnable);
}
private <E> E traceSql(String methodName, String table, String query, boolean locked, String queryPlanSql, Object[] queryPlanArgs, Returnable<E> returnable) {
if (locked) {
traceLockStart();
}
@@ -109,11 +139,19 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
}
tracer.start(methodName, params);
long startNs = slowWriteLoggingEnabled ? System.nanoTime() : 0L;
E result = returnable.run();
if (result instanceof Cursor) {
// Triggers filling the window (which is about to be done anyway), but lets us capture that time inside the trace
((Cursor) result).getCount();
}
if (slowWriteLoggingEnabled) {
if (locked) {
warnIfSlowDirectWrite(methodName, table, query, queryPlanSql, queryPlanArgs, startNs);
} else {
warnIfSlowQuery(methodName, table, query, queryPlanSql, queryPlanArgs, startNs);
}
}
tracer.end(methodName);
if (locked) {
@@ -236,13 +274,13 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
@Override
public Cursor query(SupportSQLiteQuery query) {
DatabaseMonitor.onSql(query.getSql(), null);
return wrapped.query(query);
return traceSql("query(SupportSQLiteQuery)", null, query.getSql(), false, query.getSql(), null, () -> wrapped.query(query));
}
@Override
public Cursor query(SupportSQLiteQuery query, CancellationSignal cancellationSignal) {
DatabaseMonitor.onSql(query.getSql(), null);
return wrapped.query(query, cancellationSignal);
return traceSql("query(SupportSQLiteQuery, CancellationSignal)", null, query.getSql(), false, query.getSql(), null, () -> wrapped.query(query, cancellationSignal));
}
@Override
@@ -292,6 +330,7 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
trace("beginTransaction()", wrapped::beginTransaction);
} else {
trace("beginTransaction()", () -> {
long waitStartNs = slowWriteLoggingEnabled ? System.nanoTime() : 0L;
wrapped.beginTransactionWithListener(new SQLiteTransactionListener() {
@Override
public void onBegin() { }
@@ -310,13 +349,31 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
getPendingPostSuccessfulTransactionTasks().clear();
}
});
if (slowWriteLoggingEnabled) {
long waitMs = (System.nanoTime() - waitStartNs) / 1_000_000L;
if (waitMs >= SLOW_WRITE_LOCK_WAIT_MS) {
Log.w(TAG, "Slow write-lock acquire: waited " + waitMs + "ms to BEGIN", new Throwable());
}
TRANSACTION_HOLD_START_NS.set(System.nanoTime());
}
});
}
}
public void endTransaction() {
Long holdStartNs = slowWriteLoggingEnabled ? TRANSACTION_HOLD_START_NS.get() : null;
trace("endTransaction()", wrapped::endTransaction);
traceLockEnd();
if (holdStartNs != null && !wrapped.inTransaction()) {
TRANSACTION_HOLD_START_NS.remove();
if (slowWriteLoggingEnabled) {
long holdMs = (System.nanoTime() - holdStartNs) / 1_000_000L;
if (holdMs >= SLOW_TRANSACTION_HOLD_MS) {
Log.w(TAG, "Slow transaction: held write lock for " + holdMs + "ms", new Throwable());
SlowTransactionInternalNotifier.onSlowEvent();
}
}
}
Set<Runnable> tasks = getPostSuccessfulTransactionTasks();
for (Runnable r : new HashSet<>(tasks)) {
r.run();
@@ -330,42 +387,42 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
public Cursor query(boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
DatabaseMonitor.onQuery(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
return traceSql("query(9)", table, selection, false, () -> wrapped.query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
return traceSql("query(9)", table, selection, false, buildQueryPlanSql(distinct, table, columns, selection, groupBy, having, orderBy, limit), selectionArgs, () -> wrapped.query(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
}
public Cursor queryWithFactory(net.zetetic.database.sqlcipher.SQLiteDatabase.CursorFactory cursorFactory, boolean distinct, String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
DatabaseMonitor.onQuery(distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
return traceSql("queryWithFactory()", table, selection, false, () -> wrapped.queryWithFactory(cursorFactory, distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
return traceSql("queryWithFactory()", table, selection, false, buildQueryPlanSql(distinct, table, columns, selection, groupBy, having, orderBy, limit), selectionArgs, () -> wrapped.queryWithFactory(cursorFactory, distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
}
public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy) {
DatabaseMonitor.onQuery(false, table, columns, selection, selectionArgs, groupBy, having, orderBy, null);
return traceSql("query(7)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy));
return traceSql("query(7)", table, selection, false, buildQueryPlanSql(false, table, columns, selection, groupBy, having, orderBy, null), selectionArgs, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy));
}
public Cursor query(String table, String[] columns, String selection, String[] selectionArgs, String groupBy, String having, String orderBy, String limit) {
DatabaseMonitor.onQuery(false, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit);
return traceSql("query(8)", table, selection, false, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
return traceSql("query(8)", table, selection, false, buildQueryPlanSql(false, table, columns, selection, groupBy, having, orderBy, limit), selectionArgs, () -> wrapped.query(table, columns, selection, selectionArgs, groupBy, having, orderBy, limit));
}
public Cursor rawQuery(String sql, String[] selectionArgs) {
DatabaseMonitor.onSql(sql, selectionArgs);
return traceSql("rawQuery(2a)", sql, false, () -> wrapped.rawQuery(sql, selectionArgs));
return traceSql("rawQuery(2a)", null, sql, false, sql, selectionArgs, () -> wrapped.rawQuery(sql, selectionArgs));
}
public Cursor rawQuery(String sql, Object... args) {
DatabaseMonitor.onSql(sql, args);
return traceSql("rawQuery(2b)", sql, false,() -> wrapped.rawQuery(sql, args));
return traceSql("rawQuery(2b)", null, sql, false, sql, args, () -> wrapped.rawQuery(sql, args));
}
public Cursor rawQueryWithFactory(net.zetetic.database.sqlcipher.SQLiteDatabase.CursorFactory cursorFactory, String sql, String[] selectionArgs, String editTable) {
DatabaseMonitor.onSql(sql, selectionArgs);
return traceSql("rawQueryWithFactory()", sql, false, () -> wrapped.rawQueryWithFactory(cursorFactory, sql, selectionArgs, editTable));
return traceSql("rawQueryWithFactory()", null, sql, false, sql, selectionArgs, () -> wrapped.rawQueryWithFactory(cursorFactory, sql, selectionArgs, editTable));
}
public Cursor rawQuery(String sql, String[] selectionArgs, int initialRead, int maxRead) {
DatabaseMonitor.onSql(sql, selectionArgs);
return traceSql("rawQuery(4)", sql, false, () -> rawQuery(sql, selectionArgs, initialRead, maxRead));
return traceSql("rawQuery(4)", null, sql, false, sql, selectionArgs, () -> rawQuery(sql, selectionArgs, initialRead, maxRead));
}
public long insert(String table, String nullColumnHack, ContentValues values) {
@@ -390,17 +447,17 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
public int delete(String table, String whereClause, String[] whereArgs) {
DatabaseMonitor.onDelete(table, whereClause, whereArgs);
return traceSql("delete()", table, whereClause, true, () -> wrapped.delete(table, whereClause, whereArgs));
return traceSql("delete()", table, whereClause, true, buildDeletePlanSql(table, whereClause), whereArgs, () -> wrapped.delete(table, whereClause, whereArgs));
}
public int update(String table, ContentValues values, String whereClause, String[] whereArgs) {
DatabaseMonitor.onUpdate(table, values, whereClause, whereArgs);
return traceSql("update()", table, whereClause, true, () -> wrapped.update(table, values, whereClause, whereArgs));
return traceSql("update()", table, whereClause, true, buildUpdatePlanSql(table, values, whereClause, CONFLICT_NONE), buildUpdatePlanArgs(values, whereArgs), () -> wrapped.update(table, values, whereClause, whereArgs));
}
public int updateWithOnConflict(String table, ContentValues values, String whereClause, String[] whereArgs, int conflictAlgorithm) {
DatabaseMonitor.onUpdate(table, values, whereClause, whereArgs);
return traceSql("updateWithOnConflict()", table, whereClause, true, () -> wrapped.updateWithOnConflict(table, values, whereClause, whereArgs, conflictAlgorithm));
return traceSql("updateWithOnConflict()", table, whereClause, true, buildUpdatePlanSql(table, values, whereClause, conflictAlgorithm), buildUpdatePlanArgs(values, whereArgs), () -> wrapped.updateWithOnConflict(table, values, whereClause, whereArgs, conflictAlgorithm));
}
public void execSQL(String sql) throws SQLException {
@@ -519,6 +576,149 @@ public class SQLiteDatabase implements SupportSQLiteDatabase {
wrapped.setLocale(locale);
}
private static String buildQueryPlanSql(boolean distinct, String table, String[] columns, String selection, String groupBy, String having, String orderBy, String limit) {
try {
return SQLiteQueryBuilder.buildQueryString(distinct, table, columns, selection, groupBy, having, orderBy, limit);
} catch (Throwable t) {
return null;
}
}
private static String buildDeletePlanSql(String table, String whereClause) {
try {
StringBuilder sql = new StringBuilder(120);
sql.append("DELETE FROM ").append(table);
if (whereClause != null && whereClause.length() > 0) {
sql.append(" WHERE ").append(whereClause);
}
return sql.toString();
} catch (Throwable t) {
return null;
}
}
private static String buildUpdatePlanSql(String table, ContentValues values, String whereClause, int conflictAlgorithm) {
try {
StringBuilder sql = new StringBuilder(120);
sql.append("UPDATE").append(getConflictClause(conflictAlgorithm)).append(" ").append(table).append(" SET ");
boolean needsSeparator = false;
for (Map.Entry<String, Object> entry : values.valueSet()) {
if (needsSeparator) {
sql.append(",");
}
sql.append(entry.getKey()).append("=?");
needsSeparator = true;
}
if (whereClause != null && whereClause.length() > 0) {
sql.append(" WHERE ").append(whereClause);
}
return sql.toString();
} catch (Throwable t) {
return null;
}
}
private static Object[] buildUpdatePlanArgs(ContentValues values, String[] whereArgs) {
try {
int valuesSize = values.size();
int whereSize = whereArgs != null ? whereArgs.length : 0;
Object[] bindArgs = new Object[valuesSize + whereSize];
int index = 0;
for (Map.Entry<String, Object> entry : values.valueSet()) {
bindArgs[index++] = entry.getValue();
}
if (whereArgs != null) {
for (String whereArg : whereArgs) {
bindArgs[index++] = whereArg;
}
}
return bindArgs;
} catch (Throwable t) {
return null;
}
}
private static String getConflictClause(int conflictAlgorithm) {
switch (conflictAlgorithm) {
case CONFLICT_ROLLBACK:
return " OR ROLLBACK";
case CONFLICT_ABORT:
return " OR ABORT";
case CONFLICT_FAIL:
return " OR FAIL";
case CONFLICT_IGNORE:
return " OR IGNORE";
case CONFLICT_REPLACE:
return " OR REPLACE";
case CONFLICT_NONE:
default:
return "";
}
}
private void warnIfSlowDirectWrite(String methodName, String table, String query, String queryPlanSql, Object[] queryPlanArgs, long startNs) {
if (!slowWriteLoggingEnabled || wrapped.inTransaction()) {
return;
}
long elapsedMs = (System.nanoTime() - startNs) / 1_000_000L;
long threshold = "delete()".equals(methodName) ? SLOW_DIRECT_DELETE_MS : SLOW_DIRECT_WRITE_MS;
if (elapsedMs >= threshold) {
Log.w(TAG, "Slow direct write: " + methodName + " on " + table + " took " + elapsedMs + "ms (query=" + query + ")", new Throwable());
logQueryPlan(methodName, queryPlanSql, queryPlanArgs);
}
}
private void warnIfSlowQuery(String methodName, String table, String query, String queryPlanSql, Object[] queryPlanArgs, long startNs) {
if (!slowWriteLoggingEnabled) {
return;
}
long elapsedMs = (System.nanoTime() - startNs) / 1_000_000L;
if (elapsedMs >= SLOW_QUERY_MS) {
Log.w(TAG, "Slow query: " + methodName + " on " + table + " took " + elapsedMs + "ms (query=" + query + ")", new Throwable());
logQueryPlan(methodName, queryPlanSql, queryPlanArgs);
SlowTransactionInternalNotifier.onSlowEvent();
}
}
private void logQueryPlan(String methodName, String queryPlanSql, Object[] queryPlanArgs) {
if (queryPlanSql == null) {
return;
}
try (Cursor cursor = queryPlanArgs != null ? wrapped.rawQuery("EXPLAIN QUERY PLAN " + queryPlanSql, queryPlanArgs)
: wrapped.rawQuery("EXPLAIN QUERY PLAN " + queryPlanSql, (String[]) null))
{
StringBuilder plan = new StringBuilder();
while (cursor.moveToNext()) {
if (plan.length() > 0) {
plan.append('\n');
}
plan.append(cursor.getInt(0))
.append('|')
.append(cursor.getInt(1))
.append('|')
.append(cursor.getInt(2))
.append('|')
.append(cursor.getString(3));
}
Log.w(TAG, "Slow query plan: " + methodName + " (query=" + queryPlanSql + ")\n" + plan);
} catch (Throwable t) {
Log.w(TAG, "Failed to log slow query plan: " + methodName + " (query=" + queryPlanSql + ")", t);
}
}
private static class ConvertedTransactionListener implements SQLiteTransactionListener {
private final android.database.sqlite.SQLiteTransactionListener listener;
@@ -0,0 +1,96 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import android.Manifest
import android.app.Notification
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import org.signal.core.util.PendingIntentFlags
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* Notifier that surfaces SQLite write-lock contention. Gated behind the [RemoteConfig.slowDatabaseNotifications] flag.
*/
object SlowTransactionInternalNotifier {
private const val THRESHOLD = 5
private val NOTIFY_INTERVAL = 30.minutes
private val IGNORED_STACK_TRACE_CLASSES = listOf(
"BackupRepository",
"BackupMessagesJob",
"ArchiveAttachmentReconciliationJob",
"SubmitDebugLogRepository"
)
private val count = AtomicInteger(0)
@Volatile
private var lastNotify: Duration = 0.milliseconds
@JvmStatic
fun onSlowEvent() {
if (!RemoteConfig.slowDatabaseNotifications) {
return
}
if (isExpectedSlowOperation()) {
return
}
if (count.incrementAndGet() < THRESHOLD) {
return
}
val now = System.currentTimeMillis().milliseconds
if (lastNotify + NOTIFY_INTERVAL > now) {
return
}
val context = AppDependencies.application
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
return
}
val observed = count.getAndSet(0)
lastNotify = now
val notification: Notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().FAILURES)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("[Internal-only] Slow database activity")
.setContentText("$observed slow database operations (transactions/queries) observed. Please tap to get a debug log.")
.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, SubmitDebugLogActivity::class.java), PendingIntentFlags.mutable()))
.build()
NotificationManagerCompat.from(context).notify(NotificationIds.INTERNAL_ERROR, notification)
}
private fun isExpectedSlowOperation(): Boolean {
return Thread
.currentThread()
.stackTrace
.any { element ->
IGNORED_STACK_TRACE_CLASSES.any {
element.className.contains(it)
}
}
}
}
@@ -2086,7 +2086,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
}
private fun getExpiresIn(threadId: Long): Long {
fun getExpiresIn(threadId: Long): Long {
return readableDatabase
.select(EXPIRES_IN)
.from(TABLE_NAME)
@@ -170,6 +170,9 @@ import org.thoughtcrime.securesms.database.helpers.migration.V314_FixMessageRequ
import org.thoughtcrime.securesms.database.helpers.migration.V315_CleanupE164SenderKeyShared
import org.thoughtcrime.securesms.database.helpers.migration.V316_AddVerifiedGroupNameHashMigration
import org.thoughtcrime.securesms.database.helpers.migration.V317_AddMessageThreadDateReceivedUnreadIndex
import org.thoughtcrime.securesms.database.helpers.migration.V318_AddMessageNotificationStateIndex
import org.thoughtcrime.securesms.database.helpers.migration.V319_AddAttachmentAndMessageIndexes
import org.thoughtcrime.securesms.database.helpers.migration.V320_AddAttachmentThumbnailFileAndUuidIndexes
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -347,10 +350,13 @@ object SignalDatabaseMigrations {
314 to V314_FixMessageRequestAcceptedToRecipient,
315 to V315_CleanupE164SenderKeyShared,
316 to V316_AddVerifiedGroupNameHashMigration,
317 to V317_AddMessageThreadDateReceivedUnreadIndex
317 to V317_AddMessageThreadDateReceivedUnreadIndex,
318 to V318_AddMessageNotificationStateIndex,
319 to V319_AddAttachmentAndMessageIndexes,
320 to V320_AddAttachmentThumbnailFileAndUuidIndexes
)
const val DATABASE_VERSION = 317
const val DATABASE_VERSION = 320
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Fix bad notified state across the message table so that we can use an index to improve query performance
* when fetching notification state.
*
* Note: this intentionally does *not* clean up "dead" rows (read messages where notified is still 0) that bloat
* the partial index. That cleanup will happen over time as an app migration to prevent long migration startups.
*/
@Suppress("ClassName")
object V318_AddMessageNotificationStateIndex : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val outgoingBaseTypes = "(2, 11, 21, 22, 23, 24, 25, 26, 28)"
db.execSQL("UPDATE message SET reactions_unread = 0 WHERE reactions_unread = 1 AND (type & 31) NOT IN $outgoingBaseTypes")
db.execSQL("UPDATE message SET votes_unread = 0 WHERE votes_unread = 1 AND (type & 31) NOT IN $outgoingBaseTypes")
db.execSQL("CREATE INDEX IF NOT EXISTS message_notification_state_index ON message (date_received) WHERE notified = 0 AND story_type = 0 AND latest_revision_id IS NULL")
}
}
@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
@Suppress("ClassName")
object V319_AddAttachmentAndMessageIndexes : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("CREATE INDEX IF NOT EXISTS message_expire_started_index ON message (expire_started) WHERE expire_started > 0")
db.execSQL("CREATE INDEX IF NOT EXISTS message_view_once_index ON message (view_once) WHERE view_once > 0")
db.execSQL("CREATE INDEX IF NOT EXISTS attachment_archive_thumbnail_transfer_state ON attachment (archive_thumbnail_transfer_state)")
}
}
@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
@Suppress("ClassName")
object V320_AddAttachmentThumbnailFileAndUuidIndexes : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("CREATE INDEX IF NOT EXISTS attachment_thumbnail_file_index ON attachment (thumbnail_file) WHERE thumbnail_file IS NOT NULL")
db.execSQL("CREATE INDEX IF NOT EXISTS attachment_uuid_index ON attachment (attachment_uuid) WHERE attachment_uuid IS NOT NULL")
db.execSQL("CREATE INDEX IF NOT EXISTS message_rate_limited_index ON message (_id) WHERE (type & 128) != 0")
}
}
@@ -54,6 +54,7 @@ import org.signal.archive.proto.GroupMemberRemovedUpdate;
import org.signal.archive.proto.GroupMembershipAccessLevelChangeUpdate;
import org.signal.archive.proto.GroupNameUpdate;
import org.signal.archive.proto.GroupSelfInvitationRevokedUpdate;
import org.signal.archive.proto.GroupSequenceOfRequestsAndCancelsUpdate;
import org.signal.archive.proto.GroupTerminateChangeUpdate;
import org.signal.archive.proto.GroupUnknownInviteeUpdate;
import org.signal.archive.proto.GroupV2AccessLevel;
@@ -199,6 +200,8 @@ final class GroupsV2UpdateMessageProducer {
describeGroupJoinRequestApprovedUpdate(update.groupJoinRequestApprovalUpdate, updates);
} else if (update.groupJoinRequestCanceledUpdate != null) {
describeGroupJoinRequestCanceledUpdate(update.groupJoinRequestCanceledUpdate, updates);
} else if (update.groupSequenceOfRequestsAndCancelsUpdate != null) {
describeGroupSequenceOfRequestsAndCancelsUpdate(update.groupSequenceOfRequestsAndCancelsUpdate, updates);
} else if (update.groupInviteLinkResetUpdate != null) {
describeInviteLinkResetUpdate(update.groupInviteLinkResetUpdate, updates);
} else if (update.groupInviteLinkEnabledUpdate != null) {
@@ -378,6 +381,18 @@ final class GroupsV2UpdateMessageProducer {
}
}
private void describeGroupSequenceOfRequestsAndCancelsUpdate(@NonNull GroupSequenceOfRequestsAndCancelsUpdate update, @NonNull List<UpdateDescription> updates) {
if (selfIds.matches(update.requestorAci)) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_canceled_your_request_to_join_the_group), Glyph.GROUP));
} else {
updates.add(updateDescription(R.plurals.MessageRecord_s_requested_and_cancelled_their_request_to_join_via_the_group_link,
update.count,
update.requestorAci,
update.count,
Glyph.GROUP));
}
}
private void describeGroupJoinRequestApprovedUpdate(@NonNull GroupJoinRequestApprovalUpdate update, @NonNull List<UpdateDescription> updates) {
boolean requestingMemberIsYou = selfIds.matches(update.requestorAci);
@@ -42,12 +42,12 @@ public final class LiveUpdateMessage {
}
List<LiveData<Recipient>> allMentionedRecipients = updateDescription.getMentioned().stream()
.map(uuid -> Recipient.resolved(RecipientId.from(uuid)).live().getLiveData()).collect(Collectors.toList());
.map(uuid -> Recipient.live(RecipientId.from(uuid)).getLiveDataResolved()).collect(Collectors.toList());
LiveData<?> mentionedRecipientChangeStream = allMentionedRecipients.isEmpty() ? LiveDataUtil.just(new Object())
: LiveDataUtil.merge(allMentionedRecipients);
return Transformations.map(mentionedRecipientChangeStream, event -> toSpannable(context, updateDescription, updateDescription.getSpannable(), defaultTint, adjustPosition));
return LiveDataUtil.mapAsync(mentionedRecipientChangeStream, event -> toSpannable(context, updateDescription, updateDescription.getSpannable(), defaultTint, adjustPosition));
}
/**
@@ -180,10 +180,6 @@ public abstract class MessageRecord extends DisplayRecord {
return MessageTypes.isSecureType(type);
}
public boolean isLegacyMessage() {
return MessageTypes.isLegacyType(type);
}
@Override
public boolean isFailed() {
return super.isFailed() || isFailedAdminDelete();
@@ -511,6 +507,10 @@ public abstract class MessageRecord extends DisplayRecord {
return UpdateDescription.staticDescriptionWithExpiration(string, glyph);
}
protected static @NonNull UpdateDescription staticUpdateDescriptionWithExpiration(@NonNull String string, Glyph glyph, @ColorInt int lightTint, @ColorInt int darkTint) {
return UpdateDescription.staticDescriptionWithExpiration(string, glyph, lightTint, darkTint);
}
protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string,
Glyph glyph,
@ColorInt int lightTint,
@@ -228,12 +228,6 @@ public class MmsMessageRecord extends MessageRecord {
public SpannableString getDisplayBody(@NonNull Context context) {
if (MessageTypes.isChatSessionRefresh(type)) {
return emphasisAdded(context.getString(R.string.MessageRecord_chat_session_refreshed));
} else if (MessageTypes.isDuplicateMessageType(type)) {
return emphasisAdded(context.getString(R.string.SmsMessageRecord_duplicate_message));
} else if (MessageTypes.isNoRemoteSessionType(type)) {
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session));
} else if (isLegacyMessage()) {
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
} else if (isPaymentNotification() && payment != null) {
return new SpannableString(context.getString(R.string.MessageRecord__payment_s, payment.getAmount().toString(FormatterOptions.defaults())));
} else if (isPaymentTombstone() || isPaymentNotification()) {
@@ -265,13 +259,13 @@ public class MmsMessageRecord extends MessageRecord {
if (call.getEvent() == CallTable.Event.NOT_ACCEPTED) {
int message = isVideoCall ? R.string.MessageRecord_unanswered_video_call : R.string.MessageRecord_unanswered_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(message), callDateString),
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(message), callDateString),
icon,
ContextCompat.getColor(context, R.color.core_red_shade),
ContextCompat.getColor(context, R.color.core_red));
} else {
int updateString = isVideoCall ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_outgoing_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
}
} else {
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
@@ -279,7 +273,7 @@ public class MmsMessageRecord extends MessageRecord {
if (accepted || !call.isDisplayedAsMissedCallInUi()) {
int updateString = isVideoCall ? R.string.MessageRecord_incoming_video_call : R.string.MessageRecord_incoming_voice_call;
Glyph icon = isVideoCall ? Glyph.VIDEO_CAMERA : Glyph.PHONE;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
} else {
Glyph icon = isVideoCall ? Glyph.VIDEO_CAMERA : Glyph.PHONE;
int message;
@@ -291,7 +285,7 @@ public class MmsMessageRecord extends MessageRecord {
message = isVideoCall ? R.string.MessageRecord_missed_video_call : R.string.MessageRecord_missed_voice_call;
}
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date,
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_call_message_with_date,
context.getString(message),
callDateString),
icon,
@@ -121,12 +121,14 @@ data class RecipientRecord(
)
data class Capabilities(
val rawBits: Long
val rawBits: Long,
val usernameSyncMessages: Recipient.Capability
) {
companion object {
@JvmField
val UNKNOWN = Capabilities(
rawBits = 0
rawBits = 0,
usernameSyncMessages = Recipient.Capability.UNKNOWN
)
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@@ -10,6 +11,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.ui.util.ThemeUtil;
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
import org.signal.core.models.ServiceId;
@@ -98,7 +100,14 @@ public final class UpdateDescription {
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
*/
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph) {
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true,0, 0);
return staticDescriptionWithExpiration(staticString, glyph, 0, 0);
}
/**
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
*/
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph, @ColorInt int lightTint, @ColorInt int darkTint) {
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true, lightTint, darkTint);
}
/**
@@ -161,6 +170,11 @@ public final class UpdateDescription {
return darkTint;
}
public @ColorInt int getTint(Context context) {
boolean isDarkTheme = ThemeUtil.isDarkTheme(context);
return isDarkTheme ? getDarkTint() : getLightTint();
}
public boolean hasExpiration() {
return canExpire;
}
@@ -399,8 +399,8 @@ class AttachmentDownloadJob private constructor(
}
return try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation)
val cdnNumber = attachment.cdn.cdnNumber
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation, cdnNumber)
val key = Base64.decode(attachment.remoteKey)
@@ -0,0 +1,96 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.fullWalCheckpoint
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import kotlin.time.Duration.Companion.seconds
/**
* Incrementally cleans up "dead" notification state in the message table by marking read, non-notified messages as
* notified. These rows would otherwise sit in the message_notification_state_index forever.
*/
class BackfillNotifiedStateJob private constructor(parameters: Parameters) : Job(parameters) {
companion object {
const val KEY = "BackfillNotifiedStateJob"
private val TAG = Log.tag(BackfillNotifiedStateJob::class.java)
private const val BATCH_SIZE = 1000
private val TIME_BUDGET = 3.seconds
private val RETRY_BACKOFF = 30.seconds
@JvmStatic
fun enqueue() {
AppDependencies.jobManager.add(BackfillNotifiedStateJob())
}
}
constructor() : this(
Parameters.Builder()
.setQueue(KEY)
.setMaxInstancesForFactory(1)
.setMaxAttempts(Parameters.UNLIMITED)
.setInitialDelay(30.seconds.inWholeMilliseconds)
.build()
)
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun onFailure() = Unit
override fun run(): Result {
val endTime = System.currentTimeMillis() + TIME_BUDGET.inWholeMilliseconds
var totalUpdated = 0
var lastBatchUpdateCount: Int
do {
lastBatchUpdateCount = updateBatch()
totalUpdated += lastBatchUpdateCount
} while (lastBatchUpdateCount > 0 && System.currentTimeMillis() < endTime)
Log.i(TAG, "Updated $totalUpdated rows this run.")
if (lastBatchUpdateCount > 0) {
return Result.retry(RETRY_BACKOFF.inWholeMilliseconds)
}
Log.i(TAG, "Backfill complete. Attempting to shrink WAL")
if (!SignalDatabase.writableDatabase.fullWalCheckpoint()) {
Log.w(TAG, "Failed to do a full WAL checkpoint after finished backfill.")
}
return Result.success()
}
/**
* Marks up to [BATCH_SIZE] read, non-notified messages as notified in a single transaction. Returns the number of
* rows updated, which is 0 once there is nothing left to clean up.
*/
private fun updateBatch(): Int {
return SignalDatabase.writableDatabase
.update(MessageTable.TABLE_NAME)
.values(MessageTable.NOTIFIED to 1)
.where(
"""
${MessageTable.ID} IN (
SELECT ${MessageTable.ID}
FROM ${MessageTable.TABLE_NAME}
WHERE ${MessageTable.NOTIFIED} = 0 AND ${MessageTable.READ} = 1 AND ${MessageTable.REACTIONS_UNREAD} = 0 AND ${MessageTable.VOTES_UNREAD} = 0
LIMIT $BATCH_SIZE
)
"""
)
.run()
}
class Factory : Job.Factory<BackfillNotifiedStateJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): BackfillNotifiedStateJob {
return BackfillNotifiedStateJob(parameters)
}
}
}
@@ -61,6 +61,11 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo
val batchSize = 500
val restoreTime = System.currentTimeMillis()
val orphanedCount = SignalDatabase.attachments.markRestorableAttachmentsWithoutMessageAsFailed()
if (orphanedCount > 0) {
Log.w(TAG, "$orphanedCount orphaned restorable attachments marked failed")
}
do {
val restoreThumbnailJobs: MutableList<RestoreAttachmentThumbnailJob> = mutableListOf()
val restoreFullAttachmentJobs: MutableList<RestoreAttachmentJob> = mutableListOf()
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@@ -87,6 +88,9 @@ class CheckKeyTransparencyJob private constructor(
} else if (!SignalStore.account.isRegistered) {
Log.i(TAG, "Account not registered. Exiting.")
false
} else if (TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)) {
Log.i(TAG, "Account is unauthorized. Exiting.")
false
} else if (!SignalStore.settings.automaticVerificationEnabled) {
Log.i(TAG, "Automatic verification disabled. Exiting.")
false
@@ -117,11 +121,11 @@ class CheckKeyTransparencyJob private constructor(
aciIdentityKey = SignalStore.account.aciIdentityKey.publicKey,
e164 = recipient.e164!!,
unidentifiedAccessKey = ProfileKeyUtil.profileKeyOrNull(recipient.profileKey).let { UnidentifiedAccess.deriveAccessKeyFrom(it) },
usernameHash = SignalStore.account.username?.let { Username(it).hash },
usernameHash = SignalStore.account.username?.let { Username(it).hash }.takeIf { Recipient.self().usernameSyncMessagesCapability.isSupported },
keyTransparencyStore = KeyTransparencyStore
)
Log.i(TAG, "Key transparency complete, result: $result")
Log.i(TAG, "Key transparency complete, result: $result. Included username in check: ${Recipient.self().usernameSyncMessagesCapability.isSupported}")
return when (result) {
is RequestResult.Success -> {
SignalStore.misc.hasKeyTransparencyFailure = false
@@ -167,9 +171,11 @@ class CheckKeyTransparencyJob private constructor(
* For others, it will only show once and only be cleared on the next successful verification.
*/
private fun markFailure() {
SignalStore.misc.hasKeyTransparencyFailure = true
if (RemoteConfig.internalUser) {
SignalStore.misc.hasSeenKeyTransparencyFailure = false
if (SignalStore.account.isRegistered && !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application)) {
SignalStore.misc.hasKeyTransparencyFailure = true
if (RemoteConfig.internalUser) {
SignalStore.misc.hasSeenKeyTransparencyFailure = false
}
}
}
@@ -26,6 +26,12 @@ class CheckRestoreMediaLeftJob private constructor(parameters: Parameters) : Job
const val KEY = "CheckRestoreMediaLeftJob"
private val TAG = Log.tag(CheckRestoreMediaLeftJob::class)
private val FINALIZE_LOCK = Any()
private const val STALLED_RECOVERY_QUEUE = "CheckRestoreMediaLeftJob::StalledRecovery"
fun enqueueStalledRecoveryCheck() {
AppDependencies.jobManager.add(CheckRestoreMediaLeftJob(STALLED_RECOVERY_QUEUE))
}
}
constructor(queue: String) : this(
@@ -46,28 +52,63 @@ class CheckRestoreMediaLeftJob private constructor(parameters: Parameters) : Job
if (remainingAttachmentSize == 0L) {
Log.d(TAG, "Media restore complete: there are no remaining restorable attachments.")
ArchiveRestoreProgress.allMediaRestored()
BackupMediaRestoreService.stop(context)
if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) {
SignalStore.backup.deletionState = DeletionState.MEDIA_DOWNLOAD_FINISHED
}
if (!SignalStore.backup.backsUpMedia) {
SignalDatabase.attachments.markQuotesThatNeedReconstruction()
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
}
onMediaRestoreComplete()
} else if (runAttempt == 0) {
Log.w(TAG, "Still have remaining data to restore, will retry before checking job queues, queue: ${parameters.queue} estimated remaining: $remainingAttachmentSize")
return Result.retry(15.seconds.inWholeMilliseconds)
return Result.retry(30.seconds.inWholeMilliseconds)
} else {
Log.w(TAG, "Max retries reached, queue: ${parameters.queue} estimated remaining: $remainingAttachmentSize")
// todo [local-backup] inspect jobs/queues and raise some alarm/abort?
handleRemainingAfterMaxAttempts(remainingAttachmentSize)
}
return Result.success()
}
/**
* Reached the retry limit while attachments still appear restorable. Figure out whether there is anything left actively working on the
* restore before deciding the restore is finished.
*/
private fun handleRemainingAfterMaxAttempts(remainingAttachmentSize: Long) {
synchronized(FINALIZE_LOCK) {
val otherCheckJobs = AppDependencies.jobManager.find { it.factoryKey == KEY && it.id != id }
if (otherCheckJobs.isNotEmpty()) {
Log.w(TAG, "Max retries reached but ${otherCheckJobs.size} other check job(s) remain, deferring to them. queue: ${parameters.queue} estimated remaining: $remainingAttachmentSize")
return
}
val orphanedCount = SignalDatabase.attachments.markRestorableAttachmentsWithoutMessageAsFailed()
if (orphanedCount > 0) {
Log.w(TAG, "$orphanedCount orphaned restorable attachments marked failed")
}
val restoreQueues = AppDependencies.jobManager
.find { it.factoryKey == RestoreAttachmentJob.KEY || it.factoryKey == RestoreLocalAttachmentJob.KEY }
.mapNotNull { it.queueKey }
.toSet()
if (restoreQueues.isNotEmpty()) {
Log.w(TAG, "Max retries reached but restore jobs remain in ${restoreQueues.size} queue(s), re-enqueueing check jobs. estimated remaining: $remainingAttachmentSize")
AppDependencies.jobManager.addAll(restoreQueues.map { CheckRestoreMediaLeftJob(it) })
return
}
Log.w(TAG, "Max retries reached and no restore jobs remain, treating restore as complete. estimated remaining: $remainingAttachmentSize")
onMediaRestoreComplete()
}
}
private fun onMediaRestoreComplete() {
ArchiveRestoreProgress.allMediaRestored()
BackupMediaRestoreService.stop(context)
if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) {
SignalStore.backup.deletionState = DeletionState.MEDIA_DOWNLOAD_FINISHED
}
if (!SignalStore.backup.backsUpMedia) {
SignalDatabase.attachments.markQuotesThatNeedReconstruction()
AppDependencies.jobManager.add(QuoteThumbnailReconstructionJob())
}
}
override fun onFailure() = Unit
class Factory : Job.Factory<CheckRestoreMediaLeftJob?> {
@@ -11,9 +11,14 @@ import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.either
import okio.ByteString
import okio.ByteString.Companion.toByteString
import okio.utf8Size
import org.signal.core.models.ServiceId
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.libsignal.net.ChallengeOption
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.network.service.MessageService
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.Attachment
@@ -197,7 +202,7 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
val syntheticResult = SendMessageResult.success(
SignalServiceAddress(recipient.requireServiceId(), recipient.e164.orNull()),
success.devices,
success.sentUnidentified,
success.sentSealedSender,
false,
0L,
Optional.of(content)
@@ -216,7 +221,7 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
SignalDatabase.pendingPniSignatureMessages.insertIfNecessary(recipient.id, message.sentTimeMillis, syntheticResult)
}
SignalDatabase.messages.markAsSent(messageId, success.sentUnidentified)
SignalDatabase.messages.markAsSent(messageId, success.sentSealedSender)
PushSendJob.markAttachmentsUploaded(messageId, message)
SignalDatabase.threads.updateSilently(threadId, false)
@@ -228,11 +233,11 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
}
val accessMode = recipient.sealedSenderAccessMode
if (success.sentUnidentified && accessMode == SealedSenderAccessMode.UNKNOWN && recipient.profileKey == null) {
if (success.sentSealedSender && accessMode == SealedSenderAccessMode.UNKNOWN && recipient.profileKey == null) {
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, SealedSenderAccessMode.UNRESTRICTED)
} else if (success.sentUnidentified && accessMode == SealedSenderAccessMode.UNKNOWN) {
} else if (success.sentSealedSender && accessMode == SealedSenderAccessMode.UNKNOWN) {
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, SealedSenderAccessMode.ENABLED)
} else if (!success.sentUnidentified && accessMode != SealedSenderAccessMode.DISABLED) {
} else if (!success.sentSealedSender && accessMode != SealedSenderAccessMode.DISABLED) {
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, SealedSenderAccessMode.DISABLED)
}
@@ -255,36 +260,41 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
ifLeft = { error ->
when (error) {
is MessageService.SendError.IdentityMismatch -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Identity mismatch for ${error.recipient.identifier}", error.cause)
val externalRecipient = Recipient.external(error.recipient.identifier)
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Identity mismatch for ${error.serviceId}", error.exception)
val externalRecipient = Recipient.external(error.serviceId.toString())
if (externalRecipient == null) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Failed to create a Recipient for the identifier!")
} else {
SignalDatabase.messages.addMismatchedIdentity(messageId, externalRecipient.id, error.cause.untrustedIdentity)
SignalDatabase.messages.addMismatchedIdentity(messageId, externalRecipient.id, error.exception.untrustedIdentity)
SignalDatabase.messages.markAsSentFailed(messageId)
RetrieveProfileJob.enqueue(externalRecipient.id, true)
}
Result.success()
}
MessageService.SendError.NotRegistered -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Recipient not registered")
is MessageService.SendError.NotRegistered -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Recipient not registered", error)
SignalDatabase.messages.markAsSentFailed(messageId)
PushSendJob.notifyMediaMessageDeliveryFailed(context, messageId)
AppDependencies.jobManager.add(DirectoryRefreshJob(false))
Result.success()
}
MessageService.SendError.Unauthorized -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Unauthorized send")
is MessageService.SendError.Unauthorized -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Unauthorized send", error)
Result.failure()
}
is MessageService.SendError.ChallengeRequired -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Challenge required (options=${error.options})")
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Challenge required (options=${error.options})", error)
val proofResponse = ProofRequiredResponse().apply {
token = error.token
options = error.options
options = error.options.map {
when (it) {
ChallengeOption.PUSH_CHALLENGE -> "pushChallenge"
ChallengeOption.CAPTCHA -> "captcha"
}
}
}
val proofException = ProofRequiredException(proofResponse, error.retryAfter?.inWholeSeconds ?: 0L)
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
@@ -295,23 +305,23 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
}
}
MessageService.SendError.ServerRejected -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Server rejected the send")
is MessageService.SendError.ServerRejected -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Server rejected the send", error)
Result.failure()
}
is MessageService.SendError.ContentTooLarge -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Content too large (${error.size} > ${error.maxAllowed} bytes); failing.")
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Content too large (${error.size} > ${error.maxAllowed} bytes). Failing.", error)
Result.failure()
}
MessageService.SendError.SessionAttemptsExhausted -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Exhausted device-resolution attempts; retrying")
is MessageService.SendError.SessionAttemptsExhausted -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Exhausted device-resolution attempts. Retrying", error)
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
is MessageService.SendError.PreKeyUnavailable -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Prekey unavailable: ${error.reason}")
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Prekey unavailable: ${error.reason}", error)
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
@@ -319,16 +329,16 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
val defaultBackoff = nextRunAttemptBackoff(runAttempt + 1)
val serverBackoff = error.retryAfter?.inWholeMilliseconds ?: 0L
val backoff = maxOf(defaultBackoff, serverBackoff)
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Rate limited, retryAfter=${error.retryAfter}, using backoff=${backoff}ms")
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Rate limited, retryAfter=${error.retryAfter}, using backoff=${backoff}ms", error)
Result.retry(backoff)
}
is MessageService.SendError.NetworkError -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Network error", error.cause)
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Network error", error.exception)
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
is MessageService.SendError.ApplicationError -> when (val cause = error.cause) {
is MessageService.SendError.ApplicationError -> when (val cause = error.exception) {
is RuntimeException -> {
Log.e(TAG, "${logPrefix(message.sentTimeMillis)} Encountered a fatal application error. Crash imminent.", cause)
Result.fatalFailure(cause)
@@ -393,7 +403,7 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
}
return AppDependencies.messageService.sendMessage(
recipient = SignalServiceAddress(recipient.requireServiceId(), recipient.e164.orNull()),
serviceId = recipient.requireServiceId(),
envelopeContent = envelopeContent,
timestamp = dataMessage.timestamp!!,
sealedSenderAccess = SealedSenderAccessUtil.getSealedSenderAccessFor(recipient),
@@ -409,25 +419,39 @@ class IndividualSendJobV2 private constructor(parameters: Parameters, private va
val editMessage = primaryResult.envelopeContent.content.get().editMessage
val timestamp = dataMessage?.timestamp ?: editMessage?.dataMessage?.timestamp ?: raise(MessageService.SendError.ApplicationError(IllegalStateException("No timestamp on primary message send!")))
val recipientServiceId = targetRecipient.requireServiceId()
val pniIdentityKey: ByteString? = if (recipientServiceId is ServiceId.PNI) {
AppDependencies
.protocolStore
.aci()
.identities()
.getIdentity(SignalProtocolAddress(recipientServiceId.toString(), SignalServiceAddress.DEFAULT_DEVICE_ID))?.publicKey?.serialize()?.toByteString()
} else {
null
}
val syncContent = Content(
syncMessage = SyncMessage(
sent = SyncMessage.Sent(
destinationServiceId = targetRecipient.serviceId.get().toString(),
timestamp = timestamp,
message = dataMessage,
editMessage = editMessage
editMessage = editMessage,
unidentifiedStatus = listOf(
SyncMessage.Sent.UnidentifiedDeliveryStatus(
destinationServiceIdBinary = recipientServiceId.toByteString(),
unidentified = primaryResult.sentSealedSender,
destinationPniIdentityKey = pniIdentityKey
)
)
)
)
)
val syncEnvelope = EnvelopeContent.encrypted(syncContent, ContentHint.IMPLICIT, Optional.empty())
return AppDependencies.messageService.sendMessage(
recipient = SignalServiceAddress(SignalStore.account.requireAci()),
return AppDependencies.messageService.sendSyncMessage(
envelopeContent = syncEnvelope,
timestamp = timestamp,
sealedSenderAccess = null, // We don't use sealed sender for sync messages
story = false,
isOnline = false,
urgent = true,
onEncrypted = { SignalLocalMetrics.IndividualMessageSend.onSyncMessageEncrypted(messageId) }
).bind()
@@ -55,6 +55,7 @@ import org.thoughtcrime.securesms.migrations.AvatarIdRemovalMigrationJob;
import org.thoughtcrime.securesms.migrations.AvatarMigrationJob;
import org.thoughtcrime.securesms.migrations.BackfillCollapsedEventsMigrationJob;
import org.thoughtcrime.securesms.migrations.BackfillDigestsForDuplicatesMigrationJob;
import org.thoughtcrime.securesms.migrations.BackfillNotifiedStateMigrationJob;
import org.thoughtcrime.securesms.migrations.BackupJitterMigrationJob;
import org.thoughtcrime.securesms.migrations.BackupNotificationMigrationJob;
import org.thoughtcrime.securesms.migrations.BadE164MigrationJob;
@@ -144,8 +145,9 @@ public final class JobManagerFactories {
put(AutomaticSessionResetJob.KEY, new AutomaticSessionResetJob.Factory());
put(AvatarGroupsV1DownloadJob.KEY, new AvatarGroupsV1DownloadJob.Factory());
put(AvatarGroupsV2DownloadJob.KEY, new AvatarGroupsV2DownloadJob.Factory());
put(BackfillCollapsedMessageJob.KEY, new BackfillCollapsedMessageJob.Factory());
put(BackfillCollapsedMessageJob.KEY, new BackfillCollapsedMessageJob.Factory());
put(BackfillDigestsForDataFileJob.KEY, new BackfillDigestsForDataFileJob.Factory());
put(BackfillNotifiedStateJob.KEY, new BackfillNotifiedStateJob.Factory());
put(BackupDeleteJob.KEY, new BackupDeleteJob.Factory());
put(BackupMessagesJob.KEY, new BackupMessagesJob.Factory());
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
@@ -205,6 +207,7 @@ public final class JobManagerFactories {
put(LocalPlaintextArchiveJob.KEY, new LocalPlaintextArchiveJob.Factory());
put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory());
put(MarkerJob.KEY, new MarkerJob.Factory());
put(MessageSendLogCleanupJob.KEY, new MessageSendLogCleanupJob.Factory());
put(MultiDeviceAttachmentBackfillMissingJob.KEY, new MultiDeviceAttachmentBackfillMissingJob.Factory());
put(MultiDeviceAttachmentBackfillUpdateJob.KEY, new MultiDeviceAttachmentBackfillUpdateJob.Factory());
put(MultiDeviceBlockedUpdateJob.KEY, new MultiDeviceBlockedUpdateJob.Factory());
@@ -223,6 +226,7 @@ public final class JobManagerFactories {
put(MultiDeviceStickerPackSyncJob.KEY, new MultiDeviceStickerPackSyncJob.Factory());
put(MultiDeviceStorageSyncRequestJob.KEY, new MultiDeviceStorageSyncRequestJob.Factory());
put(MultiDeviceSubscriptionSyncRequestJob.KEY, new MultiDeviceSubscriptionSyncRequestJob.Factory());
put(MultiDeviceUsernameChangeSyncJob.KEY, new MultiDeviceUsernameChangeSyncJob.Factory());
put(MultiDeviceVerifiedUpdateJob.KEY, new MultiDeviceVerifiedUpdateJob.Factory());
put(MultiDeviceViewOnceOpenJob.KEY, new MultiDeviceViewOnceOpenJob.Factory());
put(MultiDeviceViewedUpdateJob.KEY, new MultiDeviceViewedUpdateJob.Factory());
@@ -315,6 +319,7 @@ public final class JobManagerFactories {
put(AvatarMigrationJob.KEY, new AvatarMigrationJob.Factory());
put(BackfillCollapsedEventsMigrationJob.KEY, new BackfillCollapsedEventsMigrationJob.Factory());
put(BackfillDigestsForDuplicatesMigrationJob.KEY, new BackfillDigestsForDuplicatesMigrationJob.Factory());
put(BackfillNotifiedStateMigrationJob.KEY, new BackfillNotifiedStateMigrationJob.Factory());
put(BackupJitterMigrationJob.KEY, new BackupJitterMigrationJob.Factory());
put(BackupNotificationMigrationJob.KEY, new BackupNotificationMigrationJob.Factory());
put(BackupRefreshJob.KEY, new BackupRefreshJob.Factory());
@@ -0,0 +1,52 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.concurrent.TimeUnit
/**
* Trims expired entries out of the message send log after a delay.
*/
class MessageSendLogCleanupJob private constructor(parameters: Parameters) : Job(parameters) {
companion object {
const val KEY = "MessageSendLogCleanupJob"
@JvmStatic
fun enqueue() {
AppDependencies.jobManager.add(
MessageSendLogCleanupJob(
Parameters.Builder()
.setInitialDelay(TimeUnit.MINUTES.toMillis(1))
.setLifespan(TimeUnit.HOURS.toMillis(1))
.setMaxInstancesForFactory(1)
.setQueue(KEY)
.build()
)
)
}
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun onFailure() = Unit
override fun run(): Result {
SignalDatabase.messageLog.trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge)
return Result.success()
}
class Factory : Job.Factory<MessageSendLogCleanupJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): MessageSendLogCleanupJob {
return MessageSendLogCleanupJob(parameters)
}
}
}
@@ -0,0 +1,95 @@
package org.thoughtcrime.securesms.jobs
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.pad
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.IOException
import java.util.Optional
import kotlin.time.Duration.Companion.days
/**
* Sends a sync message to alert linked devices of a username change so they can reset KT.
*/
class MultiDeviceUsernameChangeSyncJob private constructor(
parameters: Parameters
) : Job(parameters) {
companion object {
const val KEY = "MultiDeviceUsernameChangeSyncJob"
private val TAG = Log.tag(MultiDeviceUsernameChangeSyncJob::class.java)
@WorkerThread
@JvmStatic
fun enqueueUsernameChangeSync() {
if (!SignalStore.account.isMultiDevice) {
return
}
AppDependencies.jobManager.add(
MultiDeviceUsernameChangeSyncJob(
parameters = Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(1.days.inWholeMilliseconds)
.build()
)
)
}
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (!Recipient.self().isRegistered) {
Log.w(TAG, "Not registered")
return Result.failure()
}
if (!SignalStore.account.isMultiDevice) {
Log.w(TAG, "Not multi-device")
return Result.failure()
}
val syncMessageContent = Content(
syncMessage = SyncMessage.Builder()
.pad()
.usernameChange(SyncMessage.UsernameChange())
.build()
)
return try {
Log.d(TAG, "Sending username change sync")
val success = AppDependencies.signalServiceMessageSender.sendSyncMessage(syncMessageContent, true, Optional.empty()).isSuccess
if (success) {
Result.success()
} else {
Log.w(TAG, "Unsuccessful username change send. Retrying.")
Result.retry(defaultBackoff())
}
} catch (e: IOException) {
Log.w(TAG, "Unable to send username change sync due to io exception", e)
Result.retry(defaultBackoff())
} catch (e: UntrustedIdentityException) {
Log.w(TAG, "Unable to send username change sync due to untrusted exception", e)
Result.failure()
}
}
override fun onFailure() = Unit
class Factory : Job.Factory<MultiDeviceUsernameChangeSyncJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceUsernameChangeSyncJob {
return MultiDeviceUsernameChangeSyncJob(parameters)
}
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.fullWalCheckpoint
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -63,6 +64,9 @@ class OptimizeMessageSearchIndexJob private constructor(parameters: Parameters)
}
val success = SignalDatabase.messageSearch.optimizeIndex(5.seconds.inWholeMilliseconds)
if (!SignalDatabase.writableDatabase.fullWalCheckpoint()) {
Log.w(TAG, "Failed to do a full WAL checkpoint after deletion.")
}
if (!success) {
throw RetryLaterException()
@@ -282,7 +282,7 @@ abstract class PushSendJob protected constructor(parameters: Parameters) : BaseJ
}
try {
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!)
val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation!!, attachment.cdn.cdnNumber)
val key = Base64.decode(attachment.remoteKey!!)
var width = attachment.width
@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException
import org.whispersystems.signalservice.api.messages.SendMessageResult
import java.io.IOException
/**
* Shared send logic for the receipt jobs ([SendReadReceiptJob], [SendDeliveryReceiptJob], [SendViewedReceiptJob]) that
* will repair on first failure and then try again.
*/
object ReceiptSender {
private val TAG = Log.tag(ReceiptSender::class.java)
/**
* @return the result of the send, or `null` if the receipt was dropped because the session could not be repaired.
*/
@JvmStatic
@Throws(IOException::class, UntrustedIdentityException::class)
fun sendWithSessionRepair(recipientId: RecipientId, operation: ReceiptSendOperation): SendMessageResult? {
return try {
operation.send()
} catch (e: IllegalStateException) {
Log.w(TAG, "Failed to send receipt, likely due to a missing or corrupt session. Archiving sessions and retrying.", e)
AppDependencies.protocolStore.aci().sessions().archiveSessions(recipientId)
AppDependencies.protocolStore.pni().sessions().archiveSessions(recipientId)
try {
operation.send()
} catch (retryError: IllegalStateException) {
Log.w(TAG, "Failed to send receipt even after archiving sessions. Dropping.", retryError)
null
}
}
}
fun interface ReceiptSendOperation {
@Throws(IOException::class, UntrustedIdentityException::class)
fun send(): SendMessageResult
}
}
@@ -46,6 +46,11 @@ class RestoreLocalAttachmentJob private constructor(
fun enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo: Map<String, DocumentFileInfo>) {
val jobManager = AppDependencies.jobManager
val orphanedCount = SignalDatabase.attachments.markRestorableAttachmentsWithoutMessageAsFailed()
if (orphanedCount > 0) {
Log.w(TAG, "Failed $orphanedCount orphaned restorable attachment(s) with no backing message before enqueueing restores.")
}
do {
val possibleRestorableAttachments: List<LocalRestorableAttachment> = SignalDatabase.attachments.getRestorableLocalAttachments(500)
val notRestorableAttachments = ArrayList<AttachmentId>(possibleRestorableAttachments.size)
@@ -72,7 +77,7 @@ class RestoreLocalAttachmentJob private constructor(
// Intentionally enqueues one at a time for safer attachment transfer state management
Log.d(TAG, "Adding ${restoreAttachmentJobs.size} restore local attachment jobs")
restoreAttachmentJobs.forEach { jobManager.add(it) }
} while (restoreAttachmentJobs.isNotEmpty())
} while (possibleRestorableAttachments.isNotEmpty())
ArchiveRestoreProgress.onRestoringMedia()
@@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken;
import org.signal.network.exceptions.PushNetworkException;
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
@@ -25,7 +26,6 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.signal.network.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import java.io.IOException;
@@ -125,12 +125,12 @@ public class SendDeliveryReceiptJob extends BaseJob {
Collections.singletonList(messageSentTimestamp),
timestamp);
SendMessageResult result = messageSender.sendReceipt(remoteAddress,
SealedSenderAccessUtil.getSealedSenderAccessFor(recipient, this::getGroupSendFullToken),
receiptMessage,
recipient.getNeedsPniSignature());
SendMessageResult result = ReceiptSender.sendWithSessionRepair(recipientId, () -> messageSender.sendReceipt(remoteAddress,
SealedSenderAccessUtil.getSealedSenderAccessFor(recipient, this::getGroupSendFullToken),
receiptMessage,
recipient.getNeedsPniSignature()));
if (messageId != null) {
if (result != null && messageId != null) {
SignalDatabase.messageLog().insertIfPossible(recipientId, timestamp, result, ContentHint.IMPLICIT, messageId, false);
}
}
@@ -152,6 +152,8 @@ public class SendDeliveryReceiptJob extends BaseJob {
public boolean onShouldRetry(@NonNull Exception e) {
if (e instanceof ServerRejectedException) return false;
if (e instanceof PushNetworkException) return true;
if (e instanceof IOException) return true;
return false;
}
@@ -191,13 +191,13 @@ public class SendReadReceiptJob extends BaseJob {
SignalServiceAddress remoteAddress = RecipientUtil.toSignalServiceAddress(context, recipient);
SignalServiceReceiptMessage receiptMessage = new SignalServiceReceiptMessage(SignalServiceReceiptMessage.Type.READ, messageSentTimestamps, timestamp);
SendMessageResult result = messageSender.sendReceipt(remoteAddress,
SealedSenderAccessUtil.getSealedSenderAccessFor(recipient,
() -> SignalDatabase.groups().getGroupSendFullToken(threadId, recipientId)),
receiptMessage,
recipient.getNeedsPniSignature());
SendMessageResult result = ReceiptSender.sendWithSessionRepair(recipientId, () -> messageSender.sendReceipt(remoteAddress,
SealedSenderAccessUtil.getSealedSenderAccessFor(recipient,
() -> SignalDatabase.groups().getGroupSendFullToken(threadId, recipientId)),
receiptMessage,
recipient.getNeedsPniSignature()));
if (Util.hasItems(messageIds)) {
if (result != null && Util.hasItems(messageIds)) {
SignalDatabase.messageLog().insertIfPossible(recipientId, timestamp, result, ContentHint.IMPLICIT, messageIds, false);
}
}
@@ -209,13 +209,13 @@ public class SendViewedReceiptJob extends BaseJob {
messageSentTimestamps,
timestamp);
SendMessageResult result = messageSender.sendReceipt(remoteAddress,
SealedSenderAccessUtil.getSealedSenderAccessFor(recipient,
() -> SignalDatabase.groups().getGroupSendFullToken(threadId, recipientId)),
receiptMessage,
recipient.getNeedsPniSignature());
SendMessageResult result = ReceiptSender.sendWithSessionRepair(recipientId, () -> messageSender.sendReceipt(remoteAddress,
SealedSenderAccessUtil.getSealedSenderAccessFor(recipient,
() -> SignalDatabase.groups().getGroupSendFullToken(threadId, recipientId)),
receiptMessage,
recipient.getNeedsPniSignature()));
if (Util.hasItems(foundMessageIds)) {
if (result != null && Util.hasItems(foundMessageIds)) {
SignalDatabase.messageLog().insertIfPossible(recipientId, timestamp, result, ContentHint.IMPLICIT, foundMessageIds, false);
}
}
@@ -400,6 +400,8 @@ class UploadAttachmentToArchiveJob private constructor(
SignalDatabase.attachments.setArchiveTransferStateFailure(attachmentId, AttachmentTable.ArchiveTransferState.TEMPORARY_FAILURE)
}
}
ArchiveUploadProgress.onAttachmentFinished(attachmentId)
}
private fun setArchiveTransferStateWithDelayedNotification(attachmentId: AttachmentId, transferState: AttachmentTable.ArchiveTransferState) {
@@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.core.ui.R as CoreUiR
private const val PLACEHOLDER = "__ICON_PLACEHOLDER__"
@@ -503,7 +504,7 @@ fun DeviceRow(device: Device, isInternalUser: Boolean, setDeviceToRemove: (Devic
.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.symbol_devices_24),
painter = painterResource(id = CoreUiR.drawable.symbol_devices_24),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
contentScale = ContentScale.Inside,
@@ -2,11 +2,13 @@ package org.thoughtcrime.securesms.lock.v2
import android.app.Activity
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.lock.v2.ConfirmSvrPinViewModel.SaveAnimation
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -16,6 +18,11 @@ import org.thoughtcrime.securesms.util.SpanUtil
internal class ConfirmSvrPinFragment : BaseSvrPinFragment<ConfirmSvrPinViewModel>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
}
override fun initializeViewStates() {
val args = ConfirmSvrPinFragmentArgs.fromBundle(requireArguments())
if (args.isPinChange) {
@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.lock.v2
import android.os.Bundle
import android.view.View
import android.view.animation.Animation
import android.view.animation.TranslateAnimation
import android.widget.EditText
@@ -8,12 +10,18 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinViewModel.NavigationEvent
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinViewModel.PinErrorEvent
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class CreateSvrPinFragment : BaseSvrPinFragment<CreateSvrPinViewModel?>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
}
override fun initializeViewStates() {
val args = CreateSvrPinFragmentArgs.fromBundle(requireArguments())
if (args.isPinChange) {
@@ -16,6 +16,7 @@ import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.rememberIsSplitPane
@@ -78,7 +79,7 @@ data class MainContentLayoutData(
val isSplitPane = resources.rememberIsSplitPane()
return remember(windowSizeClass, mode, breakpoint, isSplitPane) {
val isLargeWindowSize = breakpoint.isLargeWindow
val isLargeWindowSize = breakpoint is WindowBreakpoint.Large
MainContentLayoutData(
shape = when {
@@ -236,7 +236,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul
if (MediaUtil.isImageType(media.contentType) && editorData != null && editorData is ImageEditorFragment.Data) {
val model = editorData.readModel()
if (model != null) {
ImageEditorFragment.renderToSingleUseBlob(requireContext(), model)
ImageEditorFragment.renderToSingleSessionBlob(requireContext(), model)
} else {
media.uri
}
@@ -32,7 +32,7 @@ class TextStoryPostSendRepository {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
bitmap.recycle()
BlobProvider.getInstance().forData(outputStream.toByteArray()).createForSingleUseInMemory()
BlobProvider.getInstance().forData(outputStream.toByteArray()).createForSingleSessionInMemory()
}.subscribeOn(Schedulers.computation())
}
@@ -222,7 +222,7 @@ enum class OnboardingListItem(
),
ADD_PHOTO(
title = R.string.Megaphones_add_a_profile_photo,
icon = R.drawable.symbol_person_circle_24,
icon = CoreUiR.drawable.symbol_person_circle_24,
cardColor = R.color.onboarding_background_4
),
APPEARANCE(
@@ -14,6 +14,7 @@ import org.signal.core.util.Util
import org.signal.core.util.UuidUtil
import org.signal.core.util.isNotEmpty
import org.signal.core.util.orNull
import org.signal.libsignal.net.KeyTransparency
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.InvalidKeyException
@@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.PaymentMetaDataUtil
import org.thoughtcrime.securesms.database.SentStorySyncManifest
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.KeyTransparencyStore
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
@@ -58,6 +60,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.database.withAttachments
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi
import org.thoughtcrime.securesms.groups.BadGroupIdException
import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupId
@@ -188,6 +191,7 @@ object SyncMessageProcessor {
syncMessage.deleteForMe != null -> handleSynchronizeDeleteForMe(context, syncMessage.deleteForMe!!, envelope.clientTimestamp!!, earlyMessageCacheEntry)
syncMessage.attachmentBackfillRequest != null -> handleSynchronizeAttachmentBackfillRequest(syncMessage.attachmentBackfillRequest!!, envelope.clientTimestamp!!)
syncMessage.attachmentBackfillResponse != null -> warn(envelope.clientTimestamp!!, "Contains a backfill response, but we don't handle these!")
syncMessage.usernameChange != null -> handleSynchronizeUsernameChange(envelope.clientTimestamp!!)
else -> warn(envelope.clientTimestamp!!, "Contains no known sync types...")
}
}
@@ -2026,6 +2030,12 @@ object SyncMessageProcessor {
return threadId
}
private fun handleSynchronizeUsernameChange(timestamp: Long) {
log(timestamp, "[handleSynchronizeUsernameChange] Synchronize username change. Resetting KT.")
KeyTransparencyApi.reset(aci = SignalStore.account.requireAci().libSignalAci, field = KeyTransparency.AccountDataField.USERNAME_HASH, keyTransparencyStore = KeyTransparencyStore)
}
private fun ConversationIdentifier.toRecipientId(): RecipientId? {
val threadServiceId = ServiceId.parseOrNull(this.threadServiceId, this.threadServiceIdBinary)
return when {
@@ -10,12 +10,12 @@ import androidx.lifecycle.MutableLiveData;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.Util;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.stickers.BlessedPacks;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.VersionTracker;
import java.util.LinkedHashMap;
@@ -203,9 +203,11 @@ public class ApplicationMigrations {
static final int READ_INDEX_DB_MIGRATION = 159;
// Need to skip 160 due to release ordering issues
static final int SVR2_ENCLAVE_UPDATE_6 = 161;
static final int NOTIFICATION_INDEX_MIGRATION = 162;
static final int NOTIFICATION_STATE_CLEANUP = 163;
}
public static final int CURRENT_VERSION = 161;
public static final int CURRENT_VERSION = 163;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -940,6 +942,14 @@ public class ApplicationMigrations {
jobs.put(Version.SVR2_ENCLAVE_UPDATE_6, new Svr2MirrorMigrationJob());
}
if (lastSeenVersion < Version.NOTIFICATION_INDEX_MIGRATION) {
jobs.put(Version.NOTIFICATION_INDEX_MIGRATION, new DatabaseMigrationJob());
}
if (lastSeenVersion < Version.NOTIFICATION_STATE_CLEANUP) {
jobs.put(Version.NOTIFICATION_STATE_CLEANUP, new BackfillNotifiedStateMigrationJob());
}
return jobs;
}
@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.migrations
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.BackfillNotifiedStateJob
/**
* Kicks off a background job to clean up dead notification state left behind by V318. See [BackfillNotifiedStateJob].
*/
internal class BackfillNotifiedStateMigrationJob(
parameters: Parameters = Parameters.Builder().build()
) : MigrationJob(parameters) {
companion object {
const val KEY = "BackfillNotifiedStateMigrationJob"
}
override fun getFactoryKey(): String = KEY
override fun isUiBlocking(): Boolean = false
override fun performMigration() {
BackfillNotifiedStateJob.enqueue()
}
override fun shouldRetry(e: Exception): Boolean = false
class Factory : Job.Factory<BackfillNotifiedStateMigrationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): BackfillNotifiedStateMigrationJob {
return BackfillNotifiedStateMigrationJob(parameters)
}
}
}
@@ -164,7 +164,7 @@ object SlowNotificationHeuristics {
return false
}
if (failures.size / (failures.size + successes.size) >= failurePercentage) {
if (failures.size.toFloat() / (failures.size + successes.size) >= failurePercentage) {
Log.w(TAG, "User often unable start FCM service. ${failures.size} failed : ${successes.size} successful")
return true
}
@@ -68,7 +68,7 @@ class VitalsViewModel(private val context: Application) : AndroidViewModel(conte
return@fromCallable State.PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG
}
if (havingDelayedNotifications && SlowNotificationHeuristics.shouldPromptBatterySaver()) {
if (havingDelayedNotifications && SlowNotificationHeuristics.shouldPromptBatterySaver() && SlowNotificationHeuristics.isBatteryOptimizationsOn()) {
return@fromCallable State.PROMPT_GENERAL_BATTERY_SAVER_DIALOG
}
@@ -24,6 +24,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ClearClipboardAlarmReceiver;
import org.signal.core.util.PendingIntentFlags;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.payments.Mnemonic;
import org.signal.core.util.ServiceUtil;
@@ -47,6 +48,8 @@ public class PaymentsRecoveryPhraseFragment extends Fragment {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this);
Toolbar toolbar = view.findViewById(R.id.payments_recovery_phrase_fragment_toolbar);
RecyclerView recyclerView = view.findViewById(R.id.payments_recovery_phrase_fragment_recycler);
TextView message = view.findViewById(R.id.payments_recovery_phrase_fragment_message);
@@ -20,9 +20,13 @@ import org.signal.network.NetworkResult
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkResetResult
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceUsernameChangeSyncJob
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.confirmUsernameAndCreateNewLink
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.reserveUsername
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.updateUsernameDisplayForCurrentLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.NetworkUtil
@@ -444,6 +448,9 @@ object UsernameRepository {
SignalStore.account.usernameSyncErrorCount = 0
SignalStore.misc.needsUsernameRestore = false
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[updateUsernameDisplayForCurrentLink] Successfully updated username.")
@@ -477,6 +484,9 @@ object UsernameRepository {
SignalStore.account.usernameSyncErrorCount = 0
SignalStore.misc.needsUsernameRestore = false
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[confirmUsernameAndCreateNewLink] Successfully confirmed username.")
@@ -534,6 +544,10 @@ object UsernameRepository {
SignalStore.account.usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC
SignalStore.account.usernameSyncErrorCount = 0
SignalStore.misc.needsUsernameRestore = false
if (Recipient.self().usernameSyncMessagesCapability.isSupported) {
MultiDeviceUsernameChangeSyncJob.enqueueUsernameChangeSync()
}
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
Log.i(TAG, "[deleteUsername] Successfully deleted the username.")
@@ -352,6 +352,9 @@ class Recipient(
sealedSenderAccessModeValue
}
/** The user's capability to receive username sync messages */
val usernameSyncMessagesCapability: Capability = capabilities.usernameSyncMessages
/** The wallpaper to render as the chat background, if present. */
val wallpaper: ChatWallpaper?
get() {
@@ -312,7 +312,7 @@ private fun Content(
if (!model.isSelf && model.systemContact) {
AboutRow(
startIcon = ImageVector.vectorResource(id = R.drawable.symbol_person_circle_24),
startIcon = ImageVector.vectorResource(id = CoreUiR.drawable.symbol_person_circle_24),
text = stringResource(id = R.string.AboutSheet__s_is_in_your_system_contacts, model.shortName),
modifier = Modifier.fillMaxWidth()
)

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