Skip to content

Commit 29aa55f

Browse files
committed
Fix misleading usage of coroutines and suspend functions
Switching to IO Dispatcher from the ViewModel classes instead of where the blocking call happens breaks the "main-safe" convention of Kotlin coroutines as well as the dispatching responsibility. I migrated the suspending call down to where it is actually needed: in the `LocalFileProviderImpl`. Also cleanup the unnecessary manual buffering (for copying files/streams) as Kotlin's `InputStream.copyTo()` already does it with a default 8kiB buffer. And to keep the cancelable tasks (that was previously canceling the global IO dispatcher entirely), I've added two `Job` instances in the `CreationViewModel` that can be canceled as needed.
1 parent f84eab3 commit 29aa55f

File tree

8 files changed

+65
-66
lines changed

8 files changed

+65
-66
lines changed

core/testing/src/main/java/com/android/developers/testing/data/TestFileProvider.kt

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,22 @@ import java.io.File
2626
* It does not perform any actual file operations.
2727
*/
2828
class TestFileProvider : LocalFileProvider {
29-
override fun saveBitmapToFile(
29+
override suspend fun saveBitmapToFile(
3030
bitmap: Bitmap,
3131
file: File,
32-
): File {
32+
) {
3333
TODO("Not yet implemented")
3434
}
3535

36-
override fun getFileFromCache(fileName: String): File {
36+
override suspend fun getFileFromCache(fileName: String): File {
3737
TODO("Not yet implemented")
3838
}
3939

40-
override fun createCacheFile(fileName: String): File {
40+
override suspend fun createCacheFile(fileName: String): File {
4141
TODO("Not yet implemented")
4242
}
4343

44-
override fun saveToSharedStorage(
44+
override suspend fun saveToSharedStorage(
4545
file: File,
4646
fileName: String,
4747
mimeType: String,
@@ -53,11 +53,11 @@ class TestFileProvider : LocalFileProvider {
5353
TODO("Not yet implemented")
5454
}
5555

56-
override fun copyToInternalStorage(uri: Uri): File {
56+
override suspend fun copyToInternalStorage(uri: Uri): File {
5757
return File("")
5858
}
5959

60-
override fun saveUriToSharedStorage(
60+
override suspend fun saveUriToSharedStorage(
6161
inputUri: Uri,
6262
fileName: String,
6363
mimeType: String,

core/util/src/main/java/com/android/developers/androidify/util/LocalFileProvider.kt

Lines changed: 42 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -22,109 +22,114 @@ import android.net.Uri
2222
import android.os.Build
2323
import android.os.Environment
2424
import android.provider.MediaStore
25+
import androidx.annotation.WorkerThread
2526
import androidx.core.content.FileProvider
27+
import kotlinx.coroutines.CoroutineDispatcher
28+
import kotlinx.coroutines.Dispatchers
29+
import kotlinx.coroutines.withContext
2630
import java.io.File
27-
import java.io.FileInputStream
2831
import java.io.FileOutputStream
2932
import java.io.IOException
33+
import java.nio.file.Files
3034
import java.util.UUID
3135
import javax.inject.Inject
36+
import javax.inject.Named
3237
import javax.inject.Singleton
3338

3439
interface LocalFileProvider {
35-
fun saveBitmapToFile(bitmap: Bitmap, file: File): File
36-
fun getFileFromCache(fileName: String): File
37-
fun createCacheFile(fileName: String): File
38-
fun saveToSharedStorage(file: File, fileName: String, mimeType: String): Uri
40+
@WorkerThread
41+
suspend fun saveBitmapToFile(bitmap: Bitmap, file: File)
42+
@WorkerThread
43+
suspend fun getFileFromCache(fileName: String): File
44+
@WorkerThread
45+
suspend fun createCacheFile(fileName: String): File
46+
@WorkerThread
47+
suspend fun saveToSharedStorage(file: File, fileName: String, mimeType: String): Uri
3948
fun sharingUriForFile(file: File): Uri
40-
fun copyToInternalStorage(uri: Uri): File
41-
fun saveUriToSharedStorage(
42-
inputUri: Uri,
43-
fileName: String,
44-
mimeType: String,
45-
): Uri
49+
@WorkerThread
50+
suspend fun copyToInternalStorage(uri: Uri): File
51+
@WorkerThread
52+
suspend fun saveUriToSharedStorage(inputUri: Uri, fileName: String, mimeType: String): Uri
4653
}
4754

4855
@Singleton
49-
open class LocalFileProviderImpl @Inject constructor(val application: Context) : LocalFileProvider {
56+
open class LocalFileProviderImpl @Inject constructor(
57+
val application: Context,
58+
@Named("IO")
59+
val ioDispatcher: CoroutineDispatcher,
60+
) : LocalFileProvider {
5061

51-
override fun saveBitmapToFile(bitmap: Bitmap, file: File): File {
62+
override suspend fun saveBitmapToFile(bitmap: Bitmap, file: File) = withContext(ioDispatcher) {
5263
var outputStream: FileOutputStream? = null
5364
try {
5465
outputStream = FileOutputStream(file)
5566
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
5667
outputStream.flush()
57-
return file
5868
} catch (e: IOException) {
5969
throw e
6070
} finally {
6171
outputStream?.close()
6272
}
6373
}
64-
override fun getFileFromCache(fileName: String): File {
65-
return File(application.cacheDir, fileName)
74+
75+
override suspend fun getFileFromCache(fileName: String): File = withContext(ioDispatcher) {
76+
File(application.cacheDir, fileName)
6677
}
6778

6879
@Throws(IOException::class)
69-
override fun createCacheFile(fileName: String): File {
80+
override suspend fun createCacheFile(fileName: String): File = withContext(ioDispatcher) {
7081
val cacheDir = application.cacheDir
7182
val imageFile = File(cacheDir, fileName)
7283
if (!imageFile.createNewFile()) {
7384
throw IOException("Unable to create file: ${imageFile.absolutePath}")
7485
}
75-
return imageFile
86+
return@withContext imageFile
7687
}
7788

78-
override fun saveToSharedStorage(
89+
override suspend fun saveToSharedStorage(
7990
file: File,
8091
fileName: String,
8192
mimeType: String,
82-
): Uri {
93+
): Uri = withContext(ioDispatcher) {
8394
val (uri, contentValues) = createSharedStorageEntry(fileName, mimeType)
8495
saveFileToUri(file, uri)
8596
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
8697
contentValues.put(MediaStore.Images.ImageColumns.IS_PENDING, 0)
8798
}
8899
application.contentResolver.update(uri, contentValues, null, null)
89-
return uri
100+
return@withContext uri
90101
}
91102

92-
override fun saveUriToSharedStorage(
103+
override suspend fun saveUriToSharedStorage(
93104
inputUri: Uri,
94105
fileName: String,
95106
mimeType: String,
96-
): Uri {
107+
): Uri = withContext(ioDispatcher) {
97108
val (newUri, contentValues) = createSharedStorageEntry(fileName, mimeType)
98109
application.contentResolver.openOutputStream(newUri)?.use { outputStream ->
99110
application.contentResolver.openInputStream(inputUri)?.use { inputStream ->
100-
val buffer = ByteArray(4 * 1024) // 4 KB buffer size - adjust as needed
101-
var bytesRead: Int
102-
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
103-
outputStream.write(buffer, 0, bytesRead)
104-
}
111+
inputStream.copyTo(outputStream)
105112
}
106113
} ?: throw IOException("Failed to open output stream.")
107114
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
108115
contentValues.put(MediaStore.Images.ImageColumns.IS_PENDING, 0)
109116
}
110117
application.contentResolver.update(newUri, contentValues, null, null)
111-
return newUri
118+
return@withContext newUri
112119
}
113120

114121
@Throws(IOException::class)
122+
@WorkerThread
115123
private fun saveFileToUri(file: File, uri: Uri) {
116124
application.contentResolver.openOutputStream(uri)?.use { outputStream ->
117-
FileInputStream(file).use { inputStream ->
118-
val buffer = ByteArray(4 * 1024) // 4 KB buffer size - adjust as needed
119-
var bytesRead: Int
120-
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
121-
outputStream.write(buffer, 0, bytesRead)
122-
}
125+
file.inputStream().use { inputStream ->
126+
inputStream.copyTo(outputStream)
123127
}
124128
} ?: throw IOException("Failed to open output stream for uri: $uri")
125129
}
126130

127131
@Throws(IOException::class)
132+
@WorkerThread
128133
private fun createSharedStorageEntry(fileName: String, mimeType: String): Pair<Uri, ContentValues> {
129134
val resolver = application.contentResolver
130135
val contentValues = ContentValues().apply {
@@ -160,14 +165,14 @@ open class LocalFileProviderImpl @Inject constructor(val application: Context) :
160165
}
161166

162167
@Throws(IOException::class)
163-
override fun copyToInternalStorage(uri: Uri): File {
168+
override suspend fun copyToInternalStorage(uri: Uri): File = withContext(ioDispatcher) {
164169
val uuid = UUID.randomUUID()
165170
val file = File(application.cacheDir, "temp_file_$uuid")
166171
application.contentResolver.openInputStream(uri)?.use { inputStream ->
167172
file.outputStream().use { outputStream ->
168173
inputStream.copyTo(outputStream)
169174
}
170175
}
171-
return file
176+
return@withContext file
172177
}
173178
}

data/src/main/java/com/android/developers/androidify/data/DataModule.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ internal object DataModule {
4242

4343
@Provides
4444
@Singleton
45-
fun provideLocalFileProvider(@ApplicationContext appContext: Context): LocalFileProvider =
46-
LocalFileProviderImpl(appContext)
45+
fun provideLocalFileProvider(@ApplicationContext appContext: Context, @Named("IO") ioDispatcher: CoroutineDispatcher): LocalFileProvider =
46+
LocalFileProviderImpl(appContext, ioDispatcher)
4747

4848
@Provides
4949
@Singleton

data/src/main/java/com/android/developers/androidify/data/ImageGenerationRepository.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ internal class ImageGenerationRepositoryImpl @Inject constructor(
103103

104104
override suspend fun saveImage(imageBitmap: Bitmap): Uri {
105105
val cacheFile = localFileProvider.createCacheFile("shared_image_${UUID.randomUUID()}.jpg")
106-
val file = localFileProvider.saveBitmapToFile(imageBitmap, cacheFile)
107-
return localFileProvider.sharingUriForFile(file)
106+
localFileProvider.saveBitmapToFile(imageBitmap, cacheFile)
107+
return localFileProvider.sharingUriForFile(cacheFile)
108108
}
109109

110110
override suspend fun saveImageToExternalStorage(imageBitmap: Bitmap): Uri {

feature/creation/src/main/java/com/android/developers/androidify/creation/CreationViewModel.kt

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,24 +35,19 @@ import com.android.developers.androidify.data.TextGenerationRepository
3535
import com.android.developers.androidify.util.LocalFileProvider
3636
import dagger.hilt.android.lifecycle.HiltViewModel
3737
import dagger.hilt.android.qualifiers.ApplicationContext
38-
import kotlinx.coroutines.CoroutineDispatcher
39-
import kotlinx.coroutines.Dispatchers
40-
import kotlinx.coroutines.cancel
38+
import kotlinx.coroutines.Job
4139
import kotlinx.coroutines.flow.MutableStateFlow
4240
import kotlinx.coroutines.flow.StateFlow
4341
import kotlinx.coroutines.flow.update
4442
import kotlinx.coroutines.launch
4543
import javax.inject.Inject
46-
import javax.inject.Named
4744

4845
@HiltViewModel
4946
class CreationViewModel @Inject constructor(
5047
val internetConnectivityManager: InternetConnectivityManager,
5148
val imageGenerationRepository: ImageGenerationRepository,
5249
val textGenerationRepository: TextGenerationRepository,
5350
val fileProvider: LocalFileProvider,
54-
@Named("IO")
55-
val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
5651
@ApplicationContext
5752
val context: Context,
5853
) : ViewModel() {
@@ -74,6 +69,9 @@ class CreationViewModel @Inject constructor(
7469
val snackbarHostState: StateFlow<SnackbarHostState>
7570
get() = _snackbarHostState
7671

72+
private var promptGenerationJob: Job? = null
73+
private var imageGenerationJob: Job? = null
74+
7775
fun onImageSelected(uri: Uri?) {
7876
_uiState.update {
7977
it.copy(
@@ -96,7 +94,8 @@ class CreationViewModel @Inject constructor(
9694
}
9795

9896
fun onPromptGenerationClicked() {
99-
viewModelScope.launch(ioDispatcher) {
97+
promptGenerationJob?.cancel()
98+
promptGenerationJob = viewModelScope.launch {
10099
Log.d("CreationViewModel", "Generating prompt...")
101100
_uiState.update {
102101
it.copy(promptGenerationInProgress = true)
@@ -122,7 +121,8 @@ class CreationViewModel @Inject constructor(
122121
}
123122

124123
fun startClicked() {
125-
viewModelScope.launch(ioDispatcher) {
124+
imageGenerationJob?.cancel()
125+
imageGenerationJob = viewModelScope.launch {
126126
if (internetConnectivityManager.isInternetAvailable()) {
127127
try {
128128
_uiState.update {
@@ -196,7 +196,8 @@ class CreationViewModel @Inject constructor(
196196
}
197197

198198
fun cancelInProgressTask() {
199-
ioDispatcher.cancel()
199+
promptGenerationJob?.cancel()
200+
imageGenerationJob?.cancel()
200201
_uiState.update {
201202
it.copy(screenState = ScreenState.EDIT)
202203
}

feature/creation/src/test/kotlin/com/android/developers/androidify/creation/CreationViewModelTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ class CreationViewModelTest {
5757
imageGenerationRepository,
5858
TestTextGenerationRepository(),
5959
TestFileProvider(),
60-
UnconfinedTestDispatcher(),
6160
context = RuntimeEnvironment.getApplication(),
6261
)
6362
}

feature/results/src/main/java/com/android/developers/androidify/results/ResultsViewModel.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,16 @@ import androidx.lifecycle.ViewModel
2222
import androidx.lifecycle.viewModelScope
2323
import com.android.developers.androidify.data.ImageGenerationRepository
2424
import dagger.hilt.android.lifecycle.HiltViewModel
25-
import kotlinx.coroutines.CoroutineDispatcher
26-
import kotlinx.coroutines.Dispatchers
2725
import kotlinx.coroutines.flow.MutableStateFlow
2826
import kotlinx.coroutines.flow.StateFlow
2927
import kotlinx.coroutines.flow.asStateFlow
3028
import kotlinx.coroutines.flow.update
3129
import kotlinx.coroutines.launch
3230
import javax.inject.Inject
33-
import javax.inject.Named
3431

3532
@HiltViewModel
3633
class ResultsViewModel @Inject constructor(
3734
val imageGenerationRepository: ImageGenerationRepository,
38-
@Named("IO")
39-
val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
4035
) : ViewModel() {
4136

4237
private val _state = MutableStateFlow(ResultState())
@@ -58,7 +53,7 @@ class ResultsViewModel @Inject constructor(
5853
}
5954

6055
fun shareClicked() {
61-
viewModelScope.launch(ioDispatcher) {
56+
viewModelScope.launch {
6257
val resultUrl = state.value.resultImageBitmap
6358
if (resultUrl != null) {
6459
val imageFileUri = imageGenerationRepository.saveImage(resultUrl)
@@ -70,7 +65,7 @@ class ResultsViewModel @Inject constructor(
7065
}
7166
}
7267
fun downloadClicked() {
73-
viewModelScope.launch(ioDispatcher) {
68+
viewModelScope.launch {
7469
val resultBitmap = state.value.resultImageBitmap
7570
val originalImage = state.value.originalImageUrl
7671
if (originalImage != null) {

feature/results/src/test/kotlin/com/android/developers/androidify/results/ResultsViewModelTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@ class ResultsViewModelTest {
4848
fun setup() {
4949
viewModel = ResultsViewModel(
5050
FakeImageGenerationRepository(),
51-
UnconfinedTestDispatcher(),
5251
)
5352
}
5453

0 commit comments

Comments
 (0)