data: handle network exceptions elegantly

This commit is contained in:
Han Yin 2025-07-08 11:51:25 -07:00
parent 85434e6580
commit 7c2e24b4fe
3 changed files with 75 additions and 35 deletions

View File

@ -7,6 +7,8 @@ import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@ -26,14 +28,14 @@ interface HuggingFaceRemoteDataSource {
direction: String? = "-1",
limit: Int? = SEARCH_RESULT_LIMIT,
full: Boolean = true,
): List<HuggingFaceModel>
): Result<List<HuggingFaceModel>>
suspend fun getModelDetails(modelId: String): HuggingFaceModelDetails
/**
* Obtain selected HuggingFace model's GGUF file size from HTTP header
*/
suspend fun getFileSize(modelId: String, filePath: String): Long?
suspend fun getFileSize(modelId: String, filePath: String): Result<Long>
/**
* Download selected HuggingFace model's GGUF file via DownloadManager
@ -57,14 +59,23 @@ class HuggingFaceRemoteDataSourceImpl @Inject constructor(
limit: Int?,
full: Boolean,
) = withContext(Dispatchers.IO) {
apiService.getModels(
search = query,
filter = filter,
sort = sort,
direction = direction,
limit = limit,
full = full,
).filter { it.gated != true && it.private != true && it.getGgufFilename() != null }
try {
apiService.getModels(
search = query,
filter = filter,
sort = sort,
direction = direction,
limit = limit,
full = full,
).filter {
it.gated != true && it.private != true && it.getGgufFilename() != null
}.let {
if (it.isEmpty()) Result.failure(FileNotFoundException()) else Result.success(it)
}
} catch (e: Exception) {
Log.e(TAG, "Error searching for models on HuggingFace: ${e.message}")
Result.failure(e)
}
}
override suspend fun getModelDetails(
@ -76,18 +87,26 @@ class HuggingFaceRemoteDataSourceImpl @Inject constructor(
override suspend fun getFileSize(
modelId: String,
filePath: String
): Long? = withContext(Dispatchers.IO) {
): Result<Long> = withContext(Dispatchers.IO) {
try {
apiService.getModelFileHeader(modelId, filePath).let {
if (it.isSuccessful) {
it.headers()[HTTP_HEADER_CONTENT_LENGTH]?.toLongOrNull()
apiService.getModelFileHeader(modelId, filePath).let { resp ->
if (resp.isSuccessful) {
resp.headers()[HTTP_HEADER_CONTENT_LENGTH]?.toLongOrNull()?.let {
Result.success(it)
} ?: Result.failure(IOException("Content-Length header missing"))
} else {
null
Result.failure(
when (resp.code()) {
401 -> SecurityException("Model requires authentication")
404 -> FileNotFoundException("Model file not found")
else -> IOException("Failed to get file info: HTTP ${resp.code()}")
}
)
}
}
} catch (e: Exception) {
Log.e(TAG, "Error getting file size for $modelId: ${e.message}")
null
Result.failure(e)
}
}

View File

@ -86,7 +86,7 @@ interface ModelRepository {
/**
* Search models on HuggingFace
*/
suspend fun searchHuggingFaceModels(limit: Int = 20): List<HuggingFaceModel>
suspend fun searchHuggingFaceModels(limit: Int = 20): Result<List<HuggingFaceModel>>
/**
* Obtain the model details from HuggingFace
@ -96,7 +96,7 @@ interface ModelRepository {
/**
* Obtain the model's size from HTTP response header
*/
suspend fun getHuggingFaceModelFileSize(downloadInfo: HuggingFaceDownloadInfo): Long?
suspend fun getHuggingFaceModelFileSize(downloadInfo: HuggingFaceDownloadInfo): Result<Long>
/**
* Download a HuggingFace model via system download manager
@ -351,7 +351,7 @@ class ModelRepositoryImpl @Inject constructor(
override suspend fun searchHuggingFaceModels(
limit: Int
): List<HuggingFaceModel> = withContext(Dispatchers.IO) {
) = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.searchModels(limit = limit)
}
@ -363,7 +363,7 @@ class ModelRepositoryImpl @Inject constructor(
override suspend fun getHuggingFaceModelFileSize(
downloadInfo: HuggingFaceDownloadInfo,
): Long? = withContext(Dispatchers.IO) {
): Result<Long> = withContext(Dispatchers.IO) {
huggingFaceRemoteDataSource.getFileSize(downloadInfo.modelId, downloadInfo.filename)
}

View File

@ -38,6 +38,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.FileNotFoundException
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.inject.Inject
@HiltViewModel
@ -246,11 +249,23 @@ class ModelsManagementViewModel @Inject constructor(
huggingFaceQueryJob = viewModelScope.launch {
_managementState.emit(Download.Querying)
try {
val models = modelRepository.searchHuggingFaceModels()
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
_managementState.emit(Download.Ready(models))
modelRepository.searchHuggingFaceModels().fold(
onSuccess = { models ->
Log.d(TAG, "Fetched ${models.size} models from HuggingFace:")
_managementState.emit(Download.Ready(models))
},
onFailure = { throw it }
)
} catch (_: CancellationException) {
// no-op
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (_: FileNotFoundException) {
_managementState.emit(Download.Error(message = "No eligible models"))
} catch (e: Exception) {
_managementState.emit(Download.Error(message = e.message ?: "Unknown error"))
}
@ -267,17 +282,24 @@ class ModelsManagementViewModel @Inject constructor(
val downloadInfo = model.toDownloadInfo()
requireNotNull(downloadInfo) { "Download URL is missing!" }
val actualSize = modelRepository.getHuggingFaceModelFileSize(downloadInfo)
requireNotNull(actualSize) { "Unknown model file size!" }
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
.onSuccess { downloadId ->
activeDownloads[downloadId] = model
_managementState.value = Download.Dispatched(downloadInfo)
}
.onFailure { throw it }
modelRepository.getHuggingFaceModelFileSize(downloadInfo).fold(
onSuccess = { actualSize ->
Log.d(TAG, "Model file size: ${formatFileByteSize(actualSize)}")
modelRepository.downloadHuggingFaceModel(downloadInfo, actualSize)
.onSuccess { downloadId ->
activeDownloads[downloadId] = model
_managementState.value = Download.Dispatched(downloadInfo)
}
.onFailure { throw it }
},
onFailure = { throw it }
)
} catch (_: UnknownHostException) {
_managementState.value = Download.Error(message = "No internet connection")
} catch (_: SocketTimeoutException) {
_managementState.value = Download.Error(message = "Connection timed out")
} catch (e: IOException) {
_managementState.value = Download.Error(message = "Network error: ${e.message}")
} catch (e: InsufficientStorageException) {
_managementState.value = Download.Error(
message = e.message ?: "Insufficient storage space to download ${model.modelId}",
@ -343,7 +365,6 @@ class ModelsManagementViewModel @Inject constructor(
companion object {
private val TAG = ModelsManagementViewModel::class.java.simpleName
private const val SUBSCRIPTION_TIMEOUT_MS = 5000L
private const val SUCCESS_RESET_TIMEOUT_MS = 1000L
}
}