Add CallingAssetsDownloadJob to app startup to init calling assets

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
adel-signal
2026-04-02 22:32:11 -07:00
committed by Greyson Parrelli
parent 8ea90c8a43
commit f1ebd2dc81
6 changed files with 253 additions and 1 deletions

View File

@@ -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)

View File

@@ -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<CallingAssetsDownloadJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): CallingAssetsDownloadJob {
return CallingAssetsDownloadJob(parameters)
}
}
}

View File

@@ -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());

View File

@@ -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)
}

View File

@@ -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<ManifestEntry> = 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<String>(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
)
}

View File

@@ -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()));
}