diff --git a/app/build.gradle b/app/build.gradle index f75e9a5..43876b3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,7 @@ plugins { id 'com.android.application' id("com.github.triplet.play") version "3.6.0" id 'kotlin-android' + id "com.google.firebase.crashlytics" } apply plugin: 'kotlin-parcelize' @@ -60,6 +61,7 @@ dependencies { implementation project(":algorithms") implementation platform('com.google.firebase:firebase-bom:28.4.0') implementation 'com.google.firebase:firebase-analytics-ktx' + implementation "com.google.firebase:firebase-crashlytics" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion" implementation 'androidx.activity:activity-ktx:1.8.0' implementation 'androidx.fragment:fragment-ktx:1.6.1' @@ -69,8 +71,10 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' testImplementation 'junit:junit:4.13.2' + testImplementation("io.mockk:mockk:1.13.9") testImplementation "org.mockito:mockito-core:4.8.1" testImplementation "org.mockito.kotlin:mockito-kotlin:4.0.0" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0" androidTestImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/src/main/java/com/randomimagecreator/ImageCreator.kt b/app/src/main/java/com/randomimagecreator/ImageCreator.kt index 1c70764..46dbf8d 100644 --- a/app/src/main/java/com/randomimagecreator/ImageCreator.kt +++ b/app/src/main/java/com/randomimagecreator/ImageCreator.kt @@ -1,8 +1,6 @@ package com.randomimagecreator import android.content.ContentResolver -import android.graphics.Bitmap -import android.graphics.Color import android.net.Uri import androidx.documentfile.provider.DocumentFile import com.randomimagecreator.algorithms.ImageCreating @@ -10,21 +8,28 @@ import com.randomimagecreator.algorithms.Mandelbrot import com.randomimagecreator.algorithms.Pixelated import com.randomimagecreator.algorithms.SierpinskiCarpet import com.randomimagecreator.algorithms.SolidColor +import com.randomimagecreator.common.BitmapCreator import com.randomimagecreator.common.HsvToHexConverter import com.randomimagecreator.common.ImageSaver import com.randomimagecreator.configuration.Configuration import com.randomimagecreator.configuration.ImagePattern import com.randomimagecreator.result.ImageCreationResult import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlin.system.measureTimeMillis -class ImageCreator { - var bitmapSaveNotifier: MutableSharedFlow = MutableSharedFlow() +class ImageCreator( + private val imageSaver: ImageSaver = ImageSaver(), + private val bitmapCreator: BitmapCreator = BitmapCreator() +) { + val bitmapSafeNotifier get() = _bitmapSaveNotifier.asSharedFlow() + private var _bitmapSaveNotifier: MutableSharedFlow = MutableSharedFlow() + suspend fun create( contentResolver: ContentResolver, saveDirectory: DocumentFile, configuration: Configuration - ): ImageCreationResult { + ): Result { val width = configuration.width val height = configuration.height val algorithm: ImageCreating = when (configuration.pattern) { @@ -38,38 +43,47 @@ class ImageCreator { val images = mutableListOf>() val creationDurationInMilliseconds = measureTimeMillis { for (i in 0.. - Bitmap.createBitmap( - bitmap.map { pixel -> Color.parseColor(pixel) }.toIntArray(), - configuration.width, - configuration.height, - Bitmap.Config.ARGB_8888 - ).also { - it.setHasAlpha(false) + val bitmaps = try { + images.map { bitmap -> + bitmapCreator.create(bitmap, configuration.width, configuration.height) } + } catch (exception: IllegalArgumentException) { + return Result.failure(BitmapCreationError()) } val uris = mutableListOf() val saveDurationInMilliseconds = measureTimeMillis { for (bitmap in bitmaps) { - bitmapSaveNotifier.emit(null) - val uri = ImageSaver.saveBitmap( - bitmap, - contentResolver, - saveDirectory, - configuration.format, - ) + val uri = try { + imageSaver.saveBitmap( + bitmap, + contentResolver, + saveDirectory, + configuration.format, + ) + } catch (exception: Exception) { + return Result.failure(BitmapSaveError()) + } + _bitmapSaveNotifier.emit(null) uri?.let { uris.add(it) } } } val durationInMilliseconds = creationDurationInMilliseconds + saveDurationInMilliseconds - return ImageCreationResult(uris, durationInMilliseconds) + return Result.success(ImageCreationResult(uris, durationInMilliseconds)) } } + +class ImageCreatingAlgorithmError : Error() +class BitmapCreationError : Error() +class BitmapSaveError : Error() \ No newline at end of file diff --git a/app/src/main/java/com/randomimagecreator/MainActivity.kt b/app/src/main/java/com/randomimagecreator/MainActivity.kt index a180ee2..0fb7afa 100644 --- a/app/src/main/java/com/randomimagecreator/MainActivity.kt +++ b/app/src/main/java/com/randomimagecreator/MainActivity.kt @@ -8,9 +8,11 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.lifecycleScope +import com.google.firebase.FirebaseApp import com.randomimagecreator.choosesavedirectory.ChooseSaveDirectoryFragment import com.randomimagecreator.common.Analytics import com.randomimagecreator.configuration.ConfigurationFragment +import com.randomimagecreator.error.ErrorFragment import com.randomimagecreator.loading.LoadingFragment import com.randomimagecreator.result.ResultFragment import kotlinx.coroutines.launch @@ -21,6 +23,7 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Analytics.setup() + FirebaseApp.initializeApp(applicationContext) replaceFragment(ConfigurationFragment(), isRootFragment = true) lifecycleScope.launch { viewModel.navigationRequestBroadcaster.collect(::onNavigationRequest) @@ -54,6 +57,10 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { replaceFragment(LoadingFragment(), LoadingFragment.TAG) } + is Screen.Error -> { + replaceFragment(ErrorFragment()) + } + is Screen.Result -> { replaceFragment(ResultFragment()) } diff --git a/app/src/main/java/com/randomimagecreator/MainViewModel.kt b/app/src/main/java/com/randomimagecreator/MainViewModel.kt index 8ccbfc4..5be031a 100644 --- a/app/src/main/java/com/randomimagecreator/MainViewModel.kt +++ b/app/src/main/java/com/randomimagecreator/MainViewModel.kt @@ -3,11 +3,11 @@ package com.randomimagecreator import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import android.util.Log import androidx.core.database.getStringOrNull import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.randomimagecreator.common.errors.SaveDirectoryMissingError import com.randomimagecreator.common.extensions.query import com.randomimagecreator.configuration.Configuration @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch -private const val TAG = "MainViewModel" /** * ViewModel used throughout the app. @@ -61,15 +60,18 @@ class MainViewModel : ViewModel() { val saveDirectoryUri = configuration.saveDirectory ?: throw SaveDirectoryMissingError() val saveDirectory = DocumentFile.fromTreeUri(context, saveDirectoryUri) ?: throw SaveDirectoryMissingError() - + val config = configuration.copy(width = -1) viewModelScope.launch(Dispatchers.IO) { - try { - imageCreationResult = - imageCreator.create(context.contentResolver, saveDirectory, configuration) - navigationRequestBroadcaster.emit(Screen.Result) - } catch (exception: Exception) { - Log.e(TAG, "", exception) - } + imageCreator.create(context.contentResolver, saveDirectory, config).fold( + onSuccess = { + imageCreationResult = it + navigationRequestBroadcaster.emit(Screen.Result) + }, + onFailure = { + FirebaseCrashlytics.getInstance().recordException(it) + navigationRequestBroadcaster.emit(Screen.Error) + } + ) } } diff --git a/app/src/main/java/com/randomimagecreator/Screen.kt b/app/src/main/java/com/randomimagecreator/Screen.kt index 2b6634b..6d0e717 100644 --- a/app/src/main/java/com/randomimagecreator/Screen.kt +++ b/app/src/main/java/com/randomimagecreator/Screen.kt @@ -19,6 +19,11 @@ sealed class Screen { */ data object Loading : Screen() + /** + * Screen showing an error occurred during image generation. + */ + data object Error : Screen() + /** * Screen showing images that got created. */ diff --git a/app/src/main/java/com/randomimagecreator/common/BitmapCreator.kt b/app/src/main/java/com/randomimagecreator/common/BitmapCreator.kt new file mode 100644 index 0000000..bfc5450 --- /dev/null +++ b/app/src/main/java/com/randomimagecreator/common/BitmapCreator.kt @@ -0,0 +1,24 @@ +package com.randomimagecreator.common + +import android.graphics.Bitmap +import android.graphics.Color + +/** + * Wrapper class for creating Bitmaps. + */ +class BitmapCreator { + fun create( + bitmap: Array, + width: Int, + height: Int + ): Bitmap { + return Bitmap.createBitmap( + bitmap.map { pixel -> Color.parseColor(pixel) }.toIntArray(), + width, + height, + Bitmap.Config.ARGB_8888 + ).also { + it.setHasAlpha(false) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/randomimagecreator/common/ImageSaver.kt b/app/src/main/java/com/randomimagecreator/common/ImageSaver.kt index cc49b40..8e2ae67 100644 --- a/app/src/main/java/com/randomimagecreator/common/ImageSaver.kt +++ b/app/src/main/java/com/randomimagecreator/common/ImageSaver.kt @@ -1,7 +1,6 @@ package com.randomimagecreator.common import android.content.ContentResolver -import android.content.Context import android.graphics.Bitmap import android.net.Uri import androidx.documentfile.provider.DocumentFile @@ -10,39 +9,15 @@ import com.randomimagecreator.configuration.ImageFileFormat /** * Helper class for saving images. */ -object ImageSaver { +class ImageSaver { /** - * Saves the given array of bitmaps to the provided [directory]. + * Saves the given bitmap to the provided [saveDirectory]. * - * @param bitmaps A list of all bitmaps to save. - * @param context The context of the caller. - * @param directory Directory selected by user for saving. + * @param bitmap Bitmap to save. + * @param contentResolver Resolver for creating the outputstream. + * @param saveDirectory Directory selected by user for saving. * @param format Format to save the bitmap in. - * @param notifier Notifier that emits the amount of saved bitmaps. */ - fun saveBitmaps( - bitmaps: List, - context: Context, - directory: Uri, - format: ImageFileFormat, -// notifier: MutableSharedFlow - ): List { - val rootDocumentFile = DocumentFile.fromTreeUri(context, directory) - assert(rootDocumentFile != null) { "root document file shouldn't be null" } - - val bitmapUris = mutableListOf() - - for (bitmap in bitmaps) { - saveBitmap(bitmap, context.contentResolver, rootDocumentFile!!, format)?.let { - bitmapUris.add(it) -// scope.launch { -// notifier.emit(null) -// } - } - } - return bitmapUris - } - fun saveBitmap( bitmap: Bitmap, contentResolver: ContentResolver, diff --git a/app/src/main/java/com/randomimagecreator/error/ErrorFragment.kt b/app/src/main/java/com/randomimagecreator/error/ErrorFragment.kt new file mode 100644 index 0000000..dbeda09 --- /dev/null +++ b/app/src/main/java/com/randomimagecreator/error/ErrorFragment.kt @@ -0,0 +1,9 @@ +package com.randomimagecreator.error + +import androidx.fragment.app.Fragment +import com.randomimagecreator.R + +/** + * Provides feedback to the user an error occurred during image generation. + */ +class ErrorFragment : Fragment(R.layout.fragment_error) diff --git a/app/src/main/java/com/randomimagecreator/loading/LoadingFragment.kt b/app/src/main/java/com/randomimagecreator/loading/LoadingFragment.kt index 5caccc7..6953e6f 100644 --- a/app/src/main/java/com/randomimagecreator/loading/LoadingFragment.kt +++ b/app/src/main/java/com/randomimagecreator/loading/LoadingFragment.kt @@ -23,7 +23,7 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) { numberOfSavedImagesTextField = view.findViewById(R.id.loading_saved_amount) viewLifecycleOwner.lifecycleScope.launch { - viewModel.imageCreator.bitmapSaveNotifier.collect { + viewModel.imageCreator.bitmapSafeNotifier.collect { numberOfSavedImages++ updateNumberOfSavedImages() } diff --git a/app/src/main/res/layout/fragment_error.xml b/app/src/main/res/layout/fragment_error.xml new file mode 100644 index 0000000..3304340 --- /dev/null +++ b/app/src/main/res/layout/fragment_error.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90df0be..44cef49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,4 +34,6 @@ Pixelated Mandelbrot Sierpinski carpet + An error occurred :( + Please try it again with a different configuration diff --git a/app/src/test/java/com/randomimagecreator/ImageCreatorTests.kt b/app/src/test/java/com/randomimagecreator/ImageCreatorTests.kt new file mode 100644 index 0000000..d790e09 --- /dev/null +++ b/app/src/test/java/com/randomimagecreator/ImageCreatorTests.kt @@ -0,0 +1,107 @@ +package com.randomimagecreator + +import android.content.ContentResolver +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import com.randomimagecreator.common.BitmapCreator +import com.randomimagecreator.common.ImageSaver +import com.randomimagecreator.configuration.Configuration +import com.randomimagecreator.configuration.ImagePattern +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any + +class ImageCreatorTests { + private lateinit var imageSaverMock: ImageSaver + private lateinit var bitmapCreatorMock: BitmapCreator + private lateinit var contentResolverMock: ContentResolver + private lateinit var documentFileMock: DocumentFile + private lateinit var imageCreator: ImageCreator + private lateinit var saveDirectoryMock: Uri + + @Before + fun before() { + imageSaverMock = mockk() + every { imageSaverMock.saveBitmap(any(), any(), any(), any()) }.answers { mockk() } + bitmapCreatorMock = mockk() + every { bitmapCreatorMock.create(any(), any(), any()) }.answers { mockk() } + bitmapCreatorMock.create(any(), any(), any()) + contentResolverMock = mockk() + documentFileMock = mockk() + saveDirectoryMock = mockk() + imageCreator = ImageCreator(imageSaverMock, bitmapCreatorMock) + } + + @Test + fun `create, happy case, returns success result`() = runTest { + val configuration = createValidConfiguration() + val result = imageCreator.create(contentResolverMock, documentFileMock, configuration) + Assert.assertTrue(result.isSuccess) + } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `create, happy case, correctly emits image saves`() = runTest(UnconfinedTestDispatcher()) { + val configuration = createValidConfiguration() + + var nrOfEmits = 0 + backgroundScope.launch { + imageCreator.bitmapSafeNotifier.collectLatest { + nrOfEmits++ + } + } + imageCreator.create(contentResolverMock, documentFileMock, configuration) + Assert.assertEquals(2, nrOfEmits) + } + + @Test + fun `create, when image creation algorithm throws an exception, returns image creating algorithm error`() = + runTest { + val configuration = createInvalidConfiguration() + val result = imageCreator.create(contentResolverMock, documentFileMock, configuration) + Assert.assertTrue(result.exceptionOrNull() is ImageCreatingAlgorithmError) + } + + @Test + fun `create, when algorithm conversion to Android bitmap throws an exception, returns bitmap creation error`() = + runTest { + every { + bitmapCreatorMock.create( + any(), + any(), + any() + ) + }.throws(IllegalArgumentException()) + val configuration = createValidConfiguration() + val result = imageCreator.create(contentResolverMock, documentFileMock, configuration) + Assert.assertTrue(result.exceptionOrNull() is BitmapCreationError) + } + + @Test + fun `create, when bitmap saving on device throws an exception, returns bitmap creation error`() = + runTest { + every { imageSaverMock.saveBitmap(any(), any(), any(), any()) }.throws(Exception()) + val configuration = createValidConfiguration() + val result = imageCreator.create(contentResolverMock, documentFileMock, configuration) + Assert.assertTrue(result.exceptionOrNull() is BitmapSaveError) + } + + private fun createValidConfiguration() = + Configuration(2, 24, 24, saveDirectory = saveDirectoryMock) + + private fun createInvalidConfiguration() = Configuration( + 2, + 1, + 1, + pattern = ImagePattern.SIERPINSKI_CARPET, + saveDirectory = saveDirectoryMock + ) +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 5f4ec98..6cd2192 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ buildscript { classpath 'com.android.tools.build:gradle:8.1.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath 'com.google.gms:google-services:4.4.0' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' } }