From 59403e7da81d6cdce153cd128095487fc653d409 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 18 Nov 2024 08:47:31 -0500 Subject: [PATCH] Added a Storage Service Playground screen. --- .../securesms/backup/v2/BackupRepository.kt | 2 +- .../app/internal/InternalSettingsFragment.kt | 8 + ...nternalStorageServicePlaygroundFragment.kt | 358 ++++++++++++++++++ ...ternalStorageServicePlaygroundViewModel.kt | 74 ++++ .../jobs/StorageRotateManifestJob.kt | 2 +- .../securesms/keyvalue/InternalValues.kt | 2 + .../securesms/migrations/AepMigrationJob.kt | 10 + .../securesms/recipients/Recipient.kt | 3 +- .../app_settings_with_change_number.xml | 8 + .../signalservice/api/storage/SignalRecord.kt | 2 +- .../api/storage/StorageServiceRepository.kt | 3 +- 11 files changed, 467 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 6479c9af3d..640d77181f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -157,7 +157,7 @@ object BackupRepository { /** * Checks whether or not we do not have enough storage space for our remaining attachments to be downloaded. - * Called from the attachment / thumbnail download jobs. + * Caller from the attachment / thumbnail download jobs. */ fun checkForOutOfStorageError(tag: String): Boolean { val availableSpace = getFreeStorageSpace() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 2571f2c2b5..e8b765d97c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -178,6 +178,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + clickPref( + title = DSLSettingsText.from("Storage Service Playground"), + summary = DSLSettingsText.from("Test and view storage service stuff."), + onClick = { + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalStorageServicePlaygroundFragment()) + } + ) + switchPref( title = DSLSettingsText.from("'Internal Details' button"), summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt new file mode 100644 index 0000000000..01c14c23d3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundFragment.kt @@ -0,0 +1,358 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.storage + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dividers +import org.signal.core.ui.Previews +import org.signal.core.ui.Rows +import org.signal.core.ui.Rows.TextAndLabel +import org.signal.core.ui.SignalPreview +import org.signal.core.util.Hex +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.internal.storage.InternalStorageServicePlaygroundViewModel.OneOffEvent +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.StorageForcePushJob +import org.thoughtcrime.securesms.jobs.StorageSyncJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.storage.RecordIkm +import org.whispersystems.signalservice.api.storage.SignalStorageManifest +import org.whispersystems.signalservice.api.storage.SignalStorageRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.StorageKey +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord +import org.whispersystems.signalservice.internal.storage.protos.StorageRecord + +class InternalStorageServicePlaygroundFragment : ComposeFragment() { + + val viewModel: InternalStorageServicePlaygroundViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val manifest by viewModel.manifest + val storageRecords by viewModel.storageRecords + val oneOffEvent by viewModel.oneOffEvents + var forceSsreToggled by remember { mutableStateOf(SignalStore.internal.forceSsre2Capability) } + + Screen( + onBackPressed = { findNavController().popBackStack() }, + manifest = manifest, + storageRecords = storageRecords, + oneOffEvent = oneOffEvent, + forceSsreCapability = forceSsreToggled, + onForceSsreToggled = { checked -> + SignalStore.internal.forceSsre2Capability = checked + forceSsreToggled = checked + }, + onViewTabSelected = { viewModel.onViewTabSelected() } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun Screen( + manifest: SignalStorageManifest, + storageRecords: List, + forceSsreCapability: Boolean, + oneOffEvent: OneOffEvent, + onForceSsreToggled: (Boolean) -> Unit = {}, + onViewTabSelected: () -> Unit = {}, + onBackPressed: () -> Unit = {} +) { + var tabIndex by remember { mutableIntStateOf(0) } + val tabs = listOf("Tools", "View") + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text("Storage Service Playground") }, + navigationIcon = { + IconButton(onClick = onBackPressed) { + Icon( + painter = painterResource(R.drawable.symbol_arrow_left_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null + ) + } + } + ) + TabRow(selectedTabIndex = tabIndex) { + tabs.forEachIndexed { index, tab -> + Tab( + text = { Text(tab) }, + selected = index == tabIndex, + onClick = { + tabIndex = index + if (tabIndex == 1) { + onViewTabSelected() + } + } + ) + } + } + } + } + ) { contentPadding -> + Surface(modifier = Modifier.padding(contentPadding)) { + when (tabIndex) { + 0 -> ToolScreen( + forceSsreCapability = forceSsreCapability, + onForceSsreToggled = onForceSsreToggled + ) + 1 -> ViewScreen( + manifest = manifest, + storageRecords = storageRecords, + oneOffEvent = oneOffEvent + ) + } + } + } +} + +@Composable +fun ToolScreen( + forceSsreCapability: Boolean, + onForceSsreToggled: (Boolean) -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + ActionRow("Enqueue StorageSyncJob", "Just a normal syncing operation.") { + AppDependencies.jobManager.add(StorageSyncJob()) + } + + ActionRow("Enqueue StorageForcePushJob", "Forces your local state over the remote.") { + AppDependencies.jobManager.add(StorageForcePushJob()) + } + + ActionRow("Reset local manifest", "Makes us think we're not at the latest version (and erases RecordIkm).") { + SignalStore.storageService.manifest = SignalStorageManifest.EMPTY + } + + ActionRow("Set initial storage key", "Initializes it to something random. Will cause a decryption failure.") { + SignalStore.storageService.storageKeyForInitialDataRestore = StorageKey(Util.getSecretBytes(32)) + } + + ActionRow("Clear initial storage key", "Sets it to null.") { + SignalStore.storageService.storageKeyForInitialDataRestore = null + } + + Rows.ToggleRow( + text = "Force SSRE2 Capability", + checked = forceSsreCapability, + onCheckChanged = onForceSsreToggled + ) + } +} + +@Composable +fun ViewScreen( + manifest: SignalStorageManifest, + storageRecords: List, + oneOffEvent: OneOffEvent +) { + val context = LocalContext.current + + LaunchedEffect(oneOffEvent) { + when (oneOffEvent) { + OneOffEvent.None -> Unit + OneOffEvent.ManifestDecryptionError -> { + Toast.makeText(context, "Failed to decrypt manifest!", Toast.LENGTH_SHORT).show() + } + OneOffEvent.StorageRecordDecryptionError -> { + Toast.makeText(context, "Failed to decrypt storage records!", Toast.LENGTH_SHORT).show() + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxHeight().padding(16.dp) + ) { + item(key = "manifest") { + ManifestRow(manifest) + Dividers.Default() + } + storageRecords.forEach { record -> + item(key = Hex.toStringCondensed(record.id.raw)) { + StorageRecordRow(record) + Dividers.Default() + } + } + } +} + +@Composable +private fun ManifestRow(manifest: SignalStorageManifest) { + Column { + ManifestItemRow("Version", manifest.versionString) + ManifestItemRow("RecordIkm", manifest.recordIkm?.value?.let { Hex.toStringCondensed(it) } ?: "null") + ManifestItemRow("Total ID count", manifest.storageIds.size.toString()) + ManifestItemRow("Unknown ID count", manifest.storageIds.filter { it.isUnknown }.size.toString()) + } +} + +@Composable +private fun ManifestItemRow(title: String, value: String) { + Row(modifier = Modifier.fillMaxWidth()) { + Text(title + ":", fontWeight = FontWeight.Bold) + Spacer(Modifier.width(6.dp)) + Text(value) + } +} + +@Composable +private fun StorageRecordRow(record: SignalStorageRecord) { + Row(modifier = Modifier.fillMaxWidth()) { + when { + record.proto.account != null -> { + Column { + Text("Account", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + record.proto.contact != null -> { + Column { + Text("Contact", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + record.proto.groupV1 != null -> { + Column { + Text("GV1", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + record.proto.groupV2 != null -> { + Column { + Text("GV2", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + record.proto.callLink != null -> { + Column { + Text("Call Link", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + record.proto.storyDistributionList != null -> { + Column { + Text("Distribution List", fontWeight = FontWeight.Bold) + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + else -> { + Column { + Text("Unknown!") + ManifestItemRow("ID", Hex.toStringCondensed(record.id.raw)) + } + } + } + } +} + +@Composable +private fun ActionRow(title: String, subtitle: String, onClick: () -> Unit) { + Row( + modifier = Modifier + .padding(Rows.defaultPadding()) + .fillMaxWidth() + ) { + TextAndLabel(text = title, label = subtitle) + Spacer(Modifier.width(8.dp)) + RunButton { onClick() } + } +} + +@Composable +private fun RunButton(onClick: () -> Unit) { + Buttons.LargeTonal(onClick = onClick) { + Text("Run") + } +} + +@SignalPreview +@Composable +fun ScreenPreview() { + Previews.Preview { + Screen( + forceSsreCapability = true, + manifest = SignalStorageManifest.EMPTY, + storageRecords = emptyList(), + oneOffEvent = OneOffEvent.None + ) + } +} + +@SignalPreview +@Composable +fun ViewScreenPreview() { + val storageRecords = listOf( + SignalStorageRecord( + id = StorageId.forContact(byteArrayOf(1)), + proto = StorageRecord( + contact = ContactRecord() + ) + ), + SignalStorageRecord( + id = StorageId.forContact(byteArrayOf(2)), + proto = StorageRecord( + contact = ContactRecord() + ) + ) + ) + + Previews.Preview { + ViewScreen( + manifest = SignalStorageManifest( + version = 43, + sourceDeviceId = 2, + recordIkm = RecordIkm(ByteArray(32) { 1 }), + storageIds = storageRecords.map { it.id } + ), + storageRecords = storageRecords, + oneOffEvent = OneOffEvent.None + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundViewModel.kt new file mode 100644 index 0000000000..c19a7ce348 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/storage/InternalStorageServicePlaygroundViewModel.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.storage + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.storage.SignalStorageManifest +import org.whispersystems.signalservice.api.storage.SignalStorageRecord +import org.whispersystems.signalservice.api.storage.StorageServiceRepository + +class InternalStorageServicePlaygroundViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(InternalStorageServicePlaygroundViewModel::class) + } + + private val _manifest: MutableState = mutableStateOf(SignalStorageManifest.EMPTY) + val manifest: State + get() = _manifest + + private val _storageItems: MutableState> = mutableStateOf(emptyList()) + val storageRecords: State> + get() = _storageItems + + private val _oneOffEvents: MutableState = mutableStateOf(OneOffEvent.None) + val oneOffEvents: State + get() = _oneOffEvents + + fun onViewTabSelected() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + val repository = StorageServiceRepository(AppDependencies.storageServiceApi) + val storageKey = SignalStore.storageService.storageKeyForInitialDataRestore ?: SignalStore.storageService.storageKey + + val manifest = when (val result = repository.getStorageManifest(storageKey)) { + is StorageServiceRepository.ManifestResult.Success -> result.manifest + else -> { + Log.w(TAG, "Failed to fetch manifest!") + _oneOffEvents.value = OneOffEvent.ManifestDecryptionError + return@withContext + } + } + _manifest.value = manifest + + val records = when (val result = repository.readStorageRecords(storageKey, manifest.recordIkm, manifest.storageIds)) { + is StorageServiceRepository.StorageRecordResult.Success -> result.records + else -> { + Log.w(TAG, "Failed to fetch records!") + _oneOffEvents.value = OneOffEvent.StorageRecordDecryptionError + return@withContext + } + } + + _storageItems.value = records + } + } + } + + enum class OneOffEvent { + None, ManifestDecryptionError, StorageRecordDecryptionError + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt index be28a95517..35aa134442 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageRotateManifestJob.kt @@ -88,7 +88,7 @@ class StorageRotateManifestJob private constructor(parameters: Parameters) : Job return when (val result = repository.writeUnchangedManifest(storageServiceKey, manifestWithNewVersion)) { StorageServiceRepository.WriteStorageRecordsResult.Success -> { - Log.i(TAG, "Successfully rotated the manifest. Clearing restore key.") + Log.i(TAG, "Successfully rotated the manifest as version ${manifestWithNewVersion.version}.${manifestWithNewVersion.sourceDeviceId}. Clearing restore key.") SignalStore.storageService.storageKeyForInitialDataRestore = null Result.success() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt index fb46165875..2ab6473b6b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt @@ -151,6 +151,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal var webSocketShadowingStats by nullableBlobValue(WEB_SOCKET_SHADOWING_STATS, null).defaultForExternalUsers() + var forceSsre2Capability by booleanValue("internal.force_ssre2_capability", false).defaultForExternalUsers() + private fun SignalStoreValueDelegate.defaultForExternalUsers(): SignalStoreValueDelegate { return this.withPrecondition { RemoteConfig.internalUser } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/AepMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/AepMigrationJob.kt index 3ffcbb570d..1b39c9c3a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/AepMigrationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/AepMigrationJob.kt @@ -3,8 +3,10 @@ package org.thoughtcrime.securesms.migrations import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.MultiDeviceKeysUpdateJob import org.thoughtcrime.securesms.jobs.StorageForcePushJob import org.thoughtcrime.securesms.jobs.Svr2MirrorJob +import org.thoughtcrime.securesms.keyvalue.SignalStore /** * Migration for when we introduce the Account Entropy Pool (AEP). @@ -23,7 +25,15 @@ internal class AepMigrationJob( override fun isUiBlocking(): Boolean = false override fun performMigration() { + if (!SignalStore.account.isRegistered) { + Log.w(TAG, "Not registered! Skipping.") + return + } + AppDependencies.jobManager.add(Svr2MirrorJob()) + if (SignalStore.account.hasLinkedDevices) { + AppDependencies.jobManager.add(MultiDeviceKeysUpdateJob()) + } AppDependencies.jobManager.add(StorageForcePushJob()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index 55f9bd2062..4dc9baa1d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -322,7 +322,8 @@ class Recipient( val versionedExpirationTimerCapability: Capability = capabilities.versionedExpirationTimer /** The user's capability to handle the new storage record encryption scheme. */ - val storageServiceEncryptionV2Capability: Capability = capabilities.storageServiceEncryptionV2 + val storageServiceEncryptionV2Capability: Capability + get() = if (SignalStore.internal.forceSsre2Capability) Capability.SUPPORTED else capabilities.storageServiceEncryptionV2 /** The state around whether we can send sealed sender to this user. */ val sealedSenderAccessMode: SealedSenderAccessMode = if (pni.isPresent && pni == serviceId) { diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index b105eff295..99a7f55b41 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -748,6 +748,9 @@ + + + diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt index 423c35dd9c..3945be1351 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt @@ -8,7 +8,7 @@ import kotlin.reflect.full.memberProperties /** * Pairs a storage record with its id. Also contains some useful common methods. */ -interface SignalRecord { +sealed interface SignalRecord { val id: StorageId val proto: E diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageServiceRepository.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageServiceRepository.kt index ae15b93d3e..385630194c 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageServiceRepository.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageServiceRepository.kt @@ -189,7 +189,8 @@ class StorageServiceRepository(private val storageServiceApi: StorageServiceApi) val manifestRecord = ManifestRecord( sourceDevice = signalManifest.sourceDeviceId, version = signalManifest.version, - identifiers = manifestIds + identifiers = manifestIds, + recordIkm = signalManifest.recordIkm?.value?.toByteString() ?: ByteString.EMPTY ) val manifestKey = storageKey.deriveManifestKey(signalManifest.version)