Fix several bugs in the local backup restore flow.

This commit is contained in:
Alex Hart
2026-03-24 15:39:09 -03:00
committed by Cody Henthorne
parent 089d8a50b2
commit 9941b2d123
9 changed files with 221 additions and 154 deletions

View File

@@ -26,6 +26,7 @@ import org.signal.core.util.androidx.DocumentFileUtil.newFile
import org.signal.core.util.androidx.DocumentFileUtil.outputStream
import org.signal.core.util.androidx.DocumentFileUtil.renameTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import java.io.File
import java.io.IOException
import java.io.InputStream
@@ -41,7 +42,7 @@ import java.util.concurrent.atomic.AtomicInteger
* Provide a domain-specific interface to the root file system backing a local directory based archive.
*/
@Suppress("JoinDeclarationAndAssignment")
class ArchiveFileSystem private constructor(private val context: Context, root: DocumentFile) {
class ArchiveFileSystem private constructor(private val context: Context, root: DocumentFile, readOnly: Boolean = false) {
companion object {
val TAG = Log.tag(ArchiveFileSystem::class.java)
@@ -51,7 +52,8 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
const val TEMP_BACKUP_DIRECTORY_SUFFIX: String = "tmp"
/**
* Attempt to create an [ArchiveFileSystem] from a tree [Uri].
* Attempt to create an [ArchiveFileSystem] from a tree [Uri], creating the necessary directory
* structure if it does not already exist. Use this when writing backups.
*
* Should likely only be called on API29+
*/
@@ -62,7 +64,25 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
return null
}
return ArchiveFileSystem(context, root)
return ArchiveFileSystem(context, root, readOnly = false)
}
/**
* Attempt to open an existing [ArchiveFileSystem] from a tree [Uri] without creating any
* directories or files. Use this when reading/restoring backups.
*
* Should likely only be called on API29+
*/
fun openForRestore(context: Context, uri: Uri): ArchiveFileSystem? {
val root = DocumentFile.fromTreeUri(context, uri) ?: return null
if (!root.canRead()) return null
if (root.findFile(MAIN_DIRECTORY_NAME) == null) return null
return try {
ArchiveFileSystem(context, root, readOnly = true)
} catch (e: IOException) {
Log.w(TAG, "Unable to open backup directory for restore: $uri", e)
null
}
}
/**
@@ -71,7 +91,7 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
* Should likely only be called on < API29.
*/
fun fromFile(context: Context, backupDirectory: File): ArchiveFileSystem {
return ArchiveFileSystem(context, DocumentFile.fromFile(backupDirectory))
return ArchiveFileSystem(context, DocumentFile.fromFile(backupDirectory), readOnly = false)
}
fun openInputStream(context: Context, uri: Uri): InputStream? {
@@ -85,9 +105,22 @@ class ArchiveFileSystem private constructor(private val context: Context, root:
val filesFileSystem: FilesFileSystem
init {
signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory")
val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory")
filesFileSystem = FilesFileSystem(context, filesDirectory)
if (readOnly) {
signalBackups = root.findFile(MAIN_DIRECTORY_NAME) ?: throw IOException("SignalBackups directory not found in $root")
val filesDirectory = signalBackups.findFile("files") ?: throw IOException("files directory not found in $signalBackups")
filesFileSystem = FilesFileSystem(context, filesDirectory, readOnly = true)
} else {
signalBackups = root.mkdirp(MAIN_DIRECTORY_NAME) ?: throw IOException("Unable to create main backups directory")
val filesDirectory = signalBackups.mkdirp("files") ?: throw IOException("Unable to create files directory")
filesFileSystem = FilesFileSystem(context, filesDirectory)
val hintFileName = context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_name)
if (!root.hasFile(hintFileName)) {
root.createFile("text/plain", hintFileName)
?.outputStream(context)
?.use { out -> out.write(context.getString(R.string.ArchiveFileSystem__select_this_folder_hint_body).toByteArray()) }
}
}
}
/**
@@ -258,7 +291,7 @@ class SnapshotFileSystem(private val context: Context, private val snapshotDirec
/**
* Domain specific file system access for accessing backup files (e.g., attachments, media, etc.).
*/
class FilesFileSystem(private val context: Context, private val root: DocumentFile) {
class FilesFileSystem(private val context: Context, private val root: DocumentFile, readOnly: Boolean = false) {
companion object {
private val TAG = Log.tag(FilesFileSystem::class.java)
@@ -271,11 +304,15 @@ class FilesFileSystem(private val context: Context, private val root: DocumentFi
.mapNotNull { f -> f.name?.let { name -> name to f } }
.toMap()
subFolders = (0..255)
.map { i -> i.toString(16).padStart(2, '0') }
.associateWith { name ->
existingFolders[name] ?: root.createDirectory(name)!!
}
subFolders = if (readOnly) {
existingFolders
} else {
(0..255)
.map { i -> i.toString(16).padStart(2, '0') }
.associateWith { name ->
existingFolders[name] ?: root.createDirectory(name)!!
}
}
}
/**

View File

@@ -86,7 +86,7 @@ class EnterBackupKeyViewModel : ViewModel() {
val aep = AccountEntropyPool.parseOrNull(backupKey) ?: return false
val dirUri = SignalStore.backup.newLocalBackupsDirectory ?: return false
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(dirUri)) ?: return false
val archiveFileSystem = ArchiveFileSystem.openForRestore(AppDependencies.application, Uri.parse(dirUri)) ?: return false
val snapshot = archiveFileSystem.listSnapshots().firstOrNull { it.timestamp == selectedTimestamp } ?: return false
val snapshotFs = SnapshotFileSystem(AppDependencies.application, snapshot.file)

View File

@@ -93,7 +93,7 @@ class RestoreLocalBackupActivityViewModel : ViewModel() {
return@launch
}
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(backupDirectory))
val archiveFileSystem = ArchiveFileSystem.openForRestore(AppDependencies.application, Uri.parse(backupDirectory))
if (archiveFileSystem == null) {
Log.w(TAG, "Unable to access backup directory: $backupDirectory")
internalState.update { it.copy(restorePhase = RestorePhase.FAILED) }

View File

@@ -118,7 +118,7 @@ class RestoreLocalBackupFragment : ComposeFragment() {
restoreLocalBackupViewModel.setSelectedBackup(backup)
}
override fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
override suspend fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
return restoreLocalBackupViewModel.setSelectedBackupDirectory(context, uri)
}

View File

@@ -9,9 +9,15 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@@ -19,6 +25,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Launchers
import org.signal.core.ui.contracts.OpenDocumentContract
import org.signal.core.ui.navigation.BottomSheetSceneStrategy
@@ -41,14 +48,17 @@ fun RestoreLocalBackupNavDisplay(
val bottomSheetStrategy = remember { BottomSheetSceneStrategy<NavKey>() }
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val folderLauncher = Launchers.rememberOpenDocumentTreeLauncher {
if (it != null) {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(it, takeFlags)
if (callback.setSelectedBackupDirectory(context, it)) {
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupScreen)
scope.launch {
if (callback.setSelectedBackupDirectory(context, it)) {
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupScreen)
}
}
}
}
@@ -59,133 +69,140 @@ fun RestoreLocalBackupNavDisplay(
}
}
NavDisplay(
backStack = backstack,
sceneStrategy = bottomSheetStrategy,
entryProvider = entryProvider {
entry<RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen> {
SelectLocalBackupTypeScreen(
onSelectBackupFolderClick = {
backstack.add(RestoreLocalBackupNavKey.FolderInstructionSheet)
},
onSelectBackupFileClick = {
backstack.add(RestoreLocalBackupNavKey.FileInstructionSheet)
},
onCancelClick = {
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
}
)
}
Box(modifier = Modifier.fillMaxSize()) {
NavDisplay(
backStack = backstack,
sceneStrategy = bottomSheetStrategy,
entryProvider = entryProvider {
entry<RestoreLocalBackupNavKey.SelectLocalBackupTypeScreen> {
SelectLocalBackupTypeScreen(
onSelectBackupFolderClick = {
backstack.add(RestoreLocalBackupNavKey.FolderInstructionSheet)
},
onSelectBackupFileClick = {
backstack.add(RestoreLocalBackupNavKey.FileInstructionSheet)
},
onCancelClick = {
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
}
)
}
entry<RestoreLocalBackupNavKey.FileInstructionSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
SelectYourBackupFileSheetContent(onContinueClick = {
fileLauncher.launch(OpenDocumentContract.Input())
})
}
entry<RestoreLocalBackupNavKey.FileInstructionSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
SelectYourBackupFileSheetContent(onContinueClick = {
fileLauncher.launch(OpenDocumentContract.Input())
})
}
entry<RestoreLocalBackupNavKey.FolderInstructionSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
SelectYourBackupFolderSheetContent(onContinueClick = {
folderLauncher.launch(null)
})
}
entry<RestoreLocalBackupNavKey.FolderInstructionSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
SelectYourBackupFolderSheetContent(onContinueClick = {
folderLauncher.launch(null)
})
}
entry<RestoreLocalBackupNavKey.SelectLocalBackupScreen> {
SelectLocalBackupScreen(
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
isSelectedBackupLatest = state.selectedBackup == state.selectableBackups.firstOrNull(),
onRestoreBackupClick = {
backstack.add(RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen)
},
onCancelClick = {
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
},
onChooseADifferentBackupClick = {
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupSheet)
}
)
}
entry<RestoreLocalBackupNavKey.SelectLocalBackupScreen> {
SelectLocalBackupScreen(
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
isSelectedBackupLatest = state.selectedBackup == state.selectableBackups.firstOrNull(),
onRestoreBackupClick = {
backstack.add(RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen)
},
onCancelClick = {
backPressedDispatcher?.onBackPressedDispatcher?.onBackPressed()
},
onChooseADifferentBackupClick = {
backstack.add(RestoreLocalBackupNavKey.SelectLocalBackupSheet)
}
)
}
entry<RestoreLocalBackupNavKey.SelectLocalBackupSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
val dismissSheet = LocalBottomSheetDismiss.current
SelectLocalBackupSheetContent(
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
selectableBackups = state.selectableBackups,
onBackupSelected = {
callback.setSelectedBackup(it)
dismissSheet()
}
)
}
entry<RestoreLocalBackupNavKey.SelectLocalBackupSheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
val dismissSheet = LocalBottomSheetDismiss.current
SelectLocalBackupSheetContent(
selectedBackup = requireNotNull(state.selectedBackup) { "No chosen backup." },
selectableBackups = state.selectableBackups,
onBackupSelected = {
callback.setSelectedBackup(it)
dismissSheet()
}
)
}
entry<RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen> {
EnterLocalBackupKeyScreen(
backupKey = backupKey,
isRegistrationInProgress = isRegistrationInProgress,
isBackupKeyValid = enterBackupKeyState.backupKeyValid,
aepValidationError = enterBackupKeyState.aepValidationError,
onBackupKeyChanged = callback::onBackupKeyChanged,
onNextClicked = callback::submitBackupKey,
onNoBackupKeyClick = {
backstack.add(RestoreLocalBackupNavKey.NoRecoveryKeySheet)
},
showRegistrationError = enterBackupKeyState.showRegistrationError,
registerAccountResult = enterBackupKeyState.registerAccountResult,
onRegistrationErrorDismiss = callback::clearRegistrationError,
onBackupKeyHelp = callback::onBackupKeyHelp
)
}
entry<RestoreLocalBackupNavKey.EnterLocalBackupKeyScreen> {
EnterLocalBackupKeyScreen(
backupKey = backupKey,
isRegistrationInProgress = isRegistrationInProgress,
isBackupKeyValid = enterBackupKeyState.backupKeyValid,
aepValidationError = enterBackupKeyState.aepValidationError,
onBackupKeyChanged = callback::onBackupKeyChanged,
onNextClicked = callback::submitBackupKey,
onNoBackupKeyClick = {
backstack.add(RestoreLocalBackupNavKey.NoRecoveryKeySheet)
},
showRegistrationError = enterBackupKeyState.showRegistrationError,
registerAccountResult = enterBackupKeyState.registerAccountResult,
onRegistrationErrorDismiss = callback::clearRegistrationError,
onBackupKeyHelp = callback::onBackupKeyHelp
)
}
entry<RestoreLocalBackupNavKey.NoRecoveryKeySheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
val dismissSheet = LocalBottomSheetDismiss.current
NoRecoveryKeySheetContent(
onSkipAndDontRestoreClick = {
dismissSheet()
callback.displaySkipRestoreWarning()
},
onLearnMoreClick = {
// TODO
}
)
entry<RestoreLocalBackupNavKey.NoRecoveryKeySheet>(
metadata = BottomSheetSceneStrategy.bottomSheet()
) {
val dismissSheet = LocalBottomSheetDismiss.current
NoRecoveryKeySheetContent(
onSkipAndDontRestoreClick = {
dismissSheet()
callback.displaySkipRestoreWarning()
},
onLearnMoreClick = {
// TODO
}
)
}
}
)
RestoreLocalBackupDialogDisplay(
dialog = state.dialog,
onDialogConfirmed = {
when (it) {
RestoreLocalBackupDialog.SKIP_RESTORE_WARNING -> callback.skipRestore()
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> callback.confirmRestoreWithDifferentAccount()
else -> Unit
}
},
onDialogDenied = {
when (it) {
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> callback.denyRestoreWithDifferentAccount()
else -> Unit
}
},
onDismiss = callback::clearDialog
)
if (state.isLoadingBackupDirectory) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
)
RestoreLocalBackupDialogDisplay(
dialog = state.dialog,
onDialogConfirmed = {
when (it) {
RestoreLocalBackupDialog.SKIP_RESTORE_WARNING -> callback.skipRestore()
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> callback.confirmRestoreWithDifferentAccount()
else -> Unit
}
},
onDialogDenied = {
when (it) {
RestoreLocalBackupDialog.CONFIRM_DIFFERENT_ACCOUNT -> callback.denyRestoreWithDifferentAccount()
else -> Unit
}
},
onDismiss = callback::clearDialog
)
}
}
data class RestoreLocalBackupState(
val dialog: RestoreLocalBackupDialog? = null,
val selectedBackup: SelectableBackup? = null,
val selectableBackups: PersistentList<SelectableBackup> = persistentListOf()
val selectableBackups: PersistentList<SelectableBackup> = persistentListOf(),
val isLoadingBackupDirectory: Boolean = false
)
interface RestoreLocalBackupCallback {
fun setSelectedBackup(backup: SelectableBackup)
fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean
suspend fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean
fun displaySkipRestoreWarning()
fun clearDialog()
fun skipRestore()
@@ -199,7 +216,7 @@ interface RestoreLocalBackupCallback {
object Empty : RestoreLocalBackupCallback {
override fun setSelectedBackup(backup: SelectableBackup) = Unit
override fun setSelectedBackupDirectory(context: Context, uri: Uri) = false
override suspend fun setSelectedBackupDirectory(context: Context, uri: Uri) = false
override fun displaySkipRestoreWarning() = Unit
override fun clearDialog() = Unit
override fun skipRestore() = Unit

View File

@@ -40,41 +40,51 @@ class RestoreLocalBackupViewModel : ViewModel() {
internalState.update { it.copy(selectedBackup = backup) }
}
fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
suspend fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
internalState.update { it.copy(isLoadingBackupDirectory = true) }
val archiveFileSystem = ArchiveFileSystem.fromUri(context, uri)
val archiveFileSystem = withContext(Dispatchers.IO) { ArchiveFileSystem.openForRestore(context, uri) }
if (archiveFileSystem == null) {
Log.w(TAG, "Unable to access backup directory: $uri")
internalState.update { it.copy(selectedBackup = null, selectableBackups = persistentListOf(), dialog = RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE) }
internalState.update { it.copy(isLoadingBackupDirectory = false, selectedBackup = null, selectableBackups = persistentListOf(), dialog = RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE) }
return false
}
val selectableBackups = archiveFileSystem
.listSnapshots()
.take(2)
.map { snapshot ->
val dateLabel = if (DateUtils.isSameDay(System.currentTimeMillis(), snapshot.timestamp)) {
context.getString(R.string.DateUtils_today)
} else {
DateUtils.formatDateWithYear(Locale.getDefault(), snapshot.timestamp)
}
val timeLabel = DateUtils.getOnlyTimeString(context, snapshot.timestamp)
val sizeBytes = SnapshotFileSystem(context, snapshot.file).mainLength() ?: 0L
val selectableBackups = withContext(Dispatchers.IO) {
archiveFileSystem
.listSnapshots()
.take(2)
.map { snapshot ->
val dateLabel = if (DateUtils.isSameDay(System.currentTimeMillis(), snapshot.timestamp)) {
context.getString(R.string.DateUtils_today)
} else {
DateUtils.formatDateWithYear(Locale.getDefault(), snapshot.timestamp)
}
val timeLabel = DateUtils.getOnlyTimeString(context, snapshot.timestamp)
val sizeBytes = SnapshotFileSystem(context, snapshot.file).mainLength() ?: 0L
SelectableBackup(
timestamp = snapshot.timestamp,
backupTime = "$dateLabel$timeLabel",
backupSize = sizeBytes.bytes.toUnitString()
)
}
.toPersistentList()
SelectableBackup(
timestamp = snapshot.timestamp,
backupTime = "$dateLabel$timeLabel",
backupSize = sizeBytes.bytes.toUnitString()
)
}
.toPersistentList()
}
if (selectableBackups.isEmpty()) {
Log.w(TAG, "No snapshots found in backup directory: $uri")
internalState.update { it.copy(isLoadingBackupDirectory = false, selectedBackup = null, selectableBackups = persistentListOf(), dialog = RestoreLocalBackupDialog.FAILED_TO_LOAD_ARCHIVE) }
return false
}
internalState.update {
it.copy(
isLoadingBackupDirectory = false,
selectableBackups = selectableBackups,
selectedBackup = selectableBackups.firstOrNull()
selectedBackup = selectableBackups.first()
)
}
return true
@@ -94,7 +104,7 @@ class RestoreLocalBackupViewModel : ViewModel() {
val aep = requireNotNull(AccountEntropyPool.parseOrNull(backupKey)) { "Backup key must be valid at submission time" }
val messageBackupKey = aep.deriveMessageBackupKey()
val dirUri = requireNotNull(SignalStore.backup.newLocalBackupsDirectory) { "Backup directory must be set" }
val archiveFileSystem = requireNotNull(ArchiveFileSystem.fromUri(context, Uri.parse(dirUri))) { "Backup directory must be accessible" }
val archiveFileSystem = requireNotNull(ArchiveFileSystem.openForRestore(context, Uri.parse(dirUri))) { "Backup directory must be accessible" }
val snapshot = requireNotNull(archiveFileSystem.listSnapshots().firstOrNull { it.timestamp == timestamp }) { "Selected snapshot must still exist" }
val snapshotFs = SnapshotFileSystem(context, snapshot.file)
val actualBackupId = LocalArchiver.getBackupId(snapshotFs, messageBackupKey)

View File

@@ -74,7 +74,7 @@ class PostRegistrationRestoreLocalBackupFragment : ComposeFragment() {
restoreLocalBackupViewModel.setSelectedBackup(backup)
}
override fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
override suspend fun setSelectedBackupDirectory(context: Context, uri: Uri): Boolean {
return restoreLocalBackupViewModel.setSelectedBackupDirectory(context, uri)
}

View File

@@ -4937,6 +4937,8 @@
<string name="BackupDialog_please_acknowledge_your_understanding_by_marking_the_confirmation_check_box">Please acknowledge your understanding by marking the confirmation check box.</string>
<string name="BackupDialog_delete_backups">Delete backups?</string>
<string name="BackupDialog_disable_and_delete_all_local_backups">Disable and delete all local backups?</string>
<string name="ArchiveFileSystem__select_this_folder_hint_name">Select this folder, not SignalBackups</string>
<string name="ArchiveFileSystem__select_this_folder_hint_body">When configuring Signal backup storage, select this folder — not the SignalBackups subfolder.</string>
<string name="BackupDialog_delete_backups_statement">Delete backups</string>
<!-- Progress dialog message shown while local backup files are being deleted -->
<string name="BackupDialog_deleting_local_backup">Deleting local backup…</string>

View File

@@ -227,10 +227,11 @@ class EncryptedBackupReader private constructor(
try {
val length = stream.readVarInt32().also { if (it < 0) return null }
val frameBytes: ByteArray = stream.readNBytesOrThrow(length)
return Frame.ADAPTER.decode(frameBytes)
} catch (e: EOFException) {
return null
} catch (e: IOException) {
return read()
}
}