PM-29795: Move FileManager to data module (#6268)

This commit is contained in:
David Perez
2025-12-15 12:19:32 -06:00
committed by GitHub
parent b4414073c7
commit bdbcd5bdc2
22 changed files with 47 additions and 40 deletions

View File

@@ -54,6 +54,7 @@ dependencies {
implementation(libs.google.hilt.android)
ksp(libs.google.hilt.compiler)
implementation(libs.kotlinx.serialization)
implementation(libs.square.okhttp)
implementation(libs.timber)
// Pull in test fixtures from other modules

View File

@@ -1,10 +1,14 @@
package com.bitwarden.data.manager.di
import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.BitwardenPackageManager
import com.bitwarden.data.manager.BitwardenPackageManagerImpl
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.data.manager.NativeLibraryManagerImpl
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.data.manager.file.FileManagerImpl
import com.bitwarden.network.service.DownloadService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -25,6 +29,18 @@ object DataManagerModule {
@ApplicationContext context: Context,
): BitwardenPackageManager = BitwardenPackageManagerImpl(context = context)
@Provides
@Singleton
fun provideFileManager(
@ApplicationContext context: Context,
downloadService: DownloadService,
dispatcherManager: DispatcherManager,
): FileManager = FileManagerImpl(
context = context,
downloadService = downloadService,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideNativeLibraryManager(): NativeLibraryManager = NativeLibraryManagerImpl()

View File

@@ -0,0 +1,64 @@
package com.bitwarden.data.manager.file
import android.net.Uri
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.data.manager.model.DownloadResult
import com.bitwarden.data.manager.model.ZipFileResult
import java.io.File
/**
* Manages reading files.
*/
@OmitFromCoverage
interface FileManager {
/**
* Absolute path to the private file storage directory.
*/
val filesDirectory: String
/**
* Absolute path to the private logs storage directory.
*/
val logsDirectory: String
/**
* Deletes [files] from disk.
*/
suspend fun delete(vararg files: File)
/**
* Downloads a file temporarily to cache from [url]. A successful [DownloadResult] will contain
* the final file path.
*/
suspend fun downloadFileToCache(url: String): DownloadResult
/**
* Writes an existing [file] to a [fileUri]. `true` will be returned if the file was
* successfully saved.
*/
suspend fun fileToUri(fileUri: Uri, file: File): Boolean
/**
* Writes an [dataString] to a [fileUri]. `true` will be returned if the file was
* successfully saved.
*/
suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean
/**
* Reads the [fileUri] into memory. A successful result will contain the raw [ByteArray].
*/
suspend fun uriToByteArray(fileUri: Uri): Result<ByteArray>
/**
* Reads the file or folder on disk from the [uri] and creates a temporary zip file. A
* successful result will contain the zip [File] reference.
*/
suspend fun zipUriToCache(uri: Uri): ZipFileResult
/**
* Reads the [fileUri] into a file on disk. A successful result will contain the [File]
* reference.
*/
suspend fun writeUriToCache(fileUri: Uri): Result<File>
}

View File

@@ -0,0 +1,233 @@
@file:OmitFromCoverage
package com.bitwarden.data.manager.file
import android.content.Context
import android.net.Uri
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.sdkAgnosticTransferTo
import com.bitwarden.data.manager.model.DownloadResult
import com.bitwarden.data.manager.model.ZipFileResult
import com.bitwarden.network.service.DownloadService
import kotlinx.coroutines.withContext
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* The buffer size to be used when reading from an input stream.
*/
private const val BUFFER_SIZE: Int = 1024
/**
* The default implementation of the [FileManager] interface.
*/
internal class FileManagerImpl(
private val context: Context,
private val downloadService: DownloadService,
private val dispatcherManager: DispatcherManager,
) : FileManager {
override val filesDirectory: String
get() = context.filesDir.absolutePath
override val logsDirectory: String
get() = "${context.dataDir}/logs"
override suspend fun delete(vararg files: File) {
withContext(dispatcherManager.io) {
files.forEach { it.delete() }
}
}
@Suppress("NestedBlockDepth")
override suspend fun downloadFileToCache(url: String): DownloadResult {
val response = downloadService
.getDataStream(url)
.fold(
onSuccess = { it },
onFailure = { return DownloadResult.Failure(error = it) },
)
// Create a temporary file in cache to write to
val file = File(context.cacheDir, UUID.randomUUID().toString())
withContext(dispatcherManager.io) {
val stream = response.byteStream()
stream.use {
val buffer = ByteArray(BUFFER_SIZE)
var progress = 0
FileOutputStream(file).use { fos ->
@Suppress("TooGenericExceptionCaught")
try {
var read = stream.read(buffer)
while (read > 0) {
fos.write(buffer, 0, read)
progress += read
read = stream.read(buffer)
}
fos.flush()
} catch (e: RuntimeException) {
return@withContext DownloadResult.Failure(error = e)
}
}
}
}
return DownloadResult.Success(file)
}
@Suppress("NestedBlockDepth")
override suspend fun fileToUri(fileUri: Uri, file: File): Boolean {
@Suppress("TooGenericExceptionCaught")
return try {
withContext(dispatcherManager.io) {
context
.contentResolver
.openOutputStream(fileUri)
?.use { outputStream ->
FileInputStream(file).use { inputStream ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } != -1) {
outputStream.write(buffer, 0, length)
}
}
}
}
true
} catch (_: RuntimeException) {
false
}
}
override suspend fun stringToUri(fileUri: Uri, dataString: String): Boolean {
@Suppress("TooGenericExceptionCaught")
return try {
withContext(dispatcherManager.io) {
context
.contentResolver
.openOutputStream(fileUri)
?.use { outputStream ->
outputStream.write(dataString.toByteArray())
}
}
true
} catch (_: RuntimeException) {
false
}
}
override suspend fun uriToByteArray(fileUri: Uri): Result<ByteArray> =
runCatching {
withContext(dispatcherManager.io) {
context
.contentResolver
.openInputStream(fileUri)
?.use { inputStream ->
ByteArrayOutputStream().use { outputStream ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } != -1) {
outputStream.write(buffer, 0, length)
}
outputStream.toByteArray()
}
}
?: throw IllegalStateException("Stream has crashed")
}
}
override suspend fun zipUriToCache(
uri: Uri,
): ZipFileResult =
runCatching {
withContext(dispatcherManager.io) {
val sourceFile = File(uri.toString())
if (!sourceFile.exists()) {
ZipFileResult.NothingToZip
} else {
val zipFile = File.createTempFile(
"bitwarden_flight_recorder",
".zip",
context.cacheDir,
)
FileOutputStream(zipFile).use { fos ->
BufferedOutputStream(fos).use { bos ->
ZipOutputStream(bos).use { zos ->
zos.zipFiles(sourceFile = sourceFile)
}
}
}
ZipFileResult.Success(file = zipFile)
}
}
}
.fold(
onFailure = { ZipFileResult.Failure(error = it) },
onSuccess = { it },
)
override suspend fun writeUriToCache(fileUri: Uri): Result<File> =
runCatching {
withContext(dispatcherManager.io) {
val tempFileName = "temp_send_file.bw"
context
.contentResolver
.openInputStream(fileUri)
?.use { inputStream ->
context.openFileOutput(tempFileName, Context.MODE_PRIVATE)
.use { outputStream ->
inputStream.sdkAgnosticTransferTo(outputStream)
}
}
?: throw IllegalStateException("Stream has crashed")
File(context.filesDir, tempFileName)
}
}
}
/**
* A helper function to write the files to a zip file.
*/
@Suppress("NestedBlockDepth")
private fun ZipOutputStream.zipFiles(
sourceFile: File,
parentDir: String? = null,
) {
if (sourceFile.isDirectory) {
val parentDirName = sourceFile.name + File.separator
val entry = ZipEntry(parentDirName).apply {
this.time = sourceFile.lastModified()
this.size = sourceFile.length()
}
this.putNextEntry(entry)
sourceFile.listFiles().orEmpty().forEach { file ->
this.zipFiles(sourceFile = file, parentDir = parentDirName)
}
} else {
FileInputStream(sourceFile).use { fis ->
BufferedInputStream(fis).use { bis ->
val entry = ZipEntry("${parentDir.orEmpty()}/${sourceFile.name}").apply {
this.time = sourceFile.lastModified()
this.size = sourceFile.length()
}
this.putNextEntry(entry)
val data = ByteArray(BUFFER_SIZE)
while (true) {
val readBytes = bis.read(data)
if (readBytes == -1) break
this.write(data, 0, readBytes)
}
}
}
}
}

View File

@@ -0,0 +1,20 @@
package com.bitwarden.data.manager.model
import java.io.File
/**
* Represents a result from downloading a raw file.
*/
sealed class DownloadResult {
/**
* The download was a success, and was saved to [file].
*/
data class Success(val file: File) : DownloadResult()
/**
* The download failed.
*/
data class Failure(
val error: Throwable,
) : DownloadResult()
}

View File

@@ -0,0 +1,23 @@
package com.bitwarden.data.manager.model
import java.io.File
/**
* Represents a result from zipping a raw file or folder.
*/
sealed class ZipFileResult {
/**
* The zip was a success, and was saved to the temporary [file].
*/
data class Success(val file: File) : ZipFileResult()
/**
* There was no file or folder to zip.
*/
data object NothingToZip : ZipFileResult()
/**
* The zip failed in some generic manner.
*/
data class Failure(val error: Throwable) : ZipFileResult()
}