diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b824380..1bbf764 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 26 targetSdk = 36 versionCode = (findProperty("versionCode") as String?)?.toInt() ?: 1 - versionName = (findProperty("versionName") as String?) ?: "0.0.1" + versionName = (findProperty("versionName") as String?) ?: "0.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt new file mode 100644 index 0000000..4bb4a51 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt @@ -0,0 +1,49 @@ +package org.mozilla.tryfox.data + +import kotlinx.coroutines.delay +import java.io.File + +class FakeDownloadFileRepository( + private val simulateNetworkError: Boolean = false, + private val networkErrorMessage: String = "Fake network error", + private val downloadProgressDelayMillis: Long = 100L, +) : DownloadFileRepository { + + var downloadFileCalled = false + var downloadFileResult: NetworkResult = NetworkResult.Success(File("fake_path")) + + override suspend fun downloadFile( + downloadUrl: String, + outputFile: File, + onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, + ): NetworkResult { + downloadFileCalled = true + + if (simulateNetworkError) { + return NetworkResult.Error(networkErrorMessage, null) + } + + val totalBytes = 10_000_000L + + onProgress(0, totalBytes) + delay(downloadProgressDelayMillis) + + onProgress(totalBytes / 2, totalBytes) + delay(downloadProgressDelayMillis) + + outputFile.parentFile?.mkdirs() + try { + if (!outputFile.exists()) { + outputFile.createNewFile() + } + outputFile.writeText("This is a fake downloaded artifact: ${outputFile.name} from $downloadUrl") + } catch (e: Exception) { + return NetworkResult.Error("Failed to create fake artifact file: ${e.message}", e) + } + + onProgress(totalBytes, totalBytes) + delay(downloadProgressDelayMillis) + + return NetworkResult.Success(outputFile) + } +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeFenixRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeFenixRepository.kt index 0b40d2f..4364399 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeFenixRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeFenixRepository.kt @@ -1,12 +1,8 @@ package org.mozilla.tryfox.data -import kotlinx.coroutines.delay -import java.io.File - class FakeFenixRepository( private val simulateNetworkError: Boolean = false, private val networkErrorMessage: String = "Fake network error", - private val downloadProgressDelayMillis: Long = 100L, ) : IFenixRepository { override suspend fun getPushByRevision( @@ -81,37 +77,4 @@ class FakeFenixRepository( NetworkResult.Success(ArtifactsResponse(artifacts = listOf(dummyArtifact))) } } - - override suspend fun downloadArtifact( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): NetworkResult { - if (simulateNetworkError) { - return NetworkResult.Error(networkErrorMessage, null) - } - - val totalBytes = 10_000_000L - - onProgress(0, totalBytes) - delay(downloadProgressDelayMillis) - - onProgress(totalBytes / 2, totalBytes) - delay(downloadProgressDelayMillis) - - outputFile.parentFile?.mkdirs() - try { - if (!outputFile.exists()) { - outputFile.createNewFile() - } - outputFile.writeText("This is a fake downloaded artifact: ${outputFile.name} from $downloadUrl") - } catch (e: Exception) { - return NetworkResult.Error("Failed to create fake artifact file: ${e.message}", e) - } - - onProgress(totalBytes, totalBytes) - delay(downloadProgressDelayMillis) - - return NetworkResult.Success(outputFile) - } } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt index 2f574f5..d7887ff 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt @@ -9,6 +9,9 @@ import java.io.File */ class FakeIntentManager() : IntentManager { + var wasUninstallApkCalled: Boolean = false + private set + /** * A boolean flag to indicate whether the `installApk` method was called. */ @@ -29,4 +32,8 @@ class FakeIntentManager() : IntentManager { override fun installApk(file: File) { installedFile = file } + + override fun uninstallApk(packageName: String) { + wasUninstallApkCalled = true + } } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt index ec2213b..f97e048 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt @@ -14,6 +14,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.tryfox.data.FakeCacheManager +import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeFenixRepository import org.mozilla.tryfox.data.FakeIntentManager import org.mozilla.tryfox.data.FakeUserDataRepository @@ -26,7 +27,8 @@ class ProfileScreenTest { @get:Rule val composeTestRule = createComposeRule() - private val fenixRepository = FakeFenixRepository(downloadProgressDelayMillis = 100L) + private val fenixRepository = FakeFenixRepository() + private val downloadFileRepository = FakeDownloadFileRepository() private val userDataRepository: UserDataRepository = FakeUserDataRepository() private val cacheManager: CacheManager = FakeCacheManager() private val intentManager = FakeIntentManager() @@ -44,6 +46,7 @@ class ProfileScreenTest { fun searchPushesAndCheckDownloadAndInstallStates() { val profileViewModel = ProfileViewModel( fenixRepository = fenixRepository, + downloadFileRepository = downloadFileRepository, userDataRepository = userDataRepository, cacheManager = cacheManager, intentManager = intentManager, @@ -94,6 +97,7 @@ class ProfileScreenTest { val initialEmail = "initial@example.com" val profileViewModelWithEmail = ProfileViewModel( fenixRepository = fenixRepository, + downloadFileRepository = downloadFileRepository, userDataRepository = userDataRepository, cacheManager = cacheManager, intentManager = intentManager, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05fc82f..0c783b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index f055885..e492007 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.mozilla.tryfox.data.DownloadFileRepository import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.NetworkResult @@ -31,13 +32,14 @@ import java.io.File * ViewModel for the TryFox feature, responsible for fetching job and artifact data from the repository, * managing the download and caching of artifacts, and exposing the UI state to the composable screens. * - * @param repository The repository for fetching data from the network. + * @param fenixRepository The repository for fetching data from the network. * @param cacheManager The manager for handling application cache. * @param revision The initial revision to search for. * @param repo The initial repository to search in. */ class TryFoxViewModel( - private val repository: IFenixRepository, + private val fenixRepository: IFenixRepository, + private val downloadFileRepository: DownloadFileRepository, private val cacheManager: CacheManager, revision: String?, repo: String?, @@ -157,7 +159,7 @@ class TryFoxViewModel( cacheManager.checkCacheStatus() // Use CacheManager checkAndUpdateDownloadingStatus() // Initial check before fetching - when (val revisionResult = repository.getPushByRevision(selectedProject, revision)) { + when (val revisionResult = fenixRepository.getPushByRevision(selectedProject, revision)) { is NetworkResult.Success -> { val pushData = revisionResult.data var foundComment: String? = null @@ -195,7 +197,7 @@ class TryFoxViewModel( private suspend fun fetchJobs(pushId: Int) { Log.d("FenixInstallerViewModel", "Fetching jobs for push ID: $pushId") - when (val jobsResult = repository.getJobsForPush(pushId)) { + when (val jobsResult = fenixRepository.getJobsForPush(pushId)) { is NetworkResult.Success -> { val networkJobDetailsList = jobsResult.data.results .filter { it.isSignedBuild && !it.isTest } @@ -245,7 +247,7 @@ class TryFoxViewModel( private suspend fun fetchArtifacts(taskId: String): List { Log.d("FenixInstallerViewModel", "Fetching artifacts for task ID: $taskId") - return when (val artifactsResult = repository.getArtifactsForTask(taskId)) { + return when (val artifactsResult = fenixRepository.getArtifactsForTask(taskId)) { is NetworkResult.Success -> { val filteredApks = artifactsResult.data.artifacts.filter { it.name.endsWith(".apk", ignoreCase = true) @@ -318,7 +320,7 @@ class TryFoxViewModel( } val outputFile = File(outputDir, artifactFileName) - val result = repository.downloadArtifact( + val result = downloadFileRepository.downloadFile( downloadUrl = downloadUrl, outputFile = outputFile, onProgress = { bytesDownloaded, totalBytes -> diff --git a/app/src/main/java/org/mozilla/tryfox/data/DefaultDownloadFileRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/DefaultDownloadFileRepository.kt new file mode 100644 index 0000000..dd0ac04 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/DefaultDownloadFileRepository.kt @@ -0,0 +1,42 @@ +package org.mozilla.tryfox.data + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.mozilla.tryfox.network.DownloadApiService +import java.io.File +import java.io.FileOutputStream + +/** + * Default implementation of [DownloadFileRepository] for downloading files. + */ +class DefaultDownloadFileRepository( + private val downloadApiService: DownloadApiService, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : DownloadFileRepository { + override suspend fun downloadFile(downloadUrl: String, outputFile: File, onProgress: (Long, Long) -> Unit): NetworkResult { + return withContext(ioDispatcher) { + try { + val response = downloadApiService.downloadFile(downloadUrl) + val body = response.byteStream() + val totalBytes = response.contentLength() + var bytesCopied: Long = 0 + + body.use { inputStream -> + FileOutputStream(outputFile).use { outputStream -> + val buffer = ByteArray(4 * 1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + bytesCopied += read + onProgress(bytesCopied, totalBytes) + } + } + } + NetworkResult.Success(outputFile) + } catch (e: Exception) { + NetworkResult.Error("Failed to download file: ${e.message}", e) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt index 1f5dc33..f218210 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt @@ -15,6 +15,7 @@ import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.util.FENIX_PACKAGE import org.mozilla.tryfox.util.FOCUS_PACKAGE import org.mozilla.tryfox.util.REFERENCE_BROWSER_PACKAGE +import org.mozilla.tryfox.util.TRYFOX_PACKAGE class DefaultMozillaPackageManager(private val context: Context) : MozillaPackageManager { @@ -47,16 +48,20 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag FENIX_PACKAGE to "Fenix", FOCUS_PACKAGE to "Focus", REFERENCE_BROWSER_PACKAGE to "Reference Browser", + TRYFOX_PACKAGE to "TryFox", ) override val fenix: AppState - get() = getAppState("org.mozilla.fenix") + get() = getAppState(FENIX_PACKAGE) override val focus: AppState - get() = getAppState("org.mozilla.focus.nightly") + get() = getAppState(FOCUS_PACKAGE) override val referenceBrowser: AppState - get() = getAppState("org.mozilla.reference.browser") + get() = getAppState(REFERENCE_BROWSER_PACKAGE) + + override val tryfox: AppState + get() = getAppState(TRYFOX_PACKAGE) override val appStates: Flow = callbackFlow { val receiver = object : BroadcastReceiver() { diff --git a/app/src/main/java/org/mozilla/tryfox/data/DownloadFileRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/DownloadFileRepository.kt new file mode 100644 index 0000000..6c05c70 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/DownloadFileRepository.kt @@ -0,0 +1,18 @@ +package org.mozilla.tryfox.data + +import java.io.File + +/** + * Interface for a repository responsible for downloading files. + */ +interface DownloadFileRepository { + /** + * Downloads a file from the given URL to the specified output file, reporting progress. + * + * @param downloadUrl The URL of the file to download. + * @param outputFile The file where the downloaded content will be saved. + * @param onProgress A callback function to report download progress (bytesDownloaded, totalBytes). + * @return A [NetworkResult] indicating success with the downloaded [File] or an [NetworkResult.Error] on failure. + */ + suspend fun downloadFile(downloadUrl: String, outputFile: File, onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit): NetworkResult +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/DownloadState.kt b/app/src/main/java/org/mozilla/tryfox/data/DownloadState.kt index da24b5c..e46d93d 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/DownloadState.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/DownloadState.kt @@ -2,30 +2,9 @@ package org.mozilla.tryfox.data import java.io.File -/** - * Represents the various states of a file download operation. - */ sealed class DownloadState { - /** - * Indicates that the download has not yet started. - */ - data object NotDownloaded : DownloadState() - - /** - * Indicates that the download is currently in progress. - * @property progress A float value between 0.0 and 1.0 representing the download progress. - */ - data class InProgress(val progress: Float) : DownloadState() - - /** - * Indicates that the download has completed successfully. - * @property file The [File] object representing the downloaded file. - */ + object NotDownloaded : DownloadState() + data class InProgress(val progress: Float, val isIndeterminate: Boolean = false) : DownloadState() data class Downloaded(val file: File) : DownloadState() - - /** - * Indicates that the download has failed. - * @property errorMessage An optional string containing a message describing the reason for the failure. - */ - data class DownloadFailed(val errorMessage: String?) : DownloadState() + data class DownloadFailed(val message: String?) : DownloadState() } diff --git a/app/src/main/java/org/mozilla/tryfox/data/FenixReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/FenixReleaseRepository.kt new file mode 100644 index 0000000..a311881 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/FenixReleaseRepository.kt @@ -0,0 +1,22 @@ +package org.mozilla.tryfox.data + +import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.model.ParsedNightlyApk +import org.mozilla.tryfox.util.FENIX + +/** + * A [ReleaseRepository] for Fenix builds. + */ +class FenixReleaseRepository( + private val mozillaArchiveRepository: MozillaArchiveRepository, +) : DateAwareReleaseRepository { + override val appName: String = FENIX + + override suspend fun getLatestReleases(): NetworkResult> { + return mozillaArchiveRepository.getFenixNightlyBuilds() + } + + override suspend fun getReleases(date: LocalDate?): NetworkResult> { + return mozillaArchiveRepository.getFenixNightlyBuilds(date) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/FenixRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/FenixRepository.kt index fb60bce..4277f91 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/FenixRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/FenixRepository.kt @@ -1,23 +1,12 @@ package org.mozilla.tryfox.data -import android.util.Log // Added import -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import logcat.LogPriority -import logcat.logcat -import org.mozilla.tryfox.network.ApiService -import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.io.OutputStream +import org.mozilla.tryfox.network.TreeherderApiService class FenixRepository( - private val treeherderApiService: ApiService, + private val treeherderApiService: TreeherderApiService, ) : IFenixRepository { companion object { - private const val TAG = "FenixRepository" - const val TREEHERDER_BASE_URL = "https://treeherder.mozilla.org/api/" const val TASKCLUSTER_BASE_URL = "https://firefox-ci-tc.services.mozilla.com/api/queue/v1/" } @@ -48,70 +37,4 @@ class FenixRepository( val artifactsUrl = "${TASKCLUSTER_BASE_URL}task/$taskId/runs/0/artifacts" return safeApiCall { treeherderApiService.getArtifactsForTask(artifactsUrl) } } - - override suspend fun downloadArtifact( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): NetworkResult { - logcat(TAG) { "downloadArtifact called. URL: $downloadUrl, OutputFile: ${outputFile.absolutePath}" } - return try { - logcat(LogPriority.DEBUG, TAG) { "Attempting to download file from ApiService: $downloadUrl" } - val responseBody = treeherderApiService.downloadFile(downloadUrl) - logcat(LogPriority.DEBUG, TAG) { "Got responseBody. ContentLength: ${responseBody.contentLength()}" } - - outputFile.parentFile?.mkdirs() - logcat(LogPriority.VERBOSE, TAG) { "Parent directories created/ensured for ${outputFile.absolutePath}" } - - val totalBytes = responseBody.contentLength() - var bytesDownloaded: Long = 0 - logcat(LogPriority.DEBUG, TAG) { "Starting file write. TotalBytes: $totalBytes" } - - withContext(Dispatchers.IO) { - logcat(LogPriority.DEBUG, TAG) { "Entered withContext(Dispatchers.IO) for file writing." } - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - inputStream = responseBody.byteStream() - outputStream = FileOutputStream(outputFile) - logcat(LogPriority.VERBOSE, TAG) { "InputStream and OutputStream opened." } - val buffer = ByteArray(4 * 1024) // 4KB buffer - var read: Int - logcat(LogPriority.DEBUG, TAG) { "Starting read/write loop." } - while (inputStream.read(buffer).also { read = it } != -1) { - outputStream.write(buffer, 0, read) - bytesDownloaded += read - if (bytesDownloaded == 0L || bytesDownloaded == totalBytes || (bytesDownloaded % (totalBytes / 10).coerceAtLeast(1)) == 0L) { - logcat(LogPriority.VERBOSE, TAG) { "Progress: $bytesDownloaded / $totalBytes" } - } - onProgress(bytesDownloaded, totalBytes) - } - logcat(LogPriority.DEBUG, TAG) { "Finished read/write loop. Total bytes written: $bytesDownloaded" } - outputStream.flush() - logcat(LogPriority.VERBOSE, TAG) { "OutputStream flushed." } - } catch (e: Exception) { - logcat(LogPriority.ERROR, TAG) { "Exception during file I/O stream operations: ${e.message}\n${Log.getStackTraceString(e)}" } - throw e - } finally { - try { - inputStream?.close() - logcat(LogPriority.VERBOSE, TAG) { "InputStream closed." } - } catch (e: Exception) { - logcat(LogPriority.WARN, TAG) { "Exception closing InputStream: ${e.message}\n${Log.getStackTraceString(e)}" } - } - try { - outputStream?.close() - logcat(LogPriority.VERBOSE, TAG) { "OutputStream closed." } - } catch (e: Exception) { - logcat(LogPriority.WARN, TAG) { "Exception closing OutputStream: ${e.message}\n${Log.getStackTraceString(e)}" } - } - } - } - logcat(TAG) { "File download successful: ${outputFile.absolutePath}" } - NetworkResult.Success(outputFile) - } catch (e: Exception) { - logcat(LogPriority.ERROR, TAG) { "Download failed for URL $downloadUrl: ${e.message}\n${Log.getStackTraceString(e)}" } - NetworkResult.Error("Download failed: ${e.message}", e) - } - } } diff --git a/app/src/main/java/org/mozilla/tryfox/data/FocusReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/FocusReleaseRepository.kt new file mode 100644 index 0000000..2ef6f7d --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/FocusReleaseRepository.kt @@ -0,0 +1,22 @@ +package org.mozilla.tryfox.data + +import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.model.ParsedNightlyApk +import org.mozilla.tryfox.util.FOCUS + +/** + * A [ReleaseRepository] for Focus builds. + */ +class FocusReleaseRepository( + private val mozillaArchiveRepository: MozillaArchiveRepository, +) : DateAwareReleaseRepository { + override val appName: String = FOCUS + + override suspend fun getLatestReleases(): NetworkResult> { + return mozillaArchiveRepository.getFocusNightlyBuilds() + } + + override suspend fun getReleases(date: LocalDate?): NetworkResult> { + return mozillaArchiveRepository.getFocusNightlyBuilds(date) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/GitHubRelease.kt b/app/src/main/java/org/mozilla/tryfox/data/GitHubRelease.kt new file mode 100644 index 0000000..d9dc268 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/GitHubRelease.kt @@ -0,0 +1,17 @@ +package org.mozilla.tryfox.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GitHubRelease( + @SerialName("tag_name") val tagName: String, + val assets: List, + @SerialName("updated_at") val updatedAt: String, +) + +@Serializable +data class GitHubAsset( + val name: String, + @SerialName("browser_download_url") val browserDownloadUrl: String, +) diff --git a/app/src/main/java/org/mozilla/tryfox/data/IFenixRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/IFenixRepository.kt index 6d5cdd0..a989ae4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/IFenixRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/IFenixRepository.kt @@ -1,15 +1,8 @@ package org.mozilla.tryfox.data -import java.io.File - interface IFenixRepository { suspend fun getPushByRevision(project: String, revision: String): NetworkResult suspend fun getPushesByAuthor(author: String): NetworkResult suspend fun getJobsForPush(pushId: Int): NetworkResult suspend fun getArtifactsForTask(taskId: String): NetworkResult - suspend fun downloadArtifact( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): NetworkResult } diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepository.kt index 03286c7..e407187 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepository.kt @@ -13,9 +13,4 @@ interface MozillaArchiveRepository { * Fetches and parses the list of Focus nightly builds for the current month from the archive. */ suspend fun getFocusNightlyBuilds(date: LocalDate? = null): NetworkResult> - - /** - * Fetches and parses the list of Reference Browser nightly builds from TaskCluster storage. - */ - suspend fun getReferenceBrowserNightlyBuilds(): NetworkResult> } diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImpl.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImpl.kt index baeb428..131beb1 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImpl.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImpl.kt @@ -7,21 +7,19 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.minus import kotlinx.datetime.todayIn import org.mozilla.tryfox.model.ParsedNightlyApk -import org.mozilla.tryfox.network.ApiService +import org.mozilla.tryfox.network.MozillaArchivesApiService import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FOCUS import retrofit2.HttpException import java.util.regex.Pattern class MozillaArchiveRepositoryImpl( - private val archiveApiService: ApiService, + private val mozillaArchivesApiService: MozillaArchivesApiService, private val clock: Clock = Clock.System, ) : MozillaArchiveRepository { companion object { const val ARCHIVE_MOZILLA_BASE_URL = "https://archive.mozilla.org/" - private const val REFERENCE_BROWSER_TASK_BASE_URL = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/mobile.v2.reference-browser.nightly.latest." - private val REFERENCE_BROWSER_ABIS = listOf("arm64-v8a", "armeabi-v7a", "x86_64") internal fun archiveUrlForDate(appName: String, date: LocalDate): String { val year = date.year.toString() @@ -53,30 +51,9 @@ class MozillaArchiveRepositoryImpl( override suspend fun getFocusNightlyBuilds(date: LocalDate?): NetworkResult> = getNightlyBuilds(FOCUS, date) - override suspend fun getReferenceBrowserNightlyBuilds(): NetworkResult> { - return try { - val parsedApks = REFERENCE_BROWSER_ABIS.map { abi -> - val fullUrl = "${REFERENCE_BROWSER_TASK_BASE_URL}$abi/artifacts/public/target.$abi.apk" - val fileName = "target.$abi.apk" - ParsedNightlyApk( - originalString = "reference-browser-latest-android-$abi/", - rawDateString = null, - appName = "reference-browser", - version = "", - abiName = abi, - fullUrl = fullUrl, - fileName = fileName, - ) - } - NetworkResult.Success(parsedApks) - } catch (e: Exception) { - NetworkResult.Error("Failed to construct Reference Browser builds: ${e.message}", e) - } - } - private suspend fun fetchAndParseNightlyBuilds(archiveBaseUrl: String, appNameFilter: String, date: LocalDate?): NetworkResult> { return try { - val htmlResult = archiveApiService.getHtmlPage(archiveBaseUrl) + val htmlResult = mozillaArchivesApiService.getHtmlPage(archiveBaseUrl) val parsedApks = parseNightlyBuildsFromHtml(htmlResult, archiveBaseUrl, appNameFilter, date) NetworkResult.Success(parsedApks) } catch (e: Exception) { diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt index ced6af5..be9e267 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt @@ -23,6 +23,11 @@ interface MozillaPackageManager { */ val referenceBrowser: AppState + /** + * The [AppState] for TryFox. + */ + val tryfox: AppState + /** * A flow that emits an [AppState] whenever a monitored Mozilla application * is installed or uninstalled. diff --git a/app/src/main/java/org/mozilla/tryfox/data/ReferenceBrowserReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/ReferenceBrowserReleaseRepository.kt new file mode 100644 index 0000000..d1a6623 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/ReferenceBrowserReleaseRepository.kt @@ -0,0 +1,38 @@ +package org.mozilla.tryfox.data + +import org.mozilla.tryfox.model.ParsedNightlyApk +import org.mozilla.tryfox.util.REFERENCE_BROWSER + +/** + * A [ReleaseRepository] for Reference Browser builds. + */ +class ReferenceBrowserReleaseRepository : ReleaseRepository { + + companion object { + private const val REFERENCE_BROWSER_TASK_BASE_URL = "https://firefox-ci-tc.services.mozilla.com/api/index/v1/task/mobile.v2.reference-browser.nightly.latest." + private val REFERENCE_BROWSER_ABIS = listOf("arm64-v8a", "armeabi-v7a", "x86_64") + } + + override val appName: String = REFERENCE_BROWSER + + override suspend fun getLatestReleases(): NetworkResult> { + return try { + val parsedApks = REFERENCE_BROWSER_ABIS.map { abi -> + val fullUrl = "${REFERENCE_BROWSER_TASK_BASE_URL}$abi/artifacts/public/target.$abi.apk" + val fileName = "target.$abi.apk" + ParsedNightlyApk( + originalString = "reference-browser-latest-android-$abi/", + rawDateString = null, + appName = "reference-browser", + version = "", + abiName = abi, + fullUrl = fullUrl, + fileName = fileName, + ) + } + NetworkResult.Success(parsedApks) + } catch (e: Exception) { + NetworkResult.Error("Failed to construct Reference Browser builds: ${e.message}", e) + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/ReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/ReleaseRepository.kt new file mode 100644 index 0000000..a8c5303 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/ReleaseRepository.kt @@ -0,0 +1,23 @@ +package org.mozilla.tryfox.data + +import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.model.ParsedNightlyApk + +/** + * Interface for a repository that provides release information for a specific application. + */ +interface ReleaseRepository { + /** + * The name of the application this repository is for. + */ + val appName: String + + /** + * Fetches the latest releases for the application. + */ + suspend fun getLatestReleases(): NetworkResult> +} + +interface DateAwareReleaseRepository : ReleaseRepository { + suspend fun getReleases(date: LocalDate? = null): NetworkResult> +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/TryFoxReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/TryFoxReleaseRepository.kt new file mode 100644 index 0000000..8daee97 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/TryFoxReleaseRepository.kt @@ -0,0 +1,34 @@ +package org.mozilla.tryfox.data + +import org.mozilla.tryfox.model.ParsedNightlyApk +import org.mozilla.tryfox.network.GithubApiService +import org.mozilla.tryfox.util.TRYFOX + +/** + * A [ReleaseRepository] for TryFox builds. + */ +class TryFoxReleaseRepository( + private val githubApiService: GithubApiService, +) : ReleaseRepository { + override val appName: String = TRYFOX + + override suspend fun getLatestReleases(): NetworkResult> { + return try { + val release = githubApiService.getLatestGitHubRelease("mozilla-mobile", "TryFox") + val parsedApks = release.assets.map { asset -> + ParsedNightlyApk( + originalString = asset.name, + rawDateString = release.updatedAt, + appName = TRYFOX, + version = release.tagName, + abiName = "universal", + fullUrl = asset.browserDownloadUrl, + fileName = asset.name, + ) + } + NetworkResult.Success(parsedApks) + } catch (e: Exception) { + NetworkResult.Error("Failed to fetch TryFox releases: ${e.message}", e) + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt index a566d75..665515e 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt @@ -14,6 +14,7 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TREEHERDER +import org.mozilla.tryfox.util.TRYFOX import java.io.File class DefaultCacheManager( @@ -43,7 +44,7 @@ class DefaultCacheManager( } private fun determineCacheState(): CacheManagementState { - val cacheIsNotEmpty = listOf(FENIX, FOCUS, REFERENCE_BROWSER, TREEHERDER).any { isAppCachePopulated(it) } + val cacheIsNotEmpty = listOf(FENIX, FOCUS, REFERENCE_BROWSER, TREEHERDER, TRYFOX).any { isAppCachePopulated(it) } return if (cacheIsNotEmpty) CacheManagementState.IdleNonEmpty else CacheManagementState.IdleEmpty } diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt index 4b0e007..85028d5 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.util.Log import android.widget.Toast import androidx.core.content.FileProvider +import androidx.core.net.toUri import org.mozilla.tryfox.BuildConfig import java.io.File @@ -20,6 +21,7 @@ interface IntentManager { * @param file The APK file to install. */ fun installApk(file: File) + fun uninstallApk(packageName: String) } /** @@ -52,4 +54,17 @@ class DefaultIntentManager(private val context: Context) : IntentManager { Log.e("IntentManager", "Error installing APK", e) } } + + override fun uninstallApk(packageName: String) { + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(context, "No application found to uninstall app", Toast.LENGTH_LONG).show() + Log.e("MainActivity", "Error uninstalling app", e) + } + } } diff --git a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt index 6a96ad8..359d985 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -13,25 +13,40 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.mozilla.tryfox.BuildConfig import org.mozilla.tryfox.TryFoxViewModel +import org.mozilla.tryfox.data.DefaultDownloadFileRepository import org.mozilla.tryfox.data.DefaultMozillaPackageManager import org.mozilla.tryfox.data.DefaultUserDataRepository +import org.mozilla.tryfox.data.DownloadFileRepository +import org.mozilla.tryfox.data.FenixReleaseRepository import org.mozilla.tryfox.data.FenixRepository +import org.mozilla.tryfox.data.FocusReleaseRepository import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.MozillaArchiveRepository import org.mozilla.tryfox.data.MozillaArchiveRepositoryImpl import org.mozilla.tryfox.data.MozillaPackageManager +import org.mozilla.tryfox.data.ReferenceBrowserReleaseRepository +import org.mozilla.tryfox.data.ReleaseRepository +import org.mozilla.tryfox.data.TryFoxReleaseRepository import org.mozilla.tryfox.data.UserDataRepository import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.DefaultCacheManager import org.mozilla.tryfox.data.managers.DefaultIntentManager import org.mozilla.tryfox.data.managers.IntentManager -import org.mozilla.tryfox.network.ApiService +import org.mozilla.tryfox.network.DownloadApiService +import org.mozilla.tryfox.network.GithubApiService +import org.mozilla.tryfox.network.MozillaArchivesApiService +import org.mozilla.tryfox.network.TreeherderApiService import org.mozilla.tryfox.ui.screens.HomeViewModel import org.mozilla.tryfox.ui.screens.ProfileViewModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.REFERENCE_BROWSER +import org.mozilla.tryfox.util.TRYFOX import retrofit2.Retrofit import retrofit2.converter.scalars.ScalarsConverterFactory const val TREEHERDER_BASE_URL = "https://treeherder.mozilla.org/api/" +const val GITHUB_BASE_URL = "https://api.github.com/" const val ARCHIVE_MOZILLA_BASE_URL = "https://archive.mozilla.org/" val dispatchersModule = module { @@ -51,7 +66,7 @@ val networkModule = module { }.build() } - single { + single(named("treeherderRetrofit")) { val json = Json { ignoreUnknownKeys = true coerceInputValues = true @@ -64,22 +79,99 @@ val networkModule = module { .build() } - single { get().create(ApiService::class.java) } + single(named("githubRetrofit")) { + val json = Json { + ignoreUnknownKeys = true + } + Retrofit.Builder() + .baseUrl(GITHUB_BASE_URL) + .client(get()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + single(named("mozillaArchiveRetrofit")) { + Retrofit.Builder() + .baseUrl(ARCHIVE_MOZILLA_BASE_URL) + .client(get()) + .addConverterFactory(ScalarsConverterFactory.create()) + .build() + } + + single { + val treeherderRetrofit: Retrofit = get(named("treeherderRetrofit")) + treeherderRetrofit.create(TreeherderApiService::class.java) + } + + single { + val githubRetrofit: Retrofit = get(named("githubRetrofit")) + githubRetrofit.create(GithubApiService::class.java) + } + + single { + val treeherderRetrofit: Retrofit = + get(named("treeherderRetrofit")) // Re-using treeherder retrofit for download as it's a generic download + treeherderRetrofit.create(DownloadApiService::class.java) + } + + single { + val mozillaArchiveRetrofit: Retrofit = get(named("mozillaArchiveRetrofit")) + mozillaArchiveRetrofit.create(MozillaArchivesApiService::class.java) + } } val repositoryModule = module { + single { + DefaultDownloadFileRepository( + get(), + get(named("IODispatcher")), + ) + } single { FenixRepository(get()) } - single { DefaultUserDataRepository(androidContext()) } single { MozillaArchiveRepositoryImpl(get()) } + single { DefaultUserDataRepository(androidContext()) } single { DefaultMozillaPackageManager(androidContext()) } - single { DefaultCacheManager(androidContext().cacheDir, get(named("IODispatcher"))) } + single { + DefaultCacheManager( + androidContext().cacheDir, + get(named("IODispatcher")), + ) + } single { DefaultIntentManager(androidContext()) } + + single(named(FENIX)) { FenixReleaseRepository(get()) } + single(named(FOCUS)) { FocusReleaseRepository(get()) } + single(named(REFERENCE_BROWSER)) { ReferenceBrowserReleaseRepository() } + single(named(TRYFOX)) { TryFoxReleaseRepository(get()) } } val viewModelModule = module { - viewModel { params -> TryFoxViewModel(get(), get(), params.getOrNull(), params.getOrNull()) } - viewModel { HomeViewModel(get(), get(), get(), get(), get(), get(named("IODispatcher"))) } - viewModel { params -> ProfileViewModel(get(), get(), get(), get(), params.getOrNull()) } + viewModel { params -> + TryFoxViewModel( + get(), + get(), + get(), + params.getOrNull(), + params.getOrNull(), + ) + } + viewModel { + val releaseRepositories = listOf( + get(named(FENIX)), + get(named(FOCUS)), + get(named(REFERENCE_BROWSER)), + get(named(TRYFOX)), + ) + HomeViewModel( + releaseRepositories, + get(), + get(), + get(), + get(), + get(named("IODispatcher")), + ) + } + viewModel { params -> ProfileViewModel(get(), get(), get(), get(), get(), params.getOrNull()) } } val appModules = listOf(dispatchersModule, networkModule, repositoryModule, viewModelModule) diff --git a/app/src/main/java/org/mozilla/tryfox/network/DownloadApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/DownloadApiService.kt new file mode 100644 index 0000000..32a635a --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/network/DownloadApiService.kt @@ -0,0 +1,16 @@ +package org.mozilla.tryfox.network + +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * A Retrofit service for downloading files from a given URL. + */ +interface DownloadApiService { + @DisableLogs + @Streaming + @GET + suspend fun downloadFile(@Url downloadUrl: String): ResponseBody +} diff --git a/app/src/main/java/org/mozilla/tryfox/network/GithubApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/GithubApiService.kt new file mode 100644 index 0000000..8f1c906 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/network/GithubApiService.kt @@ -0,0 +1,13 @@ +package org.mozilla.tryfox.network + +import org.mozilla.tryfox.data.GitHubRelease +import retrofit2.http.GET +import retrofit2.http.Path + +interface GithubApiService { + @GET("repos/{owner}/{repo}/releases/latest") + suspend fun getLatestGitHubRelease( + @Path("owner") owner: String, + @Path("repo") repo: String, + ): GitHubRelease +} diff --git a/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt new file mode 100644 index 0000000..f97ccc5 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt @@ -0,0 +1,12 @@ +package org.mozilla.tryfox.network + +import retrofit2.http.GET +import retrofit2.http.Url + +/** + * A Retrofit service for interacting with the Mozilla Archives API. + */ +interface MozillaArchivesApiService { + @GET + suspend fun getHtmlPage(@Url url: String): String +} diff --git a/app/src/main/java/org/mozilla/tryfox/network/ApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt similarity index 77% rename from app/src/main/java/org/mozilla/tryfox/network/ApiService.kt rename to app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt index fa9d2bf..5178a86 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/ApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -1,16 +1,14 @@ package org.mozilla.tryfox.network -import okhttp3.ResponseBody import org.mozilla.tryfox.data.ArtifactsResponse import org.mozilla.tryfox.data.TreeherderJobsResponse import org.mozilla.tryfox.data.TreeherderRevisionResponse import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query -import retrofit2.http.Streaming import retrofit2.http.Url -interface ApiService { +interface TreeherderApiService { @GET("project/{project}/push/") suspend fun getPushByRevision( @@ -30,12 +28,4 @@ interface ApiService { @GET suspend fun getArtifactsForTask(@Url url: String): ArtifactsResponse - - @DisableLogs - @Streaming - @GET - suspend fun downloadFile(@Url downloadUrl: String): ResponseBody - - @GET - suspend fun getHtmlPage(@Url url: String): String } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt index 84565ea..61cf6f2 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt @@ -36,7 +36,6 @@ import org.mozilla.tryfox.TryFoxViewModel import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.ui.models.ArtifactUiModel import org.mozilla.tryfox.ui.models.JobDetailsUiModel -import org.mozilla.tryfox.ui.screens.ErrorState @Composable fun AppCard( @@ -164,8 +163,9 @@ private fun DisplayArtifactCard( viewModel: TryFoxViewModel, ) { if (artifact.downloadState is DownloadState.DownloadFailed) { - val errorMessage = (artifact.downloadState as DownloadState.DownloadFailed).errorMessage - ErrorState(errorMessage = stringResource(id = R.string.app_card_download_failed_message, errorMessage ?: stringResource(id = R.string.common_unknown_error))) + val rawErrorMessage = (artifact.downloadState as DownloadState.DownloadFailed).message + val displayErrorMessage = rawErrorMessage ?: stringResource(id = R.string.common_unknown_error) + ErrorState(errorMessage = stringResource(R.string.app_card_download_failed_message, displayErrorMessage)) Spacer(modifier = Modifier.padding(top = 4.dp)) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt index afe34f1..b086521 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -14,6 +15,7 @@ import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -74,6 +76,7 @@ fun ArchiveGroupCard( onDownloadClick: (ApkUiModel) -> Unit, onInstallClick: (File) -> Unit, onOpenAppClick: () -> Unit, + onUninstallClick: () -> Unit, appState: AppState?, onDateSelected: (LocalDate) -> Unit, userPickedDate: LocalDate?, @@ -134,7 +137,7 @@ fun ArchiveGroupCard( ) } apks.isNotEmpty() -> { - ArchiveGroupAbiSelector(apks, onDownloadClick, onInstallClick) + ArchiveGroupAbiSelector(apks, onDownloadClick, onInstallClick, onUninstallClick, appState) } else -> { Text( @@ -248,6 +251,8 @@ private fun ArchiveGroupAbiSelector( apks: List, onDownloadClick: (ApkUiModel) -> Unit, onInstallClick: (File) -> Unit, + onUninstallClick: () -> Unit, + appState: AppState?, ) { val firstSupportedIndex = apks.indexOfFirst { it.abi.isSupported }.takeIf { it != -1 } ?: 0 var selectedIndex by remember { mutableStateOf(firstSupportedIndex) } @@ -294,12 +299,26 @@ private fun ArchiveGroupAbiSelector( Spacer(Modifier.height(ArchiveGroupCardTokens.SpacerHeight)) - val selectedApk = apks[selectedIndex] - DownloadButton( - downloadState = selectedApk.downloadState, - onDownloadClick = { onDownloadClick(selectedApk) }, - onInstallClick = { file -> onInstallClick(file) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (appState?.isInstalled == true) { + Button( + onClick = onUninstallClick, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text(text = stringResource(id = R.string.uninstall_button_label)) + } + } + + val selectedApk = apks[selectedIndex] + DownloadButton( + downloadState = selectedApk.downloadState, + onDownloadClick = { onDownloadClick(selectedApk) }, + onInstallClick = { file -> onInstallClick(file) }, + ) + } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt index a389408..5e5ae30 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt @@ -11,7 +11,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.R import org.mozilla.tryfox.model.AppState @Composable @@ -27,7 +29,7 @@ fun CurrentInstallState( appState == null || !appState.isInstalled -> { AssistChip( onClick = { /* No action */ }, - label = { Text("Not installed") }, + label = { Text(stringResource(id = R.string.not_installed_chip_label)) }, colors = AssistChipDefaults.assistChipColors( containerColor = MaterialTheme.colorScheme.errorContainer, labelColor = MaterialTheme.colorScheme.onErrorContainer, @@ -38,7 +40,7 @@ fun CurrentInstallState( else -> { AssistChip( onClick = { /* No action */ }, - label = { Text("Installed") }, + label = { Text(stringResource(id = R.string.installed_chip_label)) }, border = AssistChipDefaults.assistChipBorder(true), ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt index d8c47ae..4d89c3d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt @@ -32,23 +32,23 @@ fun DownloadButton( } is DownloadState.InProgress -> { Button( - onClick = {}, // Corrected: was missing comma + onClick = {}, enabled = false, modifier = Modifier.testTag("action_button_downloading"), // Tag for Downloading state ) { - if (downloadState.progress > 0f) { + if (downloadState.isIndeterminate) { CircularProgressIndicator( - progress = { downloadState.progress }, modifier = Modifier .size(ButtonDefaults.IconSize) - .testTag("progress_indicator_determinate"), // Tag for determinate progress + .testTag("progress_indicator_indeterminate"), // Tag for indeterminate progress strokeWidth = 2.dp, ) } else { CircularProgressIndicator( + progress = { downloadState.progress }, modifier = Modifier .size(ButtonDefaults.IconSize) - .testTag("progress_indicator_indeterminate"), // Tag for indeterminate progress + .testTag("progress_indicator_determinate"), // Tag for determinate progress strokeWidth = 2.dp, ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ErrorStateComposable.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ErrorStateComposable.kt new file mode 100644 index 0000000..c7cfe4e --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ErrorStateComposable.kt @@ -0,0 +1,26 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ErrorState(errorMessage: String, modifier: Modifier = Modifier) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = modifier.fillMaxWidth(), + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt new file mode 100644 index 0000000..0e889f7 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt @@ -0,0 +1,66 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.ui.theme.customColors +import java.io.File + +@Composable +fun TryFoxCard( + modifier: Modifier = Modifier, + app: AppUiModel, + onDownloadClick: (ApkUiModel) -> Unit, + onInstallClick: (File) -> Unit, +) { + val latestApk = (app.apks as? ApksResult.Success)?.apks?.firstOrNull() ?: return + + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.customColors.tryFoxCardBackground, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = stringResource(id = R.string.tryfox_card_title, latestApk.version), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(modifier = Modifier.width(4.dp)) + DownloadButton( + downloadState = latestApk.downloadState, + onDownloadClick = { onDownloadClick(latestApk) }, + onInstallClick = { + val downloadedFile = (latestApk.downloadState as? DownloadState.Downloaded)?.file + downloadedFile?.let { onInstallClick(it) } + }, + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt index c339809..3fa4460 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.models import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.util.Version sealed class ApksResult { data object Loading : ApksResult() @@ -16,3 +17,14 @@ data class AppUiModel( val apks: ApksResult, val userPickedDate: LocalDate? = null, ) + +val AppUiModel.newVersionAvailable: Boolean + get() { + val latestApkVersionString = (apks as? ApksResult.Success)?.apks?.firstOrNull()?.version ?: return false + val installedVersionString = installedVersion ?: return true + + val latestVersion = Version.from(latestApkVersionString) ?: return false + val installedVersion = Version.from(installedVersionString) ?: return false + + return latestVersion > installedVersion + } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt index 4bfe367..5e53870 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -32,6 +33,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -131,6 +135,8 @@ fun HomeScreen( .padding(innerPadding) .pullRefresh(pullRefreshState), ) { + var tryFoxCardHeight by remember { mutableStateOf(0.dp) } + when (val currentScreenState = screenState) { is HomeScreenState.InitialLoading -> { Box( @@ -147,6 +153,12 @@ fun HomeScreen( } is HomeScreenState.Loaded -> { + val tryFoxApp = currentScreenState.tryfoxApp + val otherApps = currentScreenState.apps.values.toList() + + val targetSpacerHeight = if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp + val animatedSpacerHeight by animateDpAsState(targetValue = targetSpacerHeight, label = "tryFoxSpacerHeight") + LazyColumn( modifier = Modifier .fillMaxSize() @@ -154,13 +166,17 @@ fun HomeScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { - item { Spacer(modifier = Modifier.height(16.dp)) } - items(currentScreenState.apps.values.toList()) { app -> + item { + Spacer(modifier = Modifier.height(animatedSpacerHeight)) + } + + items(otherApps) { app -> AppComponent( app = app, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, onInstallClick = { homeViewModel.installApk(it) }, onOpenAppClick = { homeViewModel.openApp(it) }, + onUninstallClick = { homeViewModel.uninstallApp(it) }, onDateSelected = { appName, date -> homeViewModel.onDateSelected( appName, @@ -172,6 +188,17 @@ fun HomeScreen( ) } } + + if (tryFoxApp != null) { + TryFoxCardComponent( + modifier = Modifier.align(Alignment.TopCenter), + tryFoxApp = tryFoxApp, + onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, + onInstallClick = { homeViewModel.installApk(it) }, + onDismiss = { homeViewModel.dismissTryFoxCard() }, + onTryFoxCardHeightChange = { tryFoxCardHeight = it }, + ) + } } } @@ -201,6 +228,7 @@ fun AppComponent( onDownloadClick: (ApkUiModel) -> Unit, onInstallClick: (File) -> Unit, onOpenAppClick: (String) -> Unit, + onUninstallClick: (String) -> Unit, onDateSelected: (String, LocalDate) -> Unit, dateValidator: (LocalDate) -> Boolean, onClearDate: (String) -> Unit, @@ -229,6 +257,11 @@ fun AppComponent( onOpenAppClick(it) } }, + onUninstallClick = { + appState?.packageName?.let { + onUninstallClick(it) + } + }, onDateSelected = { date -> onDateSelected(app.name, date) }, userPickedDate = app.userPickedDate, appName = app.name, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt index 4258a3b..4ffc996 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt @@ -17,6 +17,7 @@ sealed class HomeScreenState { */ data class Loaded( val apps: Map, + val tryfoxApp: AppUiModel?, val cacheManagementState: CacheManagementState, val isDownloadingAnyFile: Boolean, ) : HomeScreenState() diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index 9ed0d50..a6c8a5b 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt @@ -18,11 +18,12 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.format.FormatStringsInDatetimeFormats import kotlinx.datetime.format.byUnicodePattern import kotlinx.datetime.todayIn +import org.mozilla.tryfox.data.DateAwareReleaseRepository +import org.mozilla.tryfox.data.DownloadFileRepository import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.IFenixRepository -import org.mozilla.tryfox.data.MozillaArchiveRepository import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.ReleaseRepository import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.model.CacheManagementState @@ -31,17 +32,18 @@ import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.ui.models.newVersionAvailable import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.REFERENCE_BROWSER +import org.mozilla.tryfox.util.TRYFOX import java.io.File -import kotlin.collections.mapValues /** * ViewModel for the Home screen, responsible for fetching and displaying nightly builds of different Mozilla apps. * - * @param mozillaArchiveRepository Repository for fetching data from the Mozilla Archive. - * @param fenixRepository Repository for fetching Fenix-related data. + * @param releaseRepositories A list of release repositories. + * @param downloadFileRepository Repository for downloading files. * @param mozillaPackageManager Manager for interacting with installed Mozilla apps. * @param cacheManager Manager for handling application cache. * @param intentManager Manager for handling intents, such as APK installation. @@ -49,26 +51,21 @@ import kotlin.collections.mapValues */ @OptIn(FormatStringsInDatetimeFormats::class) class HomeViewModel( - private val mozillaArchiveRepository: MozillaArchiveRepository, - private val fenixRepository: IFenixRepository, + private val releaseRepositories: List, + private val downloadFileRepository: DownloadFileRepository, private val mozillaPackageManager: MozillaPackageManager, private val cacheManager: CacheManager, private val intentManager: IntentManager, private val ioDispatcher: CoroutineDispatcher, + private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), ) : ViewModel() { - internal var deviceSupportedAbisForTesting: List? = null - private val _homeScreenState = MutableStateFlow(HomeScreenState.InitialLoading) val homeScreenState: StateFlow = _homeScreenState.asStateFlow() private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - private val deviceSupportedAbis: List by lazy { - deviceSupportedAbisForTesting ?: Build.SUPPORTED_ABIS?.toList() ?: emptyList() - } - init { cacheManager.cacheState .onEach { newCacheState -> @@ -87,8 +84,21 @@ class HomeViewModel( currentState.apps } + val updatedTryFoxApp = if (newCacheState is CacheManagementState.IdleEmpty) { + currentState.tryfoxApp?.let { app -> + val apksResult = app.apks as? ApksResult.Success ?: return@let app + val updatedApks = apksResult.apks.map { + it.copy(downloadState = DownloadState.NotDownloaded) + } + app.copy(apks = ApksResult.Success(updatedApks)) + } + } else { + currentState.tryfoxApp + } + currentState.copy( apps = updatedApps, + tryfoxApp = updatedTryFoxApp, cacheManagementState = newCacheState, isDownloadingAnyFile = if (newCacheState is CacheManagementState.IdleEmpty) { false @@ -146,6 +156,7 @@ class HomeViewModel( FENIX to mozillaPackageManager.fenix, FOCUS to mozillaPackageManager.focus, REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, + TRYFOX to mozillaPackageManager.tryfox, ) _homeScreenState.update { @@ -160,25 +171,23 @@ class HomeViewModel( ) } HomeScreenState.Loaded( - apps = initialApps, + apps = initialApps.filterNot { (key, _) -> key == TRYFOX }, + tryfoxApp = initialApps[TRYFOX], cacheManagementState = currentCacheState, isDownloadingAnyFile = false, ) } - val results = mapOf( - FENIX to mozillaArchiveRepository.getFenixNightlyBuilds(), - FOCUS to mozillaArchiveRepository.getFocusNightlyBuilds(), - REFERENCE_BROWSER to mozillaArchiveRepository.getReferenceBrowserNightlyBuilds(), - ) - - val newApps = results.mapValues { (appName, result) -> + val newApps = releaseRepositories.associate { repository -> + repository.appName to repository.getLatestReleases() + }.mapValues { (appName, result) -> val appState = appInfoMap[appName] val apksResult = when (result) { is NetworkResult.Success -> { val latestApks = getLatestApks(result.data) ApksResult.Success(convertParsedApksToUiModels(latestApks)) } + is NetworkResult.Error -> ApksResult.Error( "Error fetching $appName nightly builds: ${result.message}", ) @@ -196,9 +205,15 @@ class HomeViewModel( (app.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true } + val tryFoxApp = newApps[TRYFOX]?.takeIf { it.newVersionAvailable } + _homeScreenState.update { if (it is HomeScreenState.Loaded) { - it.copy(apps = newApps, isDownloadingAnyFile = isDownloading) + it.copy( + apps = newApps.filterNot { (key, _) -> key == TRYFOX }, + tryfoxApp = tryFoxApp, + isDownloadingAnyFile = isDownloading, + ) } else { it } @@ -218,7 +233,7 @@ class HomeViewModel( private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> val date = parsedApk.rawDateString?.formatApkDate() - val isCompatible = deviceSupportedAbis.any { deviceAbi -> + val isCompatible = supportedAbis.any { deviceAbi -> deviceAbi.equals( parsedApk.abiName, ignoreCase = true, @@ -277,20 +292,37 @@ class HomeViewModel( if (currentState !is HomeScreenState.Loaded) return@update currentState val updatedApps = currentState.apps.toMutableMap() - val appToUpdate = updatedApps[appName] ?: return@update currentState - val apksResult = appToUpdate.apks as? ApksResult.Success ?: return@update currentState + var updatedTryFoxApp = currentState.tryfoxApp + + if (appName == TRYFOX) { + updatedTryFoxApp = updatedTryFoxApp?.let { appToUpdate -> + val apksResult = + appToUpdate.apks as? ApksResult.Success ?: return@let appToUpdate + val updatedApks = apksResult.apks.map { + if (it.uniqueKey == uniqueKey) it.copy(downloadState = newDownloadState) else it + } + appToUpdate.copy(apks = ApksResult.Success(updatedApks)) + } + } else { + val appToUpdate = updatedApps[appName] ?: return@update currentState + val apksResult = + appToUpdate.apks as? ApksResult.Success ?: return@update currentState - val updatedApks = apksResult.apks.map { - if (it.uniqueKey == uniqueKey) it.copy(downloadState = newDownloadState) else it + val updatedApks = apksResult.apks.map { + if (it.uniqueKey == uniqueKey) it.copy(downloadState = newDownloadState) else it + } + updatedApps[appName] = appToUpdate.copy(apks = ApksResult.Success(updatedApks)) } - updatedApps[appName] = appToUpdate.copy(apks = ApksResult.Success(updatedApks)) - val isDownloading = updatedApps.values.any { app -> (app.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true - } + } || (updatedTryFoxApp?.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true - currentState.copy(apps = updatedApps, isDownloadingAnyFile = isDownloading) + currentState.copy( + apps = updatedApps, + tryfoxApp = updatedTryFoxApp, + isDownloadingAnyFile = isDownloading, + ) } } @@ -303,23 +335,24 @@ class HomeViewModel( updateApkDownloadStateInScreenState( apkInfo.appName, apkInfo.uniqueKey, - DownloadState.InProgress(0f), + DownloadState.InProgress(0f, isIndeterminate = true), ) val outputDir = apkInfo.apkDir if (!outputDir.exists()) outputDir.mkdirs() val outputFile = File(outputDir, apkInfo.fileName) - val result = fenixRepository.downloadArtifact( + val result = downloadFileRepository.downloadFile( downloadUrl = apkInfo.url, outputFile = outputFile, onProgress = { bytesDownloaded, totalBytes -> + val isIndeterminate = totalBytes <= 0 val progress = - if (totalBytes > 0) bytesDownloaded.toFloat() / totalBytes.toFloat() else 0f + if (isIndeterminate) 0f else bytesDownloaded.toFloat() / totalBytes.toFloat() updateApkDownloadStateInScreenState( apkInfo.appName, apkInfo.uniqueKey, - DownloadState.InProgress(progress), + DownloadState.InProgress(progress, isIndeterminate), ) }, ) @@ -351,6 +384,10 @@ class HomeViewModel( intentManager.installApk(file) } + fun uninstallApp(packageName: String) { + intentManager.uninstallApk(packageName) + } + fun clearAppCache() { viewModelScope.launch(ioDispatcher) { cacheManager.clearCache() @@ -358,80 +395,53 @@ class HomeViewModel( } fun onDateSelected(appName: String, date: LocalDate) { - if (appName == REFERENCE_BROWSER) { - return - } - - viewModelScope.launch(ioDispatcher) { - val currentState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch - - val appToUpdate = currentState.apps[appName] ?: return@launch - - val updatedApp = appToUpdate.copy(userPickedDate = date, apks = ApksResult.Loading) - - val updatedApps = currentState.apps.toMutableMap() - updatedApps[appName] = updatedApp - - _homeScreenState.value = currentState.copy(apps = updatedApps) - - val result = when (appName) { - FENIX -> mozillaArchiveRepository.getFenixNightlyBuilds(date) - FOCUS -> mozillaArchiveRepository.getFocusNightlyBuilds(date) - else -> return@launch - } + val repository = + releaseRepositories.firstOrNull { it.appName == appName } as? DateAwareReleaseRepository + ?: return - val newApksResult = when (result) { - is NetworkResult.Success -> { - val latestApks = getLatestApks(result.data) - ApksResult.Success(convertParsedApksToUiModels(latestApks)) - } - is NetworkResult.Error -> ApksResult.Error( - "Error fetching $appName nightly builds for $date: ${result.message}", - ) - } - - val latestState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch - val latestAppToUpdate = latestState.apps[appName] ?: return@launch + updateDate(appName, date) { + repository.getReleases(date) + } + } - val finalUpdatedApp = latestAppToUpdate.copy(apks = newApksResult) + fun onClearDate(appName: String) { + val repository = releaseRepositories.firstOrNull { it.appName == appName } ?: return - val finalUpdatedApps = latestState.apps.toMutableMap() - finalUpdatedApps[appName] = finalUpdatedApp - _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) + updateDate(appName, null) { + repository.getLatestReleases() } } - fun onClearDate(appName: String) { + private fun updateDate( + appName: String, + date: LocalDate?, + getReleases: suspend (LocalDate?) -> NetworkResult>, + ) { viewModelScope.launch(ioDispatcher) { val currentState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch val appToUpdate = currentState.apps[appName] ?: return@launch - val updatedApp = appToUpdate.copy(userPickedDate = null) val updatedApps = currentState.apps.toMutableMap() - updatedApps[appName] = updatedApp + updatedApps[appName] = + appToUpdate.copy(userPickedDate = date, apks = ApksResult.Loading) _homeScreenState.value = currentState.copy(apps = updatedApps) - val result = when (appName) { - FENIX -> mozillaArchiveRepository.getFenixNightlyBuilds() - FOCUS -> mozillaArchiveRepository.getFocusNightlyBuilds() - else -> return@launch - } - - val newApksResult = when (result) { + val newApksResult = when (val result = getReleases(date)) { is NetworkResult.Success -> { val latestApks = getLatestApks(result.data) ApksResult.Success(convertParsedApksToUiModels(latestApks)) } + is NetworkResult.Error -> ApksResult.Error( - "Error fetching $appName nightly builds: ${result.message}", + "Error fetching $appName nightly builds for $date: ${result.message}", ) } val latestState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch - val latestAppToUpdate = latestState.apps[appName] ?: return@launch - val finalUpdatedApp = latestAppToUpdate.copy(apks = newApksResult) + val finalUpdatedApp = + latestState.apps[appName]?.copy(apks = newApksResult) ?: return@launch val finalUpdatedApps = latestState.apps.toMutableMap() finalUpdatedApps[appName] = finalUpdatedApp @@ -461,6 +471,13 @@ class HomeViewModel( mozillaPackageManager.launchApp(app) } + fun dismissTryFoxCard() { + _homeScreenState.update { currentState -> + if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.copy(tryfoxApp = null) + } + } + companion object { private const val TAG = "HomeViewModel" } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt index a0a5455..18acc5d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ElevatedCard @@ -61,6 +60,7 @@ import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.AppIcon import org.mozilla.tryfox.ui.composables.BinButton import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.composables.ErrorState import org.mozilla.tryfox.ui.composables.PushCommentCard import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.util.FENIX @@ -120,7 +120,7 @@ private fun UserSearchCard( ) { val keyboardController = LocalSoftwareKeyboardController.current - Card( + androidx.compose.material3.Card( modifier = modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), ) { @@ -181,21 +181,6 @@ private fun UserSearchCard( } } -@Composable -private fun ErrorState(errorMessage: String, modifier: Modifier = Modifier) { - Card( - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), - modifier = modifier.fillMaxWidth(), - ) { - Text( - text = errorMessage, - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodyMedium, - ) - } -} - /** * Composable function for the Profile screen, which allows users to search for pushes by author email. * @@ -355,7 +340,7 @@ private fun JobCard( job.artifacts.firstOrNull { it.abi.isSupported } } - Card( + androidx.compose.material3.Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt index e787d67..ee45036 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import logcat.LogPriority import logcat.logcat +import org.mozilla.tryfox.data.DownloadFileRepository import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.NetworkResult @@ -38,6 +39,7 @@ import java.io.File */ class ProfileViewModel( private val fenixRepository: IFenixRepository, + private val downloadFileRepository: DownloadFileRepository, private val userDataRepository: UserDataRepository, private val cacheManager: CacheManager, private val intentManager: IntentManager, @@ -164,9 +166,8 @@ class ProfileViewModel( } } if (determinedPushComment == null) { - determinedPushComment = - pushResult.revisions.firstOrNull()?.comments - ?: "No comment" + determinedPushComment = pushResult.revisions.firstOrNull()?.comments + ?: "No comment" } PushUiModel( pushComment = determinedPushComment, @@ -189,7 +190,7 @@ class ProfileViewModel( } else { logcat(LogPriority.WARN, TAG) { "getJobsForPush failed for push ID: ${pushResult.id}: " + - (jobsResult as NetworkResult.Error).message + (jobsResult as NetworkResult.Error).message } null } @@ -284,7 +285,7 @@ class ProfileViewModel( val taskId = artifactUiModel.taskId logcat(TAG) { "downloadArtifact called for: ${artifactUiModel.name}, taskId: $taskId, " + - "uniqueKey: ${artifactUiModel.uniqueKey}" + "uniqueKey: ${artifactUiModel.uniqueKey}" } if (artifactUiModel.downloadState is DownloadState.InProgress || @@ -330,7 +331,7 @@ class ProfileViewModel( var lastLoggedNumericProgress = 0f logcat(TAG) { "Calling fenixRepository.downloadArtifact for ${artifactUiModel.name}" } - val result = fenixRepository.downloadArtifact( + val result = downloadFileRepository.downloadFile( downloadUrl = downloadUrl, outputFile = outputFile, onProgress = { bytesDownloaded, totalBytes -> @@ -353,9 +354,9 @@ class ProfileViewModel( } if (shouldLog) { - logcat(LogPriority.VERBOSE, TAG) { + logcat(LogPriority.VERBOSE, TAG) { "Download progress for ${artifactUiModel.name}: $bytesDownloaded / $totalBytes " + - "($currentProgressFloat)" + "($currentProgressFloat)" } } updateArtifactDownloadState( diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt new file mode 100644 index 0000000..3122dd1 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt @@ -0,0 +1,88 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.ui.composables.TryFoxCard +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.AppUiModel +import kotlin.math.abs + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeableTryFoxCard( + modifier: Modifier = Modifier, + tryFoxApp: AppUiModel, + onDownloadClick: (ApkUiModel) -> Unit, + onInstallClick: (java.io.File) -> Unit, + onDismiss: () -> Unit, + onTryFoxCardHeightChange: (Dp) -> Unit, +) { + val density = LocalDensity.current + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { + if (it == SwipeToDismissBoxValue.Settled) { + false + } else { + onDismiss() + true + } + }, + ) + + AnimatedVisibility( + visible = dismissState.currentValue == SwipeToDismissBoxValue.Settled, + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(animationSpec = tween(durationMillis = 300)), + modifier = modifier, + ) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + Box( + Modifier + .fillMaxSize() + .background(Color.Transparent), + ) {} + }, + content = { + TryFoxCard( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 8.dp) + .onGloballyPositioned { + onTryFoxCardHeightChange(with(density) { it.size.height.toDp() }) + } + .graphicsLayer { + val progress = dismissState.progress + val targetValue = dismissState.targetValue + val alphaValue = if (targetValue != SwipeToDismissBoxValue.Settled) { + 1f - abs(progress) + } else { + 1f + } + alpha = alphaValue + }, + app = tryFoxApp, + onDownloadClick = onDownloadClick, + onInstallClick = onInstallClick, + ) + }, + ) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt new file mode 100644 index 0000000..4d8db1b --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt @@ -0,0 +1,26 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.AppUiModel + +@Composable +fun TryFoxCardComponent( + modifier: Modifier = Modifier, + tryFoxApp: AppUiModel, + onDownloadClick: (ApkUiModel) -> Unit, + onInstallClick: (java.io.File) -> Unit, + onDismiss: () -> Unit, + onTryFoxCardHeightChange: (Dp) -> Unit, +) { + SwipeableTryFoxCard( + modifier = modifier, + tryFoxApp = tryFoxApp, + onDownloadClick = onDownloadClick, + onInstallClick = onInstallClick, + onDismiss = onDismiss, + onTryFoxCardHeightChange = onTryFoxCardHeightChange, + ) +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/theme/Theme.kt b/app/src/main/java/org/mozilla/tryfox/ui/theme/Theme.kt index cafb583..c1d52c5 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/theme/Theme.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/theme/Theme.kt @@ -8,6 +8,9 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( @@ -32,6 +35,25 @@ private val LightColorScheme = lightColorScheme( */ ) +@Immutable +data class CustomColors( + val tryFoxCardBackground: Color, +) + +val LocalCustomColors = staticCompositionLocalOf { + CustomColors( + tryFoxCardBackground = Color.Unspecified, + ) +} + +private val DarkCustomColors = CustomColors( + tryFoxCardBackground = Color(0xFFA89300), +) + +private val LightCustomColors = CustomColors( + tryFoxCardBackground = Color(0xFFFFEB3B), +) + @Composable fun TryFoxTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -49,9 +71,19 @@ fun TryFoxTheme( else -> LightColorScheme } + val customColors = if (darkTheme) DarkCustomColors else LightCustomColors + MaterialTheme( colorScheme = colorScheme, typography = Typography, - content = content, - ) + ) { + androidx.compose.runtime.CompositionLocalProvider( + LocalCustomColors provides customColors, + content = content, + ) + } } + +val MaterialTheme.customColors: CustomColors + @Composable + get() = LocalCustomColors.current diff --git a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt index 8d7a069..99ac6fe 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -1,11 +1,13 @@ package org.mozilla.tryfox.util -const val FENIX: String = "fenix" -const val FENIX_NIGHTLY: String = "fenix-nightly" -const val FOCUS: String = "focus" +const val FENIX = "fenix" +const val FENIX_NIGHTLY = "fenix-nightly" +const val FOCUS = "focus" const val REFERENCE_BROWSER = "reference-browser" const val TREEHERDER = "treeherder" +const val TRYFOX = "TryFox" const val FENIX_PACKAGE = "org.mozilla.fenix" const val FOCUS_PACKAGE = "org.mozilla.focus.nightly" const val REFERENCE_BROWSER_PACKAGE = "org.mozilla.reference.browser" +const val TRYFOX_PACKAGE = "org.mozilla.tryfox" diff --git a/app/src/main/java/org/mozilla/tryfox/util/Version.kt b/app/src/main/java/org/mozilla/tryfox/util/Version.kt new file mode 100644 index 0000000..fc8b2b0 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/util/Version.kt @@ -0,0 +1,85 @@ +package org.mozilla.tryfox.util + +import java.util.regex.Pattern + +data class Version( + val fullStringVersion: String, + val major: Int, + val minor: Int, + val patch: Int, + val preRelease: String? = null, +) : Comparable { + + private fun parsePreReleaseParts(preReleaseString: String?): List { + return preReleaseString?.split('.')?.map { part -> + part.toIntOrNull() ?: part // Parse to Int if numeric, otherwise keep as String + } ?: emptyList() + } + + override fun compareTo(other: Version): Int { + // Compare major, minor, patch + if (this.major != other.major) return this.major.compareTo(other.major) + if (this.minor != other.minor) return this.minor.compareTo(other.minor) + if (this.patch != other.patch) return this.patch.compareTo(other.patch) + + // If major, minor, patch are equal, compare pre-release identifiers + val thisPreReleaseParts = parsePreReleaseParts(this.preRelease) + val otherPreReleaseParts = parsePreReleaseParts(other.preRelease) + + if (thisPreReleaseParts.isEmpty() && otherPreReleaseParts.isEmpty()) { + return 0 // Both are stable, or both have no pre-release part + } + if (thisPreReleaseParts.isEmpty()) { + return 1 // This is stable, other is pre-release -> this is greater + } + if (otherPreReleaseParts.isEmpty()) { + return -1 // Other is stable, this is pre-release -> this is smaller + } + + // Both have pre-release parts, compare them + val minSize = minOf(thisPreReleaseParts.size, otherPreReleaseParts.size) + for (i in 0 until minSize) { + val thisPart = thisPreReleaseParts[i] + val otherPart = otherPreReleaseParts[i] + + val comparison = when { + thisPart is Int && otherPart is Int -> thisPart.compareTo(otherPart) + thisPart is String && otherPart is String -> thisPart.compareTo(otherPart) + thisPart is Int && otherPart is String -> -1 // Numeric has lower precedence than non-numeric + thisPart is String && otherPart is Int -> 1 // Non-numeric has higher precedence than numeric + else -> 0 // Should not happen with current parsing + } + if (comparison != 0) return comparison + } + + // If all common parts are equal, the one with more pre-release identifiers is greater + return thisPreReleaseParts.size.compareTo(otherPreReleaseParts.size) + } + + override fun toString(): String = fullStringVersion + + companion object { + // Regex to capture major, minor, patch, and an optional pre-release identifier + private val VERSION_REGEX = Pattern.compile("^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:-([0-9A-Za-z.-]+))?$") + + fun from(versionString: String): Version? { + val matcher = VERSION_REGEX.matcher(versionString) + return if (matcher.find()) { + val major = matcher.group(1)?.toIntOrNull() ?: 0 + val minor = matcher.group(2)?.toIntOrNull() ?: 0 + val patch = matcher.group(3)?.toIntOrNull() ?: 0 + val preRelease = matcher.group(4) // This will be null if no pre-release part + + Version( + fullStringVersion = versionString, + major = major, + minor = minor, + patch = patch, + preRelease = preRelease, + ) + } else { + null + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b82b5e..efeebad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,4 +57,8 @@ Reference Browser Unsupported ABI Clear date selection + Uninstall + Installed + Not installed + TryFox %1$s is available \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt new file mode 100644 index 0000000..4bb4a51 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt @@ -0,0 +1,49 @@ +package org.mozilla.tryfox.data + +import kotlinx.coroutines.delay +import java.io.File + +class FakeDownloadFileRepository( + private val simulateNetworkError: Boolean = false, + private val networkErrorMessage: String = "Fake network error", + private val downloadProgressDelayMillis: Long = 100L, +) : DownloadFileRepository { + + var downloadFileCalled = false + var downloadFileResult: NetworkResult = NetworkResult.Success(File("fake_path")) + + override suspend fun downloadFile( + downloadUrl: String, + outputFile: File, + onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, + ): NetworkResult { + downloadFileCalled = true + + if (simulateNetworkError) { + return NetworkResult.Error(networkErrorMessage, null) + } + + val totalBytes = 10_000_000L + + onProgress(0, totalBytes) + delay(downloadProgressDelayMillis) + + onProgress(totalBytes / 2, totalBytes) + delay(downloadProgressDelayMillis) + + outputFile.parentFile?.mkdirs() + try { + if (!outputFile.exists()) { + outputFile.createNewFile() + } + outputFile.writeText("This is a fake downloaded artifact: ${outputFile.name} from $downloadUrl") + } catch (e: Exception) { + return NetworkResult.Error("Failed to create fake artifact file: ${e.message}", e) + } + + onProgress(totalBytes, totalBytes) + delay(downloadProgressDelayMillis) + + return NetworkResult.Success(outputFile) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt new file mode 100644 index 0000000..3e44526 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt @@ -0,0 +1,21 @@ +package org.mozilla.tryfox.data + +import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.model.ParsedNightlyApk + +/** + * A fake implementation of [MozillaArchiveRepository] for use in unit tests. + */ +class FakeMozillaArchiveRepository( + private val fenixBuilds: NetworkResult> = NetworkResult.Success(emptyList()), + private val focusBuilds: NetworkResult> = NetworkResult.Success(emptyList()), +) : MozillaArchiveRepository { + + override suspend fun getFenixNightlyBuilds(date: LocalDate?): NetworkResult> { + return fenixBuilds + } + + override suspend fun getFocusNightlyBuilds(date: LocalDate?): NetworkResult> { + return focusBuilds + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeReferenceBrowserReleaseRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeReferenceBrowserReleaseRepository.kt new file mode 100644 index 0000000..8befc82 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeReferenceBrowserReleaseRepository.kt @@ -0,0 +1,18 @@ +package org.mozilla.tryfox.data + +import org.mozilla.tryfox.model.ParsedNightlyApk +import org.mozilla.tryfox.util.REFERENCE_BROWSER + +/** + * A fake implementation of [ReferenceBrowserReleaseRepository] for use in unit tests. + */ +class FakeReferenceBrowserReleaseRepository( + private val releases: NetworkResult> = NetworkResult.Success(emptyList()), +) : ReleaseRepository { + + override val appName: String = REFERENCE_BROWSER + + override suspend fun getLatestReleases(): NetworkResult> { + return releases + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeTryFoxReleaseRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeTryFoxReleaseRepository.kt new file mode 100644 index 0000000..5e4ab46 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeTryFoxReleaseRepository.kt @@ -0,0 +1,18 @@ +package org.mozilla.tryfox.data + +import org.mozilla.tryfox.model.ParsedNightlyApk +import org.mozilla.tryfox.util.TRYFOX + +/** + * A fake implementation of [TryFoxReleaseRepository] for use in unit tests. + */ +class FakeTryFoxReleaseRepository( + private val releases: NetworkResult> = NetworkResult.Success(emptyList()), +) : ReleaseRepository { + + override val appName: String = TRYFOX + + override suspend fun getLatestReleases(): NetworkResult> { + return releases + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt b/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt index 4f2cf7b..adeb8ac 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt @@ -20,7 +20,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.mozilla.tryfox.model.ParsedNightlyApk -import org.mozilla.tryfox.network.ApiService +import org.mozilla.tryfox.network.MozillaArchivesApiService import retrofit2.HttpException import retrofit2.Response @@ -41,7 +41,7 @@ class MozillaArchiveRepositoryImplTest { } @Mock - private lateinit var mockApiService: ApiService + private lateinit var mockMozillaArchivesApiService: MozillaArchivesApiService private lateinit var repository: MozillaArchiveRepositoryImpl @@ -66,7 +66,7 @@ class MozillaArchiveRepositoryImplTest { fun setUp() { val testDate = LocalDate(2023, 10, 1) val clock = FixedClock(testDate.atStartOfDayIn(TimeZone.UTC)) - repository = MozillaArchiveRepositoryImpl(mockApiService, clock) + repository = MozillaArchiveRepositoryImpl(mockMozillaArchivesApiService, clock) } private fun createMockHtmlResponse(vararg dirStrings: String): String { @@ -79,7 +79,7 @@ class MozillaArchiveRepositoryImplTest { fun `getFenixNightlyBuilds success - single latest build`() = runTest { val mockHtml = createMockHtmlResponse(fenixDirString1) val expectedUrl = "https://archive.mozilla.org/pub/fenix/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) val result = repository.getFenixNightlyBuilds() @@ -101,7 +101,7 @@ class MozillaArchiveRepositoryImplTest { val olderDateDir = "2023-09-30-00-00-00-$FENIX-$fenixVersion-android-arm64-v8a/" val mockHtml = createMockHtmlResponse(fenixDirString1, fenixDirString2, olderDateDir) val expectedUrl = "https://archive.mozilla.org/pub/fenix/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) val result = repository.getFenixNightlyBuilds() @@ -125,7 +125,7 @@ class MozillaArchiveRepositoryImplTest { val mockHtml = createMockHtmlResponse(olderDateDir, latestDateDir, middleDateDir) val expectedUrl = "https://archive.mozilla.org/pub/fenix/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) val result = repository.getFenixNightlyBuilds() assertTrue(result is NetworkResult.Success) @@ -138,7 +138,7 @@ class MozillaArchiveRepositoryImplTest { fun `getFenixNightlyBuilds success - no builds found`() = runTest { val mockHtml = "Some other HTML" val expectedUrl = "https://archive.mozilla.org/pub/fenix/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) val result = repository.getFenixNightlyBuilds() @@ -150,7 +150,7 @@ class MozillaArchiveRepositoryImplTest { fun `getFenixNightlyBuilds network error`() = runTest { val errorMessage = "Network error" val expectedUrl = "https://archive.mozilla.org/pub/fenix/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenThrow(RuntimeException(errorMessage)) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenThrow(RuntimeException(errorMessage)) val result = repository.getFenixNightlyBuilds() @@ -163,7 +163,7 @@ class MozillaArchiveRepositoryImplTest { // Given val testDate = LocalDate(2024, 3, 15) val clock = FixedClock(testDate.atStartOfDayIn(TimeZone.UTC)) - val repository = MozillaArchiveRepositoryImpl(mockApiService, clock) + val repository = MozillaArchiveRepositoryImpl(mockMozillaArchivesApiService, clock) val currentMonthUrl = MozillaArchiveRepositoryImpl.archiveUrlForDate(FENIX, testDate) @@ -174,10 +174,10 @@ class MozillaArchiveRepositoryImplTest { val previousMonthDirString = "$previousMonthDateString-$FENIX-$fenixVersion-android-arm64-v8a/" val previousMonthMockHtml = createMockHtmlResponse(previousMonthDirString) - whenever(mockApiService.getHtmlPage(currentMonthUrl)).thenThrow( + whenever(mockMozillaArchivesApiService.getHtmlPage(currentMonthUrl)).thenThrow( HttpException(Response.error(404, "".toResponseBody(null))), ) - whenever(mockApiService.getHtmlPage(previousMonthUrl)).thenReturn(previousMonthMockHtml) + whenever(mockMozillaArchivesApiService.getHtmlPage(previousMonthUrl)).thenReturn(previousMonthMockHtml) // When val result = repository.getFenixNightlyBuilds() @@ -193,7 +193,7 @@ class MozillaArchiveRepositoryImplTest { fun `getFocusNightlyBuilds success - single latest build`() = runTest { val mockHtml = createMockHtmlResponse(focusDirString) val expectedUrl = "https://archive.mozilla.org/pub/focus/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenReturn(mockHtml) val result = repository.getFocusNightlyBuilds() @@ -214,7 +214,7 @@ class MozillaArchiveRepositoryImplTest { fun `getFocusNightlyBuilds network error`() = runTest { val errorMessage = "Network error for Focus" val expectedUrl = "https://archive.mozilla.org/pub/focus/nightly/2023/10/" - whenever(mockApiService.getHtmlPage(eq(expectedUrl))).thenThrow(RuntimeException(errorMessage)) + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(expectedUrl))).thenThrow(RuntimeException(errorMessage)) val result = repository.getFocusNightlyBuilds() diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt index cf92287..cc287f0 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt @@ -14,6 +14,9 @@ class FakeIntentManager() : IntentManager { var wasInstallApkCalled: Boolean = false private set + var wasUninstallApkCalled: Boolean = false + private set + /** * Overrides the `installApk` method to set the `wasInstallApkCalled` flag to true. * @@ -22,4 +25,8 @@ class FakeIntentManager() : IntentManager { override fun installApk(file: File) { wasInstallApkCalled = true } + + override fun uninstallApk(packageName: String) { + wasUninstallApkCalled = true + } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt index d0c6bfb..a93fa22 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt @@ -6,7 +6,7 @@ import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.model.AppState class FakeMozillaPackageManager( - private val apps: Map = mapOf(), + private val apps: Map = emptyMap(), ) : MozillaPackageManager { override val fenix: AppState @@ -18,6 +18,9 @@ class FakeMozillaPackageManager( override val referenceBrowser: AppState get() = apps["org.mozilla.reference.browser"] ?: AppState("Reference Browser", "org.mozilla.reference.browser", null, null) + override val tryfox: AppState + get() = apps["org.mozilla.tryfox"] ?: AppState("TryFox", "org.mozilla.tryfox", null, null) + override val appStates: Flow = emptyFlow() override fun launchApp(appName: String) { diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt index 37dbf44..976030d 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt @@ -23,16 +23,22 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import org.mozilla.tryfox.data.DownloadFileRepository import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.IFenixRepository -import org.mozilla.tryfox.data.MozillaArchiveRepository +import org.mozilla.tryfox.data.FakeMozillaArchiveRepository +import org.mozilla.tryfox.data.FakeReferenceBrowserReleaseRepository +import org.mozilla.tryfox.data.FakeTryFoxReleaseRepository +import org.mozilla.tryfox.data.FenixReleaseRepository +import org.mozilla.tryfox.data.FocusReleaseRepository +import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.ReleaseRepository import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager +import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.ParsedNightlyApk import org.mozilla.tryfox.ui.models.AbiUiModel @@ -41,6 +47,7 @@ import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.REFERENCE_BROWSER +import org.mozilla.tryfox.util.TRYFOX import java.io.File @ExperimentalCoroutinesApi @@ -55,14 +62,9 @@ class HomeViewModelTest { private lateinit var viewModel: HomeViewModel private lateinit var fakeCacheManager: FakeCacheManager - private lateinit var fakeMozillaPackageManager: FakeMozillaPackageManager @Mock - private lateinit var mockFenixRepository: IFenixRepository - - @Mock - private lateinit var mockMozillaArchiveRepository: MozillaArchiveRepository - + private lateinit var downloadFileRepository: DownloadFileRepository private val intentManager = FakeIntentManager() @TempDir @@ -71,6 +73,7 @@ class HomeViewModelTest { private val testFenixAppName = FENIX private val testFocusAppName = FOCUS private val testReferenceBrowserAppName = REFERENCE_BROWSER + private val testTryFoxAppName = TRYFOX private val testVersion = "125.0a1" private val testDateRaw = "2023-11-01-01-01-01" private val testAbi = "arm64-v8a" @@ -144,25 +147,24 @@ class HomeViewModelTest { } @BeforeEach - fun setUp() = runTest { - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(emptyList())) - whenever(mockMozillaArchiveRepository.getFocusNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(emptyList())) - whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(emptyList())) - + fun setUp() { fakeCacheManager = FakeCacheManager(tempCacheDir) - fakeMozillaPackageManager = FakeMozillaPackageManager() - - viewModel = HomeViewModel( - mozillaArchiveRepository = mockMozillaArchiveRepository, - fenixRepository = mockFenixRepository, - mozillaPackageManager = fakeMozillaPackageManager, - cacheManager = fakeCacheManager, - intentManager = intentManager, - ioDispatcher = mainCoroutineRule.testDispatcher, - ) - viewModel.deviceSupportedAbisForTesting = listOf("arm64-v8a", "x86_64", "armeabi-v7a") + viewModel = createViewModel() } + private fun createViewModel( + releaseRepositories: List = emptyList(), + mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), + ) = HomeViewModel( + releaseRepositories = releaseRepositories, + downloadFileRepository = downloadFileRepository, + mozillaPackageManager = mozillaPackageManager, + cacheManager = fakeCacheManager, + intentManager = intentManager, + ioDispatcher = mainCoroutineRule.testDispatcher, + supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), + ) + private fun String.formatApkDateForTest(): String { return try { val inputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd-HH-mm-ss") } @@ -179,21 +181,40 @@ class HomeViewModelTest { } @Test - fun `initialLoad when no data then homeScreenState is InitialLoading before load completes`() = runTest { - assertTrue( - viewModel.homeScreenState.value is HomeScreenState.InitialLoading, - "Initial HomeScreenState should be InitialLoading", - ) - } + fun `initialLoad when no data then homeScreenState is InitialLoading before load completes`() = + runTest { + assertTrue( + viewModel.homeScreenState.value is HomeScreenState.InitialLoading, + "Initial HomeScreenState should be InitialLoading", + ) + } @Test fun `initialLoad success should update HomeScreenState to Loaded with data`() = runTest { - val fenixParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) - val focusParsed = createTestParsedNightlyApk(testFocusAppName, testDateRaw, "126.0a1", "x86_64") - val rbParsed = createTestParsedNightlyApk(testReferenceBrowserAppName, testDateRaw, "latest", "armeabi-v7a") - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(fenixParsed))) - whenever(mockMozillaArchiveRepository.getFocusNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(focusParsed))) - whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(listOf(rbParsed))) + val fenixParsed = + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val focusParsed = + createTestParsedNightlyApk(testFocusAppName, testDateRaw, "126.0a1", "x86_64") + val rbParsed = createTestParsedNightlyApk( + testReferenceBrowserAppName, + testDateRaw, + "latest", + "armeabi-v7a", + ) + val tryFoxParsed = + createTestParsedNightlyApk(testTryFoxAppName, null, "1.0.0", "armeabi-v7a") + + val releaseRepositories = listOf( + FenixReleaseRepository(FakeMozillaArchiveRepository(fenixBuilds = NetworkResult.Success(listOf(fenixParsed)))), + FocusReleaseRepository(FakeMozillaArchiveRepository(focusBuilds = NetworkResult.Success(listOf(focusParsed)))), + FakeReferenceBrowserReleaseRepository(releases = NetworkResult.Success(listOf(rbParsed))), + FakeTryFoxReleaseRepository(releases = NetworkResult.Success(listOf(tryFoxParsed))), + ) + + viewModel = createViewModel( + releaseRepositories = releaseRepositories, + ) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() @@ -218,6 +239,9 @@ class HomeViewModelTest { assertTrue(rbApp!!.apks is ApksResult.Success, "Reference Browser builds should be Success") assertEquals(1, (rbApp.apks as ApksResult.Success).apks.size) + val tryFoxApp = loadedState.apps[TRYFOX] + assertNull(tryFoxApp, "TryFox app should be null when loading") + assertEquals(CacheManagementState.IdleEmpty, loadedState.cacheManagementState) assertTrue(fakeCacheManager.checkCacheStatusCalled) } @@ -236,8 +260,10 @@ class HomeViewModelTest { "125.0a1", testAbi, ) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) - .thenReturn(NetworkResult.Success(listOf(olderFenixParsed, newerFenixParsed))) + val releaseRepositories = listOf( + FenixReleaseRepository(FakeMozillaArchiveRepository(fenixBuilds = NetworkResult.Success(listOf(olderFenixParsed, newerFenixParsed)))), + ) + viewModel = createViewModel(releaseRepositories = releaseRepositories) fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() @@ -248,55 +274,73 @@ class HomeViewModelTest { assertNotNull(fenixApp) val fenixApksResult = fenixApp!!.apks as ApksResult.Success assertEquals(1, fenixApksResult.apks.size) - assertEquals(newerFenixParsed.rawDateString?.formatApkDateForTest(), fenixApksResult.apks.first().date) + assertEquals( + newerFenixParsed.rawDateString?.formatApkDateForTest(), + fenixApksResult.apks.first().date, + ) } @Test - fun `initialLoad with empty cache should result in IdleEmpty cache state from CacheManager`() = runTest { - fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) - - viewModel.initialLoad() - advanceUntilIdle() - - val state = viewModel.homeScreenState.value as? HomeScreenState.Loaded - assertNotNull(state, "State should be Loaded") - assertEquals(CacheManagementState.IdleEmpty, state!!.cacheManagementState, "Cache state should be IdleEmpty") - assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertTrue((state.apps[FENIX]!!.apks as? ApksResult.Success)?.apks?.isEmpty() ?: false) - assertTrue((state.apps[FOCUS]!!.apks as? ApksResult.Success)?.apks?.isEmpty() ?: false) - assertTrue((state.apps[REFERENCE_BROWSER]!!.apks as? ApksResult.Success)?.apks?.isEmpty() ?: false) - } + fun `initialLoad with empty cache should result in IdleEmpty cache state from CacheManager`() = + runTest { + viewModel = createViewModel() + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as? HomeScreenState.Loaded + assertNotNull(state, "State should be Loaded") + assertEquals( + CacheManagementState.IdleEmpty, + state!!.cacheManagementState, + "Cache state should be IdleEmpty", + ) + assertTrue(fakeCacheManager.checkCacheStatusCalled) + } @Test - fun `initialLoad with fenix cache populated should result in IdleNonEmpty from CacheManager`() = runTest { - val fenixParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) - val fenixApkUi = createTestApkUiModel(fenixParsed) - val fenixCacheSubDir = File(tempCacheDir, "${fenixApkUi.appName}/${fenixApkUi.date.take(10)}") - fenixCacheSubDir.mkdirs() - File(fenixCacheSubDir, fenixApkUi.fileName).createNewFile() - - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) - .thenReturn(NetworkResult.Success(listOf(fenixParsed))) - fakeCacheManager.setCacheState(CacheManagementState.IdleNonEmpty) - - viewModel.initialLoad() - advanceUntilIdle() - - val state = viewModel.homeScreenState.value as? HomeScreenState.Loaded - assertNotNull(state, "State should be Loaded") - assertEquals(CacheManagementState.IdleNonEmpty, state!!.cacheManagementState, "Cache state should be IdleNonEmpty") - assertTrue(fakeCacheManager.checkCacheStatusCalled) - val fenixApks = (state.apps[FENIX]!!.apks as? ApksResult.Success)?.apks - assertTrue(fenixApks?.first()?.downloadState is DownloadState.Downloaded) - } + fun `initialLoad with fenix cache populated should result in IdleNonEmpty from CacheManager`() = + runTest { + val fenixParsed = + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val fenixApkUi = createTestApkUiModel(fenixParsed) + val fenixCacheSubDir = + File(tempCacheDir, "${fenixApkUi.appName}/${fenixApkUi.date.take(10)}") + fenixCacheSubDir.mkdirs() + File(fenixCacheSubDir, fenixApkUi.fileName).createNewFile() + + val releaseRepositories = listOf( + FenixReleaseRepository(FakeMozillaArchiveRepository(fenixBuilds = NetworkResult.Success(listOf(fenixParsed)))), + ) + viewModel = createViewModel(releaseRepositories = releaseRepositories) + fakeCacheManager.setCacheState(CacheManagementState.IdleNonEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as? HomeScreenState.Loaded + assertNotNull(state, "State should be Loaded") + assertEquals( + CacheManagementState.IdleNonEmpty, + state!!.cacheManagementState, + "Cache state should be IdleNonEmpty", + ) + assertTrue(fakeCacheManager.checkCacheStatusCalled) + val fenixApks = (state.apps[FENIX]!!.apks as? ApksResult.Success)?.apks + assertTrue(fenixApks?.first()?.downloadState is DownloadState.Downloaded) + } @Test fun `clearAppCache should call CacheManager and update states to NotDownloaded`() = runTest { - val fenixParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) - val rbParsed = createTestParsedNightlyApk(testReferenceBrowserAppName, "", "latest", testAbi) + val fenixParsed = + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val rbParsed = + createTestParsedNightlyApk(testReferenceBrowserAppName, "", "latest", testAbi) val fenixApkUiForCache = createTestApkUiModel(fenixParsed) - val fenixCacheActualDir = File(tempCacheDir, "${fenixApkUiForCache.appName}/${fenixApkUiForCache.date.take(10)}") + val fenixCacheActualDir = + File(tempCacheDir, "${fenixApkUiForCache.appName}/${fenixApkUiForCache.date.take(10)}") fenixCacheActualDir.mkdirs() val cachedFenixFile = File(fenixCacheActualDir, fenixApkUiForCache.fileName) cachedFenixFile.createNewFile() @@ -311,8 +355,11 @@ class HomeViewModelTest { fakeCacheManager.setCacheState(CacheManagementState.IdleNonEmpty) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(fenixParsed))) - whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(listOf(rbParsed))) + val releaseRepositories = listOf( + FenixReleaseRepository(FakeMozillaArchiveRepository(fenixBuilds = NetworkResult.Success(listOf(fenixParsed)))), + FakeReferenceBrowserReleaseRepository(releases = NetworkResult.Success(listOf(rbParsed))), + ) + viewModel = createViewModel(releaseRepositories = releaseRepositories) viewModel.initialLoad() advanceUntilIdle() @@ -349,7 +396,10 @@ class HomeViewModelTest { ) val fenixStateAfterClear = loadedState.apps[FENIX]!!.apks as ApksResult.Success - assertFalse(fenixStateAfterClear.apks.isEmpty(), "Fenix APK list should not be empty after clear") + assertFalse( + fenixStateAfterClear.apks.isEmpty(), + "Fenix APK list should not be empty after clear", + ) assertTrue( fenixStateAfterClear.apks.first().downloadState is DownloadState.NotDownloaded, "Fenix APK download state should be NotDownloaded after clear", @@ -365,13 +415,17 @@ class HomeViewModelTest { @Test fun `downloadNightlyApk success sequence`() = runTest { - val fenixParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val fenixParsed = + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) val apkToDownload = createTestApkUiModel(fenixParsed, DownloadState.NotDownloaded) - val expectedApkDir = File(tempCacheDir, "${apkToDownload.appName}/${apkToDownload.date.take(10)}") + val expectedApkDir = + File(tempCacheDir, "${apkToDownload.appName}/${apkToDownload.date.take(10)}") val expectedApkFile = File(expectedApkDir, apkToDownload.fileName) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) - .thenReturn(NetworkResult.Success(listOf(fenixParsed))) + val releaseRepositories = listOf( + FenixReleaseRepository(FakeMozillaArchiveRepository(fenixBuilds = NetworkResult.Success(listOf(fenixParsed)))), + ) + viewModel = createViewModel(releaseRepositories = releaseRepositories) fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() @@ -381,12 +435,14 @@ class HomeViewModelTest { assertTrue(initialLoadedState.apps[FENIX]!!.apks is ApksResult.Success) whenever( - mockFenixRepository.downloadArtifact(eq(apkToDownload.url), eq(expectedApkFile), any()), + downloadFileRepository.downloadFile(eq(apkToDownload.url), eq(expectedApkFile), any()), ).thenAnswer { invocation -> val onProgress = invocation.arguments[2] as (Long, Long) -> Unit onProgress(50L, 100L) val parentDir = expectedApkFile.parentFile - if (parentDir != null && !parentDir.exists()) { parentDir.mkdirs() } + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } expectedApkFile.createNewFile() NetworkResult.Success(expectedApkFile) } @@ -396,29 +452,40 @@ class HomeViewModelTest { val loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded val fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success - val downloadedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } + val downloadedApkInfo = + fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } assertNotNull(downloadedApkInfo, "Downloaded APK info should not be null") assertTrue( downloadedApkInfo!!.downloadState is DownloadState.Downloaded, "DownloadState should be Downloaded", ) - assertEquals(expectedApkFile.path, (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path) + assertEquals( + expectedApkFile.path, + (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path, + ) assertTrue(fakeCacheManager.checkCacheStatusCalled) assertTrue(intentManager.wasInstallApkCalled) - assertFalse(loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after success") + assertFalse( + loadedState.isDownloadingAnyFile, + "isDownloadingAnyFile should be false after success", + ) } @Test fun `downloadNightlyApk failure sequence`() = runTest { - val fenixParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val fenixParsed = + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) val apkToDownload = createTestApkUiModel(fenixParsed, DownloadState.NotDownloaded) - val expectedApkDir = File(tempCacheDir, "${apkToDownload.appName}/${apkToDownload.date.substring(0, 10)}") + val expectedApkDir = + File(tempCacheDir, "${apkToDownload.appName}/${apkToDownload.date.substring(0, 10)}") val expectedApkFile = File(expectedApkDir, apkToDownload.fileName) val downloadErrorMessage = "Download Canceled" - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) - .thenReturn(NetworkResult.Success(listOf(fenixParsed))) + val releaseRepositories = listOf( + FenixReleaseRepository(FakeMozillaArchiveRepository(fenixBuilds = NetworkResult.Success(listOf(fenixParsed)))), + ) + viewModel = createViewModel(releaseRepositories = releaseRepositories) fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() advanceUntilIdle() @@ -427,7 +494,7 @@ class HomeViewModelTest { assertTrue(initialLoadedState.apps[FENIX]!!.apks is ApksResult.Success) whenever( - mockFenixRepository.downloadArtifact(eq(apkToDownload.url), eq(expectedApkFile), any()), + downloadFileRepository.downloadFile(eq(apkToDownload.url), eq(expectedApkFile), any()), ).thenAnswer { NetworkResult.Error(downloadErrorMessage) } viewModel.downloadNightlyApk(apkToDownload) @@ -442,68 +509,15 @@ class HomeViewModelTest { failedApkInfo!!.downloadState is DownloadState.DownloadFailed, "DownloadState should be DownloadFailed", ) - assertEquals(downloadErrorMessage, (failedApkInfo.downloadState as DownloadState.DownloadFailed).errorMessage) + assertEquals( + downloadErrorMessage, + (failedApkInfo.downloadState as DownloadState.DownloadFailed).message, + ) assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertFalse(loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after failure") - } - - @Test - fun `onDateSelected should update userPickedDate and fetch new builds`() = runTest { - val selectedDate = LocalDate(2023, 10, 20) - val fenixParsed = createTestParsedNightlyApk(testFenixAppName, "2023-10-20-01-01-01", "124.0a1", testAbi) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(eq(selectedDate))) - .thenReturn(NetworkResult.Success(listOf(fenixParsed))) - - viewModel.initialLoad() - advanceUntilIdle() - - viewModel.onDateSelected(FENIX, selectedDate) - advanceUntilIdle() - - val state = viewModel.homeScreenState.value as HomeScreenState.Loaded - val fenixApp = state.apps[FENIX] - assertNotNull(fenixApp) - assertEquals(selectedDate, fenixApp!!.userPickedDate) - val fenixApksResult = fenixApp.apks as ApksResult.Success - assertEquals(1, fenixApksResult.apks.size) - assertEquals("124.0a1", fenixApksResult.apks.first().version) - } - - @Test - fun `onClearDate should reset userPickedDate and fetch latest builds`() = runTest { - val selectedDate = LocalDate(2023, 10, 20) - val initialParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) - val dateSpecificParsed = createTestParsedNightlyApk( - testFenixAppName, - "2023-10-20-01-01-01", - "124.0a1", - testAbi, + assertFalse( + loadedState.isDownloadingAnyFile, + "isDownloadingAnyFile should be false after failure", ) - - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(null)) - .thenReturn(NetworkResult.Success(listOf(initialParsed))) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(eq(selectedDate))) - .thenReturn(NetworkResult.Success(listOf(dateSpecificParsed))) - - viewModel.initialLoad() - advanceUntilIdle() - - viewModel.onDateSelected(FENIX, selectedDate) - advanceUntilIdle() - - var state = viewModel.homeScreenState.value as HomeScreenState.Loaded - assertEquals(selectedDate, state.apps[FENIX]?.userPickedDate) - - viewModel.onClearDate(FENIX) - advanceUntilIdle() - - state = viewModel.homeScreenState.value as HomeScreenState.Loaded - val fenixApp = state.apps[FENIX] - assertNotNull(fenixApp) - assertNull(fenixApp!!.userPickedDate) - val fenixApksResult = fenixApp.apks as ApksResult.Success - assertEquals(1, fenixApksResult.apks.size) - assertEquals(testVersion, fenixApksResult.apks.first().version) } @Test @@ -530,4 +544,84 @@ class HomeViewModelTest { val rbValidDate = LocalDate(2022, 1, 1) assertTrue(rbValidator(rbValidDate)) } + + @Test + fun `TryFox update card is shown when new version is available`() = runTest { + val fakePackageManager = FakeMozillaPackageManager( + mapOf( + "org.mozilla.tryfox" to AppState("TryFox", "org.mozilla.tryfox", "0.0.1", null), + ), + ) + val tryFoxParsed = + createTestParsedNightlyApk(testTryFoxAppName, null, "v0.0.2", "universal") + val fakeTryFoxReleaseRepository = FakeTryFoxReleaseRepository(NetworkResult.Success(listOf(tryFoxParsed))) + viewModel = createViewModel( + releaseRepositories = listOf(fakeTryFoxReleaseRepository), + mozillaPackageManager = fakePackageManager, + ) + + viewModel.initialLoad() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertNotNull(state.tryfoxApp) + assertEquals("v0.0.2", (state.tryfoxApp!!.apks as ApksResult.Success).apks.first().version) + } + + @Test + fun `TryFox update card is not shown when version is current`() = runTest { + val fakePackageManager = FakeMozillaPackageManager( + mapOf( + "org.mozilla.tryfox" to AppState("TryFox", "org.mozilla.tryfox", "v0.0.2", null), + ), + ) + val tryFoxParsed = + createTestParsedNightlyApk(testTryFoxAppName, null, "v0.0.2", "universal") + val fakeTryFoxReleaseRepository = FakeTryFoxReleaseRepository(NetworkResult.Success(listOf(tryFoxParsed))) + viewModel = createViewModel( + releaseRepositories = listOf(fakeTryFoxReleaseRepository), + mozillaPackageManager = fakePackageManager, + ) + + viewModel.initialLoad() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertNull(state.tryfoxApp) + } + + @Test + fun `dismissTryFoxCard should remove the TryFox app from state`() = runTest { + val fakePackageManager = FakeMozillaPackageManager( + mapOf( + "org.mozilla.tryfox" to AppState("TryFox", "org.mozilla.tryfox", "0.0.1", null), + ), + ) + val tryFoxParsed = + createTestParsedNightlyApk(testTryFoxAppName, null, "v0.0.2", "universal") + val fakeTryFoxReleaseRepository = FakeTryFoxReleaseRepository(NetworkResult.Success(listOf(tryFoxParsed))) + viewModel = createViewModel( + releaseRepositories = listOf(fakeTryFoxReleaseRepository), + mozillaPackageManager = fakePackageManager, + ) + + viewModel.initialLoad() + advanceUntilIdle() + + var state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertNotNull(state.tryfoxApp) + + viewModel.dismissTryFoxCard() + advanceUntilIdle() + + state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertNull(state.tryfoxApp) + } + + @Test + fun `uninstallApp should call intentManager`() { + val packageName = "org.mozilla.fenix" + viewModel.uninstallApp(packageName) + assertTrue(intentManager.wasUninstallApkCalled) + } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt index cf196fa..d13b581 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt @@ -11,6 +11,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.io.TempDir import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension +import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager @@ -29,6 +30,8 @@ class ProfileViewModelTest { private val userDataRepository = FakeUserDataRepository() + private val downloadFileRepository = FakeDownloadFileRepository() + private val intentManager = FakeIntentManager() @TempDir @@ -40,6 +43,7 @@ class ProfileViewModelTest { viewModel = ProfileViewModel( fenixRepository = fenixRepository, + downloadFileRepository = downloadFileRepository, userDataRepository = userDataRepository, cacheManager = cacheManager, intentManager = intentManager, @@ -55,7 +59,7 @@ class ProfileViewModelTest { @Test fun `updateAuthorEmail should update the authorEmail state`() = runTest { // Given - val viewModel = ProfileViewModel(fenixRepository, userDataRepository, cacheManager, intentManager, null) + val viewModel = ProfileViewModel(fenixRepository, downloadFileRepository, userDataRepository, cacheManager, intentManager, null) val newEmail = "test@example.com" viewModel.authorEmail.test { diff --git a/app/src/test/java/org/mozilla/tryfox/util/VersionUtilsTest.kt b/app/src/test/java/org/mozilla/tryfox/util/VersionUtilsTest.kt new file mode 100644 index 0000000..f0ead41 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/util/VersionUtilsTest.kt @@ -0,0 +1,111 @@ +package org.mozilla.tryfox.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class VersionUtilsTest { + + @Test + fun `from with v prefix and no pre-release`() { + assertEquals(Version("v1.2.3", 1, 2, 3, null), Version.from("v1.2.3")) + } + + @Test + fun `from without prefix and no pre-release`() { + assertEquals(Version("10.0.0", 10, 0, 0, null), Version.from("10.0.0")) + } + + @Test + fun `from with pre-release`() { + assertEquals(Version("1.0.0-alpha1", 1, 0, 0, "alpha1"), Version.from("1.0.0-alpha1")) + } + + @Test + fun `from with invalid string`() { + assertEquals(null, Version.from("invalid")) + } + + @Test + fun `version comparison with patch update`() { + assertTrue(Version.from("1.2.4")!! > Version.from("1.2.3")!!) + } + + @Test + fun `version comparison with minor update`() { + assertTrue(Version.from("1.3.0")!! > Version.from("1.2.3")!!) + } + + @Test + fun `version comparison with major update`() { + assertTrue(Version.from("2.0.0")!! > Version.from("1.2.3")!!) + } + + @Test + fun `version comparison with same version`() { + assertFalse(Version.from("1.2.3")!! > Version.from("1.2.3")!!) + assertTrue(Version.from("1.2.3")!! >= Version.from("1.2.3")!!) + assertTrue(Version.from("1.2.3")!! <= Version.from("1.2.3")!!) + } + + @Test + fun `version comparison with older patch`() { + assertTrue(Version.from("1.2.2")!! < Version.from("1.2.3")!!) + } + + @Test + fun `version comparison with older minor`() { + assertTrue(Version.from("1.1.0")!! < Version.from("1.2.3")!!) + } + + @Test + fun `version comparison with older major`() { + assertTrue(Version.from("0.9.9")!! < Version.from("1.2.3")!!) + } + + @Test + fun `pre-release comparison alpha to alpha numeric`() { + assertTrue(Version.from("1.0.0-alpha")!! < Version.from("1.0.0-alpha.1")!!) + } + + @Test + fun `pre-release comparison alpha numeric to beta`() { + assertTrue(Version.from("1.0.0-alpha.1")!! < Version.from("1.0.0-beta")!!) + } + + @Test + fun `pre-release comparison beta to beta numeric`() { + assertTrue(Version.from("1.0.0-beta")!! < Version.from("1.0.0-beta.2")!!) + } + + @Test + fun `pre-release comparison beta numeric to beta numeric higher`() { + assertTrue(Version.from("1.0.0-beta.2")!! < Version.from("1.0.0-beta.11")!!) + } + + @Test + fun `pre-release comparison beta numeric to rc`() { + assertTrue(Version.from("1.0.0-beta.11")!! < Version.from("1.0.0-rc.1")!!) + } + + @Test + fun `pre-release comparison rc to stable`() { + assertTrue(Version.from("1.0.0-rc.1")!! < Version.from("1.0.0")!!) + } + + @Test + fun `pre-release comparison same pre-release`() { + assertFalse(Version.from("1.0.0-alpha")!! > Version.from("1.0.0-alpha")!!) + } + + @Test + fun `pre-release comparison different pre-release parts`() { + assertTrue(Version.from("1.0.0-alpha.beta")!! < Version.from("1.0.0-alpha.gamma")!!) + } + + @Test + fun `pre-release comparison numeric vs non-numeric`() { + assertTrue(Version.from("1.0.0-1")!! < Version.from("1.0.0-alpha")!!) + } +}