From e257c71c6bbeca5995083a9c230ed980f99ed2a4 Mon Sep 17 00:00:00 2001 From: Matthew Tighe Date: Tue, 5 Feb 2019 14:22:01 -0800 Subject: [PATCH] Add download state cache. (#590) * Stop erroneously tracking non-ula downloads. * Update asset preferences to contain additional methods for tracking download progress. * Use asset preferences to check asset download times. * Move download tracking to utility, and stub using cached state to restore if app is closed. * Sync session startup state with previous downloads. * Encapsulate each session state stage in own sealed class. * Refactor to check requirements only on session fsm transition events. * Handle downloads through process death. * Trick tests to pass. * Update session fsm tests. * Handle and test situation where non-userland download intent is received. * Update download utility tests. * Update comment. --- app/src/main/java/tech/ula/MainActivity.kt | 14 +- .../ula/model/repositories/AssetRepository.kt | 4 +- .../tech/ula/model/state/SessionStartupFsm.kt | 114 +++++---- .../java/tech/ula/utils/AndroidUtility.kt | 67 ++++- .../java/tech/ula/utils/DownloadUtility.kt | 97 ++++++-- .../ula/viewmodel/MainActivityViewModel.kt | 195 ++++++++------- app/src/main/res/values/strings.xml | 1 + .../model/repositories/AssetRepositoryTest.kt | 18 +- .../ula/model/state/SessionStartupFsmTest.kt | 138 ++++++---- .../tech/ula/utils/DownloadUtilityTest.kt | 235 ++++++++++++++---- .../viewmodel/MainActivityViewModelTest.kt | 24 +- 11 files changed, 617 insertions(+), 290 deletions(-) diff --git a/app/src/main/java/tech/ula/MainActivity.kt b/app/src/main/java/tech/ula/MainActivity.kt index 53648ea4b..a6426f035 100644 --- a/app/src/main/java/tech/ula/MainActivity.kt +++ b/app/src/main/java/tech/ula/MainActivity.kt @@ -104,16 +104,15 @@ class MainActivity : AppCompatActivity(), SessionListFragment.SessionSelection, private val viewModel: MainActivityViewModel by lazy { val ulaDatabase = UlaDatabase.getInstance(this) - val timestampPreferences = TimestampPreferences(this.getSharedPreferences("file_timestamps", Context.MODE_PRIVATE)) val assetPreferences = AssetPreferences(this.getSharedPreferences("assetLists", Context.MODE_PRIVATE)) - val assetRepository = AssetRepository(filesDir.path, timestampPreferences, assetPreferences) + val assetRepository = AssetRepository(filesDir.path, assetPreferences) val execUtility = ExecUtility(filesDir.path, Environment.getExternalStorageDirectory().absolutePath, DefaultPreferences(defaultSharedPreferences)) val filesystemUtility = FilesystemUtility(filesDir.path, execUtility) val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val downloadManagerWrapper = DownloadManagerWrapper(downloadManager) - val downloadUtility = DownloadUtility(timestampPreferences, downloadManagerWrapper, filesDir) + val downloadUtility = DownloadUtility(assetPreferences, downloadManagerWrapper, filesDir) val appsPreferences = AppsPreferences(this.getSharedPreferences("apps", Context.MODE_PRIVATE)) @@ -342,7 +341,7 @@ class MainActivity : AppCompatActivity(), SessionListFragment.SessionSelection, is NoAppSelectedWhenPreferenceSubmitted -> { getString(R.string.illegal_state_no_app_selected_when_preference_submitted) } - is NoAppSelectedWhenPreparationStarted -> { + is NoAppSelectedWhenTransitionNecessary -> { getString(R.string.illegal_state_no_app_selected_when_preparation_started) } is ErrorFetchingAppDatabaseEntries -> { @@ -351,7 +350,7 @@ class MainActivity : AppCompatActivity(), SessionListFragment.SessionSelection, is ErrorCopyingAppScript -> { getString(R.string.illegal_state_error_copying_app_script) } - is NoSessionSelectedWhenPreparationStarted -> { + is NoSessionSelectedWhenTransitionNecessary -> { getString(R.string.illegal_state_no_session_selected_when_preparation_started) } is ErrorFetchingAssetLists -> { @@ -366,6 +365,9 @@ class MainActivity : AppCompatActivity(), SessionListFragment.SessionSelection, is AssetsHaveNotBeenDownloaded -> { getString(R.string.illegal_state_assets_have_not_been_downloaded) } + is DownloadCacheAccessedWhileEmpty -> { + getString(R.string.illegal_state_empty_download_cache_access) + } is FailedToCopyAssetsToFilesystem -> { getString(R.string.illegal_state_failed_to_copy_assets_to_filesystem) } @@ -488,7 +490,7 @@ class MainActivity : AppCompatActivity(), SessionListFragment.SessionSelection, val step = getString(R.string.progress_copying_downloads) updateProgressBar(step, "") } - is FilesystemExtraction -> { + is FilesystemExtractionStep -> { val step = getString(R.string.progress_setting_up_filesystem) val details = getString(R.string.progress_extraction_details, state.extractionTarget) updateProgressBar(step, details) diff --git a/app/src/main/java/tech/ula/model/repositories/AssetRepository.kt b/app/src/main/java/tech/ula/model/repositories/AssetRepository.kt index b5cee11c5..33ae5cf5c 100644 --- a/app/src/main/java/tech/ula/model/repositories/AssetRepository.kt +++ b/app/src/main/java/tech/ula/model/repositories/AssetRepository.kt @@ -4,7 +4,6 @@ import tech.ula.model.entities.Asset import tech.ula.model.entities.Filesystem import tech.ula.utils.AssetPreferences import tech.ula.utils.ConnectionUtility -import tech.ula.utils.TimestampPreferences import java.io.BufferedReader import java.io.File import java.io.InputStreamReader @@ -12,7 +11,6 @@ import java.lang.Exception class AssetRepository( private val applicationFilesDirPath: String, - private val timestampPreferences: TimestampPreferences, private val assetPreferences: AssetPreferences, private val connectionUtility: ConnectionUtility = ConnectionUtility() ) { @@ -22,7 +20,7 @@ class AssetRepository( if (!assetFile.exists()) return true - val localTimestamp = timestampPreferences.getSavedTimestampForFile(asset.concatenatedName) + val localTimestamp = assetPreferences.getLastUpdatedTimestampForAsset(asset) return localTimestamp < asset.remoteTimestamp } diff --git a/app/src/main/java/tech/ula/model/state/SessionStartupFsm.kt b/app/src/main/java/tech/ula/model/state/SessionStartupFsm.kt index 4a390263a..7b70d1e69 100644 --- a/app/src/main/java/tech/ula/model/state/SessionStartupFsm.kt +++ b/app/src/main/java/tech/ula/model/state/SessionStartupFsm.kt @@ -11,10 +11,7 @@ import tech.ula.model.entities.Filesystem import tech.ula.model.entities.Session import tech.ula.model.repositories.AssetRepository import tech.ula.model.repositories.UlaDatabase -import tech.ula.utils.CrashlyticsWrapper -import tech.ula.utils.DownloadUtility -import tech.ula.utils.FilesystemUtility -import tech.ula.utils.TimeUtility +import tech.ula.utils.* // ktlint-disable no-wildcard-imports class SessionStartupFsm( ulaDatabase: UlaDatabase, @@ -35,14 +32,15 @@ class SessionStartupFsm( private val filesystemsLiveData = filesystemDao.getAllFilesystems() private val filesystems = mutableListOf() - private val downloadingIds = mutableListOf() - private val downloadedIds = mutableListOf() - private val extractionLogger: (String) -> Unit = { line -> state.postValue(ExtractingFilesystem(line)) } init { + if (downloadUtility.downloadStateHasBeenCached()) { + state.postValue(DownloadingAssets(0, 0)) // Reset state so events can be submitted + handleAssetDownloadState(downloadUtility.syncStateWithCache()) + } activeSessionsLiveData.observeForever { it?.let { list -> activeSessions.clear() @@ -77,7 +75,11 @@ class SessionStartupFsm( is RetrieveAssetLists -> currentState is SessionIsReadyForPreparation is GenerateDownloads -> currentState is AssetListsRetrievalSucceeded is DownloadAssets -> currentState is DownloadsRequired - is AssetDownloadComplete -> currentState is DownloadingRequirements + is AssetDownloadComplete -> { + // If we are currently downloading assets, we can handle completed downloads that + // don't belong to us. Otherwise, we still don't want to post an illegal transition. + currentState is DownloadingAssets || !downloadUtility.downloadIsForUserland(event.downloadAssetId) + } is CopyDownloadsToLocalStorage -> currentState is DownloadsHaveSucceeded is VerifyFilesystemAssets -> currentState is NoDownloadsRequired || currentState is LocalDirectoryCopySucceeded is ExtractFilesystem -> currentState is FilesystemAssetVerificationSucceeded @@ -164,38 +166,29 @@ class SessionStartupFsm( } private fun handleDownloadAssets(assetsToDownload: List) { - downloadingIds.clear() - downloadedIds.clear() - // If the state isn't updated first, AssetDownloadComplete events will be submitted before // the transition is acceptable. - state.postValue(DownloadingRequirements(0, assetsToDownload.size)) - val newDownloads = downloadUtility.downloadRequirements(assetsToDownload) - downloadingIds.addAll(newDownloads) + state.postValue(DownloadingAssets(0, assetsToDownload.size)) + downloadUtility.downloadRequirements(assetsToDownload) } private fun handleAssetsDownloadComplete(downloadId: Long) { - if (!downloadUtility.downloadedSuccessfully(downloadId)) { - val reason = downloadUtility.getReasonForDownloadFailure(downloadId) - state.postValue(DownloadsHaveFailed(reason)) - return - } - - downloadedIds.add(downloadId) - downloadUtility.setTimestampForDownloadedFile(downloadId) - if (downloadingIds.size != downloadedIds.size) { - state.postValue(DownloadingRequirements(downloadedIds.size, downloadingIds.size)) - return - } + val result = downloadUtility.handleDownloadComplete(downloadId) + handleAssetDownloadState(result) + } - downloadedIds.sort() - downloadingIds.sort() - if (downloadedIds != downloadingIds) { - state.postValue(DownloadsHaveFailed("Downloads completed with non-enqueued downloads")) - return + private fun handleAssetDownloadState(assetDownloadState: AssetDownloadState) { + return when (assetDownloadState) { + // We don't care if some other app has downloaded something, though we may intercept the + // broadcast from the Download Manager. + is NonUserlandDownloadFound -> {} + is CacheSyncAttemptedWhileCacheIsEmpty -> state.postValue(AttemptedCacheAccessWhileEmpty) + is AllDownloadsCompletedSuccessfully -> state.postValue(DownloadsHaveSucceeded) + is CompletedDownloadsUpdate -> { + state.postValue(DownloadingAssets(assetDownloadState.numCompleted, assetDownloadState.numTotal)) + } + is AssetDownloadFailure -> state.postValue(DownloadsHaveFailed(assetDownloadState.reason)) } - - state.postValue(DownloadsHaveSucceeded) } private suspend fun handleCopyDownloadsToLocalDirectories(filesystem: Filesystem) = withContext(Dispatchers.IO) { @@ -261,30 +254,47 @@ class SessionStartupFsm( } sealed class SessionStartupState +// One-off events data class IncorrectSessionTransition(val event: SessionStartupEvent, val state: SessionStartupState) : SessionStartupState() object WaitingForSessionSelection : SessionStartupState() object SingleSessionSupported : SessionStartupState() data class SessionIsRestartable(val session: Session) : SessionStartupState() data class SessionIsReadyForPreparation(val session: Session, val filesystem: Filesystem) : SessionStartupState() -object RetrievingAssetLists : SessionStartupState() -data class AssetListsRetrievalSucceeded(val assetLists: List>) : SessionStartupState() -object AssetListsRetrievalFailed : SessionStartupState() -object GeneratingDownloadRequirements : SessionStartupState() -data class DownloadsRequired(val requiredDownloads: List, val largeDownloadRequired: Boolean) : SessionStartupState() -object NoDownloadsRequired : SessionStartupState() -data class DownloadingRequirements(val numCompleted: Int, val numTotal: Int) : SessionStartupState() -object DownloadsHaveSucceeded : SessionStartupState() -data class DownloadsHaveFailed(val reason: String) : SessionStartupState() -object CopyingFilesToLocalDirectories : SessionStartupState() -object LocalDirectoryCopySucceeded : SessionStartupState() -object LocalDirectoryCopyFailed : SessionStartupState() -object VerifyingFilesystemAssets : SessionStartupState() -object FilesystemAssetVerificationSucceeded : SessionStartupState() -object AssetsAreMissingFromSupportDirectories : SessionStartupState() -object FilesystemAssetCopyFailed : SessionStartupState() -data class ExtractingFilesystem(val extractionTarget: String) : SessionStartupState() -object ExtractionHasCompletedSuccessfully : SessionStartupState() -object ExtractionFailed : SessionStartupState() + +// Asset retrieval states +sealed class AssetRetrievalState : SessionStartupState() +object RetrievingAssetLists : AssetRetrievalState() +data class AssetListsRetrievalSucceeded(val assetLists: List>) : AssetRetrievalState() +object AssetListsRetrievalFailed : AssetRetrievalState() + +// Download requirements generation state +sealed class DownloadRequirementsGenerationState : SessionStartupState() +object GeneratingDownloadRequirements : DownloadRequirementsGenerationState() +data class DownloadsRequired(val requiredDownloads: List, val largeDownloadRequired: Boolean) : DownloadRequirementsGenerationState() +object NoDownloadsRequired : DownloadRequirementsGenerationState() + +// Downloading asset states +sealed class DownloadingAssetsState : SessionStartupState() +data class DownloadingAssets(val numCompleted: Int, val numTotal: Int) : DownloadingAssetsState() +object DownloadsHaveSucceeded : DownloadingAssetsState() +data class DownloadsHaveFailed(val reason: String) : DownloadingAssetsState() +object AttemptedCacheAccessWhileEmpty : DownloadingAssetsState() + +sealed class CopyingFilesLocallyState : SessionStartupState() +object CopyingFilesToLocalDirectories : CopyingFilesLocallyState() +object LocalDirectoryCopySucceeded : CopyingFilesLocallyState() +object LocalDirectoryCopyFailed : CopyingFilesLocallyState() + +sealed class AssetVerificationState : SessionStartupState() +object VerifyingFilesystemAssets : AssetVerificationState() +object FilesystemAssetVerificationSucceeded : AssetVerificationState() +object AssetsAreMissingFromSupportDirectories : AssetVerificationState() +object FilesystemAssetCopyFailed : AssetVerificationState() + +sealed class ExtractionState : SessionStartupState() +data class ExtractingFilesystem(val extractionTarget: String) : ExtractionState() +object ExtractionHasCompletedSuccessfully : ExtractionState() +object ExtractionFailed : ExtractionState() sealed class SessionStartupEvent data class SessionSelected(val session: Session) : SessionStartupEvent() diff --git a/app/src/main/java/tech/ula/utils/AndroidUtility.kt b/app/src/main/java/tech/ula/utils/AndroidUtility.kt index 3d0e77939..0fc1b78f2 100644 --- a/app/src/main/java/tech/ula/utils/AndroidUtility.kt +++ b/app/src/main/java/tech/ula/utils/AndroidUtility.kt @@ -73,20 +73,55 @@ class DefaultPreferences(private val prefs: SharedPreferences) { } } -class TimestampPreferences(private val prefs: SharedPreferences) { - fun getSavedTimestampForFile(assetConcatenatedName: String): Long { - return prefs.getLong(assetConcatenatedName, 0) +class AssetPreferences(private val prefs: SharedPreferences) { + private fun String.addTimestampPrefix(): String { + return "timestamp-" + this + } + + fun getLastUpdatedTimestampForAsset(asset: Asset): Long { + return prefs.getLong(asset.concatenatedName.addTimestampPrefix(), -1) } - fun setSavedTimestampForFileToNow(assetConcatenatedName: String) { + fun setLastUpdatedTimestampForAssetUsingConcatenatedName(assetConcatenatedName: String, currentTimeSeconds: Long) { with(prefs.edit()) { - putLong(assetConcatenatedName, currentTimeSeconds()) + putLong(assetConcatenatedName.addTimestampPrefix(), currentTimeSeconds) + apply() + } + } + + private val downloadsAreInProgressKey = "downloadsAreInProgress" + fun getDownloadsAreInProgress(): Boolean { + return prefs.getBoolean(downloadsAreInProgressKey, false) + } + + fun setDownloadsAreInProgress(inProgress: Boolean) { + with(prefs.edit()) { + putBoolean(downloadsAreInProgressKey, inProgress) + apply() + } + } + + private val enqueuedDownloadsKey = "currentlyEnqueuedDownloads" + fun getEnqueuedDownloads(): Set { + val enqueuedDownloadsAsStrings = prefs.getStringSet(enqueuedDownloadsKey, setOf()) ?: setOf() + return enqueuedDownloadsAsStrings.map { it.toLong() }.toSet() + } + + fun setEnqueuedDownloads(downloads: Set) { + val enqueuedDownloadsAsStrings = downloads.map { it.toString() }.toSet() + with(prefs.edit()) { + putStringSet(enqueuedDownloadsKey, enqueuedDownloadsAsStrings) + apply() + } + } + + fun clearEnqueuedDownloadsCache() { + with(prefs.edit()) { + remove(enqueuedDownloadsKey) apply() } } -} -class AssetPreferences(private val prefs: SharedPreferences) { fun getAssetLists(allAssetListTypes: List>): List> { val assetLists = ArrayList>() allAssetListTypes.forEach { @@ -277,12 +312,22 @@ class DownloadManagerWrapper(private val downloadManager: DownloadManager) { return "" } - fun downloadHasNotFailed(id: Long): Boolean { + fun downloadHasSucceeded(id: Long): Boolean { val query = generateQuery(id) val cursor = generateCursor(query) if (cursor.moveToFirst()) { val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) - return status != DownloadManager.STATUS_FAILED + return status == DownloadManager.STATUS_SUCCESSFUL + } + return false + } + + fun downloadHasFailed(id: Long): Boolean { + val query = generateQuery(id) + val cursor = generateCursor(query) + if (cursor.moveToFirst()) { + val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + return status == DownloadManager.STATUS_FAILED } return false } @@ -331,6 +376,10 @@ class LocalFileLocator(private val applicationFilesDir: String, private val reso } class TimeUtility { + fun getCurrentTimeSeconds(): Long { + return currentTimeSeconds() + } + fun getCurrentTimeMillis(): Long { return System.currentTimeMillis() } diff --git a/app/src/main/java/tech/ula/utils/DownloadUtility.kt b/app/src/main/java/tech/ula/utils/DownloadUtility.kt index f00a69901..2af1ac0bc 100644 --- a/app/src/main/java/tech/ula/utils/DownloadUtility.kt +++ b/app/src/main/java/tech/ula/utils/DownloadUtility.kt @@ -3,25 +3,86 @@ package tech.ula.utils import tech.ula.model.entities.Asset import java.io.File +sealed class AssetDownloadState +object CacheSyncAttemptedWhileCacheIsEmpty : AssetDownloadState() +object NonUserlandDownloadFound : AssetDownloadState() +object AllDownloadsCompletedSuccessfully : AssetDownloadState() +data class CompletedDownloadsUpdate(val numCompleted: Int, val numTotal: Int) : AssetDownloadState() +data class AssetDownloadFailure(val reason: String) : AssetDownloadState() + class DownloadUtility( - private val timestampPreferences: TimestampPreferences, + private val assetPreferences: AssetPreferences, private val downloadManagerWrapper: DownloadManagerWrapper, - private val applicationFilesDir: File + private val applicationFilesDir: File, + private val timeUtility: TimeUtility = TimeUtility() ) { - private val downloadDirectory = downloadManagerWrapper.getDownloadsDirectory() - fun downloadRequirements(assetList: List): List { + private val userlandDownloadPrefix = "UserLAnd-" + + private val enqueuedDownloadIds = mutableSetOf() + private val completedDownloadIds = mutableSetOf() + + private fun String.containsUserland(): Boolean { + return this.toLowerCase().contains(userlandDownloadPrefix.toLowerCase()) + } + + fun downloadStateHasBeenCached(): Boolean { + return assetPreferences.getDownloadsAreInProgress() + } + + fun syncStateWithCache(): AssetDownloadState { + if (!downloadStateHasBeenCached()) return CacheSyncAttemptedWhileCacheIsEmpty + + enqueuedDownloadIds.addAll(assetPreferences.getEnqueuedDownloads()) + + for (id in enqueuedDownloadIds) { + // Skip in-progress downloads + if (!downloadManagerWrapper.downloadHasFailed(id) && !downloadManagerWrapper.downloadHasSucceeded(id)) { + continue + } + val state = handleDownloadComplete(id) + if (state !is CompletedDownloadsUpdate) return state + } + return CompletedDownloadsUpdate(completedDownloadIds.size, enqueuedDownloadIds.size) + } + + fun downloadRequirements(assetList: List) { clearPreviousDownloadsFromDownloadsDirectory() - return assetList.map { download(it) } + assetPreferences.clearEnqueuedDownloadsCache() + + enqueuedDownloadIds.addAll(assetList.map { download(it) }) + assetPreferences.setDownloadsAreInProgress(inProgress = true) + assetPreferences.setEnqueuedDownloads(enqueuedDownloadIds) } - fun downloadedSuccessfully(id: Long): Boolean { - return downloadManagerWrapper.downloadHasNotFailed(id) + fun handleDownloadComplete(downloadId: Long): AssetDownloadState { + if (!downloadIsForUserland(downloadId)) return NonUserlandDownloadFound + + if (downloadManagerWrapper.downloadHasFailed(downloadId)) { + val reason = downloadManagerWrapper.getDownloadFailureReason(downloadId) + return AssetDownloadFailure(reason) + } + + completedDownloadIds.add(downloadId) + setTimestampForDownloadedFile(downloadId) + if (completedDownloadIds.size != enqueuedDownloadIds.size) { + return CompletedDownloadsUpdate(completedDownloadIds.size, enqueuedDownloadIds.size) + } + + if (!enqueuedDownloadIds.containsAll(completedDownloadIds)) { + return AssetDownloadFailure("Tried to finish download process with items we did not enqueue.") + } + + enqueuedDownloadIds.clear() + completedDownloadIds.clear() + assetPreferences.setDownloadsAreInProgress(inProgress = false) + assetPreferences.clearEnqueuedDownloadsCache() + return AllDownloadsCompletedSuccessfully } - fun getReasonForDownloadFailure(id: Long): String { - return downloadManagerWrapper.getDownloadFailureReason(id) + fun downloadIsForUserland(id: Long): Boolean { + return enqueuedDownloadIds.contains(id) } private fun download(asset: Asset): Long { @@ -38,9 +99,12 @@ class DownloadUtility( } private fun clearPreviousDownloadsFromDownloadsDirectory() { - for (file in downloadDirectory.listFiles()) { - if (file.name.toLowerCase().contains("userland")) { - file.delete() + val downloadDirectoryFiles = downloadDirectory.listFiles() + downloadDirectoryFiles?.let { + for (file in downloadDirectoryFiles) { + if (file.name.containsUserland()) { + file.delete() + } } } } @@ -52,17 +116,18 @@ class DownloadUtility( localFile.delete() } - fun setTimestampForDownloadedFile(id: Long) { + private fun setTimestampForDownloadedFile(id: Long) { val titleName = downloadManagerWrapper.getDownloadTitle(id) - if (titleName == "" || !titleName.contains("UserLAnd")) return + if (!titleName.containsUserland()) return // Title should be asset.concatenatedName - timestampPreferences.setSavedTimestampForFileToNow(titleName) + val currentTimeSeconds = timeUtility.getCurrentTimeSeconds() + assetPreferences.setLastUpdatedTimestampForAssetUsingConcatenatedName(titleName, currentTimeSeconds) } @Throws(Exception::class) fun moveAssetsToCorrectLocalDirectory() { downloadDirectory.walkBottomUp() - .filter { it.name.contains("UserLAnd-") } + .filter { it.name.containsUserland() } .forEach { val delimitedContents = it.name.split("-", limit = 3) if (delimitedContents.size != 3) return@forEach diff --git a/app/src/main/java/tech/ula/viewmodel/MainActivityViewModel.kt b/app/src/main/java/tech/ula/viewmodel/MainActivityViewModel.kt index 8b0031874..85212d552 100644 --- a/app/src/main/java/tech/ula/viewmodel/MainActivityViewModel.kt +++ b/app/src/main/java/tech/ula/viewmodel/MainActivityViewModel.kt @@ -77,27 +77,6 @@ class MainActivityViewModel( } } state.addSource(sessionState) { it?.let { update -> crashlyticsWrapper.setString("Last observed session state from viewmodel", "$update") - // Update stateful variables before handling the update so they can be used during it - if (update !is WaitingForSessionSelection) { - sessionsAreWaitingForSelection = false - } - when (update) { - is WaitingForSessionSelection -> { - sessionsAreWaitingForSelection = true - } - is SessionIsReadyForPreparation -> { - lastSelectedSession = update.session - lastSelectedFilesystem = update.filesystem - } - is SessionIsRestartable -> { - state.postValue(SessionCanBeRestarted(update.session)) - resetStartupState() - } - is SingleSessionSupported -> { - state.postValue(CanOnlyStartSingleSession) - resetStartupState() - } - } handleSessionPreparationState(update) } } } @@ -190,7 +169,7 @@ class MainActivityViewModel( return } if (!appsPreparationRequirementsHaveBeenSelected()) { - state.postValue(NoAppSelectedWhenPreparationStarted) + state.postValue(NoAppSelectedWhenTransitionNecessary) return } // Return when statement for compile-time exhaustiveness check @@ -232,40 +211,70 @@ class MainActivityViewModel( } } + // Post state values and delegate responsibility appropriately private fun handleSessionPreparationState(newState: SessionStartupState) { - // Exit early if we aren't expecting preparation requirements to have been met - if (newState is WaitingForSessionSelection || newState is SingleSessionSupported || - newState is SessionIsRestartable) { - return + // Update stateful variables before handling the update so they can be used during it + if (newState !is WaitingForSessionSelection) { + sessionsAreWaitingForSelection = false } - if (!sessionPreparationRequirementsHaveBeenSelected()) { - state.postValue(NoSessionSelectedWhenPreparationStarted) - return - } - // Return when statement for compile-time exhaustiveness check + // Return for compile-time exhaustiveness check return when (newState) { is IncorrectSessionTransition -> { state.postValue(IllegalStateTransition("$newState")) } - is WaitingForSessionSelection -> {} - is SingleSessionSupported -> {} - is SessionIsRestartable -> {} + is WaitingForSessionSelection -> { + sessionsAreWaitingForSelection = true + } + is SingleSessionSupported -> { + state.postValue(CanOnlyStartSingleSession) + resetStartupState() + } + is SessionIsRestartable -> { + state.postValue(SessionCanBeRestarted(newState.session)) + resetStartupState() + } is SessionIsReadyForPreparation -> { + lastSelectedSession = newState.session + lastSelectedFilesystem = newState.filesystem state.postValue(StartingSetup) - submitSessionStartupEvent(RetrieveAssetLists(lastSelectedFilesystem)) + doTransitionIfRequirementsAreSelected { + submitSessionStartupEvent(RetrieveAssetLists(lastSelectedFilesystem)) + } + } + is AssetRetrievalState -> { + handleAssetRetrievalState(newState) } - is RetrievingAssetLists -> { - state.postValue(FetchingAssetLists) + is DownloadRequirementsGenerationState -> { + handleDownloadRequirementsGenerationState(newState) } - is AssetListsRetrievalSucceeded -> { - submitSessionStartupEvent(GenerateDownloads(lastSelectedFilesystem, newState.assetLists)) + is DownloadingAssetsState -> { + handleDownloadingAssetsState(newState) } - is AssetListsRetrievalFailed -> { - state.postValue(ErrorFetchingAssetLists) + is CopyingFilesLocallyState -> { + handleCopyingFilesLocallyState(newState) } - is GeneratingDownloadRequirements -> { - state.postValue(CheckingForAssetsUpdates) + is AssetVerificationState -> { + handleAssetVerificationState(newState) } + is ExtractionState -> { + handleExtractionState(newState) + } + } + } + + private fun handleAssetRetrievalState(newState: AssetRetrievalState) { + return when (newState) { + is RetrievingAssetLists -> state.postValue(FetchingAssetLists) + is AssetListsRetrievalSucceeded -> { doTransitionIfRequirementsAreSelected { + submitSessionStartupEvent(GenerateDownloads(lastSelectedFilesystem, newState.assetLists)) + } } + is AssetListsRetrievalFailed -> state.postValue(ErrorFetchingAssetLists) + } + } + + private fun handleDownloadRequirementsGenerationState(newState: DownloadRequirementsGenerationState) { + return when (newState) { + is GeneratingDownloadRequirements -> state.postValue(CheckingForAssetsUpdates) is DownloadsRequired -> { if (newState.largeDownloadRequired) { state.postValue(LargeDownloadRequired(newState.requiredDownloads)) @@ -273,49 +282,60 @@ class MainActivityViewModel( startAssetDownloads(newState.requiredDownloads) } } - is NoDownloadsRequired -> { - submitSessionStartupEvent(VerifyFilesystemAssets(lastSelectedFilesystem)) - } - is DownloadingRequirements -> { - state.postValue(DownloadProgress(newState.numCompleted, newState.numTotal)) - } + is NoDownloadsRequired -> { doTransitionIfRequirementsAreSelected { + submitSessionStartupEvent(VerifyFilesystemAssets(lastSelectedFilesystem)) + } } + } + } + + private fun handleDownloadingAssetsState(newState: DownloadingAssetsState) { + return when (newState) { + is DownloadingAssets -> state.postValue(DownloadProgress(newState.numCompleted, newState.numTotal)) is DownloadsHaveSucceeded -> { - submitSessionStartupEvent(CopyDownloadsToLocalStorage(lastSelectedFilesystem)) - } - is DownloadsHaveFailed -> { - state.postValue(DownloadsDidNotCompleteSuccessfully(newState.reason)) + if (sessionPreparationRequirementsHaveBeenSelected()) { + submitSessionStartupEvent(CopyDownloadsToLocalStorage(lastSelectedFilesystem)) + } else { + state.postValue(ProgressBarOperationComplete) + resetStartupState() + } } - is CopyingFilesToLocalDirectories -> { - state.postValue(CopyingDownloads) + is DownloadsHaveFailed -> state.postValue(DownloadsDidNotCompleteSuccessfully(newState.reason)) + is AttemptedCacheAccessWhileEmpty -> { + state.postValue(DownloadCacheAccessedWhileEmpty) + resetStartupState() } - is LocalDirectoryCopySucceeded -> { + } + } + + private fun handleCopyingFilesLocallyState(newState: CopyingFilesLocallyState) { + return when (newState) { + is CopyingFilesToLocalDirectories -> state.postValue(CopyingDownloads) + is LocalDirectoryCopySucceeded -> { doTransitionIfRequirementsAreSelected { submitSessionStartupEvent(VerifyFilesystemAssets(lastSelectedFilesystem)) - } - is LocalDirectoryCopyFailed -> { - state.postValue(FailedToCopyAssetsToLocalStorage) - } - is VerifyingFilesystemAssets -> { - state.postValue(VerifyingFilesystem) - } - is FilesystemAssetVerificationSucceeded -> { - submitSessionStartupEvent(ExtractFilesystem(lastSelectedFilesystem)) - } - is AssetsAreMissingFromSupportDirectories -> { - state.postValue(AssetsHaveNotBeenDownloaded) - } - is FilesystemAssetCopyFailed -> { - state.postValue(FailedToCopyAssetsToFilesystem) - } - is ExtractingFilesystem -> { - state.postValue(FilesystemExtraction(newState.extractionTarget)) - } - is ExtractionHasCompletedSuccessfully -> { + } } + is LocalDirectoryCopyFailed -> state.postValue(FailedToCopyAssetsToLocalStorage) + } + } + + private fun handleAssetVerificationState(newState: AssetVerificationState) { + return when (newState) { + is VerifyingFilesystemAssets -> state.postValue(VerifyingFilesystem) + is FilesystemAssetVerificationSucceeded -> { doTransitionIfRequirementsAreSelected { + submitSessionStartupEvent(ExtractFilesystem(lastSelectedFilesystem)) + } } + is AssetsAreMissingFromSupportDirectories -> state.postValue(AssetsHaveNotBeenDownloaded) + is FilesystemAssetCopyFailed -> state.postValue(FailedToCopyAssetsToFilesystem) + } + } + + private fun handleExtractionState(newState: ExtractionState) { + return when (newState) { + is ExtractingFilesystem -> state.postValue(FilesystemExtractionStep(newState.extractionTarget)) + is ExtractionHasCompletedSuccessfully -> { doTransitionIfRequirementsAreSelected { state.postValue(SessionCanBeStarted(lastSelectedSession)) resetStartupState() - } - is ExtractionFailed -> { - state.postValue(FailedToExtractFilesystem) - } + } } + is ExtractionFailed -> state.postValue(FailedToExtractFilesystem) } } @@ -335,6 +355,14 @@ class MainActivityViewModel( return lastSelectedApp != unselectedApp && sessionPreparationRequirementsHaveBeenSelected() } + private fun doTransitionIfRequirementsAreSelected(transition: () -> Unit) { + if (!sessionPreparationRequirementsHaveBeenSelected()) { + state.postValue(NoSessionSelectedWhenTransitionNecessary) + return + } + transition() + } + private fun sessionPreparationRequirementsHaveBeenSelected(): Boolean { return lastSelectedSession != unselectedSession && lastSelectedFilesystem != unselectedFilesystem } @@ -361,12 +389,13 @@ object TooManySelectionsMadeWhenPermissionsGranted : IllegalState() object NoSelectionsMadeWhenPermissionsGranted : IllegalState() object NoFilesystemSelectedWhenCredentialsSubmitted : IllegalState() object NoAppSelectedWhenPreferenceSubmitted : IllegalState() -object NoAppSelectedWhenPreparationStarted : IllegalState() +object NoAppSelectedWhenTransitionNecessary : IllegalState() object ErrorFetchingAppDatabaseEntries : IllegalState() object ErrorCopyingAppScript : IllegalState() -object NoSessionSelectedWhenPreparationStarted : IllegalState() +object NoSessionSelectedWhenTransitionNecessary : IllegalState() object ErrorFetchingAssetLists : IllegalState() data class DownloadsDidNotCompleteSuccessfully(val reason: String) : IllegalState() +object DownloadCacheAccessedWhileEmpty : IllegalState() object FailedToCopyAssetsToLocalStorage : IllegalState() object AssetsHaveNotBeenDownloaded : IllegalState() object FailedToCopyAssetsToFilesystem : IllegalState() @@ -385,8 +414,8 @@ object FetchingAssetLists : ProgressBarUpdateState() object CheckingForAssetsUpdates : ProgressBarUpdateState() data class DownloadProgress(val numComplete: Int, val numTotal: Int) : ProgressBarUpdateState() object CopyingDownloads : ProgressBarUpdateState() -data class FilesystemExtraction(val extractionTarget: String) : ProgressBarUpdateState() object VerifyingFilesystem : ProgressBarUpdateState() +data class FilesystemExtractionStep(val extractionTarget: String) : ProgressBarUpdateState() object ClearingSupportFiles : ProgressBarUpdateState() object ProgressBarOperationComplete : ProgressBarUpdateState() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53a3b4e5f..8c6403962 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,6 +65,7 @@ Downloads have failed due to: %1$s. Failed to copy assets to local storage. Try clearing support files. Assets need to be copied to your filesystem, but it looks like they have not been downloaded. Try clearing support files. + The downloads cache was empty when access was attempted. Failed to copy assets to filesystem. Try clearing support files. Failed to extract filesystem. Try clearing support files. Failed to clear support files. diff --git a/app/src/test/java/tech/ula/model/repositories/AssetRepositoryTest.kt b/app/src/test/java/tech/ula/model/repositories/AssetRepositoryTest.kt index 3f1adfde5..240ba891a 100644 --- a/app/src/test/java/tech/ula/model/repositories/AssetRepositoryTest.kt +++ b/app/src/test/java/tech/ula/model/repositories/AssetRepositoryTest.kt @@ -8,16 +8,13 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers.anyString import org.mockito.Mock import org.mockito.Mockito.`when` -import org.mockito.Mockito.times import org.mockito.junit.MockitoJUnitRunner import tech.ula.model.entities.Asset import tech.ula.model.entities.Filesystem import tech.ula.utils.AssetPreferences import tech.ula.utils.ConnectionUtility -import tech.ula.utils.TimestampPreferences import java.io.File @RunWith(MockitoJUnitRunner::class) @@ -26,9 +23,6 @@ class AssetRepositoryTest { @get:Rule val tempFolder = TemporaryFolder() - @Mock - lateinit var timestampPreferences: TimestampPreferences - @Mock lateinit var assetPreferences: AssetPreferences @@ -51,7 +45,7 @@ class AssetRepositoryTest { @Before fun setup() { applicationFilesDirPath = tempFolder.root.path - assetRepository = AssetRepository(applicationFilesDirPath, timestampPreferences, + assetRepository = AssetRepository(applicationFilesDirPath, assetPreferences, connectionUtility) } @@ -91,7 +85,7 @@ class AssetRepositoryTest { val asset = Asset("name", distType, archType, Long.MAX_VALUE) assertTrue(assetRepository.doesAssetNeedToUpdated(asset)) - verify(timestampPreferences, never()).getSavedTimestampForFile(anyString()) +// verify(timestampPreferences, never()).getSavedTimestampForFile(anyString()) } @Test @@ -99,8 +93,8 @@ class AssetRepositoryTest { val asset = Asset("late", distType, archType, Long.MAX_VALUE) tempFolder.newFolder("dist") File("${tempFolder.root.path}/${asset.pathName}").createNewFile() - `when`(timestampPreferences.getSavedTimestampForFile(asset.concatenatedName)) - .thenReturn(Long.MIN_VALUE) +// `when`(timestampPreferences.getSavedTimestampForFile(asset.concatenatedName)) +// .thenReturn(Long.MIN_VALUE) assertTrue(assetRepository.doesAssetNeedToUpdated(asset)) } @@ -110,8 +104,8 @@ class AssetRepositoryTest { val asset = Asset("early", distType, archType, Long.MIN_VALUE) tempFolder.newFolder("dist") File("${tempFolder.root.path}/${asset.pathName}").createNewFile() - `when`(timestampPreferences.getSavedTimestampForFile(asset.concatenatedName)) - .thenReturn(Long.MAX_VALUE) +// `when`(timestampPreferences.getSavedTimestampForFile(asset.concatenatedName)) +// .thenReturn(Long.MAX_VALUE) assertFalse(assetRepository.doesAssetNeedToUpdated(asset)) } diff --git a/app/src/test/java/tech/ula/model/state/SessionStartupFsmTest.kt b/app/src/test/java/tech/ula/model/state/SessionStartupFsmTest.kt index c257a78ca..5835a2fdd 100644 --- a/app/src/test/java/tech/ula/model/state/SessionStartupFsmTest.kt +++ b/app/src/test/java/tech/ula/model/state/SessionStartupFsmTest.kt @@ -20,10 +20,7 @@ import tech.ula.model.entities.Filesystem import tech.ula.model.entities.Session import tech.ula.model.repositories.AssetRepository import tech.ula.model.repositories.UlaDatabase -import tech.ula.utils.CrashlyticsWrapper -import tech.ula.utils.DownloadUtility -import tech.ula.utils.FilesystemUtility -import tech.ula.utils.TimeUtility +import tech.ula.utils.* // ktlint-disable no-wildcard-imports import kotlin.Exception @RunWith(MockitoJUnitRunner::class) @@ -92,7 +89,7 @@ class SessionStartupFsmTest { GeneratingDownloadRequirements, NoDownloadsRequired, DownloadsRequired(singleAssetList, false), - DownloadingRequirements(0, 0), + DownloadingAssets(0, 0), DownloadsHaveSucceeded, DownloadsHaveFailed(""), CopyingFilesToLocalDirectories, @@ -131,6 +128,14 @@ class SessionStartupFsmTest { for (event in possibleEvents) { for (state in possibleStates) { + if (state is WaitingForSessionSelection) { + // Test the branch for receiving downloads not enqueued by us + whenever(mockDownloadUtility.downloadIsForUserland(0L)) + .thenReturn(false) + } else { + whenever(mockDownloadUtility.downloadIsForUserland(0L)) + .thenReturn(true) + } sessionFsm.setState(state) val result = sessionFsm.transitionIsAcceptable(event) when { @@ -138,7 +143,7 @@ class SessionStartupFsmTest { event is RetrieveAssetLists && state is SessionIsReadyForPreparation -> assertTrue(result) event is GenerateDownloads && state is AssetListsRetrievalSucceeded -> assertTrue(result) event is DownloadAssets && state is DownloadsRequired -> assertTrue(result) - event is AssetDownloadComplete && state is DownloadingRequirements -> assertTrue(result) + event is AssetDownloadComplete && (state is DownloadingAssets || state is WaitingForSessionSelection) -> assertTrue(result) event is CopyDownloadsToLocalStorage && state is DownloadsHaveSucceeded -> assertTrue(result) event is VerifyFilesystemAssets && (state is NoDownloadsRequired || state is LocalDirectoryCopySucceeded) -> assertTrue(result) event is ExtractFilesystem && state is FilesystemAssetVerificationSucceeded -> assertTrue(result) @@ -149,6 +154,21 @@ class SessionStartupFsmTest { } } + @Test + fun `AssetDownloadComplete events can be submitted at any time if they are not userland downloads`() { + val downloadId = 0L + sessionFsm.setState(WaitingForSessionSelection) + sessionFsm.getState().observeForever(mockStateObserver) + + whenever(mockDownloadUtility.handleDownloadComplete(downloadId)) + .thenReturn(NonUserlandDownloadFound) + + runBlocking { sessionFsm.submitEvent(AssetDownloadComplete(downloadId), this) } + + verify(mockStateObserver, times(1)).onChanged(WaitingForSessionSelection) + verifyNoMoreInteractions(mockStateObserver) + } + @Test fun `Exits early if incorrect transition event submitted`() { val state = WaitingForSessionSelection @@ -312,82 +332,96 @@ class SessionStartupFsmTest { @Test fun `State is DownloadsHaveSucceeded once downloads succeed`() { - val downloadList = listOf(asset, largeAsset) - sessionFsm.setState(DownloadsRequired(downloadList, true)) + sessionFsm.setState(DownloadingAssets(0, 0)) sessionFsm.getState().observeForever(mockStateObserver) - whenever(mockDownloadUtility.downloadRequirements(downloadList)) - .thenReturn(listOf(0L, 1L)) - whenever(mockDownloadUtility.downloadedSuccessfully(0)) - .thenReturn(true) - whenever(mockDownloadUtility.downloadedSuccessfully(1)) - .thenReturn(true) + whenever(mockDownloadUtility.handleDownloadComplete(1)) + .thenReturn(AllDownloadsCompletedSuccessfully) runBlocking { - sessionFsm.submitEvent(DownloadAssets(downloadList), this) - sessionFsm.submitEvent(AssetDownloadComplete(0), this) sessionFsm.submitEvent(AssetDownloadComplete(1), this) } - verify(mockDownloadUtility).setTimestampForDownloadedFile(0) - verify(mockDownloadUtility).setTimestampForDownloadedFile(1) - verify(mockStateObserver).onChanged(DownloadingRequirements(0, 2)) - verify(mockStateObserver).onChanged(DownloadingRequirements(1, 2)) verify(mockStateObserver).onChanged(DownloadsHaveSucceeded) } @Test - fun `State is DownloadsHaveFailed if any downloads fail`() { - val downloadList = listOf(asset, largeAsset) - sessionFsm.setState(DownloadsRequired(downloadList, true)) + fun `State is updated as downloads complete`() { + sessionFsm.setState(DownloadingAssets(0, 0)) sessionFsm.getState().observeForever(mockStateObserver) - whenever(mockDownloadUtility.downloadRequirements(downloadList)) - .thenReturn(listOf(0L, 1L)) - whenever(mockDownloadUtility.downloadedSuccessfully(0)) - .thenReturn(true) - whenever(mockDownloadUtility.downloadedSuccessfully(1)) - .thenReturn(false) - whenever(mockDownloadUtility.getReasonForDownloadFailure(1)) - .thenReturn("fail") + whenever(mockDownloadUtility.handleDownloadComplete(0)) + .thenReturn(CompletedDownloadsUpdate(1, 3)) + whenever(mockDownloadUtility.handleDownloadComplete(1)) + .thenReturn(CompletedDownloadsUpdate(2, 3)) runBlocking { - sessionFsm.submitEvent(DownloadAssets(downloadList), this) sessionFsm.submitEvent(AssetDownloadComplete(0), this) sessionFsm.submitEvent(AssetDownloadComplete(1), this) } - verify(mockDownloadUtility).setTimestampForDownloadedFile(0) - verify(mockDownloadUtility, never()).setTimestampForDownloadedFile(1) - verify(mockStateObserver).onChanged(DownloadingRequirements(0, 2)) - verify(mockStateObserver).onChanged(DownloadingRequirements(1, 2)) + verify(mockStateObserver).onChanged(DownloadingAssets(1, 3)) + verify(mockStateObserver).onChanged(DownloadingAssets(2, 3)) + } + + @Test + fun `State is DownloadsHaveFailed if any downloads fail`() { + sessionFsm.setState(DownloadingAssets(0, 0)) + sessionFsm.getState().observeForever(mockStateObserver) + + whenever(mockDownloadUtility.handleDownloadComplete(0)) + .thenReturn(AssetDownloadFailure("fail")) + + runBlocking { + sessionFsm.submitEvent(AssetDownloadComplete(0), this) + } + verify(mockStateObserver).onChanged(DownloadsHaveFailed("fail")) } @Test - fun `State is DownloadsHaveFailed with reason that we registered an non-enqueued download`() { - val downloadList = listOf(asset, largeAsset) - sessionFsm.setState(DownloadsRequired(downloadList, true)) + fun `State is unaffected if we intercept a download enqueued by something else`() { + sessionFsm.setState(DownloadingAssets(0, 2)) sessionFsm.getState().observeForever(mockStateObserver) - whenever(mockDownloadUtility.downloadRequirements(downloadList)) - .thenReturn(listOf(0L, 1L)) - whenever(mockDownloadUtility.downloadedSuccessfully(0)) - .thenReturn(true) - whenever(mockDownloadUtility.downloadedSuccessfully(2)) - .thenReturn(true) + whenever(mockDownloadUtility.handleDownloadComplete(0)) + .thenReturn(NonUserlandDownloadFound) + + runBlocking { + sessionFsm.submitEvent(AssetDownloadComplete(0), this) + } + + verify(mockStateObserver, never()).onChanged(DownloadingAssets(1, 2)) + } + + @Test + // This case shouldn't ever actually happen + fun `Passes on illegal cache access attempts`() { + sessionFsm.setState(DownloadingAssets(0, 0)) + sessionFsm.getState().observeForever(mockStateObserver) + + whenever(mockDownloadUtility.handleDownloadComplete(0)) + .thenReturn(CacheSyncAttemptedWhileCacheIsEmpty) runBlocking { - sessionFsm.submitEvent(DownloadAssets(downloadList), this) sessionFsm.submitEvent(AssetDownloadComplete(0), this) - sessionFsm.submitEvent(AssetDownloadComplete(2), this) } - verify(mockDownloadUtility).setTimestampForDownloadedFile(0) - verify(mockDownloadUtility, never()).setTimestampForDownloadedFile(1) - verify(mockStateObserver).onChanged(DownloadingRequirements(0, 2)) - verify(mockStateObserver).onChanged(DownloadingRequirements(1, 2)) - verify(mockStateObserver).onChanged(DownloadsHaveFailed("Downloads completed with non-enqueued downloads")) + verify(mockStateObserver).onChanged(AttemptedCacheAccessWhileEmpty) + } + + @Test + fun `Automatically syncs with download cache if download state has been cached`() { + whenever(mockDownloadUtility.downloadStateHasBeenCached()) + .thenReturn(true) + whenever(mockDownloadUtility.syncStateWithCache()) + .thenReturn(AllDownloadsCompletedSuccessfully) + + val syncedSessionFsm = SessionStartupFsm(mockUlaDatabase, mockAssetRepository, mockFilesystemUtility, mockDownloadUtility, mockTimeUtility, mockCrashlyticsWrapper) + syncedSessionFsm.getState().observeForever(mockStateObserver) + + verify(mockDownloadUtility).syncStateWithCache() + verify(mockStateObserver).onChanged(DownloadsHaveSucceeded) } @Test diff --git a/app/src/test/java/tech/ula/utils/DownloadUtilityTest.kt b/app/src/test/java/tech/ula/utils/DownloadUtilityTest.kt index 03a5b572a..37d562a4f 100644 --- a/app/src/test/java/tech/ula/utils/DownloadUtilityTest.kt +++ b/app/src/test/java/tech/ula/utils/DownloadUtilityTest.kt @@ -2,13 +2,13 @@ package tech.ula.utils import android.app.DownloadManager import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever import org.junit.Assert.* // ktlint-disable no-wildcard-imports import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith -import org.mockito.ArgumentMatchers import org.mockito.Mock import org.mockito.Mockito.* // ktlint-disable no-wildcard-imports import org.mockito.junit.MockitoJUnitRunner @@ -19,55 +19,210 @@ import kotlin.text.Charsets.UTF_8 @RunWith(MockitoJUnitRunner::class) class DownloadUtilityTest { - @get:Rule - val tempFolder = TemporaryFolder() + @get:Rule val tempFolder = TemporaryFolder() - @Mock - lateinit var timestampPreferences: TimestampPreferences + @Mock lateinit var assetPreferences: AssetPreferences - @Mock - lateinit var downloadManagerWrapper: DownloadManagerWrapper + @Mock lateinit var downloadManagerWrapper: DownloadManagerWrapper - @Mock - lateinit var requestReturn: DownloadManager.Request + @Mock lateinit var requestReturn1: DownloadManager.Request + + @Mock lateinit var requestReturn2: DownloadManager.Request lateinit var downloadDirectory: File - lateinit var asset1: Asset - lateinit var asset2: Asset - lateinit var assetList: List - lateinit var downloadUtility: DownloadUtility + val asset1 = Asset("name1", "distType1", "archType1", 0) + val asset2 = Asset("name2", "distType2", "archType2", 0) + val assetList = listOf(asset1, asset2) + + val url1 = getDownloadUrl(asset1.distributionType, asset1.architectureType, asset1.name) + val destination1 = asset1.concatenatedName - val branch = "master" + val url2 = getDownloadUrl(asset2.distributionType, asset2.architectureType, asset2.name) + val destination2 = asset2.concatenatedName + + lateinit var downloadUtility: DownloadUtility @Before fun setup() { downloadDirectory = tempFolder.newFolder("downloads") - `when`(downloadManagerWrapper.getDownloadsDirectory()).thenReturn(downloadDirectory) - downloadUtility = DownloadUtility(timestampPreferences, downloadManagerWrapper, applicationFilesDir = tempFolder.root) + whenever(downloadManagerWrapper.getDownloadsDirectory()) + .thenReturn(downloadDirectory) + whenever(downloadManagerWrapper.generateDownloadRequest(url1, destination1)) + .thenReturn(requestReturn1) + whenever(downloadManagerWrapper.generateDownloadRequest(url2, destination2)) + .thenReturn(requestReturn2) + + downloadUtility = DownloadUtility(assetPreferences, downloadManagerWrapper, applicationFilesDir = tempFolder.root) + } + + private fun getDownloadUrl(distType: String, archType: String, name: String): String { + val branch = "master" + return "https://github.com/CypherpunkArmory/UserLAnd-Assets-$distType/raw/$branch/assets/$archType/$name" + } + + @Test + fun `Returns appropriate value from asset preferences about whether cache is populated`() { + val expectedFirstResult = true + val expectedSecondResult = false + whenever(assetPreferences.getDownloadsAreInProgress()) + .thenReturn(expectedFirstResult) + .thenReturn(expectedSecondResult) + + val firstResult = downloadUtility.downloadStateHasBeenCached() + val secondResult = downloadUtility.downloadStateHasBeenCached() + + assertEquals(expectedFirstResult, firstResult) + assertEquals(expectedSecondResult, secondResult) + } - asset1 = Asset("name1", "distType1", "archType1", 0) - asset2 = Asset("name2", "distType2", "archType2", 0) - assetList = listOf(asset1, asset2) + @Test + fun `Returns CacheSyncAttemptedWhileCacheIsEmpty if sync cache called while nothing is cached`() { + whenever(assetPreferences.getDownloadsAreInProgress()) + .thenReturn(false) - val url1 = getDownloadUrl(asset1.distributionType, asset1.architectureType, asset1.name) - val destination1 = asset1.concatenatedName - `when`(downloadManagerWrapper.generateDownloadRequest(url1, destination1)).thenReturn(requestReturn) + val result = downloadUtility.syncStateWithCache() - val url2 = getDownloadUrl(asset2.distributionType, asset2.architectureType, asset2.name) - val destination2 = asset2.concatenatedName - `when`(downloadManagerWrapper.generateDownloadRequest(url2, destination2)).thenReturn(requestReturn) + assertTrue(result is CacheSyncAttemptedWhileCacheIsEmpty) } - fun getDownloadUrl(distType: String, archType: String, name: String): String { - return "https://github.com/CypherpunkArmory/UserLAnd-Assets-$distType/raw/$branch/assets/$archType/$name" + @Test + fun `Returns AssetDownloadFailure while syncing if any cached downloads failed`() { + val downloadId = 0L + val failureReason = "fail" + whenever(assetPreferences.getDownloadsAreInProgress()) + .thenReturn(true) + whenever(assetPreferences.getEnqueuedDownloads()) + .thenReturn(setOf(downloadId)) + + whenever(downloadManagerWrapper.downloadHasFailed(downloadId)) + .thenReturn(true) + whenever(downloadManagerWrapper.getDownloadFailureReason(downloadId)) + .thenReturn(failureReason) + + val result = downloadUtility.syncStateWithCache() + assertTrue(result is AssetDownloadFailure) + val cast = result as AssetDownloadFailure + assertEquals(failureReason, cast.reason) + } + + @Test + fun `Returns AllDownloadsCompletedSuccessfully if all downloads have completed since cache was updated`() { + val downloadId = 0L + whenever(assetPreferences.getDownloadsAreInProgress()) + .thenReturn(true) + whenever(assetPreferences.getEnqueuedDownloads()) + .thenReturn(setOf(downloadId)) + whenever(downloadManagerWrapper.downloadHasFailed(downloadId)) + .thenReturn(false) + whenever(downloadManagerWrapper.downloadHasSucceeded(downloadId)) + .thenReturn(true) + whenever(downloadManagerWrapper.getDownloadTitle(downloadId)) + .thenReturn("title") + + val result = downloadUtility.syncStateWithCache() + + assertTrue(result is AllDownloadsCompletedSuccessfully) + verify(assetPreferences).setDownloadsAreInProgress(false) + verify(assetPreferences).clearEnqueuedDownloadsCache() } @Test - fun enqueuesDownload() { + fun `Returns CompletedDownloadsUpdate if downloads are still in progress during sync`() { + val downloadIds = setOf(0, 1) + whenever(assetPreferences.getDownloadsAreInProgress()) + .thenReturn(true) + whenever(assetPreferences.getEnqueuedDownloads()) + .thenReturn(downloadIds) + whenever(downloadManagerWrapper.downloadHasFailed(0)) + .thenReturn(false) + whenever(downloadManagerWrapper.downloadHasSucceeded(0)) + .thenReturn(true) + whenever(downloadManagerWrapper.downloadHasFailed(1)) + .thenReturn(false) + whenever(downloadManagerWrapper.downloadHasSucceeded(1)) + .thenReturn(false) + whenever(downloadManagerWrapper.getDownloadTitle(0)) + .thenReturn("title") + + val result = downloadUtility.syncStateWithCache() + + assertTrue(result is CompletedDownloadsUpdate) + val cast = result as CompletedDownloadsUpdate + assertEquals(1, cast.numCompleted) + assertEquals(2, cast.numTotal) + } + + @Test + fun `Sets up download process`() { + whenever(downloadManagerWrapper.enqueue(requestReturn1)) + .thenReturn(0) + whenever(downloadManagerWrapper.enqueue(requestReturn2)) + .thenReturn(1) + downloadUtility.downloadRequirements(assetList) - verify(downloadManagerWrapper, times(2)).enqueue(requestReturn) + verify(assetPreferences).clearEnqueuedDownloadsCache() + verify(assetPreferences).setDownloadsAreInProgress(true) + verify(assetPreferences).setEnqueuedDownloads(setOf(0, 1)) + } + + private fun setupDownloadState() { + whenever(downloadManagerWrapper.enqueue(requestReturn1)) + .thenReturn(0) + whenever(downloadManagerWrapper.enqueue(requestReturn2)) + .thenReturn(1) + + downloadUtility.downloadRequirements(assetList) + } + + @Test + fun `Returns NonUserLandDownloadFound if a a download we did not start is found`() { + setupDownloadState() + + val result = downloadUtility.handleDownloadComplete(-1) + + assertTrue(result is NonUserlandDownloadFound) + } + + @Test + fun `Returns AssetDownloadFailure if any downloads fail`() { + setupDownloadState() + whenever(downloadManagerWrapper.downloadHasFailed(0)) + .thenReturn(true) + whenever(downloadManagerWrapper.getDownloadFailureReason(0)) + .thenReturn("fail") + + val result = downloadUtility.handleDownloadComplete(0) + + assertTrue(result is AssetDownloadFailure) + result as AssetDownloadFailure + assertEquals("fail", result.reason) + } + + @Test + fun `Completes downloads and then resets cache when all complete`() { + setupDownloadState() + whenever(downloadManagerWrapper.downloadHasFailed(0)) + .thenReturn(false) + whenever(downloadManagerWrapper.downloadHasFailed(1)) + .thenReturn(false) + whenever(downloadManagerWrapper.getDownloadTitle(0)) + .thenReturn("userland-") + whenever(downloadManagerWrapper.getDownloadTitle(1)) + .thenReturn("userland-") + + val result1 = downloadUtility.handleDownloadComplete(0) + val result2 = downloadUtility.handleDownloadComplete(1) + + assertTrue(result1 is CompletedDownloadsUpdate) + assertTrue(result2 is AllDownloadsCompletedSuccessfully) + result1 as CompletedDownloadsUpdate + result2 as AllDownloadsCompletedSuccessfully + assertEquals(1, result1.numCompleted) + assertEquals(2, result1.numTotal) + verify(assetPreferences).setDownloadsAreInProgress(false) + verify(assetPreferences, times(2)).clearEnqueuedDownloadsCache() } @Test @@ -104,7 +259,6 @@ class DownloadUtilityTest { assertTrue(asset2DownloadsFile.exists()) downloadUtility.downloadRequirements(assetList) - verify(downloadManagerWrapper, times(2)).enqueue(requestReturn) assertFalse(asset1File.exists()) assertFalse(asset2File.exists()) @@ -112,27 +266,6 @@ class DownloadUtilityTest { assertFalse(asset2DownloadsFile.exists()) } - @Test - fun setsTimestampWhenTitleIsRelevant() { - val id = 1L - `when`(downloadManagerWrapper.getDownloadTitle(id)).thenReturn(asset1.concatenatedName) - - downloadUtility.setTimestampForDownloadedFile(id) - - verify(timestampPreferences).setSavedTimestampForFileToNow(asset1.concatenatedName) - } - - @Test - fun ignoresIrrelevantDownloads() { - val id = 1L - val titleName = "notuserland" - `when`(downloadManagerWrapper.getDownloadTitle(id)).thenReturn(titleName) - - downloadUtility.setTimestampForDownloadedFile(id) - - verify(timestampPreferences, never()).setSavedTimestampForFileToNow(ArgumentMatchers.anyString()) - } - @Test fun movesAssetsToCorrectLocationAndUpdatesPermissions() { val asset1DownloadsFile = File("${downloadDirectory.path}/${asset1.concatenatedName}") diff --git a/app/src/test/java/tech/ula/viewmodel/MainActivityViewModelTest.kt b/app/src/test/java/tech/ula/viewmodel/MainActivityViewModelTest.kt index 9d5d488e7..ceb0d14ef 100644 --- a/app/src/test/java/tech/ula/viewmodel/MainActivityViewModelTest.kt +++ b/app/src/test/java/tech/ula/viewmodel/MainActivityViewModelTest.kt @@ -282,21 +282,21 @@ class MainActivityViewModelTest { fun `Does not post IllegalState if app, session, and filesystem have not been selected and observed event is WaitingForAppSelection`() { appsStartupStateLiveData.postValue(WaitingForAppSelection) - verify(mockStateObserver, never()).onChanged(NoAppSelectedWhenPreparationStarted) + verify(mockStateObserver, never()).onChanged(NoAppSelectedWhenTransitionNecessary) } @Test fun `Does not post IllegalState if app, session, and filesystem have not been selected and observed event is FetchingDatabaseEntries`() { appsStartupStateLiveData.postValue(FetchingDatabaseEntries) - verify(mockStateObserver, never()).onChanged(NoAppSelectedWhenPreparationStarted) + verify(mockStateObserver, never()).onChanged(NoAppSelectedWhenTransitionNecessary) } @Test fun `Posts IllegalState if app, session, and filesystem have not been selected and an app state event is observed that is not the above`() { appsStartupStateLiveData.postValue(DatabaseEntriesFetchFailed) - verify(mockStateObserver).onChanged(NoAppSelectedWhenPreparationStarted) + verify(mockStateObserver).onChanged(NoAppSelectedWhenTransitionNecessary) } @Test @@ -414,7 +414,7 @@ class MainActivityViewModelTest { fun `Posts IllegalState if session preparation event is observed that is not WaitingForSelection and prep reqs have not been met`() { sessionStartupStateLiveData.postValue(NoDownloadsRequired) - verify(mockStateObserver).onChanged(NoSessionSelectedWhenPreparationStarted) + verify(mockStateObserver).onChanged(NoSessionSelectedWhenTransitionNecessary) } @Test @@ -536,7 +536,7 @@ class MainActivityViewModelTest { fun `Posts DownloadProgress as it observes requirements downloading`() { makeSessionSelections() - sessionStartupStateLiveData.postValue(DownloadingRequirements(0, 0)) + sessionStartupStateLiveData.postValue(DownloadingAssets(0, 0)) verify(mockStateObserver).onChanged(DownloadProgress(0, 0)) } @@ -562,6 +562,18 @@ class MainActivityViewModelTest { verify(mockStateObserver).onChanged(DownloadsDidNotCompleteSuccessfully(reason)) } + @Test + fun `Posts DownloadCacheAccessedWhileEmpty and resets state if it observes similar state`() { + sessionStartupStateLiveData.postValue(AttemptedCacheAccessWhileEmpty) + + verify(mockStateObserver).onChanged(DownloadCacheAccessedWhileEmpty) + verify(mockAppsStartupFsm).submitEvent(ResetAppState, mainActivityViewModel) + verify(mockSessionStartupFsm).submitEvent(ResetSessionState, mainActivityViewModel) + assertEquals(unselectedApp, mainActivityViewModel.lastSelectedApp) + assertEquals(unselectedSession, mainActivityViewModel.lastSelectedSession) + assertEquals(unselectedFilesystem, mainActivityViewModel.lastSelectedFilesystem) + } + @Test fun `Posts CopyingDownloads when equivalent state is observed`() { makeSessionSelections() @@ -636,7 +648,7 @@ class MainActivityViewModelTest { val target = "bullseye" sessionStartupStateLiveData.postValue(ExtractingFilesystem(target)) - verify(mockStateObserver).onChanged(FilesystemExtraction(target)) + verify(mockStateObserver).onChanged(FilesystemExtractionStep(target)) } @Test