diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index dd86b5f4ca..e4422db033 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob; import org.thoughtcrime.securesms.jobs.BackupRefreshJob; import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob; import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob; +import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob; import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob; import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob; import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob; @@ -102,6 +103,7 @@ 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.thoughtcrime.securesms.util.AppForegroundObserver; @@ -226,6 +228,7 @@ public class ApplicationContext extends Application implements AppForegroundObse .addPostRender(RetrieveRemoteAnnouncementsJob::enqueue) .addPostRender(AndroidTelecomUtil::registerPhoneAccount) .addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob())) + .addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob())) .addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary) .addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary) .addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CallingAssetsDownloadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallingAssetsDownloadJob.kt new file mode 100644 index 0000000000..51bf998767 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CallingAssetsDownloadJob.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint +import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.service.webrtc.CallingAssets +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +/** + * Job that downloads missing calling assets. + */ +class CallingAssetsDownloadJob private constructor(parameters: Parameters) : Job(parameters) { + companion object { + private val TAG = Log.tag(CallingAssetsDownloadJob::class) + + const val KEY = "CallingAssetsDownloadJob" + } + + constructor() : this( + Parameters.Builder() + .addConstraint(AutoDownloadEmojiConstraint.KEY) + .setLifespan(3.days.inWholeMilliseconds) + .setMaxAttempts(5) + .setMaxInstancesForFactory(1) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + var succeeded = true + if (SignalStore.misc.callingAssetsVersion != CallingAssets.CURRENT_VERSION) { + succeeded = CallingAssets.downloadMissingAssets() + } + + CallingAssets.registerAssetsIfNeeded() + + if (!succeeded) { + Log.w(TAG, "Failed to download some calling assets") + return Result.retry(BackoffUtil.exponentialBackoff(runAttempt + 1, 1.hours.inWholeMilliseconds)) + } + SignalStore.misc.callingAssetsVersion = CallingAssets.CURRENT_VERSION + return Result.success() + } + + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): CallingAssetsDownloadJob { + return CallingAssetsDownloadJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 8a8989d088..7936f8596f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -154,6 +154,7 @@ public final class JobManagerFactories { put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory()); put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory()); put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory()); + put(CallingAssetsDownloadJob.KEY, new CallingAssetsDownloadJob.Factory()); put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory()); put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory()); put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt index b57c9a8966..68d3cf0e75 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/MiscellaneousValues.kt @@ -47,8 +47,9 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto private const val HAS_KEY_TRANSPARENCY_FAILURE = "misc.has_key_transparency_failure" private const val HAS_SEEN_KEY_TRANSPARENCY_FAILURE = "misc.has_seen_key_transparency_failure" private const val CAMERA_FACING_FRONT = "misc.camera_facing_front" - private const val CAPTCHA_LAST_VIEWED_AT = "misc.captcha_last_viewed_at" private const val COMPLETED_COLLAPSED_EVENTS_MIGRATION = "misc.completed_collapsed_events_migration" + private const val CAPTCHA_LAST_VIEWED_AT = "misc.captcha_last_viewed_at" + private const val CALLING_ASSETS_VERSION = "misc.calling_assets_version" } public override fun onFirstEverAppLaunch() { @@ -324,4 +325,11 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto * The last time the user viewed the captcha/recaptcha proof activity. */ var captchaLastViewedAt: Long by longValue(CAPTCHA_LAST_VIEWED_AT, 0) + + /** + * The last successfully-downloaded calling assets version. Compared against + * [org.thoughtcrime.securesms.service.webrtc.CallingAssets.CURRENT_VERSION] to determine + * if new assets need to be fetched. + */ + var callingAssetsVersion: Int by integerValue(CALLING_ASSETS_VERSION, 0) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallingAssets.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallingAssets.kt new file mode 100644 index 0000000000..d3e5e9e7ce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallingAssets.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.service.webrtc + +import android.content.Context +import okio.IOException +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.s3.S3 +import java.io.File +import java.net.URI +import java.security.MessageDigest +import java.util.Base64 + +/** + * Manages downloading and registering calling assets (e.g. DRED weights). + */ +object CallingAssets { + private val TAG = Log.tag(CallingAssets::class) + + private const val BASE_DIRECTORY = "calling-assets" + + /** Increment this whenever an asset is added, removed, or updated. */ + const val CURRENT_VERSION = 1 + + private val ASSETS: List = listOf( + ManifestEntry( + assetGroup = "opus-dred", + name = "calling-dred_weights-1_6_1-f4aed08a.bin", + digest = "sdfpdb/u3wiTfBr2s0gx1LJX6jii4tquyax/UBThTGWTEXyOCSKjYmYV+9tKQZcO+Q1B1ReoGSW3VbvzeMGKaQ==", + url = "https://updates2.signal.org/static/android/calling/deep_plc-dred_weights-1_6_1-f4aed08a.bin", + size = 1998208 + ) + ) + + private val registeredLog = HashSet(ASSETS.size) + + /** + * Registers any downloaded assets with the call manager that haven't been registered yet this session. + * Safe to call multiple times -- assets already registered are skipped. + */ + @JvmStatic + fun registerAssetsIfNeeded() { + ASSETS.forEach { entry -> + if (registeredLog.contains(entry.name)) { + return@forEach + } + + try { + val content = getFromFile(entry.name) ?: return@forEach + if (verify(content, entry)) { + AppDependencies.signalCallManager.addAsset(entry.assetGroup, content) + registeredLog.add(entry.name) + Log.i(TAG, "Registered calling asset: ${entry.name}") + } else { + Log.w(TAG, "Invalid calling asset on disk, skipping registration: ${entry.name}") + } + } catch (e: IOException) { + Log.e(TAG, "Failed to register calling asset ${entry.name}", e) + } + } + } + + /** + * Downloads any assets not yet present on disk. + * @return true if all assets are present on disk after this call. + */ + fun downloadMissingAssets(): Boolean { + var allDownloaded = true + + ASSETS.forEach { entry -> + try { + val dataOnDisk = getFromFile(entry.name) + if (dataOnDisk != null) { + if (verify(dataOnDisk, entry)) { + Log.i(TAG, "Calling asset already on disk: ${entry.name}") + return@forEach + } else { + Log.w(TAG, "Invalid calling asset found on disk: ${entry.name}") + } + } + + val remoteData = getFromRemote(entry.url) + if (remoteData != null) { + if (verify(remoteData, entry)) { + Log.i(TAG, "Calling asset successfully downloaded: ${entry.name}") + val dir = AppDependencies.application.getDir(BASE_DIRECTORY, Context.MODE_PRIVATE) + File(dir, entry.name).writeBytes(remoteData) + return@forEach + } else { + Log.w(TAG, "Failed to verify calling asset: ${entry.name}") + } + } + + Log.w(TAG, "Unable to find or download calling asset ${entry.name}") + allDownloaded = false + } catch (e: Exception) { + Log.e(TAG, "Unexpected exception while trying to find calling asset ${entry.name}", e) + allDownloaded = false + } + } + + if (allDownloaded) { + deleteStaleAssets() + } + + return allDownloaded + } + + private fun getFromFile(assetName: String): ByteArray? { + try { + val file = File(AppDependencies.application.getDir(BASE_DIRECTORY, Context.MODE_PRIVATE), assetName) + return if (file.exists()) file.readBytes() else null + } catch (e: Exception) { + Log.e(TAG, "Exception while checking files for calling asset $assetName", e) + return null + } + } + + private fun getFromRemote(url: String): ByteArray? { + try { + val path = URI(url).path + S3.getObject(path).use { response -> + if (!response.isSuccessful) { + throw RuntimeException("Failed to download calling asset from $url: HTTP ${response.code}") + } + return response.body.bytes() + } + } catch (e: IOException) { + Log.e(TAG, "Exception while downloading calling asset from $url", e) + return null + } + } + + private fun verify(content: ByteArray, entry: ManifestEntry): Boolean { + if (content.size != entry.size) { + Log.w(TAG, "Unexpected size for calling asset ${entry.name}: expected=${entry.name},actual=${content.size}") + return false + } + val hash = MessageDigest.getInstance("SHA-512").digest(content) + val encodedHash = Base64.getEncoder().encodeToString(hash) + return encodedHash == entry.digest + } + + private fun deleteStaleAssets() { + try { + val expectedNames = ASSETS.map { it.name }.toSet() + val dir = AppDependencies.application.getDir(BASE_DIRECTORY, Context.MODE_PRIVATE) + dir.listFiles()?.forEach { file -> + if (file.name !in expectedNames) { + Log.i(TAG, "Deleting stale calling asset: ${file.name}") + file.delete() + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to clean up stale calling assets", e) + } + } + + data class ManifestEntry( + val assetGroup: String, + val name: String, + val digest: String, + val url: String, + val size: Int + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index 40f01462b2..7a48a84a07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -1364,6 +1364,13 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. return new SignalCallLinkManager(Objects.requireNonNull(callManager)); } + public void addAsset(String assetGroup, byte[] content) throws CallException { + if (callManager == null) { + throw new CallException("Unable to add asset, call manager is not initialized"); + } + callManager.addAsset(assetGroup, content); + } + public void relaunchPipOnForeground() { AppForegroundObserver.addListener(new RelaunchListener(AppForegroundObserver.isForegrounded())); }