From 5ab417bf788a071c3d9c57cc6599f88e58322f13 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Sat, 11 Jul 2026 12:45:28 +0200 Subject: [PATCH 01/24] Use WorkManager to download apks in background. --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 6 + .../DefaultDownloadFileRepository.kt | 13 +- .../repositories/DownloadFileRepository.kt | 6 +- .../java/org/mozilla/tryfox/di/AppModule.kt | 10 + .../tryfox/download/ApkDownloadCoordinator.kt | 14 + .../tryfox/download/ApkDownloadRequest.kt | 14 + .../tryfox/download/ApkDownloadStore.kt | 101 +++++ .../download/DefaultApkDownloadCoordinator.kt | 77 ++++ .../download/DownloadNotificationFactory.kt | 65 +++ .../download/model/PersistedDownloadState.kt | 35 ++ .../download/worker/ApkDownloadWorker.kt | 300 ++++++++++++++ .../tryfox/ui/composables/DownloadButton.kt | 15 +- .../tryfox/ui/screens/HistoryViewModel.kt | 199 +++------ .../tryfox/ui/screens/HomeViewModel.kt | 233 +++++------ .../tryfox/ui/screens/ProfileViewModel.kt | 204 ++++++---- app/src/main/res/values/strings.xml | 5 + .../tryfox/data/FakeDownloadFileRepository.kt | 2 +- .../tryfox/data/managers/FakeCacheManager.kt | 5 + .../tryfox/ui/screens/HistoryViewModelTest.kt | 380 ++++++++++++------ .../tryfox/ui/screens/HomeViewModelTest.kt | 150 +++++-- .../tryfox/ui/screens/ProfileViewModelTest.kt | 295 ++++++++++++-- gradle/libs.versions.toml | 2 + plans/workmanager-apk-download-plan.md | 232 +++++++++++ 24 files changed, 1817 insertions(+), 547 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt create mode 100644 plans/workmanager-apk-download-plan.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2d978c..4286643 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { // Additional Compose dependencies implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.work.runtime.ktx) // DataStore implementation(libs.androidx.datastore.preferences) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9d7bbe1..f4808ec 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -90,6 +91,11 @@ android:exported="false" android:foregroundServiceType="connectedDevice" /> + + diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt index c9e1752..651a428 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.data.repositories +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,7 +18,11 @@ 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 { + override suspend fun downloadFile( + downloadUrl: String, + outputFile: File, + onProgress: suspend (Long, Long) -> Unit, + ): NetworkResult { return withContext(ioDispatcher) { val partialFile = File(outputFile.parentFile, "${outputFile.name}.part") var backupFile = File(outputFile.parentFile, "${outputFile.name}.bak") @@ -118,6 +123,12 @@ class DefaultDownloadFileRepository( "totalBytes=$totalBytes, exists=${outputFile.exists()}, length=${outputFile.length()}" } NetworkResult.Success(outputFile) + } catch (e: CancellationException) { + partialFile.delete() + if (backupFile.exists() && !outputFile.exists()) { + backupFile.renameTo(outputFile) + } + throw e } catch (e: Exception) { partialFile.delete() if (backupFile.exists() && !outputFile.exists()) { diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt index 8340a14..a241733 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt @@ -15,5 +15,9 @@ interface DownloadFileRepository { * @param onProgress A callback function to report download progress (bytesDownloaded, totalBytes). * @return A [org.mozilla.tryfox.data.NetworkResult] indicating success with the downloaded [File] or an [org.mozilla.tryfox.data.NetworkResult.Error] on failure. */ - suspend fun downloadFile(downloadUrl: String, outputFile: File, onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit): NetworkResult + suspend fun downloadFile( + downloadUrl: String, + outputFile: File, + onProgress: suspend (bytesDownloaded: Long, totalBytes: Long) -> Unit, + ): NetworkResult } 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 400fdbb..9276bc4 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import androidx.work.WorkManager import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named @@ -37,6 +38,11 @@ import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.data.repositories.TryFoxReleaseRepository import org.mozilla.tryfox.data.repositories.UserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadStore +import org.mozilla.tryfox.download.DefaultApkDownloadCoordinator +import org.mozilla.tryfox.download.DefaultApkDownloadStore +import org.mozilla.tryfox.download.DownloadNotificationFactory import org.mozilla.tryfox.lan.DefaultLanMessageHistoryRepository import org.mozilla.tryfox.lan.LanMessageHistoryRepository import org.mozilla.tryfox.lan.LanReceiveIdentityManager @@ -164,6 +170,10 @@ val repositoryModule = module { ) } single { DefaultIntentManager(androidContext()) } + single { DefaultApkDownloadStore(androidContext(), get(named("IODispatcher"))) } + single { DownloadNotificationFactory(androidContext()) } + single { WorkManager.getInstance(androidContext()) } + single { DefaultApkDownloadCoordinator(androidContext(), get(), get()) } single(named(FENIX)) { FenixReleaseRepository(get()) } single(named(FENIX_RELEASE)) { FenixReleaseReleaseRepository(get()) } diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt new file mode 100644 index 0000000..92c2b39 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt @@ -0,0 +1,14 @@ +package org.mozilla.tryfox.download + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mozilla.tryfox.download.model.PersistedDownloadState + +interface ApkDownloadCoordinator { + val downloads: StateFlow> + + fun enqueue(request: ApkDownloadRequest): String + fun retry(request: ApkDownloadRequest): String + fun cancel(uniqueKey: String) + fun observe(uniqueKey: String): Flow +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt new file mode 100644 index 0000000..1cc3290 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt @@ -0,0 +1,14 @@ +package org.mozilla.tryfox.download + +import java.io.File + +data class ApkDownloadRequest( + val uniqueKey: String, + val downloadUrl: String, + val outputFile: File, + val appName: String, + val fileName: String, + val cacheRelativePath: String? = null, +) { + val outputPath: String = outputFile.absolutePath +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt new file mode 100644 index 0000000..cfc93ae --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt @@ -0,0 +1,101 @@ +package org.mozilla.tryfox.download + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.mozilla.tryfox.download.model.PersistedDownloadState +import java.io.File + +interface ApkDownloadStore { + val downloads: StateFlow> + + fun observe(uniqueKey: String): Flow + fun get(uniqueKey: String): PersistedDownloadState? + fun upsert(state: PersistedDownloadState) + fun remove(uniqueKey: String) + fun clear() +} + +class DefaultApkDownloadStore( + context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true + }, +) : ApkDownloadStore { + private val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + ioDispatcher) + private val lock = Mutex() + private val stateFile = File(context.filesDir, "apk-download-state.json") + private val _downloads = MutableStateFlow(loadInitialState()) + override val downloads: StateFlow> = _downloads.asStateFlow() + + override fun observe(uniqueKey: String): Flow = downloads.map { it[uniqueKey] } + + override fun get(uniqueKey: String): PersistedDownloadState? = downloads.value[uniqueKey] + + override fun upsert(state: PersistedDownloadState) { + _downloads.value = _downloads.value + (state.uniqueKey to state) + schedulePersist() + } + + override fun remove(uniqueKey: String) { + _downloads.value = _downloads.value - uniqueKey + schedulePersist() + } + + override fun clear() { + _downloads.value = emptyMap() + schedulePersist() + } + + private fun schedulePersist() { + scope.launch { + lock.withLock { + persistLocked(_downloads.value) + } + } + } + + private fun loadInitialState(): Map = + try { + if (!stateFile.exists()) { + emptyMap() + } else { + runBlocking(ioDispatcher) { + lock.withLock { + val raw = stateFile.readText() + json.decodeFromString>(raw) + } + } + } + } catch (_: SerializationException) { + emptyMap() + } catch (_: Exception) { + emptyMap() + } + + private fun persistLocked(downloads: Map) { + try { + stateFile.parentFile?.mkdirs() + stateFile.writeText(json.encodeToString(downloads)) + } catch (_: Exception) { + // Best effort persistence. The in-memory state remains authoritative until the next write. + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt new file mode 100644 index 0000000..044ef53 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt @@ -0,0 +1,77 @@ +package org.mozilla.tryfox.download + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OutOfQuotaPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.download.worker.ApkDownloadWorker + +class DefaultApkDownloadCoordinator( + context: Context, + private val store: ApkDownloadStore = DefaultApkDownloadStore(context.applicationContext), + private val workManager: WorkManager = WorkManager.getInstance(context.applicationContext), +) : ApkDownloadCoordinator { + override val downloads: StateFlow> = store.downloads + + override fun enqueue(request: ApkDownloadRequest): String { + val workRequest = + OneTimeWorkRequestBuilder() + .setInputData(ApkDownloadWorker.createInputData(request)) + .addTag(request.uniqueKey) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + store.upsert( + request.toPersistedState( + status = DownloadStatus.QUEUED, + workId = workRequest.id.toString(), + ), + ) + workManager.enqueueUniqueWork(request.uniqueKey, ExistingWorkPolicy.REPLACE, workRequest) + return workRequest.id.toString() + } + + override fun retry(request: ApkDownloadRequest): String { + return enqueue(request) + } + + override fun cancel(uniqueKey: String) { + workManager.cancelUniqueWork(uniqueKey) + store.get(uniqueKey)?.let { current -> + store.upsert( + current.copy( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + ), + ) + } + } + + override fun observe(uniqueKey: String): Flow = store.observe(uniqueKey) + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + workId: String? = null, + bytesDownloaded: Long = 0L, + totalBytes: Long = -1L, + errorMessage: String? = null, + ): PersistedDownloadState = + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + errorMessage = errorMessage, + workId = workId, + ) +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt new file mode 100644 index 0000000..916daa1 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt @@ -0,0 +1,65 @@ +package org.mozilla.tryfox.download + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import org.mozilla.tryfox.R + +class DownloadNotificationFactory( + private val context: Context, +) { + fun createForegroundInfo( + appName: String, + progress: Int? = null, + isIndeterminate: Boolean = true, + ): ForegroundInfo { + ensureChannel() + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(context.getString(R.string.download_notification_title, appName)) + .setContentText( + if (progress == null) { + context.getString(R.string.download_notification_in_progress) + } else { + context.getString(R.string.download_notification_progress, progress.coerceIn(0, 100)) + }, + ) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(100, progress ?: 0, isIndeterminate) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val existingChannel = notificationManager.getNotificationChannel(CHANNEL_ID) + if (existingChannel != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.download_notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.download_notification_channel_description) + } + notificationManager.createNotificationChannel(channel) + } + + private companion object { + const val CHANNEL_ID = "tryfox_downloads" + const val NOTIFICATION_ID = 0x7478 + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt b/app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt new file mode 100644 index 0000000..8487041 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt @@ -0,0 +1,35 @@ +package org.mozilla.tryfox.download.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class DownloadStatus { + QUEUED, + RUNNING, + SUCCEEDED, + FAILED, + CANCELED, +} + +@Serializable +data class PersistedDownloadState( + val uniqueKey: String, + val downloadUrl: String, + val outputPath: String, + val appName: String, + val fileName: String, + val cacheRelativePath: String? = null, + val status: DownloadStatus = DownloadStatus.QUEUED, + val bytesDownloaded: Long = 0L, + val totalBytes: Long = -1L, + val errorMessage: String? = null, + val workId: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = createdAt, +) { + val progress: Float? + get() = if (totalBytes > 0L) bytesDownloaded.toFloat() / totalBytes.toFloat() else null + + val isTerminal: Boolean + get() = status == DownloadStatus.SUCCEEDED || status == DownloadStatus.FAILED || status == DownloadStatus.CANCELED +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt new file mode 100644 index 0000000..c7c6b54 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt @@ -0,0 +1,300 @@ +package org.mozilla.tryfox.download.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.managers.CacheManager +import org.mozilla.tryfox.data.repositories.DownloadFileRepository +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.ApkDownloadStore +import org.mozilla.tryfox.download.DownloadNotificationFactory +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import java.io.File + +class ApkDownloadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params), KoinComponent { + private val downloadFileRepository: DownloadFileRepository by inject() + private val cacheManager: CacheManager by inject() + private val downloadStore: ApkDownloadStore by inject() + private val notificationFactory: DownloadNotificationFactory by inject() + + override suspend fun doWork(): Result { + val request = inputData.toRequest() ?: return Result.failure() + val startedAt = System.currentTimeMillis() + var lastBytesDownloaded = 0L + var lastTotalBytes = -1L + var lastProgressUpdateAt = 0L + var lastProgressPercent = -1 + setForeground(notificationFactory.createForegroundInfo(request.appName)) + + updateState( + request = request, + status = DownloadStatus.RUNNING, + startedAt = startedAt, + bytesDownloaded = 0L, + totalBytes = -1L, + ) + + val outputFile = File(request.outputPath) + outputFile.parentFile?.mkdirs() + + return try { + when ( + val result = withContext(Dispatchers.IO) { + downloadFileRepository.downloadFile( + downloadUrl = request.downloadUrl, + outputFile = outputFile, + ) { bytesDownloaded, totalBytes -> + lastBytesDownloaded = bytesDownloaded + lastTotalBytes = totalBytes + val progressPercent = + if (totalBytes > 0) ((bytesDownloaded * 100) / totalBytes).toInt() else -1 + val now = System.currentTimeMillis() + val shouldPublish = + totalBytes <= 0 || + lastProgressPercent < 0 || + bytesDownloaded == totalBytes || + progressPercent >= lastProgressPercent + MIN_PROGRESS_PERCENT_STEP || + now - lastProgressUpdateAt >= PROGRESS_UPDATE_INTERVAL_MS + if (shouldPublish) { + lastProgressUpdateAt = now + lastProgressPercent = progressPercent + updateState( + request = request, + status = DownloadStatus.RUNNING, + startedAt = startedAt, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + ) + setProgress( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_BYTES_DOWNLOADED to bytesDownloaded, + KEY_TOTAL_BYTES to totalBytes, + ), + ) + setForeground( + notificationFactory.createForegroundInfo( + appName = request.appName, + progress = if (totalBytes > 0) progressPercent else null, + isIndeterminate = totalBytes <= 0, + ), + ) + } + } + } + ) { + is NetworkResult.Success -> { + val downloadedFile = result.data.takeIf { it.exists() } ?: outputFile.takeIf { it.exists() } + if (downloadedFile == null) { + updateFailure( + request = request, + message = "Downloaded file is missing", + startedAt = startedAt, + ) + Result.failure( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_ERROR_MESSAGE to "Downloaded file is missing", + ), + ) + } else { + updateSuccess( + request = request, + startedAt = startedAt, + bytesDownloaded = lastBytesDownloaded, + totalBytes = lastTotalBytes, + ) + cacheManager.checkCacheStatus() + Result.success( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_OUTPUT_PATH to downloadedFile.absolutePath, + ), + ) + } + } + + is NetworkResult.Error -> { + updateFailure( + request = request, + message = result.message, + startedAt = startedAt, + ) + cacheManager.checkCacheStatus() + Result.failure( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_ERROR_MESSAGE to result.message, + ), + ) + } + } + } catch (e: CancellationException) { + updateCanceled(request = request, startedAt = startedAt) + cacheManager.checkCacheStatus() + throw e + } + } + + private fun updateSuccess( + request: ApkDownloadRequest, + startedAt: Long, + bytesDownloaded: Long, + totalBytes: Long, + ) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun updateFailure(request: ApkDownloadRequest, message: String?, startedAt: Long) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = DownloadStatus.FAILED, + errorMessage = message, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun updateCanceled(request: ApkDownloadRequest, startedAt: Long) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun updateState( + request: ApkDownloadRequest, + status: DownloadStatus, + startedAt: Long, + bytesDownloaded: Long, + totalBytes: Long, + ) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + bytesDownloaded: Long = 0L, + totalBytes: Long = -1L, + errorMessage: String? = null, + workId: String? = null, + createdAt: Long = System.currentTimeMillis(), + updatedAt: Long = createdAt, + ): PersistedDownloadState = + downloadStore.get(uniqueKey)?.let { existing -> + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + errorMessage = errorMessage, + workId = workId ?: existing.workId, + createdAt = existing.createdAt, + updatedAt = updatedAt, + ) + } ?: PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + errorMessage = errorMessage, + workId = workId, + createdAt = createdAt, + updatedAt = updatedAt, + ) + + private fun isCurrentRequest(request: ApkDownloadRequest): Boolean = + downloadStore.get(request.uniqueKey)?.let { persistedState -> + persistedState.workId == id.toString() && persistedState.status != DownloadStatus.CANCELED + } == true + + private fun Data.toRequest(): ApkDownloadRequest? { + val uniqueKey = getString(KEY_UNIQUE_KEY) ?: return null + val downloadUrl = getString(KEY_DOWNLOAD_URL) ?: return null + val outputPath = getString(KEY_OUTPUT_PATH) ?: return null + val appName = getString(KEY_APP_NAME) ?: return null + val fileName = getString(KEY_FILE_NAME) ?: return null + val cacheRelativePath = getString(KEY_CACHE_RELATIVE_PATH) + + return ApkDownloadRequest( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputFile = File(outputPath), + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + ) + } + + companion object { + private const val PROGRESS_UPDATE_INTERVAL_MS = 500L + private const val MIN_PROGRESS_PERCENT_STEP = 5 + const val KEY_UNIQUE_KEY = "download_unique_key" + const val KEY_DOWNLOAD_URL = "download_url" + const val KEY_OUTPUT_PATH = "download_output_path" + const val KEY_APP_NAME = "download_app_name" + const val KEY_FILE_NAME = "download_file_name" + const val KEY_CACHE_RELATIVE_PATH = "download_cache_relative_path" + const val KEY_BYTES_DOWNLOADED = "download_bytes_downloaded" + const val KEY_TOTAL_BYTES = "download_total_bytes" + const val KEY_ERROR_MESSAGE = "download_error_message" + + fun createInputData(request: ApkDownloadRequest): Data = + Data.Builder() + .putString(KEY_UNIQUE_KEY, request.uniqueKey) + .putString(KEY_DOWNLOAD_URL, request.downloadUrl) + .putString(KEY_OUTPUT_PATH, request.outputPath) + .putString(KEY_APP_NAME, request.appName) + .putString(KEY_FILE_NAME, request.fileName) + .apply { + request.cacheRelativePath?.let { putString(KEY_CACHE_RELATIVE_PATH, it) } + } + .build() + } +} 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 4d89c3d..4cbc440 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 @@ -2,6 +2,9 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -31,6 +34,16 @@ fun DownloadButton( } } is DownloadState.InProgress -> { + val animatedProgress = + if (downloadState.isIndeterminate) { + 0f + } else { + animateFloatAsState( + targetValue = downloadState.progress, + animationSpec = tween(durationMillis = 250, easing = LinearEasing), + label = "downloadProgress", + ).value + } Button( onClick = {}, enabled = false, @@ -45,7 +58,7 @@ fun DownloadButton( ) } else { CircularProgressIndicator( - progress = { downloadState.progress }, + progress = { animatedProgress }, modifier = Modifier .size(ButtonDefaults.IconSize) .testTag("progress_indicator_determinate"), // Tag for determinate progress diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt index ffde9cf..a5a5f4d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt @@ -3,9 +3,7 @@ package org.mozilla.tryfox.ui.screens import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,19 +15,21 @@ import kotlinx.coroutines.launch import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager -import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.HistoryRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.ui.models.HistoryItemUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File class HistoryViewModel( private val historyRepository: HistoryRepository, - private val downloadFileRepository: DownloadFileRepository, + private val downloadCoordinator: ApkDownloadCoordinator, private val cacheManager: CacheManager, private val intentManager: IntentManager, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -41,10 +41,7 @@ class HistoryViewModel( } private val downloadStates = MutableStateFlow>(emptyMap()) - private val activeDownloads = MutableStateFlow>(emptyMap()) - private val canceledDownloads = MutableStateFlow>(emptyMap()) private val cacheRefreshEvents = MutableStateFlow(0) - private var nextDownloadGeneration = 0L private val _historyItems = MutableStateFlow>(emptyList()) val historyItems: StateFlow> = _historyItems.asStateFlow() @@ -56,6 +53,12 @@ class HistoryViewModel( cacheManager.checkCacheStatus() } + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads.toDownloadStates() + } + .launchIn(viewModelScope) + historyRepository.historyEntries .combine(cacheManager.cacheState) { entries, _ -> entries } .combine(cacheRefreshEvents) { entries, _ -> entries } @@ -77,12 +80,6 @@ class HistoryViewModel( "download requested uniqueKey=${entry.uniqueKey}, currentState=${currentState.javaClass.simpleName}, " + "historyItemState=${historyItem.downloadState.javaClass.simpleName}" } - if (canceledDownloads.value.keys.any { it.uniqueKey == entry.uniqueKey }) { - logcat(LogPriority.DEBUG, TAG) { - "download ignored because a canceled download is still finishing uniqueKey=${entry.uniqueKey}" - } - return - } when (currentState) { is DownloadState.InProgress -> { logcat(LogPriority.DEBUG, TAG) { @@ -107,107 +104,23 @@ class HistoryViewModel( else -> Unit } - val generation = nextDownloadGeneration++ - lateinit var downloadJob: Job - downloadJob = viewModelScope.launch(ioDispatcher, start = CoroutineStart.LAZY) { - updateDownloadStateIfActive(entry.uniqueKey, generation, DownloadState.InProgress(0f)) - val outputFile = getCachedFile(entry).selectedFile - outputFile.parentFile?.mkdirs() - logcat(LogPriority.DEBUG, TAG) { - "download started uniqueKey=${entry.uniqueKey}, url=${entry.downloadUrl}, " + - "outputPath=${outputFile.absolutePath}, parentExists=${outputFile.parentFile?.exists()}, " + - "preExisting=${outputFile.exists()}, preExistingLength=${outputFile.length()}" - } - - when ( - val result = downloadFileRepository.downloadFile( - downloadUrl = entry.downloadUrl, - outputFile = outputFile, - onProgress = { bytesDownloaded, totalBytes -> - val progress = if (totalBytes > 0) { - bytesDownloaded.toFloat() / totalBytes.toFloat() - } else { - 0f - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.InProgress(progress), - ) - }, - ) - ) { - is NetworkResult.Success -> { - logcat(LogPriority.DEBUG, TAG) { - "download repository success uniqueKey=${entry.uniqueKey}, " + - "resultPath=${result.data.absolutePath}, resultExists=${result.data.exists()}, " + - "resultLength=${result.data.length()}, outputExists=${outputFile.exists()}, " + - "outputLength=${outputFile.length()}, parentExists=${outputFile.parentFile?.exists()}" - } - val downloadedFile = result.data.takeIf { it.exists() } ?: outputFile.takeIf { it.exists() } - if (downloadedFile == null) { - logcat(LogPriority.ERROR, TAG) { - "download success but file is missing uniqueKey=${entry.uniqueKey}, " + - "resultPath=${result.data.absolutePath}, outputPath=${outputFile.absolutePath}" - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.DownloadFailed("Downloaded file is missing"), - ) - } else { - logcat(LogPriority.DEBUG, TAG) { - "download marked downloaded uniqueKey=${entry.uniqueKey}, " + - "path=${downloadedFile.absolutePath}, length=${downloadedFile.length()}" - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.Downloaded(downloadedFile), - ) - } - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } - } - is NetworkResult.Error -> { - logcat(LogPriority.ERROR, TAG) { - "download repository error uniqueKey=${entry.uniqueKey}, message=${result.message}" - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.DownloadFailed(result.message), - ) - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } - } - } + val outputFile = getCachedFile(entry).selectedFile + outputFile.parentFile?.mkdirs() + logcat(LogPriority.DEBUG, TAG) { + "download enqueued uniqueKey=${entry.uniqueKey}, url=${entry.downloadUrl}, " + + "outputPath=${outputFile.absolutePath}, parentExists=${outputFile.parentFile?.exists()}, " + + "preExisting=${outputFile.exists()}, preExistingLength=${outputFile.length()}" } - val activeDownload = ActiveDownload( - job = downloadJob, - generation = generation, - entry = entry, + downloadCoordinator.enqueue( + ApkDownloadRequest( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputFile = outputFile, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + ), ) - val downloadIdentity = DownloadIdentity(entry.uniqueKey, generation) - activeDownloads.update { it + (entry.uniqueKey to activeDownload) } - downloadJob.invokeOnCompletion { - activeDownloads.update { downloads -> - if (downloads[entry.uniqueKey]?.generation == generation) { - downloads - entry.uniqueKey - } else { - downloads - } - } - canceledDownloads.value[downloadIdentity]?.let { canceledEntry -> - if (activeDownloads.value[canceledEntry.uniqueKey] == null) { - deleteDownloadFiles(canceledEntry) - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } - } - canceledDownloads.update { it - downloadIdentity } - } - } - downloadJob.start() } fun install(historyItem: HistoryItemUiModel, file: File) { @@ -227,19 +140,10 @@ class HistoryViewModel( val uniqueKey = historyItem.entry.uniqueKey viewModelScope.launch(ioDispatcher) { try { - activeDownloads.value[uniqueKey]?.let { activeDownload -> - val downloadIdentity = DownloadIdentity(uniqueKey, activeDownload.generation) - canceledDownloads.update { it + (downloadIdentity to activeDownload.entry) } - activeDownloads.update { downloads -> - if (downloads[uniqueKey]?.generation == activeDownload.generation) { - downloads - uniqueKey - } else { - downloads - } - } - activeDownload.job.cancel() - deleteDownloadFiles(activeDownload.entry) + if (downloadStates.value[uniqueKey] is DownloadState.InProgress) { + downloadCoordinator.cancel(uniqueKey) } + deleteDownloadFiles(historyItem.entry) historyRepository.delete(uniqueKey) downloadStates.update { it - uniqueKey } cacheManager.checkCacheStatus() @@ -256,20 +160,28 @@ class HistoryViewModel( downloadStates.update { it + (uniqueKey to downloadState) } } - private fun updateDownloadStateIfActive( - uniqueKey: String, - generation: Long, - downloadState: DownloadState, - ) { - if (activeDownloads.value[uniqueKey]?.generation == generation) { - updateDownloadState(uniqueKey, downloadState) - } else { - logcat(LogPriority.DEBUG, TAG) { - "ignored stale download state uniqueKey=$uniqueKey, generation=$generation, " + - "state=${downloadState.javaClass.simpleName}" + private fun Map.toDownloadStates(): Map = + mapValues { (_, persistedState) -> persistedState.toDownloadState() } + + private fun PersistedDownloadState.toDownloadState(): DownloadState = + when (status) { + DownloadStatus.QUEUED, + DownloadStatus.RUNNING, + -> DownloadState.InProgress( + progress = progress ?: 0f, + isIndeterminate = totalBytes <= 0L, + ) + DownloadStatus.SUCCEEDED -> { + val file = File(outputPath) + if (file.exists()) { + DownloadState.Downloaded(file) + } else { + DownloadState.NotDownloaded + } } + DownloadStatus.FAILED -> DownloadState.DownloadFailed(errorMessage) + DownloadStatus.CANCELED -> DownloadState.NotDownloaded } - } private fun List.toUiModels( states: Map, @@ -277,9 +189,7 @@ class HistoryViewModel( map { entry -> val cacheResolution = getCachedFile(entry) val rememberedState = states[entry.uniqueKey] - val isCanceledDownloadFinishing = canceledDownloads.value.keys.any { it.uniqueKey == entry.uniqueKey } val downloadState = when { - isCanceledDownloadFinishing -> DownloadState.InProgress(0f, isIndeterminate = true) rememberedState is DownloadState.InProgress -> rememberedState rememberedState is DownloadState.DownloadFailed -> rememberedState @@ -328,17 +238,6 @@ class HistoryViewModel( val selectedFile: File, ) - private data class ActiveDownload( - val job: Job, - val generation: Long, - val entry: TreeherderInstallHistoryEntry, - ) - - private data class DownloadIdentity( - val uniqueKey: String, - val generation: Long, - ) - private fun deleteDownloadFiles(entry: TreeherderInstallHistoryEntry) { val cacheResolution = getCachedFile(entry) setOf( 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 0273ebd..de91c86 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 @@ -21,12 +21,15 @@ import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository -import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.MozillaArchiveApk +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult @@ -46,7 +49,7 @@ import java.io.File * ViewModel for the Home screen, responsible for fetching and displaying nightly builds of different Mozilla apps. * * @param releaseRepositories A list of release repositories. - * @param downloadFileRepository Repository for downloading files. + * @param downloadCoordinator Coordinator for WorkManager-backed APK downloads. * @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. @@ -54,7 +57,7 @@ import java.io.File */ class HomeViewModel( private val releaseRepositories: List, - private val downloadFileRepository: DownloadFileRepository, + private val downloadCoordinator: ApkDownloadCoordinator, private val mozillaPackageManager: MozillaPackageManager, private val cacheManager: CacheManager, private val intentManager: IntentManager, @@ -67,48 +70,23 @@ class HomeViewModel( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val downloadStates = MutableStateFlow>(emptyMap()) init { + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads + syncLoadedStateDownloadStates() + } + .launchIn(viewModelScope) + cacheManager.cacheState .onEach { newCacheState -> _homeScreenState.update { currentState -> if (currentState !is HomeScreenState.Loaded) return@update currentState - - val updatedApps = if (newCacheState is CacheManagementState.IdleEmpty) { - currentState.apps.mapValues { (_, app) -> - val apksResult = app.apks as? ApksResult.Success ?: return@mapValues app - val updatedApks = apksResult.apks.map { - it.copy(downloadState = DownloadState.NotDownloaded) - } - app.copy(apks = ApksResult.Success(updatedApks)) - } - } else { - 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 - } else { - currentState.isDownloadingAnyFile - }, - ) + currentState.copy(cacheManagementState = newCacheState) } + syncLoadedStateDownloadStates() } .launchIn(viewModelScope) @@ -193,19 +171,15 @@ class HomeViewModel( repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) } - val isDownloading = newApps.values.any { app -> - (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( + val loadedState = it.copy( apps = newApps.filterNot { (key, _) -> key == TRYFOX }, tryfoxApp = tryFoxApp, - isDownloadingAnyFile = isDownloading, ) + loadedState.applyDownloadStates(downloadStates.value) } else { it } @@ -329,101 +303,23 @@ class HomeViewModel( } } - private fun updateApkDownloadStateInScreenState( - appName: String, - uniqueKey: String, - newDownloadState: DownloadState, - ) { - _homeScreenState.update { currentState -> - if (currentState !is HomeScreenState.Loaded) return@update currentState - - val updatedApps = currentState.apps.toMutableMap() - 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 - } - 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, - tryfoxApp = updatedTryFoxApp, - isDownloadingAnyFile = isDownloading, - ) - } - } - fun downloadNightlyApk(apkInfo: ApkUiModel) { if (apkInfo.downloadState is DownloadState.InProgress || apkInfo.downloadState is DownloadState.Downloaded) { return } - viewModelScope.launch(ioDispatcher) { - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.InProgress(0f, isIndeterminate = true), - ) - - val outputDir = apkInfo.apkDir - if (!outputDir.exists()) outputDir.mkdirs() - val outputFile = File(outputDir, apkInfo.fileName) - - val result = downloadFileRepository.downloadFile( + val outputFile = File(apkInfo.apkDir, apkInfo.fileName) + outputFile.parentFile?.mkdirs() + downloadCoordinator.enqueue( + ApkDownloadRequest( + uniqueKey = apkInfo.uniqueKey, downloadUrl = apkInfo.url, outputFile = outputFile, - onProgress = { bytesDownloaded, totalBytes -> - val isIndeterminate = totalBytes <= 0 - val progress = - if (isIndeterminate) 0f else bytesDownloaded.toFloat() / totalBytes.toFloat() - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.InProgress(progress, isIndeterminate), - ) - }, - ) - - when (result) { - is NetworkResult.Success -> { - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.Downloaded(result.data), - ) - cacheManager.checkCacheStatus() // Notify cache manager about new file - installApk(result.data) - } - - is NetworkResult.Error -> { - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.DownloadFailed(result.message), - ) - cacheManager.checkCacheStatus() // Check cache even on error - } - } - } + appName = apkInfo.appName, + fileName = apkInfo.fileName, + cacheRelativePath = cacheRelativePathFor(apkInfo), + ), + ) } fun installApk(file: File) { @@ -530,6 +426,7 @@ class HomeViewModel( ) _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) + syncLoadedStateDownloadStates() } } @@ -582,6 +479,7 @@ class HomeViewModel( val finalUpdatedApps = latestState.apps.toMutableMap() finalUpdatedApps[appName] = finalUpdatedApp _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) + syncLoadedStateDownloadStates() } } @@ -627,6 +525,77 @@ class HomeViewModel( } } + private fun syncLoadedStateDownloadStates() { + val persistedDownloads = downloadStates.value + _homeScreenState.update { currentState -> + if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.applyDownloadStates(persistedDownloads) + } + } + + private fun HomeScreenState.Loaded.applyDownloadStates( + persistedDownloads: Map, + ): HomeScreenState.Loaded { + val updatedApps = apps.mapValues { (_, app) -> app.withDownloadStates(persistedDownloads) } + val updatedTryFoxApp = tryfoxApp?.withDownloadStates(persistedDownloads) + val isDownloading = updatedApps.values.any { app -> app.containsActiveDownload() } || + updatedTryFoxApp?.containsActiveDownload() == true + + return copy( + apps = updatedApps, + tryfoxApp = updatedTryFoxApp, + isDownloadingAnyFile = isDownloading, + ) + } + + private fun AppUiModel.withDownloadStates( + persistedDownloads: Map, + ): AppUiModel { + val apksResult = apks as? ApksResult.Success ?: return this + val updatedApks = apksResult.apks.map { apk -> + apk.copy(downloadState = resolveDownloadState(apk, persistedDownloads)) + } + return copy(apks = ApksResult.Success(updatedApks)) + } + + private fun AppUiModel.containsActiveDownload(): Boolean = + (apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true + + private fun resolveDownloadState( + apk: ApkUiModel, + persistedDownloads: Map, + ): DownloadState { + val resolvedFile = File(apk.apkDir, apk.fileName) + return persistedDownloads[apk.uniqueKey]?.toDownloadState(resolvedFile) + ?: if (resolvedFile.exists()) { + DownloadState.Downloaded(resolvedFile) + } else { + DownloadState.NotDownloaded + } + } + + private fun PersistedDownloadState.toDownloadState(file: File): DownloadState = + when (status) { + DownloadStatus.QUEUED, + DownloadStatus.RUNNING, + -> DownloadState.InProgress( + progress = progress ?: 0f, + isIndeterminate = totalBytes <= 0L, + ) + DownloadStatus.SUCCEEDED -> if (file.exists()) { + DownloadState.Downloaded(file) + } else { + DownloadState.NotDownloaded + } + DownloadStatus.FAILED -> DownloadState.DownloadFailed(errorMessage) + DownloadStatus.CANCELED -> DownloadState.NotDownloaded + } + + private fun cacheRelativePathFor(apkInfo: ApkUiModel): String? { + val cacheRoot = cacheManager.getCacheDir(apkInfo.appName).parentFile ?: return null + return apkInfo.apkDir.relativeToOrNull(cacheRoot)?.path + } + companion object { private const val TAG = "HomeViewModel" } 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 2f70e5d..08f94b2 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 @@ -17,9 +17,12 @@ import logcat.logcat import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager -import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.HistoryRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.data.repositories.UserDataRepository @@ -42,11 +45,11 @@ import java.io.File */ class ProfileViewModel( private val fenixRepository: TreeherderRepository, - private val downloadFileRepository: DownloadFileRepository, private val userDataRepository: UserDataRepository, private val cacheManager: CacheManager, private val intentManager: IntentManager, private val historyRepository: HistoryRepository, + private val downloadCoordinator: ApkDownloadCoordinator, authorEmail: String?, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, ) : ViewModel() { @@ -66,6 +69,8 @@ class ProfileViewModel( private val _pushes = MutableStateFlow>(emptyList()) val pushes: StateFlow> = _pushes.asStateFlow() + private val downloadStates = MutableStateFlow>(emptyMap()) + private val pendingAutoInstallDownloads = mutableSetOf() val cacheState: StateFlow = cacheManager.cacheState @@ -73,6 +78,13 @@ class ProfileViewModel( init { logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel for email: $authorEmail" } + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads + syncLoadedStateDownloadStates() + } + .launchIn(viewModelScope) + cacheManager.cacheState.onEach { state -> if (state is CacheManagementState.IdleEmpty) { val updatedPushes = _pushes.value.map { @@ -87,6 +99,7 @@ class ProfileViewModel( ) } _pushes.value = updatedPushes + syncLoadedStateDownloadStates() } }.launchIn(viewModelScope) @@ -204,6 +217,7 @@ class ProfileViewModel( }.awaitAll().filterNotNull() _pushes.value = pushesWithJobsAndArtifacts + syncLoadedStateDownloadStates() logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } if (pushesWithJobsAndArtifacts.isEmpty()) { _errorMessage.value = "No signed builds found for this author." @@ -233,12 +247,12 @@ class ProfileViewModel( ) { "Found ${filteredApks.size} APKs for taskId: $taskId" } filteredApks.map { artifact -> val artifactFileName = artifact.name.substringAfterLast('/') - val downloadedFile = getDownloadedFile(artifactFileName, taskId) - val downloadState = if (downloadedFile != null) { - DownloadState.Downloaded(downloadedFile) - } else { - DownloadState.NotDownloaded - } + val uniqueKey = "$taskId/$artifactFileName" + val downloadState = resolveDownloadState( + artifactName = artifactFileName, + taskId = taskId, + uniqueKey = uniqueKey, + ) val isCompatible = artifact.abi != null && deviceSupportedAbis.any { deviceAbi -> deviceAbi.equals(artifact.abi, ignoreCase = true) @@ -269,7 +283,7 @@ class ProfileViewModel( fun getDownloadedFile(artifactName: String, taskId: String): File? { if (taskId.isBlank()) return null - val taskSpecificDir = File(cacheManager.getCacheDir("treeherder"), taskId) + val taskSpecificDir = File(cacheManager.getCacheDir(TREEHERDER), taskId) val outputFile = File(taskSpecificDir, artifactName) val exists = outputFile.exists() logcat( @@ -320,7 +334,7 @@ class ProfileViewModel( logcat( LogPriority.DEBUG, TAG, - ) { "Starting download coroutine for ${artifactUiModel.name}" } + ) { "Enqueuing WorkManager download for ${artifactUiModel.name}" } val downloadedArtifact = findArtifact(artifactUiModel.uniqueKey) if (downloadedArtifact != null) { try { @@ -331,10 +345,7 @@ class ProfileViewModel( } updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.InProgress(0f)) - val downloadUrl = artifactUiModel.downloadUrl - logcat(LogPriority.DEBUG, TAG) { "Download URL: $downloadUrl" } - - val outputDir = File(cacheManager.getCacheDir("treeherder"), taskId) + val outputDir = File(cacheManager.getCacheDir(TREEHERDER), taskId) if (!outputDir.exists()) { outputDir.mkdirs() logcat( @@ -345,75 +356,31 @@ class ProfileViewModel( val outputFile = File(outputDir, artifactFileName) logcat(LogPriority.DEBUG, TAG) { "Output file: ${outputFile.absolutePath}" } - var lastLoggedNumericProgress = 0f - - logcat(TAG) { "Calling fenixRepository.downloadArtifact for ${artifactUiModel.name}" } - val result = downloadFileRepository.downloadFile( - downloadUrl = downloadUrl, + val request = ApkDownloadRequest( + uniqueKey = artifactUiModel.uniqueKey, + downloadUrl = artifactUiModel.downloadUrl, outputFile = outputFile, - onProgress = { bytesDownloaded, totalBytes -> - val currentProgressFloat = if (totalBytes > 0) { - bytesDownloaded.toFloat() / totalBytes.toFloat() - } else { - 0f - } - - var shouldLog = false - if (bytesDownloaded == 0L) { - shouldLog = true - lastLoggedNumericProgress = 0f - } else if (bytesDownloaded == totalBytes) { - shouldLog = true - lastLoggedNumericProgress = currentProgressFloat - } else if (currentProgressFloat - lastLoggedNumericProgress >= 0.02f) { - shouldLog = true - lastLoggedNumericProgress = currentProgressFloat - } - - if (shouldLog) { - logcat(LogPriority.VERBOSE, TAG) { - "Download progress for ${artifactUiModel.name}: $bytesDownloaded / $totalBytes " + - "($currentProgressFloat)" - } - } - updateArtifactDownloadState( - taskId, - artifactUiModel.name, - DownloadState.InProgress(currentProgressFloat), - ) - }, + appName = TREEHERDER, + fileName = artifactFileName, + cacheRelativePath = "$TREEHERDER/$taskId/$artifactFileName", ) - logcat(TAG) { "fenixRepository.downloadArtifact result for ${artifactUiModel.name}: $result" } - when (result) { - is NetworkResult.Success -> { - updateArtifactDownloadState( - taskId, - artifactUiModel.name, - DownloadState.Downloaded(result.data), - ) - cacheManager.checkCacheStatus() - logcat(TAG) { "Download success for ${artifactUiModel.name}. APK is ready to be installed." } - installApk(result.data) - } - - is NetworkResult.Error -> { - val failureMessage = "Download failed for $artifactFileName: ${result.message}" - if (result.cause != null) { - logcat( - LogPriority.ERROR, - TAG, - ) { "$failureMessage\n${result.cause.stackTraceToString()}" } - } else { - logcat(LogPriority.ERROR, TAG) { "$failureMessage (No cause available)" } - } - updateArtifactDownloadState( - taskId, - artifactUiModel.name, - DownloadState.DownloadFailed(result.message), - ) - cacheManager.checkCacheStatus() + pendingAutoInstallDownloads += artifactUiModel.uniqueKey + try { + downloadCoordinator.enqueue(request) + downloadStates.value = downloadCoordinator.downloads.value + syncLoadedStateDownloadStates() + } catch (e: Exception) { + pendingAutoInstallDownloads.remove(artifactUiModel.uniqueKey) + logcat(LogPriority.ERROR, TAG) { + "Failed to enqueue download for ${artifactUiModel.name}: ${e.message}" } + updateArtifactDownloadState( + taskId, + artifactUiModel.name, + DownloadState.DownloadFailed(e.message), + ) + cacheManager.checkCacheStatus() } } } @@ -475,6 +442,85 @@ class ProfileViewModel( }, ) + private fun syncLoadedStateDownloadStates() { + val persistedDownloads = downloadStates.value + _pushes.value = _pushes.value.map { push -> + push.copy( + jobs = push.jobs.map { job -> + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy( + downloadState = resolveDownloadState( + artifactName = artifact.name.substringAfterLast('/'), + taskId = artifact.taskId, + uniqueKey = artifact.uniqueKey, + ), + ) + }, + ) + }, + ) + } + maybeAutoInstallCompletedDownloads(persistedDownloads) + } + + private fun resolveDownloadState( + artifactName: String, + taskId: String, + uniqueKey: String, + ): DownloadState { + val downloadedFile = getDownloadedFile(artifactName, taskId) + return downloadStates.value[uniqueKey]?.toDownloadState(downloadedFile) + ?: if (downloadedFile != null) { + DownloadState.Downloaded(downloadedFile) + } else { + DownloadState.NotDownloaded + } + } + + private fun PersistedDownloadState.toDownloadState(file: File?): DownloadState = + when (status) { + DownloadStatus.QUEUED, + DownloadStatus.RUNNING, + -> DownloadState.InProgress( + progress = progress ?: 0f, + isIndeterminate = totalBytes <= 0L, + ) + + DownloadStatus.SUCCEEDED -> if (file != null && file.exists()) { + DownloadState.Downloaded(file) + } else { + DownloadState.NotDownloaded + } + + DownloadStatus.FAILED -> DownloadState.DownloadFailed(errorMessage) + DownloadStatus.CANCELED -> DownloadState.NotDownloaded + } + + private fun maybeAutoInstallCompletedDownloads(persistedDownloads: Map) { + val completedKeys = pendingAutoInstallDownloads.filter { uniqueKey -> + persistedDownloads[uniqueKey]?.status == DownloadStatus.SUCCEEDED + } + + completedKeys.forEach { uniqueKey -> + val persistedDownload = persistedDownloads[uniqueKey] ?: return@forEach + val file = File(persistedDownload.outputPath) + if (file.exists()) { + logcat(TAG) { "Auto-installing completed download for uniqueKey=$uniqueKey" } + installApk(file) + } else { + logcat(LogPriority.WARN, TAG) { + "Download completed for uniqueKey=$uniqueKey but file is missing at ${file.absolutePath}" + } + } + } + + pendingAutoInstallDownloads.removeAll(completedKeys.toSet()) + pendingAutoInstallDownloads.removeAll( + persistedDownloads.filterValues { it.isTerminal }.keys, + ) + } + private fun findDownloadedArtifact(file: File): DownloadedArtifact? = _pushes.value.firstNotNullOfOrNull { push -> push.jobs.firstNotNullOfOrNull { job -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4e3b3a..e2558f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,4 +140,9 @@ Push timestamp: %1$s Open TryFox to inspect the received message. Reason: %1$s + TryFox downloads + Keeps APK downloads running in the background. + %1$s download + Downloading in the background + %1$d%% complete diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt index 0ed5d71..239575e 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt @@ -16,7 +16,7 @@ class FakeDownloadFileRepository( override suspend fun downloadFile( downloadUrl: String, outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, + onProgress: suspend (bytesDownloaded: Long, totalBytes: Long) -> Unit, ): NetworkResult { downloadFileCalled = true diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt index adfcc0f..7bfa58c 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt @@ -22,6 +22,11 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { override suspend fun clearCache() { clearCacheCalled = true + cacheDir.listFiles()?.forEach { child -> + if (child.isDirectory) { + child.deleteRecursively() + } + } // Simulate the behavior of DefaultCacheManager: set to Clearing then to IdleEmpty _cacheState.value = CacheManagementState.Clearing _cacheState.value = CacheManagementState.IdleEmpty diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt index 96ab561..cce9688 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt @@ -1,20 +1,27 @@ package org.mozilla.tryfox.ui.screens import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeHistoryRepository import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState import java.io.File @OptIn(ExperimentalCoroutinesApi::class) @@ -27,6 +34,70 @@ class HistoryViewModelTest { @TempDir lateinit var tempCacheDir: File + private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + private val _downloads = MutableStateFlow>(emptyMap()) + val enqueuedRequests = mutableListOf() + val canceledKeys = mutableSetOf() + private val currentWorkIds = mutableMapOf() + private val canceledWorkIds = mutableSetOf() + private var workIdSequence = 0 + + override val downloads = _downloads.asStateFlow() + + override fun enqueue(request: ApkDownloadRequest): String { + enqueuedRequests += request + val workId = "${request.uniqueKey}#${++workIdSequence}" + currentWorkIds[request.uniqueKey] = workId + updateState( + request.uniqueKey, + request.toPersistedState(DownloadStatus.QUEUED, workId), + ) + return workId + } + + override fun retry(request: ApkDownloadRequest): String = enqueue(request) + + override fun cancel(uniqueKey: String) { + canceledKeys += uniqueKey + currentWorkIds[uniqueKey]?.let { canceledWorkIds += it } + _downloads.value[uniqueKey]?.let { current -> + updateState( + uniqueKey, + current.copy(status = DownloadStatus.CANCELED, updatedAt = System.currentTimeMillis()), + ) + } + } + + override fun observe(uniqueKey: String) = downloads.map { it[uniqueKey] } + + fun emit(state: PersistedDownloadState) { + val currentWorkId = currentWorkIds[state.uniqueKey] ?: return + if (state.workId != currentWorkId || state.workId in canceledWorkIds) { + return + } + updateState(state.uniqueKey, state) + } + + private fun updateState(uniqueKey: String, state: PersistedDownloadState) { + _downloads.value = _downloads.value + (uniqueKey to state) + } + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + workId: String? = null, + ): PersistedDownloadState = + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + workId = workId, + ) + } + @Test fun `history item uses downloaded state when apk exists in cache`() = runTest { val entry = historyEntry() @@ -68,16 +139,42 @@ class HistoryViewModelTest { fun `download uses stored url and writes apk to treeherder cache`() = runTest { val entry = historyEntry(downloadUrl = "https://example.com/artifact.apk") val cacheManager = FakeCacheManager(tempCacheDir) + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) }, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() + val workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertEquals(entry.downloadUrl, enqueuedRequest.downloadUrl) + assertNotNull(workId) + val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") + downloadedFile.parentFile?.mkdirs() + downloadedFile.writeText("downloaded apk from ${entry.downloadUrl}") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = workId, + ), + ) + advanceUntilIdle() + assertTrue(downloadedFile.exists()) assertTrue(downloadedFile.readText().contains(entry.downloadUrl)) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) @@ -87,15 +184,37 @@ class HistoryViewModelTest { fun `download retries when rendered item is not downloaded but remembered downloaded file is missing`() = runTest { val entry = historyEntry(downloadUrl = "https://example.com/artifact.apk") val cacheManager = FakeCacheManager(tempCacheDir) + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) }, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") + val firstWorkId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(firstWorkId) + downloadedFile.parentFile?.mkdirs() + downloadedFile.writeText("downloaded apk") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = firstWorkId, + ), + ) + advanceUntilIdle() assertTrue(downloadedFile.delete()) viewModel.refreshCachedDownloadStates() advanceUntilIdle() @@ -104,6 +223,26 @@ class HistoryViewModelTest { viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(2, downloadCoordinator.enqueuedRequests.size) + val secondWorkId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(secondWorkId) + downloadedFile.writeText("downloaded apk again") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = secondWorkId, + ), + ) + advanceUntilIdle() + assertTrue(downloadedFile.exists()) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) } @@ -112,22 +251,57 @@ class HistoryViewModelTest { fun `in progress download state is kept even if output file already exists`() = runTest { val entry = historyEntry() val cacheManager = FakeCacheManager(tempCacheDir) - val blockingDownloadRepository = BlockingDownloadFileRepository() + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) }, - downloadFileRepository = blockingDownloadRepository, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() + val workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(workId) + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}").absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.RUNNING, + bytesDownloaded = 1L, + totalBytes = 10L, + workId = workId, + ), + ) + val cachedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") + cachedFile.parentFile?.mkdirs() + cachedFile.writeText("partial") assertTrue(cachedFile.exists()) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.InProgress) - blockingDownloadRepository.complete() + cachedFile.writeText("complete") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = cachedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = cachedFile.length(), + totalBytes = cachedFile.length(), + workId = workId, + ), + ) advanceUntilIdle() assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) @@ -208,28 +382,31 @@ class HistoryViewModelTest { val entry = historyEntry() val cacheManager = FakeCacheManager(tempCacheDir) val historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) } - val downloadFileRepository = CancellationIgnoringFailingDownloadFileRepository() + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.InProgress) val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") val partialFile = File(downloadedFile.parentFile, "${downloadedFile.name}.part") val managedBackupFile = File(downloadedFile.parentFile, "${downloadedFile.name}.bak.1") val unmanagedBackupLikeFile = File(downloadedFile.parentFile, "${downloadedFile.name}.bak.tmp") + downloadedFile.parentFile?.mkdirs() + partialFile.writeText("partial") managedBackupFile.writeText("backup") unmanagedBackupLikeFile.writeText("not managed by downloader") viewModel.delete(viewModel.historyItems.value.single()) advanceUntilIdle() - assertTrue(downloadFileRepository.wasCanceled) + assertTrue(downloadCoordinator.canceledKeys.contains(entry.uniqueKey)) assertEquals(emptyList(), historyRepository.recordedEntries) assertTrue(viewModel.historyItems.value.isEmpty()) assertFalse(downloadedFile.exists()) @@ -237,6 +414,20 @@ class HistoryViewModelTest { assertFalse(managedBackupFile.exists()) assertTrue(unmanagedBackupLikeFile.exists()) + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = 10L, + totalBytes = 10L, + workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId, + ), + ) historyRepository.setEntries(listOf(entry)) advanceUntilIdle() @@ -248,46 +439,80 @@ class HistoryViewModelTest { val entry = historyEntry() val cacheManager = FakeCacheManager(tempCacheDir) val historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) } - val downloadFileRepository = DelayedCanceledThenBlockingDownloadFileRepository() + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val firstRequest = downloadCoordinator.enqueuedRequests.single() + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}").absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.RUNNING, + bytesDownloaded = 1L, + totalBytes = 10L, + workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId, + ), + ) viewModel.delete(viewModel.historyItems.value.single()) advanceUntilIdle() historyRepository.setEntries(listOf(entry)) advanceUntilIdle() - assertTrue( - (viewModel.historyItems.value.single().downloadState as DownloadState.InProgress) - .isIndeterminate, - ) + assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.NotDownloaded) viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") - val partialFile = File(downloadedFile.parentFile, "${downloadedFile.name}.part") - assertEquals(1, downloadFileRepository.startedDownloads) - assertFalse(partialFile.exists()) - - downloadFileRepository.completeCanceledDownload() - advanceUntilIdle() - assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.NotDownloaded) - - viewModel.download(viewModel.historyItems.value.single()) + assertEquals(2, downloadCoordinator.enqueuedRequests.size) + val secondRequest = downloadCoordinator.enqueuedRequests.last() + val secondWorkId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(secondWorkId) + downloadedFile.parentFile?.mkdirs() + downloadedFile.writeText("second complete") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = secondWorkId, + ), + ) advanceUntilIdle() + assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) - assertTrue(partialFile.exists()) - assertEquals(2, downloadFileRepository.startedDownloads) - assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.InProgress) - - downloadFileRepository.completeSecondDownload() + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = secondWorkId, + ), + ) advanceUntilIdle() assertTrue(downloadedFile.exists()) @@ -297,14 +522,13 @@ class HistoryViewModelTest { private fun createViewModel( cacheManager: FakeCacheManager, historyRepository: FakeHistoryRepository, - downloadFileRepository: org.mozilla.tryfox.data.repositories.DownloadFileRepository = - FakeDownloadFileRepository(downloadProgressDelayMillis = 0), + downloadCoordinator: ApkDownloadCoordinator = FakeApkDownloadCoordinator(), intentManager: FakeIntentManager = FakeIntentManager(), currentTimeMillisProvider: () -> Long = { 0L }, ): HistoryViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, cacheManager = cacheManager, intentManager = intentManager, ioDispatcher = mainCoroutineRule.testDispatcher, @@ -336,100 +560,4 @@ class HistoryViewModelTest { historyRecordedTimestamp = historyRecordedTimestamp, lastInstallerLaunchTimestamp = lastInstallerLaunchTimestamp, ) - - private class BlockingDownloadFileRepository : org.mozilla.tryfox.data.repositories.DownloadFileRepository { - private val completion = kotlinx.coroutines.CompletableDeferred() - - override suspend fun downloadFile( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): org.mozilla.tryfox.data.NetworkResult { - outputFile.parentFile?.mkdirs() - outputFile.writeText("partial") - onProgress(1L, 10L) - completion.await() - outputFile.writeText("complete") - onProgress(10L, 10L) - return org.mozilla.tryfox.data.NetworkResult.Success(outputFile) - } - - fun complete() { - completion.complete(Unit) - } - } - - private class CancellationIgnoringFailingDownloadFileRepository : org.mozilla.tryfox.data.repositories.DownloadFileRepository { - var wasCanceled = false - private set - - override suspend fun downloadFile( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): org.mozilla.tryfox.data.NetworkResult { - outputFile.parentFile?.mkdirs() - outputFile.writeText("partial") - File(outputFile.parentFile, "${outputFile.name}.part").writeText("partial") - onProgress(1L, 10L) - - try { - kotlinx.coroutines.awaitCancellation() - } catch (_: kotlinx.coroutines.CancellationException) { - wasCanceled = true - } - - outputFile.writeText("late complete") - File(outputFile.parentFile, "${outputFile.name}.part").writeText("late partial") - onProgress(10L, 10L) - return org.mozilla.tryfox.data.NetworkResult.Error("late failure after cancellation", null) - } - } - - private class DelayedCanceledThenBlockingDownloadFileRepository : org.mozilla.tryfox.data.repositories.DownloadFileRepository { - private val canceledDownloadCanComplete = kotlinx.coroutines.CompletableDeferred() - private val secondDownloadCanComplete = kotlinx.coroutines.CompletableDeferred() - - var startedDownloads = 0 - private set - - override suspend fun downloadFile( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): org.mozilla.tryfox.data.NetworkResult { - startedDownloads += 1 - outputFile.parentFile?.mkdirs() - val partialFile = File(outputFile.parentFile, "${outputFile.name}.part") - - return if (startedDownloads == 1) { - partialFile.writeText("first partial") - onProgress(1L, 10L) - try { - kotlinx.coroutines.awaitCancellation() - } catch (_: kotlinx.coroutines.CancellationException) { - kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { - canceledDownloadCanComplete.await() - } - } - org.mozilla.tryfox.data.NetworkResult.Error("first download canceled", null) - } else { - partialFile.writeText("second partial") - onProgress(1L, 10L) - secondDownloadCanComplete.await() - partialFile.delete() - outputFile.writeText("second complete") - onProgress(10L, 10L) - org.mozilla.tryfox.data.NetworkResult.Success(outputFile) - } - } - - fun completeCanceledDownload() { - canceledDownloadCanComplete.complete(Unit) - } - - fun completeSecondDownload() { - secondDownloadCanComplete.complete(Unit) - } - } } 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 fc80873..6648013 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 @@ -1,6 +1,9 @@ package org.mozilla.tryfox.ui.screens import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate @@ -22,9 +25,6 @@ import org.junit.jupiter.api.io.TempDir 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.eq -import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.FakeMozillaArchiveRepository @@ -34,7 +34,6 @@ import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager -import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.FenixReleaseReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseRepository import org.mozilla.tryfox.data.repositories.FocusNightlyRepository @@ -43,6 +42,10 @@ import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.MozillaArchiveApk +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult @@ -66,9 +69,7 @@ class HomeViewModelTest { private lateinit var viewModel: HomeViewModel private lateinit var fakeCacheManager: FakeCacheManager - - @Mock - private lateinit var downloadFileRepository: DownloadFileRepository + private lateinit var fakeDownloadCoordinator: FakeApkDownloadCoordinator private val intentManager = FakeIntentManager() @TempDir @@ -174,6 +175,7 @@ class HomeViewModelTest { @BeforeEach fun setUp() { fakeCacheManager = FakeCacheManager(tempCacheDir) + fakeDownloadCoordinator = FakeApkDownloadCoordinator() viewModel = createViewModel() } @@ -182,7 +184,7 @@ class HomeViewModelTest { mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), ) = HomeViewModel( releaseRepositories = releaseRepositories, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = fakeDownloadCoordinator, mozillaPackageManager = mozillaPackageManager, cacheManager = fakeCacheManager, intentManager = intentManager, @@ -190,6 +192,64 @@ class HomeViewModelTest { supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) + private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + private val _downloads = MutableStateFlow>(emptyMap()) + val enqueuedRequests = mutableListOf() + + override val downloads = _downloads.asStateFlow() + + override fun enqueue(request: ApkDownloadRequest): String { + enqueuedRequests += request + updateState( + request.uniqueKey, + request.toPersistedState( + status = DownloadStatus.QUEUED, + workId = request.uniqueKey, + ), + ) + return request.uniqueKey + } + + override fun retry(request: ApkDownloadRequest): String = enqueue(request) + + override fun cancel(uniqueKey: String) { + _downloads.value[uniqueKey]?.let { current -> + updateState( + uniqueKey, + current.copy( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + ), + ) + } + } + + override fun observe(uniqueKey: String) = downloads.map { it[uniqueKey] } + + fun emit(state: PersistedDownloadState) { + updateState(state.uniqueKey, state) + } + + private fun updateState(uniqueKey: String, state: PersistedDownloadState) { + _downloads.value = _downloads.value + (uniqueKey to state) + } + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + workId: String? = null, + ): PersistedDownloadState = + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + workId = workId, + ) + } + private fun String.formatApkDateForTest(): String { return try { val inputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd-HH-mm-ss") } @@ -561,25 +621,45 @@ class HomeViewModelTest { val initialLoadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded assertTrue(initialLoadedState.apps[FENIX]!!.apks is ApksResult.Success) - whenever( - 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() - } - expectedApkFile.createNewFile() - NetworkResult.Success(expectedApkFile) - } - viewModel.downloadNightlyApk(apkToDownload) advanceUntilIdle() - val loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded - val fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success - val downloadedApkInfo = + assertEquals(1, fakeDownloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = fakeDownloadCoordinator.enqueuedRequests.first() + assertEquals(apkToDownload.uniqueKey, enqueuedRequest.uniqueKey) + + var loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded + var fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success + var downloadedApkInfo = + fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } + + assertNotNull(downloadedApkInfo, "Queued APK info should not be null") + assertTrue( + downloadedApkInfo!!.downloadState is DownloadState.InProgress, + "DownloadState should be InProgress while work is queued", + ) + + expectedApkFile.parentFile?.mkdirs() + expectedApkFile.writeText("downloaded apk") + fakeDownloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = apkToDownload.uniqueKey, + downloadUrl = apkToDownload.url, + outputPath = expectedApkFile.absolutePath, + appName = apkToDownload.appName, + fileName = apkToDownload.fileName, + cacheRelativePath = null, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = expectedApkFile.length(), + totalBytes = expectedApkFile.length(), + workId = enqueuedRequest.uniqueKey, + ), + ) + advanceUntilIdle() + + loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded + fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success + downloadedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } assertNotNull(downloadedApkInfo, "Downloaded APK info should not be null") @@ -592,7 +672,7 @@ class HomeViewModelTest { (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path, ) assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertTrue(intentManager.wasInstallApkCalled) + assertFalse(intentManager.wasInstallApkCalled) assertFalse( loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after success", @@ -618,13 +698,25 @@ class HomeViewModelTest { val initialLoadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded assertTrue(initialLoadedState.apps[FENIX]!!.apks is ApksResult.Success) - whenever( - downloadFileRepository.downloadFile(eq(apkToDownload.url), eq(expectedApkFile), any()), - ).thenAnswer { NetworkResult.Error(downloadErrorMessage) } - viewModel.downloadNightlyApk(apkToDownload) advanceUntilIdle() + assertEquals(1, fakeDownloadCoordinator.enqueuedRequests.size) + fakeDownloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = apkToDownload.uniqueKey, + downloadUrl = apkToDownload.url, + outputPath = expectedApkFile.absolutePath, + appName = apkToDownload.appName, + fileName = apkToDownload.fileName, + cacheRelativePath = null, + status = DownloadStatus.FAILED, + errorMessage = downloadErrorMessage, + workId = fakeDownloadCoordinator.enqueuedRequests.first().uniqueKey, + ), + ) + advanceUntilIdle() + val loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded val fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success val failedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } 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 4ba91b3..8fd8aee 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 @@ -2,39 +2,56 @@ package org.mozilla.tryfox.ui.screens import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach 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.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.api.extension.RegisterExtension 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.Artifact +import org.mozilla.tryfox.data.ArtifactsResponse import org.mozilla.tryfox.data.FakeHistoryRepository +import org.mozilla.tryfox.data.JobDetails +import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.RevisionDetail +import org.mozilla.tryfox.data.RevisionMeta +import org.mozilla.tryfox.data.RevisionResult +import org.mozilla.tryfox.data.TreeherderJobsResponse +import org.mozilla.tryfox.data.TreeherderRevisionResponse import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager import org.mozilla.tryfox.data.managers.FakeUserDataRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.util.TREEHERDER import java.io.File @ExperimentalCoroutinesApi -@ExtendWith(MockitoExtension::class) class ProfileViewModelTest { + @JvmField + @RegisterExtension + val mainCoroutineRule = MainCoroutineRule() + private lateinit var viewModel: ProfileViewModel private lateinit var cacheManager: FakeCacheManager + private lateinit var downloadCoordinator: FakeApkDownloadCoordinator - @Mock - private lateinit var fenixRepository: TreeherderRepository + private lateinit var fenixRepository: FakeTreeherderRepository private val userDataRepository = FakeUserDataRepository() - - private val downloadFileRepository = FakeDownloadFileRepository() - private val intentManager = FakeIntentManager() - private val historyRepository = FakeHistoryRepository() @TempDir @@ -43,16 +60,19 @@ class ProfileViewModelTest { @BeforeEach fun setUp() = runTest { cacheManager = FakeCacheManager(tempCacheDir) - + fenixRepository = FakeTreeherderRepository() + downloadCoordinator = FakeApkDownloadCoordinator() + stubProfileSearch() viewModel = ProfileViewModel( fenixRepository = fenixRepository, - downloadFileRepository = downloadFileRepository, userDataRepository = userDataRepository, cacheManager = cacheManager, intentManager = intentManager, historyRepository = historyRepository, - authorEmail = null, + downloadCoordinator = downloadCoordinator, + authorEmail = "test@example.com", ) + advanceUntilIdle() } @AfterEach @@ -62,26 +82,247 @@ class ProfileViewModelTest { @Test fun `updateAuthorEmail should update the authorEmail state`() = runTest { - // Given - val viewModel = ProfileViewModel( - fenixRepository, - downloadFileRepository, - userDataRepository, - cacheManager, - intentManager, - historyRepository, - null, - ) + val viewModel = createViewModel(authorEmail = null) val newEmail = "test@example.com" viewModel.authorEmail.test { - assertEquals("", awaitItem()) // Consume initial value + assertEquals("", awaitItem()) - // When viewModel.updateAuthorEmail(newEmail) - // Then assertEquals(newEmail, awaitItem()) } } + + @Test + fun `downloadArtifact should enqueue WorkManager work and reflect persisted progress`() = runTest { + val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val outputFile = File(cacheManager.getCacheDir(TREEHERDER), "task-123/${artifact.name.substringAfterLast('/')}") + + viewModel.downloadArtifact(artifact) + advanceUntilIdle() + + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() + assertEquals(artifact.uniqueKey, enqueuedRequest.uniqueKey) + assertEquals(artifact.downloadUrl, enqueuedRequest.downloadUrl) + assertEquals(outputFile.absolutePath, enqueuedRequest.outputPath) + assertEquals(TREEHERDER, enqueuedRequest.appName) + + val inProgressArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + assertTrue(inProgressArtifact.downloadState is DownloadState.InProgress) + + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = artifact.uniqueKey, + downloadUrl = artifact.downloadUrl, + outputPath = outputFile.absolutePath, + appName = TREEHERDER, + fileName = artifact.name.substringAfterLast('/'), + cacheRelativePath = "$TREEHERDER/task-123/${artifact.name.substringAfterLast('/')}", + status = DownloadStatus.RUNNING, + bytesDownloaded = 25, + totalBytes = 100, + workId = "work-1", + ), + ) + advanceUntilIdle() + + val runningArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val runningState = runningArtifact.downloadState as DownloadState.InProgress + assertEquals(0.25f, runningState.progress) + assertFalse(runningState.isIndeterminate) + + outputFile.parentFile?.mkdirs() + outputFile.writeText("fake apk") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = artifact.uniqueKey, + downloadUrl = artifact.downloadUrl, + outputPath = outputFile.absolutePath, + appName = TREEHERDER, + fileName = artifact.name.substringAfterLast('/'), + cacheRelativePath = "$TREEHERDER/task-123/${artifact.name.substringAfterLast('/')}", + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = 100, + totalBytes = 100, + workId = "work-1", + ), + ) + advanceUntilIdle() + + val downloadedArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val downloadedState = downloadedArtifact.downloadState as DownloadState.Downloaded + assertEquals(outputFile.absolutePath, downloadedState.file.absolutePath) + assertTrue(intentManager.wasInstallApkCalled) + } + + @Test + fun `downloadArtifact should map persisted failures to download failed state`() = runTest { + val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val failureMessage = "network failed" + + viewModel.downloadArtifact(artifact) + advanceUntilIdle() + + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = artifact.uniqueKey, + downloadUrl = artifact.downloadUrl, + outputPath = File( + cacheManager.getCacheDir(TREEHERDER), + "task-123/${artifact.name.substringAfterLast('/')}", + ).absolutePath, + appName = TREEHERDER, + fileName = artifact.name.substringAfterLast('/'), + cacheRelativePath = "$TREEHERDER/task-123/${artifact.name.substringAfterLast('/')}", + status = DownloadStatus.FAILED, + errorMessage = failureMessage, + workId = "work-1", + ), + ) + advanceUntilIdle() + + val failedArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val failedState = failedArtifact.downloadState as DownloadState.DownloadFailed + assertEquals(failureMessage, failedState.message) + } + + private fun createViewModel(authorEmail: String?): ProfileViewModel = + ProfileViewModel( + fenixRepository = fenixRepository, + userDataRepository = userDataRepository, + cacheManager = cacheManager, + intentManager = intentManager, + historyRepository = historyRepository, + downloadCoordinator = downloadCoordinator, + authorEmail = authorEmail, + ) + + private fun stubProfileSearch() { + val email = "test@example.com" + fenixRepository.pushesByAuthorResult = + NetworkResult.Success( + TreeherderRevisionResponse( + meta = RevisionMeta(revision = null, count = 1, repository = "try"), + results = listOf( + RevisionResult( + id = 1, + revision = "abc123", + author = email, + revisions = listOf( + RevisionDetail( + resultSetId = 1, + repositoryId = 1, + revision = "abc123", + author = email, + comments = "Bug 123", + ), + ), + revisionCount = 1, + pushTimestamp = 1_700_000_000, + repositoryId = 1, + ), + ), + ), + ) + fenixRepository.jobsForPushResult = + NetworkResult.Success( + TreeherderJobsResponse( + results = listOf( + JobDetails( + appName = "Fenix Nightly", + jobName = "Android ARM64", + jobSymbol = "Bs", + taskId = "task-123", + ), + ), + ), + ) + fenixRepository.artifactsForTaskResult = + NetworkResult.Success( + ArtifactsResponse( + artifacts = listOf( + Artifact( + storageType = "s3", + name = "public/target.apk", + expires = "2099-01-01T00:00:00Z", + contentType = "application/vnd.android.package-archive", + ), + ), + ) + ) + } + + private class FakeTreeherderRepository : TreeherderRepository { + var pushesByAuthorResult: NetworkResult = + NetworkResult.Error("Not stubbed") + var jobsForPushResult: NetworkResult = + NetworkResult.Error("Not stubbed") + var artifactsForTaskResult: NetworkResult = + NetworkResult.Error("Not stubbed") + + override suspend fun getPushByRevision( + project: String, + revision: String, + ): NetworkResult = pushesByAuthorResult + + override suspend fun getPushesByAuthor(author: String): NetworkResult = + pushesByAuthorResult + + override suspend fun getJobsForPush(pushId: Int): NetworkResult = + jobsForPushResult + + override suspend fun getJobsForPushPage( + pushId: Int, + page: Int, + count: Int, + ): NetworkResult = jobsForPushResult + + override suspend fun getArtifactsForTask(taskId: String): NetworkResult = + artifactsForTaskResult + } + + private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + private val _downloads = MutableStateFlow>(emptyMap()) + val enqueuedRequests = mutableListOf() + + override val downloads = _downloads.asStateFlow() + + override fun enqueue(request: ApkDownloadRequest): String { + enqueuedRequests += request + _downloads.value = _downloads.value + ( + request.uniqueKey to PersistedDownloadState( + uniqueKey = request.uniqueKey, + downloadUrl = request.downloadUrl, + outputPath = request.outputPath, + appName = request.appName, + fileName = request.fileName, + cacheRelativePath = request.cacheRelativePath, + status = DownloadStatus.QUEUED, + workId = request.uniqueKey, + ) + ) + return request.uniqueKey + } + + override fun retry(request: ApkDownloadRequest): String = enqueue(request) + + override fun cancel(uniqueKey: String) { + _downloads.value[uniqueKey]?.let { current -> + _downloads.value = _downloads.value + ( + uniqueKey to current.copy( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + ) + ) + } + } + + override fun observe(uniqueKey: String) = downloads.map { it[uniqueKey] } + + fun emit(state: PersistedDownloadState) { + _downloads.value = _downloads.value + (state.uniqueKey to state) + } + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eed9f72..21f720b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ camerax = "1.5.3" mlkitBarcodeScanning = "17.3.0" ktor = "3.5.0" zxing = "3.5.3" +androidxWork = "2.9.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -81,6 +82,7 @@ ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/plans/workmanager-apk-download-plan.md b/plans/workmanager-apk-download-plan.md new file mode 100644 index 0000000..f314b3b --- /dev/null +++ b/plans/workmanager-apk-download-plan.md @@ -0,0 +1,232 @@ +# WorkManager APK Download Plan + +## Goal + +Move APK downloads out of `viewModelScope` and into a `WorkManager`-backed pipeline so downloads can continue when the app is backgrounded or the process is recreated, while keeping TryFox's existing internal cache model. + +## Key Decisions + +- Keep the APK cache in internal `filesDir/download-cache`. +- Use `WorkManager` rather than `DownloadManager`. +- Reuse the existing `DownloadFileRepository` for transfer logic. +- Persist download state so the UI can reconnect after process death. +- Treat installation as an explicit user action after download completion. + +## Phase 1: Add Download Domain Owned By WorkManager + +Create a dedicated download layer instead of pushing worker logic into the existing view models. + +Files to add: + +- `app/src/main/java/org/mozilla/tryfox/download/ApkDownloadWorker.kt` +- `app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt` +- `app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt` +- `app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt` +- `app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt` + +Responsibilities: + +- `ApkDownloadWorker`: execute one APK download, run as foreground work, report progress. +- `ApkDownloadCoordinator`: app-facing API for `enqueue`, `cancel`, `observe`, `retry`. +- `ApkDownloadStore`: persistent state for reconnecting UI after process death. +- `PersistedDownloadState`: queued, running, succeeded, failed, canceled plus progress metadata. + +Use the existing `DownloadFileRepository` instead of replacing it. + +## Phase 2: Wire Persistence For Reconnectable State + +Add a persistent store before moving UI off `viewModelScope`. + +Preferred approach: + +- Add a small SQLite table beside the existing history DB. + +Reasoning: + +- The repo already uses SQLite in `DefaultHistoryRepository`. +- This keeps query semantics straightforward. +- It avoids pushing restart-critical state into an in-memory-only model. + +Suggested fields: + +- `unique_key` +- `download_url` +- `cache_relative_path` +- `app_name` +- `file_name` +- `status` +- `bytes_downloaded` +- `total_bytes` +- `error_message` +- `work_id` +- `created_at` +- `updated_at` + +This store should be the source of truth for logical download state, while file existence remains the source of truth for whether an APK is actually downloaded. + +## Phase 3: Add WorkManager And DI Integration + +Update build and app wiring. + +Files to change: + +- `app/build.gradle.kts` +- `app/src/main/java/org/mozilla/tryfox/TryFoxApplication.kt` +- `app/src/main/java/org/mozilla/tryfox/di/AppModule.kt` + +Changes: + +- Add `androidx.work:work-runtime-ktx`. +- Register `WorkManager` access in DI. +- Register the new coordinator and store. +- Add a custom `WorkerFactory` if needed so the worker can receive `DownloadFileRepository`, `CacheManager`, and the store via DI. + +At this stage, do not touch the UI yet. Only make it possible to enqueue a worker end-to-end. + +## Phase 4: Implement Foreground Download Worker + +`ApkDownloadWorker` should: + +- Resolve the output file from `cacheRelativePath` and the current cache root. +- Call `setForeground()` immediately. +- Reuse `DefaultDownloadFileRepository`. +- Update both `setProgress(...)` and `ApkDownloadStore` during transfer. +- Mark the final state in the store on success, failure, or cancellation. +- Call `cacheManager.checkCacheStatus()` on completion. +- Post a completion notification when the download succeeds. + +Important detail: + +- Do not attempt background auto-install. +- Completion should produce a notification or UI state that lets the user install explicitly. + +## Phase 5: Move One Screen First, Then The Other + +Start with history. It already has more explicit lifecycle and cancellation semantics than home. + +### History flow + +Refactor `HistoryViewModel` to replace: + +- direct `viewModelScope.launch(ioDispatcher)` download execution +- `activeDownloads` +- `canceledDownloads` +- most in-memory progress bookkeeping + +With: + +- coordinator `enqueueDownload(entry)` +- coordinator `cancelDownload(uniqueKey)` +- derived UI state from: + - history entries + - persisted download states + - file existence + +Keep: + +- delete and history cleanup logic +- cache resolution logic around `cacheRelativePath` + +### Home flow + +Refactor `HomeViewModel` to replace: + +- `downloadNightlyApk()` direct repository call +- in-memory `InProgress` updates + +With: + +- coordinator start +- observed progress mapped by `apkInfo.uniqueKey` + +### Install flow + +Keep `IntentManager` unchanged. + +- Installation should happen when the user taps a downloaded item. +- The completion notification can also deep link back into the app for install. + +## Notification Work + +Add a small notification helper. + +Files to add: + +- `app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt` + +Files to update: + +- `app/src/main/AndroidManifest.xml` +- `app/src/main/res/values/strings.xml` + +Needed behavior: + +- progress notification while worker is active +- completion notification with pending intent back into app +- optional failure notification with retry action + +## State Model Changes + +Relevant files: + +- `app/src/main/java/org/mozilla/tryfox/data/DownloadState.kt` +- `app/src/main/java/org/mozilla/tryfox/ui/models/HistoryItemUiModel.kt` +- `app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt` + +Approach: + +- Keep `DownloadState` as the UI contract. +- Add a mapper from persisted worker or store state to `DownloadState`. +- Let file existence win when a file is actually present. + +This avoids unnecessary UI model churn. + +## Testing Plan + +Unit tests to add: + +- worker success, failure, and cancellation behavior +- coordinator deduplication and cancellation +- state mapping from persisted store to `DownloadState` + +Existing tests to extend: + +- `app/src/test/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepositoryTest.kt` +- `app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt` +- `app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt` + +New tests to add: + +- `app/src/test/java/org/mozilla/tryfox/download/ApkDownloadWorkerTest.kt` +- `app/src/test/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinatorTest.kt` + +Instrumentation tests later: + +- start download, background app, resume app, state reconnects +- completion notification leads to install path + +## Recommended Implementation Order + +1. Add WorkManager dependency and DI scaffolding. +2. Add persistent download store. +3. Implement `ApkDownloadWorker`. +4. Implement coordinator and unique work policies. +5. Move `HistoryViewModel`. +6. Add foreground and completion notifications. +7. Move `HomeViewModel`. +8. Remove obsolete in-memory download lifecycle code. + +## Policies To Lock Before Coding + +- Unique work name: use the current `uniqueKey`. +- Cache root: keep internal `filesDir/download-cache`. +- Success UX: downloaded and ready to install, not auto-install in background. +- Retry policy: manual retry first, automatic retry only for clearly transient network failures. +- Cancellation: cancel worker and delete `.part` and managed temp files, but never delete a completed APK. + +## Main Risks + +- Duplicate state sources if `DownloadState`, file existence, and worker progress are not unified carefully. +- Race conditions around cancel versus complete. +- Worker DI and test setup friction. +- UX inconsistency if some flows still auto-install while others move to explicit install. From 999d9947b84f6a47549686a63ccbdbf9148d4545 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 23 Jul 2026 18:00:45 +0200 Subject: [PATCH 02/24] Improve server screen layout and navigation --- .../java/org/mozilla/tryfox/MainActivity.kt | 5 + .../ui/screens/ReceiveFromDesktopScreen.kt | 278 ++++++++++-------- 2 files changed, 156 insertions(+), 127 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 3e38b8b..3f3d19f 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -165,6 +165,11 @@ class MainActivity : ComponentActivity() { onNavigateToMessageHistory = { localNavController.navigate(NavScreen.ReceiveMessageHistory.route) }, + onNavigateToTreeherderRevision = { project, revision -> + localNavController.navigate( + NavScreen.TreeherderSearchWithArgs.createRoute(project, revision), + ) + }, receiveFromDesktopViewModel = koinViewModel(), startReceiverOnEnter = receiveFromDesktopStartRequested, onStartReceiverOnEnterConsumed = { diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt index 638bca1..9e3d8ac 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt @@ -12,11 +12,12 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -57,6 +58,7 @@ import org.mozilla.tryfox.lan.TryFoxLanReceiveService fun ReceiveFromDesktopScreen( onNavigateUp: () -> Unit, onNavigateToMessageHistory: () -> Unit, + onNavigateToTreeherderRevision: (project: String, revision: String) -> Unit, receiveFromDesktopViewModel: ReceiveFromDesktopViewModel, startReceiverOnEnter: Boolean = false, onStartReceiverOnEnterConsumed: () -> Unit = {}, @@ -159,167 +161,189 @@ fun ReceiveFromDesktopScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 16.dp), ) { - if (permissionState != NotificationPermissionState.GRANTED) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(top = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (permissionState != NotificationPermissionState.GRANTED) { + Card( + modifier = Modifier.clickable { requestOrOpenSettings() }, + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource( + id = if (permissionState == NotificationPermissionState.BLOCKED) { + R.string.lan_receive_permission_card_blocked_title + } else { + R.string.lan_receive_permission_card_title + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource( + id = if (permissionState == NotificationPermissionState.BLOCKED) { + R.string.lan_receive_permission_card_blocked_body + } else { + R.string.lan_receive_permission_card_body + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + Card( - modifier = Modifier.clickable { requestOrOpenSettings() }, shape = RoundedCornerShape(8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource( - id = if (permissionState == NotificationPermissionState.BLOCKED) { - R.string.lan_receive_permission_card_blocked_title - } else { - R.string.lan_receive_permission_card_title - }, - ), + text = stringResource(id = R.string.lan_receive_state_label, state.status.toDisplayName()), style = MaterialTheme.typography.titleMedium, ) - Text( - text = stringResource( - id = if (permissionState == NotificationPermissionState.BLOCKED) { - R.string.lan_receive_permission_card_blocked_body - } else { - R.string.lan_receive_permission_card_body - }, - ), - style = MaterialTheme.typography.bodyMedium, - ) + if (state.endpoint != null) { + Text( + text = stringResource(id = R.string.lan_receive_endpoint_label, state.endpoint!!), + style = MaterialTheme.typography.bodyMedium, + ) + } + if (state.errorMessage != null) { + Text( + text = stringResource(id = R.string.lan_receive_error_label, state.errorMessage!!), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } } } - } - Card( - shape = RoundedCornerShape(8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(id = R.string.lan_receive_state_label, state.status.toDisplayName()), - style = MaterialTheme.typography.titleMedium, - ) - if (state.endpoint != null) { - Text( - text = stringResource(id = R.string.lan_receive_endpoint_label, state.endpoint!!), - style = MaterialTheme.typography.bodyMedium, - ) + if (state.status == LanReceiveStatus.LISTENING && state.qrPayloadJson != null) { + val qrCode = remember(state.qrPayloadJson) { + qrCodeBitmap(state.qrPayloadJson!!, sizePx = 768) } - if (state.errorMessage != null) { - Text( - text = stringResource(id = R.string.lan_receive_error_label, state.errorMessage!!), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - - if (state.status == LanReceiveStatus.LISTENING && state.qrPayloadJson != null) { - val qrCode = remember(state.qrPayloadJson) { - qrCodeBitmap(state.qrPayloadJson!!, sizePx = 768) - } - Card(shape = RoundedCornerShape(8.dp)) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(id = R.string.lan_receive_qr_title), - style = MaterialTheme.typography.titleMedium, - ) - Image( - bitmap = qrCode, - contentDescription = stringResource(id = R.string.lan_receive_qr_description), - modifier = Modifier.size(240.dp), - ) + Card(shape = RoundedCornerShape(8.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(id = R.string.lan_receive_qr_title), + style = MaterialTheme.typography.titleMedium, + ) + Image( + bitmap = qrCode, + contentDescription = stringResource(id = R.string.lan_receive_qr_description), + modifier = Modifier.size(240.dp), + ) + } } } - } - if (state.lastReceivedMessage != null) { - val lastMessage = state.lastReceivedMessage!! - Card(shape = RoundedCornerShape(8.dp)) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + state.lastReceivedMessage?.let { lastMessage -> + val project = lastMessage.repo?.takeIf { it.isNotBlank() } ?: "try" + val canOpenTreeherder = !lastMessage.revision.isNullOrBlank() + Card( + modifier = Modifier.clickable(enabled = canOpenTreeherder) { + onNavigateToTreeherderRevision( + project, + lastMessage.revision!!, + ) + }, + shape = RoundedCornerShape(8.dp), ) { - Text( - text = stringResource(id = R.string.lan_receive_last_message_title), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = if (lastMessage.accepted) { - stringResource(id = R.string.lan_receive_last_message_accepted) - } else { - stringResource( - id = R.string.lan_receive_last_message_rejected, - lastMessage.error ?: stringResource(id = R.string.common_unknown_error), - ) - }, - style = MaterialTheme.typography.bodyMedium, - ) - lastMessage.revision?.let { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = stringResource(id = R.string.lan_receive_last_message_revision, it), - style = MaterialTheme.typography.bodySmall, + text = stringResource(id = R.string.lan_receive_last_message_title), + style = MaterialTheme.typography.titleMedium, ) - } - lastMessage.author?.let { Text( - text = stringResource(id = R.string.lan_receive_last_message_author, it), - style = MaterialTheme.typography.bodySmall, + text = if (lastMessage.accepted) { + stringResource(id = R.string.lan_receive_last_message_accepted) + } else { + stringResource( + id = R.string.lan_receive_last_message_rejected, + lastMessage.error ?: stringResource(id = R.string.common_unknown_error), + ) + }, + style = MaterialTheme.typography.bodyMedium, ) + lastMessage.revision?.let { + Text( + text = stringResource(id = R.string.lan_receive_last_message_revision, it), + style = MaterialTheme.typography.bodySmall, + ) + } + lastMessage.author?.let { + Text( + text = stringResource(id = R.string.lan_receive_last_message_author, it), + style = MaterialTheme.typography.bodySmall, + ) + } } } } } - Spacer(modifier = Modifier.weight(1f)) - - if (state.status == LanReceiveStatus.LISTENING || state.status == LanReceiveStatus.STARTING) { - Button( - onClick = { context.startService(TryFoxLanReceiveService.stopIntent(context)) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(id = R.string.lan_receive_stop_button)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.status == LanReceiveStatus.LISTENING || state.status == LanReceiveStatus.STARTING) { + Button( + onClick = { context.startService(TryFoxLanReceiveService.stopIntent(context)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(id = R.string.lan_receive_stop_button)) + } + } else { + Button( + onClick = requestStartReceiver, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(id = R.string.lan_receive_start_button)) + } } - } else { - Button( - onClick = requestStartReceiver, + + OutlinedButton( + onClick = onNavigateToMessageHistory, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(id = R.string.lan_receive_start_button)) + Text(stringResource(id = R.string.lan_receive_message_history_button)) } } - - OutlinedButton( - onClick = onNavigateToMessageHistory, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(id = R.string.lan_receive_message_history_button)) - } } } } From 1a54144d1a7e39eb8ff4db2260cacd7f95dd0dad Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 30 Jul 2026 15:52:05 +0200 Subject: [PATCH 03/24] Unify build search entry points --- .../tryfox/data/FakeApkDownloadCoordinator.kt | 17 ++++ .../tryfox/data/FakeDownloadFileRepository.kt | 2 +- .../tryfox/ui/screens/HistoryScreenTest.kt | 6 +- .../tryfox/ui/screens/ProfileScreenTest.kt | 7 +- .../org/mozilla/tryfox/AppDeepLinkParser.kt | 6 +- .../mozilla/tryfox/AppDeepLinkRouteMapper.kt | 4 +- .../main/java/org/mozilla/tryfox/AppRoutes.kt | 7 +- .../java/org/mozilla/tryfox/MainActivity.kt | 61 +++++++----- .../org/mozilla/tryfox/TryFoxViewModel.kt | 2 +- .../mozilla/tryfox/UnifiedSearchViewModel.kt | 7 ++ .../DefaultTreeherderRepository.kt | 6 +- .../data/repositories/TreeherderRepository.kt | 6 ++ .../java/org/mozilla/tryfox/di/AppModule.kt | 6 +- .../tryfox/download/ApkDownloadStore.kt | 1 - .../download/DefaultApkDownloadCoordinator.kt | 2 +- .../tryfox/network/TreeherderApiService.kt | 3 +- .../tryfox/ui/composables/DownloadButton.kt | 4 +- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 15 +-- .../tryfox/ui/screens/HomeViewModel.kt | 5 +- .../tryfox/ui/screens/ProfileScreen.kt | 68 +++++++++++-- .../tryfox/ui/screens/ProfileViewModel.kt | 95 +++++++++++++++---- .../ui/screens/ReceiveFromDesktopScreen.kt | 2 +- .../mozilla/tryfox/ui/screens/SearchQuery.kt | 27 ++++++ .../tryfox/ui/screens/TreeherderApksScreen.kt | 48 ++++++++-- app/src/main/res/values/strings.xml | 19 ++-- .../tryfox/AppDeepLinkRouteMapperTest.kt | 4 +- .../tryfox/ui/screens/HomeViewModelTest.kt | 7 +- .../tryfox/ui/screens/ProfileViewModelTest.kt | 32 ++++++- .../ui/screens/SearchQueryClassifierTest.kt | 20 ++++ 29 files changed, 374 insertions(+), 115 deletions(-) create mode 100644 app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt new file mode 100644 index 0000000..293d8a7 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt @@ -0,0 +1,17 @@ +package org.mozilla.tryfox.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.PersistedDownloadState + +class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + override val downloads = MutableStateFlow>(emptyMap()) + + override fun enqueue(request: ApkDownloadRequest): String = request.uniqueKey + override fun retry(request: ApkDownloadRequest): String = request.uniqueKey + override fun cancel(uniqueKey: String) = Unit + override fun observe(uniqueKey: String): Flow = flowOf(downloads.value[uniqueKey]) +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt index 0ed5d71..239575e 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt @@ -16,7 +16,7 @@ class FakeDownloadFileRepository( override suspend fun downloadFile( downloadUrl: String, outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, + onProgress: suspend (bytesDownloaded: Long, totalBytes: Long) -> Unit, ): NetworkResult { downloadFileCalled = true diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt index e1f8fbb..4f3c3b7 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt @@ -13,8 +13,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.FakeApkDownloadCoordinator import org.mozilla.tryfox.data.FakeCacheManager -import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeHistoryRepository import org.mozilla.tryfox.data.FakeIntentManager import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry @@ -34,7 +34,7 @@ class HistoryScreenTest { } val historyViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = FakeDownloadFileRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), intentManager = FakeIntentManager(), ) @@ -68,7 +68,7 @@ class HistoryScreenTest { } val historyViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = FakeDownloadFileRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), intentManager = FakeIntentManager(), ) 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 228dd82..e5476ef 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 @@ -13,8 +13,8 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mozilla.tryfox.data.FakeApkDownloadCoordinator import org.mozilla.tryfox.data.FakeCacheManager -import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeHistoryRepository import org.mozilla.tryfox.data.FakeIntentManager import org.mozilla.tryfox.data.FakeTreeherderRepository @@ -29,7 +29,6 @@ class ProfileScreenTest { val composeTestRule = createComposeRule() private val fenixRepository = FakeTreeherderRepository() - private val downloadFileRepository = FakeDownloadFileRepository() private val userDataRepository: UserDataRepository = FakeUserDataRepository() private val cacheManager: CacheManager = FakeCacheManager() private val intentManager = FakeIntentManager() @@ -48,7 +47,7 @@ class ProfileScreenTest { fun searchPushesAndCheckDownloadAndInstallStates() { val profileViewModel = ProfileViewModel( fenixRepository = fenixRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = FakeApkDownloadCoordinator(), userDataRepository = userDataRepository, cacheManager = cacheManager, intentManager = intentManager, @@ -108,7 +107,7 @@ class ProfileScreenTest { val initialEmail = "initial@example.com" val profileViewModelWithEmail = ProfileViewModel( fenixRepository = fenixRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = FakeApkDownloadCoordinator(), userDataRepository = userDataRepository, cacheManager = cacheManager, intentManager = intentManager, diff --git a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt index 4ea6906..afc1ca2 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt @@ -12,6 +12,7 @@ sealed interface AppDeepLinkDestination { data class Profile( val email: String, + val project: String = "try", ) : AppDeepLinkDestination } @@ -61,7 +62,10 @@ object AppDeepLinkParser { val author = parameters["author"]?.takeIf { it.isNotBlank() } if (author != null) { - return AppDeepLinkDestination.Profile(email = author) + return AppDeepLinkDestination.Profile( + email = author, + project = parameters["repo"]?.takeIf { it.isNotBlank() } ?: DEFAULT_PROJECT, + ) } return null diff --git a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt index c08dc0f..12ca168 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt @@ -6,12 +6,12 @@ object AppDeepLinkRouteMapper { is AppDeepLinkDestination.TreeherderSearch -> { AppRoutes.createTreeherderSearchRoute( project = destination.project, - revision = destination.revision, + query = destination.revision, ) } is AppDeepLinkDestination.Profile -> { - AppRoutes.createProfileByEmailRoute(destination.email) + AppRoutes.createTreeherderSearchRoute(project = destination.project, query = destination.email) } null -> null diff --git a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt index 5e6d69a..1df7cf0 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt @@ -9,12 +9,11 @@ object AppRoutes { const val RECEIVE_MESSAGE_HISTORY = "receive_message_history" const val QR_SCANNER = "qr_scanner" const val TREEHERDER_SEARCH = "treeherder_search" - const val TREEHERDER_SEARCH_WITH_ARGS = "treeherder_search/{project}/{revision}" - const val PROFILE = "profile" + const val TREEHERDER_SEARCH_WITH_ARGS = "treeherder_search/{project}/{query}" const val PROFILE_BY_EMAIL = "profile_by_email?email={email}" - fun createTreeherderSearchRoute(project: String, revision: String): String { - return "treeherder_search/${encode(project)}/${encode(revision)}" + fun createTreeherderSearchRoute(project: String, query: String): String { + return "treeherder_search/${encode(project)}/${encode(query)}" } fun createProfileByEmailRoute(email: String): String { diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 3f3d19f..0445c50 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -26,6 +26,8 @@ import org.mozilla.tryfox.ui.screens.ProfileScreen import org.mozilla.tryfox.ui.screens.QrCodeScannerScreen import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopScreen import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryScreen +import org.mozilla.tryfox.ui.screens.SearchQuery +import org.mozilla.tryfox.ui.screens.SearchQueryClassifier import org.mozilla.tryfox.ui.screens.TryFoxMainScreen import org.mozilla.tryfox.ui.theme.TryFoxTheme @@ -59,7 +61,7 @@ sealed class NavScreen(val route: String) { data object TreeherderSearch : NavScreen(AppRoutes.TREEHERDER_SEARCH) /** - * Represents the Treeherder search screen with project and revision arguments. + * Represents the Treeherder search screen with project and query arguments. */ data object TreeherderSearchWithArgs : NavScreen(AppRoutes.TREEHERDER_SEARCH_WITH_ARGS) { /** @@ -70,15 +72,10 @@ sealed class NavScreen(val route: String) { */ fun createRoute(project: String, revision: String) = AppRoutes.createTreeherderSearchRoute( project = project, - revision = revision, + query = revision, ) } - /** - * Represents the Profile screen. - */ - data object Profile : NavScreen(AppRoutes.PROFILE) - /** * Represents the Profile screen filtered by email. */ @@ -132,8 +129,7 @@ class MainActivity : ComponentActivity() { composable(NavScreen.Home.route) { // Inject HomeViewModel using Koin in Composable HomeScreen( - onNavigateToTreeherder = { localNavController.navigate(NavScreen.TreeherderSearch.route) }, - onNavigateToProfile = { localNavController.navigate(NavScreen.Profile.route) }, + onNavigateToSearch = { localNavController.navigate(NavScreen.TreeherderSearch.route) }, onNavigateToQrScanner = { localNavController.navigate(NavScreen.QrScanner.route) }, onNavigateToReceiveFromDesktop = { localNavController.navigate(NavScreen.ReceiveFromDesktop.route) }, onNavigateToHistory = { localNavController.navigate(NavScreen.History.route) }, @@ -191,29 +187,43 @@ class MainActivity : ComponentActivity() { deepLinkProject = null, deepLinkRevision = null, onNavigateUp = { localNavController.popBackStack() }, + onSearchEmail = { project, email -> + localNavController.navigate(AppRoutes.createTreeherderSearchRoute(project, email)) + }, ) } composable( route = NavScreen.TreeherderSearchWithArgs.route, arguments = listOf( navArgument("project") { type = NavType.StringType }, - navArgument("revision") { type = NavType.StringType }, + navArgument("query") { type = NavType.StringType }, ), ) { backStackEntry -> val project = backStackEntry.arguments?.getString("project") - val revision = backStackEntry.arguments?.getString("revision") - TryFoxMainScreen( - tryFoxViewModel = koinViewModel { parametersOf(project, revision) }, - deepLinkProject = project, - deepLinkRevision = revision, - onNavigateUp = { localNavController.popBackStack() }, - ) - } - composable(NavScreen.Profile.route) { - ProfileScreen( - onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel(), - ) + val query = backStackEntry.arguments?.getString("query")?.let(Uri::decode).orEmpty() + when (SearchQueryClassifier.classify(query).getOrNull()) { + is SearchQuery.Email -> ProfileScreen( + onNavigateUp = { localNavController.popBackStack() }, + profileViewModel = koinViewModel { parametersOf(query, project) }, + onSearchRevision = { selectedProject, revision -> + localNavController.navigate( + AppRoutes.createTreeherderSearchRoute(selectedProject, revision), + ) + }, + ) + is SearchQuery.Revision -> TryFoxMainScreen( + tryFoxViewModel = koinViewModel { parametersOf(project, query) }, + deepLinkProject = project, + deepLinkRevision = query, + onNavigateUp = { localNavController.popBackStack() }, + ) + null -> TryFoxMainScreen( + tryFoxViewModel = koinViewModel { parametersOf(project, query) }, + deepLinkProject = project, + deepLinkRevision = query, + onNavigateUp = { localNavController.popBackStack() }, + ) + } } composable( route = NavScreen.ProfileByEmail.route, @@ -223,7 +233,10 @@ class MainActivity : ComponentActivity() { ProfileScreen( onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel { parametersOf(email) }, + profileViewModel = koinViewModel { parametersOf(email, "try") }, + onSearchRevision = { project, revision -> + localNavController.navigate(AppRoutes.createTreeherderSearchRoute(project, revision)) + }, ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 184870e..3efc68d 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -129,7 +129,7 @@ class TryFoxViewModel( private val deviceSupportedAbis: List by lazy { supportedAbis } init { - if (revision != null) { + if (!revision.isNullOrBlank() && '@' !in revision) { searchJobsAndArtifacts() } cacheManager.cacheState.onEach { state -> diff --git a/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt b/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt new file mode 100644 index 0000000..6966d6a --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt @@ -0,0 +1,7 @@ +package org.mozilla.tryfox + +/** + * Canonical name for the build-search state holder. The implementation remains in + * [TryFoxViewModel] while its long-lived download/cache API is migrated incrementally. + */ +typealias UnifiedSearchViewModel = TryFoxViewModel diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt index 7477cb0..8fa5b62 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt @@ -30,7 +30,11 @@ class DefaultTreeherderRepository( } override suspend fun getPushesByAuthor(author: String): NetworkResult { - return safeApiCall { treeherderApiService.getPushByAuthor(author = author) } + return getPushesByAuthor(project = "try", author = author) + } + + override suspend fun getPushesByAuthor(project: String, author: String): NetworkResult { + return safeApiCall { treeherderApiService.getPushByAuthor(project = project, author = author) } } override suspend fun getJobsForPush(pushId: Int): NetworkResult { diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt index a6c647a..646fe1d 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt @@ -7,7 +7,13 @@ import org.mozilla.tryfox.data.TreeherderRevisionResponse interface TreeherderRepository { suspend fun getPushByRevision(project: String, revision: String): NetworkResult + + /** Legacy, project-independent lookup kept for callers which predate project selection. */ suspend fun getPushesByAuthor(author: String): NetworkResult + + /** Looks up an author's pushes in the selected Treeherder project. */ + suspend fun getPushesByAuthor(project: String, author: String): NetworkResult = + getPushesByAuthor(author) suspend fun getJobsForPush(pushId: Int): NetworkResult suspend fun getJobsForPushPage( pushId: Int, 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 9276bc4..bef83d8 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.di +import androidx.work.WorkManager import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -7,7 +8,6 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor -import androidx.work.WorkManager import org.koin.android.ext.koin.androidContext import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named @@ -218,7 +218,9 @@ val viewModelModule = module { get(named("IODispatcher")), ) } - viewModel { params -> ProfileViewModel(get(), get(), get(), get(), get(), get(), params.getOrNull()) } + viewModel { params -> + ProfileViewModel(get(), get(), get(), get(), get(), get(), params.getOrNull(), project = params.getOrNull() ?: "try") + } } val appModules = listOf(dispatchersModule, networkModule, repositoryModule, viewModelModule) diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt index cfc93ae..0de0a0f 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt @@ -3,7 +3,6 @@ package org.mozilla.tryfox.download import android.content.Context import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt index 044ef53..79af552 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt @@ -2,8 +2,8 @@ package org.mozilla.tryfox.download import android.content.Context import androidx.work.ExistingWorkPolicy -import androidx.work.OutOfQuotaPolicy import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow diff --git a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt index d4b79ce..375aca8 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -16,8 +16,9 @@ interface TreeherderApiService { @Query("revision") revision: String, ): TreeherderRevisionResponse - @GET("project/try/push/") + @GET("project/{project}/push/") suspend fun getPushByAuthor( + @Path("project") project: String, @Query("full") full: Boolean = true, @Query("count") count: Int = 10, @Query("author") author: String, 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 4cbc440..b8bba22 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 @@ -1,10 +1,10 @@ package org.mozilla.tryfox.ui.composables -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator 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 17779ab..777d268 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 @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search @@ -63,8 +62,7 @@ import java.io.File * Composable function for the Home screen, which displays a list of available apps and allows users to interact with them. * * @param modifier The modifier to be applied to the component. - * @param onNavigateToTreeherder Callback to navigate to the Treeherder search screen. - * @param onNavigateToProfile Callback to navigate to the Profile screen. + * @param onNavigateToSearch Callback to navigate to the unified build search screen. * @param onNavigateToHistory Callback to navigate to the History screen. * @param homeViewModel The ViewModel for the Home screen. */ @@ -72,8 +70,7 @@ import java.io.File @Composable fun HomeScreen( modifier: Modifier = Modifier, - onNavigateToTreeherder: () -> Unit, - onNavigateToProfile: () -> Unit, + onNavigateToSearch: () -> Unit, onNavigateToQrScanner: () -> Unit, onNavigateToReceiveFromDesktop: () -> Unit, onNavigateToHistory: () -> Unit, @@ -123,13 +120,7 @@ fun HomeScreen( contentDescription = null, ) } - IconButton(onClick = onNavigateToProfile) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = stringResource(id = R.string.home_profile_button_description), - ) - } - IconButton(onClick = onNavigateToTreeherder) { + IconButton(onClick = onNavigateToSearch) { Icon( imageVector = Icons.Filled.Search, contentDescription = stringResource( 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 de91c86..7f50853 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 @@ -23,13 +23,12 @@ import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository -import org.mozilla.tryfox.model.AppState -import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.model.AppState +import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult 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 5c6bd6e..1506a96 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 @@ -27,15 +27,20 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar @@ -44,7 +49,9 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -111,16 +118,20 @@ private fun ProfileSearchButton( } } -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable private fun UserSearchCard( email: String, onEmailChange: (String) -> Unit, + project: String, + onProjectChange: (String) -> Unit, onSearchClick: () -> Unit, isLoading: Boolean, modifier: Modifier = Modifier, ) { val keyboardController = LocalSoftwareKeyboardController.current + val projects = listOf("try", "mozilla-central", "mozilla-beta", "mozilla-release") + var projectMenuExpanded by remember { mutableStateOf(false) } androidx.compose.material3.Card( modifier = modifier.fillMaxWidth(), @@ -135,6 +146,36 @@ private fun UserSearchCard( style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) + ExposedDropdownMenuBox( + expanded = projectMenuExpanded, + onExpandedChange = { projectMenuExpanded = !projectMenuExpanded }, + ) { + TextField( + value = project, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(id = R.string.treeherder_apks_project_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = projectMenuExpanded) }, + modifier = Modifier + .menuAnchor(MenuAnchorType.PrimaryNotEditable) + .fillMaxWidth() + .testTag("unified_search_project_input"), + ) + ExposedDropdownMenu( + expanded = projectMenuExpanded, + onDismissRequest = { projectMenuExpanded = false }, + ) { + projects.forEach { candidate -> + DropdownMenuItem( + text = { Text(candidate) }, + onClick = { + onProjectChange(candidate) + projectMenuExpanded = false + }, + ) + } + } + } Row( modifier = Modifier .fillMaxWidth() @@ -196,8 +237,10 @@ fun ProfileScreen( modifier: Modifier = Modifier, onNavigateUp: () -> Unit, profileViewModel: ProfileViewModel, + onSearchRevision: (project: String, revision: String) -> Unit = { _, _ -> }, ) { val authorEmail by profileViewModel.authorEmail.collectAsState() + val selectedProject by profileViewModel.selectedProject.collectAsState() val pushes by profileViewModel.pushes.collectAsState() val isLoading by profileViewModel.isLoading.collectAsState() val errorMessage by profileViewModel.errorMessage.collectAsState() @@ -264,7 +307,15 @@ fun ProfileScreen( UserSearchCard( email = authorEmail, onEmailChange = { profileViewModel.updateAuthorEmail(it) }, - onSearchClick = { profileViewModel.searchByAuthor() }, + project = selectedProject, + onProjectChange = { profileViewModel.updateSelectedProject(it) }, + onSearchClick = { + when (val query = SearchQueryClassifier.classify(authorEmail).getOrNull()) { + is SearchQuery.Email -> profileViewModel.searchByAuthor() + is SearchQuery.Revision -> onSearchRevision(selectedProject, query.value) + null -> profileViewModel.showInvalidQueryError() + } + }, isLoading = isLoading && pushes.isEmpty(), ) @@ -282,13 +333,13 @@ fun ProfileScreen( ) } } - errorMessage != null -> { - ErrorState(errorMessage = errorMessage!!) - } pushes.isNotEmpty() -> { LazyColumn( contentPadding = PaddingValues(bottom = 16.dp), ) { + errorMessage?.let { message -> + item { ErrorState(errorMessage = message) } + } items(pushes, key = { push -> push.pushComment + push.author + (push.jobs.firstOrNull()?.taskId ?: "") }) { push -> @@ -314,6 +365,9 @@ fun ProfileScreen( } } } + errorMessage != null -> { + ErrorState(errorMessage = errorMessage!!) + } !isLoading && errorMessage == null && pushes.isEmpty() -> { Box( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), @@ -338,7 +392,7 @@ private fun JobCard( profileViewModel: ProfileViewModel, ) { val appNameForIconAndLogic = job.appName - val displayAppName = formatAppNameForDisplay(appNameForIconAndLogic) + val displayJobName = job.jobName.ifBlank { formatAppNameForDisplay(appNameForIconAndLogic) } val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } @@ -361,7 +415,7 @@ private fun JobCard( AppIcon(appName = appNameForIconAndLogic, modifier = Modifier.size(40.dp)) Spacer(Modifier.width(8.dp)) Text( - text = displayAppName, + text = displayJobName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) 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 08f94b2..ee0ec4c 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 @@ -17,15 +17,15 @@ import logcat.logcat import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry -import org.mozilla.tryfox.download.ApkDownloadCoordinator -import org.mozilla.tryfox.download.ApkDownloadRequest -import org.mozilla.tryfox.download.model.DownloadStatus -import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.HistoryRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.data.repositories.UserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -33,6 +33,7 @@ import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File +import java.util.concurrent.atomic.AtomicInteger /** * ViewModel for the Profile screen, responsible for fetching pushes and artifacts by author, managing downloads, and handling user interactions. @@ -52,15 +53,27 @@ class ProfileViewModel( private val downloadCoordinator: ApkDownloadCoordinator, authorEmail: String?, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, + project: String = "try", ) : ViewModel() { + private data class ArtifactLoadResult( + val artifacts: List, + val failed: Boolean, + ) + companion object { private const val TAG = "ProfileViewModel" + private val apkJobNameHints = listOf("signing-apk", "android-apk", "apk-focus", "apk-fenix", "apk-reference-browser", "apk-geckoview") + private val nonAndroidPlatformHints = listOf("ios", "mac", "macos", "macosx", "win", "windows", "linux", "desktop") + private val androidProductHints = listOf("focus", "fenix", "reference-browser", "geckoview", "android") } private val _authorEmail = MutableStateFlow(authorEmail ?: "") val authorEmail: StateFlow = _authorEmail.asStateFlow() + private val _selectedProject = MutableStateFlow(project) + val selectedProject: StateFlow = _selectedProject.asStateFlow() + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() @@ -74,7 +87,9 @@ class ProfileViewModel( val cacheState: StateFlow = cacheManager.cacheState - private val deviceSupportedAbis: List by lazy { Build.SUPPORTED_ABIS.toList() } + private val deviceSupportedAbis: List by lazy { + runCatching { Build.SUPPORTED_ABIS.toList() }.getOrDefault(emptyList()) + } init { logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel for email: $authorEmail" } @@ -126,6 +141,21 @@ class ProfileViewModel( fun updateAuthorEmail(email: String) { logcat(LogPriority.DEBUG, TAG) { "Updating author email to: $email" } _authorEmail.value = email + _errorMessage.value = null + } + + fun updateSelectedProject(project: String) { + _selectedProject.value = project + } + + fun setEmailFromDeepLinkAndSearch(project: String?, email: String) { + _selectedProject.value = project ?: "try" + _authorEmail.value = email + searchByAuthor() + } + + fun showInvalidQueryError() { + _errorMessage.value = "Enter a valid email address or a revision without @." } fun searchByAuthor() { @@ -136,6 +166,10 @@ class ProfileViewModel( logcat(LogPriority.WARN, TAG) { "Search attempt with blank email" } return } + if (SearchQueryClassifier.classify(emailToSearch).getOrNull() !is SearchQuery.Email) { + _errorMessage.value = "Enter a valid email address." + return + } viewModelScope.launch { userDataRepository.saveLastSearchedEmail(emailToSearch) _isLoading.value = true @@ -143,22 +177,28 @@ class ProfileViewModel( _pushes.value = emptyList() logcat(LogPriority.DEBUG, TAG) { "Starting search..." } - when (val result = fenixRepository.getPushesByAuthor(emailToSearch)) { + when (val result = fenixRepository.getPushesByAuthor(_selectedProject.value, emailToSearch)) { is NetworkResult.Success -> { logcat( LogPriority.DEBUG, TAG, ) { "getPushesByAuthor success, processing ${result.data.results.size} pushes" } + val failedPushCount = AtomicInteger(0) val pushesWithJobsAndArtifacts = result.data.results.map { pushResult -> async { val jobsResult = fenixRepository.getJobsForPush(pushResult.id) if (jobsResult is NetworkResult.Success) { - val filteredJobs = - jobsResult.data.results.filter { it.isSignedBuild && !it.isTest } + val candidates = jobsResult.data.results.filter(::isAndroidApkCandidate) + val filteredJobs = candidates.filter { it.jobName.contains("signing-apk", ignoreCase = true) } + .ifEmpty { candidates } if (filteredJobs.isNotEmpty()) { val jobsWithArtifacts = filteredJobs.map { jobDetails -> async { - val artifacts = fetchArtifacts(jobDetails.taskId) + val artifactResult = fetchArtifacts(jobDetails.taskId) + if (artifactResult.failed) { + failedPushCount.incrementAndGet() + } + val artifacts = artifactResult.artifacts if (artifacts.isNotEmpty()) { JobDetailsUiModel( appName = jobDetails.appName, @@ -207,6 +247,7 @@ class ProfileViewModel( null } } else { + failedPushCount.incrementAndGet() logcat(LogPriority.WARN, TAG) { "getJobsForPush failed for push ID: ${pushResult.id}: " + (jobsResult as NetworkResult.Error).message @@ -219,7 +260,9 @@ class ProfileViewModel( _pushes.value = pushesWithJobsAndArtifacts syncLoadedStateDownloadStates() logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } - if (pushesWithJobsAndArtifacts.isEmpty()) { + if (failedPushCount.get() > 0) { + _errorMessage.value = "Some pushes could not be loaded." + } else if (pushesWithJobsAndArtifacts.isEmpty()) { _errorMessage.value = "No signed builds found for this author." logcat(TAG) { "No signed builds found for author." } } @@ -234,7 +277,7 @@ class ProfileViewModel( } } - private suspend fun fetchArtifacts(taskId: String): List { + private suspend fun fetchArtifacts(taskId: String): ArtifactLoadResult { logcat(LogPriority.DEBUG, TAG) { "fetchArtifacts called for taskId: $taskId" } return when (val artifactsResult = fenixRepository.getArtifactsForTask(taskId)) { is NetworkResult.Success -> { @@ -245,7 +288,16 @@ class ProfileViewModel( LogPriority.VERBOSE, TAG, ) { "Found ${filteredApks.size} APKs for taskId: $taskId" } - filteredApks.map { artifact -> + // A task can expose several ABI variants. Surface only the first variant + // matching Android's device ABI preference order. + val selectedArtifact = deviceSupportedAbis.asSequence().mapNotNull { preferredAbi -> + filteredApks.firstOrNull { artifact -> + artifact.abi.equals(preferredAbi, ignoreCase = true) + } + }.firstOrNull() ?: deviceSupportedAbis + .takeIf { it.isEmpty() } + ?.let { filteredApks.firstOrNull() } + val artifacts = selectedArtifact?.let { artifact -> listOf(artifact) }.orEmpty().map { artifact -> val artifactFileName = artifact.name.substringAfterLast('/') val uniqueKey = "$taskId/$artifactFileName" val downloadState = resolveDownloadState( @@ -253,16 +305,12 @@ class ProfileViewModel( taskId = taskId, uniqueKey = uniqueKey, ) - val isCompatible = - artifact.abi != null && deviceSupportedAbis.any { deviceAbi -> - deviceAbi.equals(artifact.abi, ignoreCase = true) - } ArtifactUiModel( name = artifact.name, taskId = taskId, abi = AbiUiModel( name = artifact.abi, - isSupported = isCompatible, + isSupported = true, ), downloadUrl = artifact.getDownloadUrl(taskId), expires = artifact.expires, @@ -270,17 +318,28 @@ class ProfileViewModel( uniqueKey = "$taskId/${artifact.name.substringAfterLast('/')}", ) } + ArtifactLoadResult(artifacts = artifacts, failed = false) } is NetworkResult.Error -> { logcat(LogPriority.WARN, TAG) { "fetchArtifacts error for taskId $taskId: ${artifactsResult.message}" } - emptyList() + ArtifactLoadResult(artifacts = emptyList(), failed = true) } } } + private fun isAndroidApkCandidate(job: org.mozilla.tryfox.data.JobDetails): Boolean { + if (job.isTest || !job.isSignedBuild) return false + val appName = job.appName.lowercase() + val jobName = job.jobName.lowercase() + val hasAndroidSource = jobName.contains("build-android") || + androidProductHints.any { hint -> jobName.contains(hint) || appName == hint } + if (!hasAndroidSource || nonAndroidPlatformHints.any(jobName::contains)) return false + return apkJobNameHints.any(jobName::contains) || jobName.contains("apk") + } + fun getDownloadedFile(artifactName: String, taskId: String): File? { if (taskId.isBlank()) return null val taskSpecificDir = File(cacheManager.getCacheDir(TREEHERDER), taskId) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt index 9e3d8ac..08f97a5 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt @@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt new file mode 100644 index 0000000..420adec --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt @@ -0,0 +1,27 @@ +package org.mozilla.tryfox.ui.screens + +/** The two Treeherder request types accepted by the unified build search. */ +sealed interface SearchQuery { + val value: String + + data class Email(override val value: String) : SearchQuery + data class Revision(override val value: String) : SearchQuery +} + +/** + * Keeps query validation independent of Compose and networking. An @ is deliberately + * treated strictly: it must form an email rather than accidentally becoming a revision. + */ +object SearchQueryClassifier { + private val emailPattern = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + + fun classify(input: String): Result { + val value = input.trim() + return when { + value.isBlank() -> Result.failure(IllegalArgumentException("Enter an email or revision.")) + '@' !in value -> Result.success(SearchQuery.Revision(value)) + emailPattern.matches(value) -> Result.success(SearchQuery.Email(value)) + else -> Result.failure(IllegalArgumentException("Enter a valid email address or a revision without @.")) + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt index 13eb97b..4ea8a56 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt @@ -66,6 +66,7 @@ import org.mozilla.tryfox.TryFoxViewModel import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.AppCard import org.mozilla.tryfox.ui.composables.BinButton +import org.mozilla.tryfox.ui.composables.ErrorState import org.mozilla.tryfox.ui.composables.PushCommentCard // Project name mappings @@ -82,15 +83,22 @@ internal const val TREEHERDER_RESULTS_HEADER_TAG = "treeherder_results_header" @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TryFoxMainScreen( +fun SearchScreen( tryFoxViewModel: TryFoxViewModel, deepLinkProject: String?, deepLinkRevision: String?, onNavigateUp: () -> Unit, + onSearchEmail: (project: String, email: String) -> Unit = { _, _ -> }, ) { val cacheState by tryFoxViewModel.cacheState.collectAsState() val isDownloading by tryFoxViewModel.isDownloadingAnyFile.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current + var queryValidationError by remember(deepLinkRevision) { + mutableStateOf( + deepLinkRevision?.takeIf { SearchQueryClassifier.classify(it).isFailure } + ?.let { "Enter a valid email address or a revision without @." }, + ) + } LaunchedEffect(Unit) { tryFoxViewModel.checkCacheStatus() @@ -109,12 +117,13 @@ fun TryFoxMainScreen( } LaunchedEffect(deepLinkProject, deepLinkRevision) { - if (!deepLinkRevision.isNullOrBlank()) { + val revisionQuery = SearchQueryClassifier.classify(deepLinkRevision.orEmpty()).getOrNull() as? SearchQuery.Revision + if (revisionQuery != null) { val resolvedProject = deepLinkProject ?: "try" val projectChanged = tryFoxViewModel.selectedProject != resolvedProject - val revisionChanged = tryFoxViewModel.revision != deepLinkRevision + val revisionChanged = tryFoxViewModel.revision != revisionQuery.value if (projectChanged || revisionChanged) { - tryFoxViewModel.setRevisionFromDeepLinkAndSearch(resolvedProject, deepLinkRevision) + tryFoxViewModel.setRevisionFromDeepLinkAndSearch(resolvedProject, revisionQuery.value) } } } @@ -170,8 +179,23 @@ fun TryFoxMainScreen( selectedProject = tryFoxViewModel.selectedProject, onProjectSelected = { actualProjectValue -> tryFoxViewModel.updateSelectedProject(actualProjectValue) }, revision = tryFoxViewModel.revision, - onRevisionChange = { tryFoxViewModel.updateRevision(it) }, - onSearchClick = { tryFoxViewModel.searchJobsAndArtifacts() }, + onRevisionChange = { + queryValidationError = null + tryFoxViewModel.updateRevision(it) + }, + onSearchClick = { + when (val query = SearchQueryClassifier.classify(tryFoxViewModel.revision).getOrNull()) { + is SearchQuery.Email -> { + queryValidationError = null + onSearchEmail(tryFoxViewModel.selectedProject, query.value) + } + is SearchQuery.Revision -> { + queryValidationError = null + tryFoxViewModel.searchJobsAndArtifacts() + } + null -> queryValidationError = "Enter a valid email address or a revision without @." + } + }, isLoading = tryFoxViewModel.isLoading, ) } @@ -183,6 +207,8 @@ fun TryFoxMainScreen( } } + queryValidationError?.let { item { ErrorState(errorMessage = it) } } + tryFoxViewModel.relevantPushComment?.let { comment -> val pushTimestamp = tryFoxViewModel.relevantPushTimestamp if ((comment.isNotBlank() || tryFoxViewModel.relevantPushAuthor != null) && pushTimestamp != null) { @@ -234,6 +260,16 @@ fun TryFoxMainScreen( } } +/** Backwards-compatible name retained for callers and existing UI tests. */ +@Composable +fun TryFoxMainScreen( + tryFoxViewModel: TryFoxViewModel, + deepLinkProject: String?, + deepLinkRevision: String?, + onNavigateUp: () -> Unit, + onSearchEmail: (project: String, email: String) -> Unit = { _, _ -> }, +) = SearchScreen(tryFoxViewModel, deepLinkProject, deepLinkRevision, onNavigateUp, onSearchEmail) + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchSection( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e2558f5..28611d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,6 @@ TryFox - Profile - Search Treeherder + Search builds History Scan QR code Receive from desktop @@ -9,10 +8,10 @@ Loading initial app data... An unknown error occurred. Back - Search Fenix Artifacts + Search builds Project - Revision - Revision hash... + Email or revision + Email address or revision... Found %1$d job(s) matching criteria: No jobs found matching the specified criteria for this push. Search @@ -44,17 +43,17 @@ Not supported by your device (%1$d) No APKs found for this date. No APKs found for this release. - Profile + Search builds Loading pushes… No pushes found for this author. Job: Task ID: Compatible APKs No compatible APKs found for this job. - Try pushes - User email - Search pushes by email - Enter a user email and tap search to find pushes. + Search builds + Email or revision + Search builds + Enter an email or revision and tap search. Clear email field History No history yet diff --git a/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt b/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt index d3d983f..bc2b239 100644 --- a/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt @@ -34,12 +34,12 @@ class AppDeepLinkRouteMapperTest { } @Test - fun `maps scanned author link to encoded profile route`() { + fun `maps scanned author link to encoded unified search route`() { val route = AppDeepLinkRouteMapper.routeFor( "tryfox://jobs?author=try%2Buser%40mozilla.com", ) - assertEquals("profile_by_email?email=try%2Buser%40mozilla.com", route) + assertEquals("treeherder_search/try/try%2Buser%40mozilla.com", route) } @Test 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 6648013..ce54f01 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 @@ -22,7 +22,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir -import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.quality.Strictness @@ -39,13 +38,13 @@ import org.mozilla.tryfox.data.repositories.FenixReleaseRepository import org.mozilla.tryfox.data.repositories.FocusNightlyRepository import org.mozilla.tryfox.data.repositories.FocusReleaseRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository -import org.mozilla.tryfox.model.AppState -import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.model.AppState +import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult 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 8fd8aee..d541575 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 @@ -17,6 +17,7 @@ import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import org.mozilla.tryfox.data.Artifact import org.mozilla.tryfox.data.ArtifactsResponse +import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.FakeHistoryRepository import org.mozilla.tryfox.data.JobDetails import org.mozilla.tryfox.data.NetworkResult @@ -33,7 +34,6 @@ import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState -import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -94,6 +94,20 @@ class ProfileViewModelTest { } } + @Test + fun `author search forwards selected project and rejects malformed emails`() = runTest { + val projectViewModel = createViewModel(authorEmail = null, project = "mozilla-central") + fenixRepository.lastAuthorProject = null + projectViewModel.updateAuthorEmail("not-an-email@") + projectViewModel.searchByAuthor() + assertEquals(null, fenixRepository.lastAuthorProject) + + projectViewModel.updateAuthorEmail("test@example.com") + projectViewModel.searchByAuthor() + advanceUntilIdle() + assertEquals("mozilla-central", fenixRepository.lastAuthorProject) + } + @Test fun `downloadArtifact should enqueue WorkManager work and reflect persisted progress`() = runTest { val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() @@ -188,7 +202,7 @@ class ProfileViewModelTest { assertEquals(failureMessage, failedState.message) } - private fun createViewModel(authorEmail: String?): ProfileViewModel = + private fun createViewModel(authorEmail: String?, project: String = "try"): ProfileViewModel = ProfileViewModel( fenixRepository = fenixRepository, userDataRepository = userDataRepository, @@ -197,6 +211,7 @@ class ProfileViewModelTest { historyRepository = historyRepository, downloadCoordinator = downloadCoordinator, authorEmail = authorEmail, + project = project, ) private fun stubProfileSearch() { @@ -232,7 +247,7 @@ class ProfileViewModelTest { results = listOf( JobDetails( appName = "Fenix Nightly", - jobName = "Android ARM64", + jobName = "Fenix Android APK", jobSymbol = "Bs", taskId = "task-123", ), @@ -250,11 +265,12 @@ class ProfileViewModelTest { contentType = "application/vnd.android.package-archive", ), ), - ) + ), ) } private class FakeTreeherderRepository : TreeherderRepository { + var lastAuthorProject: String? = null var pushesByAuthorResult: NetworkResult = NetworkResult.Error("Not stubbed") var jobsForPushResult: NetworkResult = @@ -270,6 +286,14 @@ class ProfileViewModelTest { override suspend fun getPushesByAuthor(author: String): NetworkResult = pushesByAuthorResult + override suspend fun getPushesByAuthor( + project: String, + author: String, + ): NetworkResult { + lastAuthorProject = project + return pushesByAuthorResult + } + override suspend fun getJobsForPush(pushId: Int): NetworkResult = jobsForPushResult diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt new file mode 100644 index 0000000..d8ea7c3 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt @@ -0,0 +1,20 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SearchQueryClassifierTest { + @Test fun `classifies trimmed email`() { + assertEquals(SearchQuery.Email("person+try@mozilla.org"), SearchQueryClassifier.classify(" person+try@mozilla.org ").getOrThrow()) + } + + @Test fun `classifies revision`() { + assertEquals(SearchQuery.Revision("abc123"), SearchQueryClassifier.classify(" abc123 ").getOrThrow()) + } + + @Test fun `rejects blank and malformed at input`() { + assertTrue(SearchQueryClassifier.classify(" ").isFailure) + assertTrue(SearchQueryClassifier.classify("person@mozilla").isFailure) + } +} From d632570d14744c60547693293ba5650f0d5e577d Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 30 Jul 2026 16:45:53 +0200 Subject: [PATCH 04/24] Refine unified search layout --- .../tryfox/ui/composables/DownloadButton.kt | 7 +- .../tryfox/ui/screens/ProfileScreen.kt | 13 ++-- .../tryfox/ui/screens/TreeherderApksScreen.kt | 72 ++++++++----------- 3 files changed, 41 insertions(+), 51 deletions(-) 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 b8bba22..a9bb1f7 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 @@ -23,12 +23,13 @@ fun DownloadButton( downloadState: DownloadState, onDownloadClick: () -> Unit, onInstallClick: (File) -> Unit, + modifier: Modifier = Modifier, ) { when (downloadState) { is DownloadState.Downloaded -> { Button( onClick = { onInstallClick(downloadState.file) }, - modifier = Modifier.testTag("action_button_install_ready"), // Tag for Install state + modifier = modifier.testTag("action_button_install_ready"), // Tag for Install state ) { Text(stringResource(id = R.string.download_button_install)) } @@ -47,7 +48,7 @@ fun DownloadButton( Button( onClick = {}, enabled = false, - modifier = Modifier.testTag("action_button_downloading"), // Tag for Downloading state + modifier = modifier.testTag("action_button_downloading"), // Tag for Downloading state ) { if (downloadState.isIndeterminate) { CircularProgressIndicator( @@ -72,7 +73,7 @@ fun DownloadButton( is DownloadState.NotDownloaded, is DownloadState.DownloadFailed -> { Button( onClick = onDownloadClick, - modifier = Modifier.testTag("action_button_download_initial"), // Tag for Download state + modifier = modifier.testTag("action_button_download_initial"), // Tag for Download state ) { Text(stringResource(id = R.string.download_button_download)) } 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 1506a96..e7578ca 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 @@ -60,6 +60,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState @@ -141,11 +142,6 @@ private fun UserSearchCard( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = stringResource(id = R.string.profile_screen_search_card_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) ExposedDropdownMenuBox( expanded = projectMenuExpanded, onExpandedChange = { projectMenuExpanded = !projectMenuExpanded }, @@ -416,15 +412,18 @@ private fun JobCard( Spacer(Modifier.width(8.dp)) Text( text = displayJobName, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.Bold, + softWrap = true, + modifier = Modifier.weight(1f), ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) apk?.let { DownloadButton( downloadState = it.downloadState, onDownloadClick = { profileViewModel.downloadArtifact(it) }, onInstallClick = { file -> profileViewModel.installApk(file) }, + modifier = Modifier.width(128.dp), ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt index 4ea8a56..3852330 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt @@ -5,14 +5,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -134,7 +132,7 @@ fun SearchScreen( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text(stringResource(id = R.string.app_name)) }, + title = { Text(stringResource(id = R.string.profile_screen_title)) }, navigationIcon = { IconButton(onClick = onNavigateUp) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.common_back_button_description)) @@ -290,11 +288,35 @@ fun SearchSection( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = stringResource(id = R.string.treeherder_apks_search_artifacts_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth(), + ) { + TextField( + value = projectActualToDisplayMap[selectedProject] ?: selectedProject, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(id = R.string.treeherder_apks_project_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + projectDisplayOptions.forEach { displayKey -> + DropdownMenuItem( + text = { Text(displayKey) }, + onClick = { + onProjectSelected(projectDisplayToActualMap[displayKey] ?: displayKey) + expanded = false + }, + ) + } + } + } Row( modifier = Modifier @@ -302,44 +324,12 @@ fun SearchSection( .height(IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically, ) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.weight(0.5f).fillMaxHeight(), - ) { - TextField( - value = projectActualToDisplayMap[selectedProject] ?: selectedProject, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(id = R.string.treeherder_apks_project_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - projectDisplayOptions.forEach { displayKey -> - DropdownMenuItem( - text = { Text(displayKey) }, - onClick = { - onProjectSelected(projectDisplayToActualMap[displayKey] ?: displayKey) - expanded = false - }, - ) - } - } - } - - Spacer(Modifier.width(8.dp)) - OutlinedTextField( value = revision, onValueChange = onRevisionChange, label = { Text(stringResource(id = R.string.treeherder_apks_revision_label)) }, placeholder = { Text(stringResource(id = R.string.treeherder_apks_revision_placeholder)) }, - modifier = Modifier.weight(0.5f).fillMaxHeight(), + modifier = Modifier.weight(1f).fillMaxHeight(), singleLine = true, shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp), // Matched ProfileScreen colors = OutlinedTextFieldDefaults.colors(), From aa313d51d07e894822d808471b5659511928ce19 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 30 Jul 2026 19:21:29 +0200 Subject: [PATCH 05/24] Redesign email search results --- .../tryfox/ui/screens/ProfileScreenTest.kt | 19 +- .../org/mozilla/tryfox/TryFoxViewModel.kt | 25 +- .../mozilla/tryfox/ui/composables/AppIcon.kt | 30 +- .../tryfox/ui/composables/PushCommentCard.kt | 111 +++----- .../tryfox/ui/screens/ProfileScreen.kt | 151 +++++----- .../tryfox/ui/screens/ProfileViewModel.kt | 47 +++- .../tryfox/ui/screens/PushTimeFormatter.kt | 25 ++ .../java/org/mozilla/tryfox/util/Consts.kt | 2 + .../res/drawable/ic_fenix_beta_foreground.xml | 221 +++++++++++++++ .../drawable/ic_fenix_debug_foreground.xml | 125 +++++++++ .../drawable/ic_fenix_nightly_foreground.xml | 205 ++++++++++++++ .../res/drawable/ic_focus_beta_foreground.xml | 262 ++++++++++++++++++ .../drawable/ic_focus_debug_foreground.png | Bin 0 -> 29236 bytes .../drawable/ic_focus_nightly_foreground.xml | 253 +++++++++++++++++ app/src/main/res/values/strings.xml | 4 + .../tryfox/ui/screens/JobIconNameTest.kt | 31 +++ .../tryfox/ui/screens/ProfileViewModelTest.kt | 40 +++ .../ui/screens/PushTimeFormatterTest.kt | 53 ++++ doc/imported-app-icons.md | 14 + 19 files changed, 1458 insertions(+), 160 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt create mode 100644 app/src/main/res/drawable/ic_fenix_beta_foreground.xml create mode 100644 app/src/main/res/drawable/ic_fenix_debug_foreground.xml create mode 100644 app/src/main/res/drawable/ic_fenix_nightly_foreground.xml create mode 100644 app/src/main/res/drawable/ic_focus_beta_foreground.xml create mode 100644 app/src/main/res/drawable/ic_focus_debug_foreground.png create mode 100644 app/src/main/res/drawable/ic_focus_nightly_foreground.xml create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt create mode 100644 doc/imported-app-icons.md 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 e5476ef..759b388 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 @@ -6,6 +6,7 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -75,12 +76,22 @@ class ProfileScreenTest { .fetchSemanticsNodes().isNotEmpty() } - val timestampChips = composeTestRule - .onAllNodesWithTag("push_timestamp_chip_fakerevision123", useUnmergedTree = true) + val searchResultsHeading = composeTestRule + .onAllNodesWithTag("email_search_results_heading", useUnmergedTree = true) .fetchSemanticsNodes() assertTrue( - "Expected a push timestamp chip to be rendered for Try push entry", - timestampChips.isNotEmpty(), + "Expected an email-search result count heading", + searchResultsHeading.isNotEmpty(), + ) + composeTestRule.onNodeWithTag("email_search_results_heading").assert(hasText("1 push found")) + composeTestRule.onNodeWithText("Build Fenix for arm64-v8a").assert(hasText("Build Fenix for arm64-v8a")) + + val pushCards = composeTestRule + .onAllNodesWithTag("email_search_push_fakerevision123", useUnmergedTree = true) + .fetchSemanticsNodes() + assertTrue( + "Expected a compact push card to be rendered for the Try push entry", + pushCards.isNotEmpty(), ) composeTestRule.onNodeWithTag(downloadButtonInitialTag, useUnmergedTree = true) diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 3efc68d..038fc0a 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -35,6 +35,8 @@ import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel import org.mozilla.tryfox.ui.models.JobDetailsUiModel +import org.mozilla.tryfox.ui.screens.isPerfAgainRetry +import org.mozilla.tryfox.ui.screens.selectPreferredPushComment import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -211,27 +213,30 @@ class TryFoxViewModel( when (val revisionResult = fenixRepository.getPushByRevision(selectedProject, revision)) { is NetworkResult.Success -> { val pushData = revisionResult.data - var foundComment: String? = null val firstPushResult = pushData.results.firstOrNull() if (firstPushResult != null) { - for (revDetail in firstPushResult.revisions) { - if (revDetail.comments.startsWith("Bug ")) { - foundComment = revDetail.comments - break + val precedingPushRevisions = if (isPerfAgainRetry(firstPushResult.revisions)) { + when (val authorPushes = fenixRepository.getPushesByAuthor(selectedProject, firstPushResult.author)) { + is NetworkResult.Success -> { + val pushIndex = authorPushes.data.results.indexOfFirst { it.id == firstPushResult.id } + authorPushes.data.results + .take(pushIndex.coerceAtLeast(0)) + .asReversed() + .map { it.revisions } + } + is NetworkResult.Error -> emptyList() } + } else { + emptyList() } - if (foundComment == null) { - foundComment = firstPushResult.revisions.firstOrNull()?.comments ?: "No comment" - } + relevantPushComment = selectPreferredPushComment(firstPushResult.revisions, precedingPushRevisions) relevantPushAuthor = firstPushResult.author relevantPushTimestamp = firstPushResult.pushTimestamp } else { relevantPushAuthor = null relevantPushTimestamp = null } - relevantPushComment = foundComment - if (pushData.results.isEmpty()) { errorMessage = "No push found for project: $selectedProject, revision: $revision" isLoading = false diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt index d6ced33..50e9d17 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt @@ -11,19 +11,41 @@ import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_NIGHTLY import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_NIGHTLY import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER @Composable -fun AppIcon(appName: String, modifier: Modifier = Modifier) { +fun AppIcon( + appName: String, + modifier: Modifier = Modifier, + useSearchResultVariant: Boolean = false, +) { val (iconResId, contentDescResId) = when { appName == REFERENCE_BROWSER -> R.drawable.ic_reference_browser to R.string.app_icon_reference_browser_description - appName == FENIX -> R.drawable.ic_fenix_nightly to R.string.app_icon_firefox_nightly_description - appName == FENIX_BETA -> R.drawable.ic_firefox_beta to R.string.app_icon_firefox_description + appName == FENIX -> { + (if (useSearchResultVariant) R.drawable.ic_fenix_debug_foreground else R.drawable.ic_fenix_nightly) to + R.string.app_icon_firefox_nightly_description + } + appName == FENIX_NIGHTLY -> { + (if (useSearchResultVariant) R.drawable.ic_fenix_nightly_foreground else R.drawable.ic_fenix_nightly) to + R.string.app_icon_firefox_nightly_description + } + appName == FENIX_BETA -> { + (if (useSearchResultVariant) R.drawable.ic_fenix_beta_foreground else R.drawable.ic_firefox_beta) to + R.string.app_icon_firefox_description + } appName == FENIX_RELEASE -> R.drawable.ic_firefox to R.string.app_icon_firefox_description - appName == FOCUS -> R.drawable.ic_focus to R.string.app_icon_focus_description + appName == FOCUS -> { + (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground else R.drawable.ic_focus) to + R.string.app_icon_focus_description + } + appName == FOCUS_NIGHTLY -> R.drawable.ic_focus_nightly_foreground to R.string.app_icon_focus_description + appName == FOCUS_BETA -> R.drawable.ic_focus_beta_foreground to R.string.app_icon_focus_description appName == FOCUS_RELEASE -> R.drawable.ic_focus to R.string.app_icon_focus_description else -> { println("Titouan - Error - $appName") diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt index 1580edb..ee77960 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag // Added import +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles @@ -50,87 +51,67 @@ private data class LinkableSpan( val url: String, ) -@OptIn(FormatStringsInDatetimeFormats::class) @Composable -fun PushCommentCard( - title: String? = null, - comment: String, - author: String?, - revision: String, - pushTimestamp: Long, -) { - val urlPattern = remember { - Pattern.compile( - "(https?://|www\\.)" + // Scheme or www. - "([\\da-zA-Z.-]+)" + // Domain name - "(\\.[a-zA-Z.]{2,6})" + // TLD - "([/\\w .-]*)*/?", // Path and query +fun rememberLinkedPushComment(comment: String): AnnotatedString { + val linkColor = MaterialTheme.colorScheme.primary + return remember(comment, linkColor) { + val urlPattern = Pattern.compile( + "(https?://|www\\.)" + + "([\\da-zA-Z.-]+)" + + "(\\.[a-zA-Z.]{2,6})" + + "([/\\w .-]*)*/?", ) - } - val bugPattern = remember { - Pattern.compile("Bug\\s*(\\d+)", Pattern.CASE_INSENSITIVE) - } - - val linkSpans = remember(comment) { - // Recalculate if comment changes + val bugPattern = Pattern.compile("Bug\\s*(\\d+)", Pattern.CASE_INSENSITIVE) val spans = mutableListOf() - val urlMatcher = urlPattern.matcher(comment) while (urlMatcher.find()) { - spans.add( - LinkableSpan( - start = urlMatcher.start(), - end = urlMatcher.end(), - displayText = urlMatcher.group(0) ?: "", - url = urlMatcher.group(0) ?: "", - ), - ) + spans += LinkableSpan(urlMatcher.start(), urlMatcher.end(), urlMatcher.group(0) ?: "", urlMatcher.group(0) ?: "") } - val bugMatcher = bugPattern.matcher(comment) while (bugMatcher.find()) { - val bugNumber = bugMatcher.group(1) - if (bugNumber != null) { - spans.add( - LinkableSpan( - start = bugMatcher.start(), - end = bugMatcher.end(), - displayText = bugMatcher.group(0) ?: "", - url = "https://bugzilla.mozilla.org/show_bug.cgi?id=$bugNumber", - ), + bugMatcher.group(1)?.let { bugNumber -> + spans += LinkableSpan( + bugMatcher.start(), + bugMatcher.end(), + bugMatcher.group(0) ?: "", + "https://bugzilla.mozilla.org/show_bug.cgi?id=$bugNumber", ) } } spans.sortBy { it.start } - spans - } - - val annotatedString = buildAnnotatedString { - var lastMatchEnd = 0 - linkSpans.forEach { span -> - if (span.start > lastMatchEnd) { - append(comment.substring(lastMatchEnd, span.start)) - } - withLink( - link = LinkAnnotation.Url( - url = span.url, - styles = TextLinkStyles( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - textDecoration = TextDecoration.Underline, + buildAnnotatedString { + var lastMatchEnd = 0 + spans.forEach { span -> + if (span.start > lastMatchEnd) append(comment.substring(lastMatchEnd, span.start)) + withLink( + LinkAnnotation.Url( + span.url, + TextLinkStyles( + style = SpanStyle( + color = linkColor, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline, + ), + ), ), - ), - ), - ) { - append(span.displayText) + ) { append(span.displayText) } + lastMatchEnd = span.end } - lastMatchEnd = span.end - } - if (lastMatchEnd < comment.length) { - append(comment.substring(lastMatchEnd)) + if (lastMatchEnd < comment.length) append(comment.substring(lastMatchEnd)) } } +} + +@OptIn(FormatStringsInDatetimeFormats::class) +@Composable +fun PushCommentCard( + title: String? = null, + comment: String, + author: String?, + revision: String, + pushTimestamp: Long, +) { + val annotatedString = rememberLinkedPushComment(comment) Card( modifier = Modifier 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 e7578ca..5b71bad 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,13 +25,14 @@ 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.DropdownMenuItem -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -57,6 +58,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -69,11 +71,16 @@ 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.composables.rememberLinkedPushComment import org.mozilla.tryfox.ui.models.JobDetailsUiModel +import org.mozilla.tryfox.ui.models.PushUiModel import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_NIGHTLY +import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_NIGHTLY import org.mozilla.tryfox.util.FOCUS_RELEASE import java.util.Locale @@ -88,6 +95,21 @@ private fun formatAppNameForDisplay(appName: String): String { } } +internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String { + val normalizedJobName = jobName.lowercase(Locale.ROOT) + return when { + "focus-debug" in normalizedJobName -> FOCUS + "focus-nightly" in normalizedJobName -> FOCUS_NIGHTLY + "focus-beta" in normalizedJobName -> FOCUS_BETA + "focus" in normalizedJobName -> FOCUS + "fenix-debug" in normalizedJobName -> FENIX + "fenix-nightly" in normalizedJobName -> FENIX_NIGHTLY + "fenix-release" in normalizedJobName -> FENIX_RELEASE + "fenix-beta" in normalizedJobName -> FENIX_BETA + else -> fallbackAppName + } +} + @Composable private fun ProfileSearchButton( onClick: () -> Unit, @@ -332,32 +354,21 @@ fun ProfileScreen( pushes.isNotEmpty() -> { LazyColumn( contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { errorMessage?.let { message -> item { ErrorState(errorMessage = message) } } - items(pushes, key = { push -> - push.pushComment + push.author + (push.jobs.firstOrNull()?.taskId ?: "") - }) { push -> - ElevatedCard( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - PushCommentCard( - comment = push.pushComment, - author = push.author, - revision = push.revision ?: "unknown_revision", - pushTimestamp = push.pushTimestamp, - ) - push.jobs.forEach { job -> - JobCard(job = job, profileViewModel = profileViewModel) - } - } - } + item { + Text( + text = pluralStringResource(R.plurals.profile_screen_pushes_found, pushes.size, pushes.size), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.testTag("email_search_results_heading"), + ) + } + items(pushes, key = { push -> push.revision ?: push.pushComment }) { push -> + EmailPushCard(push = push, profileViewModel = profileViewModel) } } } @@ -383,50 +394,62 @@ fun ProfileScreen( } @Composable -private fun JobCard( - job: JobDetailsUiModel, - profileViewModel: ProfileViewModel, -) { - val appNameForIconAndLogic = job.appName - val displayJobName = job.jobName.ifBlank { formatAppNameForDisplay(appNameForIconAndLogic) } - val apk = remember(job.artifacts) { - job.artifacts.firstOrNull { it.abi.isSupported } +private fun EmailPushCard(push: PushUiModel, profileViewModel: ProfileViewModel) { + val commitTitle = remember(push.pushComment) { + push.pushComment.lineSequence().firstOrNull().orEmpty().trim() } - - androidx.compose.material3.Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + Card( + modifier = Modifier.fillMaxWidth().testTag("email_search_push_${push.revision}"), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - AppIcon(appName = appNameForIconAndLogic, modifier = Modifier.size(40.dp)) - Spacer(Modifier.width(8.dp)) - Text( - text = displayJobName, - style = MaterialTheme.typography.titleMedium.copy(hyphens = Hyphens.Auto), - fontWeight = FontWeight.Bold, - softWrap = true, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - apk?.let { - DownloadButton( - downloadState = it.downloadState, - onDownloadClick = { profileViewModel.downloadArtifact(it) }, - onInstallClick = { file -> profileViewModel.installApk(file) }, - modifier = Modifier.width(128.dp), - ) - } + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = rememberLinkedPushComment(commitTitle), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "${formatRelativePushTime(push.pushTimestamp)} · ${push.author}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + HorizontalDivider(modifier = Modifier.padding(top = 14.dp)) + push.jobs.forEachIndexed { index, job -> + if (index > 0) HorizontalDivider() + CompactApkRow(job = job, profileViewModel = profileViewModel) } } } } + +@Composable +private fun CompactApkRow(job: JobDetailsUiModel, profileViewModel: ProfileViewModel) { + val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } + val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppIcon( + appName = appIconName, + modifier = Modifier.size(34.dp), + useSearchResultVariant = true, + ) + Text( + text = job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }, + style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + apk?.let { + DownloadButton( + downloadState = it.downloadState, + onDownloadClick = { profileViewModel.downloadArtifact(it) }, + onInstallClick = profileViewModel::installApk, + modifier = Modifier.width(112.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 ee0ec4c..7639afd 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 @@ -16,6 +16,7 @@ import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.RevisionDetail import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager @@ -35,6 +36,31 @@ import org.mozilla.tryfox.util.TREEHERDER import java.io.File import java.util.concurrent.atomic.AtomicInteger +private fun bugComment(revisions: List): String? { + return revisions.firstOrNull { revision -> + revision.comments.trimStart().startsWith("Bug ", ignoreCase = true) || + revision.comments.trimStart().startsWith("[Bug ", ignoreCase = true) + }?.comments +} + +internal fun selectPreferredPushComment( + revisions: List, + precedingPushRevisions: List> = emptyList(), +): String { + val ownComment = bugComment(revisions) ?: revisions.firstOrNull()?.comments.orEmpty().ifBlank { "No comment" } + val isPerfAgainRetry = ownComment.contains("Pushed via `mach try perf-again`", ignoreCase = true) + return if (isPerfAgainRetry) { + precedingPushRevisions.firstNotNullOfOrNull(::bugComment) ?: ownComment + } else { + ownComment + } +} + +internal fun isPerfAgainRetry(revisions: List): Boolean { + val firstComment = revisions.firstOrNull()?.comments.orEmpty() + return firstComment.contains("Pushed via `mach try perf-again`", ignoreCase = true) +} + /** * ViewModel for the Profile screen, responsible for fetching pushes and artifacts by author, managing downloads, and handling user interactions. * @@ -184,7 +210,7 @@ class ProfileViewModel( TAG, ) { "getPushesByAuthor success, processing ${result.data.results.size} pushes" } val failedPushCount = AtomicInteger(0) - val pushesWithJobsAndArtifacts = result.data.results.map { pushResult -> + val pushesWithJobsAndArtifacts = result.data.results.mapIndexed { pushIndex, pushResult -> async { val jobsResult = fenixRepository.getJobsForPush(pushResult.id) if (jobsResult is NetworkResult.Success) { @@ -216,19 +242,14 @@ class ProfileViewModel( }.awaitAll().filterNotNull() if (jobsWithArtifacts.isNotEmpty()) { - var determinedPushComment: String? = null - for (revDetail in pushResult.revisions) { - if (revDetail.comments.startsWith("Bug ")) { - determinedPushComment = revDetail.comments - break - } - } - if (determinedPushComment == null) { - determinedPushComment = pushResult.revisions.firstOrNull()?.comments - ?: "No comment" - } PushUiModel( - pushComment = determinedPushComment, + pushComment = selectPreferredPushComment( + revisions = pushResult.revisions, + precedingPushRevisions = result.data.results + .take(pushIndex) + .asReversed() + .map { it.revisions }, + ), author = pushResult.author, jobs = jobsWithArtifacts, revision = pushResult.revision, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt new file mode 100644 index 0000000..40f9dab --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt @@ -0,0 +1,25 @@ +package org.mozilla.tryfox.ui.screens + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale + +internal fun formatRelativePushTime( + pushTimestampSeconds: Long, + nowMillis: Long = System.currentTimeMillis(), + zoneId: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + val pushTime = Instant.ofEpochSecond(pushTimestampSeconds).atZone(zoneId) + val today = Instant.ofEpochMilli(nowMillis).atZone(zoneId).toLocalDate() + val date = pushTime.toLocalDate() + val time = pushTime.toLocalTime().truncatedTo(ChronoUnit.MINUTES) + .format(DateTimeFormatter.ofPattern("HH:mm", locale)) + return when { + date == today -> "Today at $time" + date == today.minusDays(1) -> "Yesterday at $time" + else -> "${date.format(DateTimeFormatter.ofPattern("MMM d", locale))} at $time" + } +} 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 736e68a..7a3e2f2 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -6,6 +6,8 @@ const val FENIX_BETA = "fenix-beta" const val FENIX_NIGHTLY = "fenix-nightly" const val FOCUS = "focus" const val FOCUS_RELEASE = "focus-release" +const val FOCUS_NIGHTLY = "focus-nightly" +const val FOCUS_BETA = "focus-beta" const val REFERENCE_BROWSER = "reference-browser" const val TREEHERDER = "treeherder" const val TRYFOX = "TryFox" diff --git a/app/src/main/res/drawable/ic_fenix_beta_foreground.xml b/app/src/main/res/drawable/ic_fenix_beta_foreground.xml new file mode 100644 index 0000000..d08cf87 --- /dev/null +++ b/app/src/main/res/drawable/ic_fenix_beta_foreground.xml @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_fenix_debug_foreground.xml b/app/src/main/res/drawable/ic_fenix_debug_foreground.xml new file mode 100644 index 0000000..ca6a894 --- /dev/null +++ b/app/src/main/res/drawable/ic_fenix_debug_foreground.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_fenix_nightly_foreground.xml b/app/src/main/res/drawable/ic_fenix_nightly_foreground.xml new file mode 100644 index 0000000..77e0035 --- /dev/null +++ b/app/src/main/res/drawable/ic_fenix_nightly_foreground.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_focus_beta_foreground.xml b/app/src/main/res/drawable/ic_focus_beta_foreground.xml new file mode 100644 index 0000000..96508c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_focus_beta_foreground.xml @@ -0,0 +1,262 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_focus_debug_foreground.png b/app/src/main/res/drawable/ic_focus_debug_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..4a562102cf11d61faef2a9d93632281cbf53f3f2 GIT binary patch literal 29236 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

nuUpEq*lJnMgCP-6NK$ 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28611d2..e6f8436 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,10 @@ Search builds Loading pushes… No pushes found for this author. + + %1$d push found + %1$d pushes found + Job: Task ID: Compatible APKs diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt new file mode 100644 index 0000000..3329093 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt @@ -0,0 +1,31 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_NIGHTLY +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_NIGHTLY + +class JobIconNameTest { + + @Test + fun `selects the requested app icon from the job name`() { + assertEquals(FOCUS, appIconNameForJob("Focus x86_64 build", "unknown")) + assertEquals(FENIX, appIconNameForJob("fenix-debug arm64-v8a", "unknown")) + assertEquals(FENIX_NIGHTLY, appIconNameForJob("fenix-nightly arm64-v8a", "unknown")) + assertEquals(FENIX_RELEASE, appIconNameForJob("fenix-release arm64-v8a", "unknown")) + assertEquals(FENIX_BETA, appIconNameForJob("fenix-beta arm64-v8a", "unknown")) + assertEquals(FOCUS, appIconNameForJob("focus-debug arm64-v8a", "unknown")) + assertEquals(FOCUS_NIGHTLY, appIconNameForJob("focus-nightly arm64-v8a", "unknown")) + assertEquals(FOCUS_BETA, appIconNameForJob("focus-beta arm64-v8a", "unknown")) + } + + @Test + fun `uses the job app name when no requested marker is present`() { + assertEquals("fenix", appIconNameForJob("Build Fenix for arm64-v8a", "fenix")) + } +} 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 d541575..484d019 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 @@ -94,6 +94,46 @@ class ProfileViewModelTest { } } + @Test + fun `prefers bracketed Bug commit messages over generated try syntax`() { + val generatedTryMessage = "Perf selections= Queries=[ \"\" ]" + val bugMessage = "[Bug 0000000](https://bugzilla.mozilla.org/show_bug.cgi?id=0000000) - Run benchmarks" + val revisions = listOf( + RevisionDetail(1, 1, "try", "author", generatedTryMessage), + RevisionDetail(1, 1, "bug", "author", bugMessage), + ) + + assertEquals(bugMessage, selectPreferredPushComment(revisions)) + } + + @Test + fun `perf-again retry inherits the nearest preceding Bug commit message`() { + val retry = RevisionDetail( + 1, + 1, + "9240aec3d50cab971bbbacab005f3eee0aeb9eb1", + "author", + "Perf selections= Queries=[ \"\" ]\n\nPushed via `mach try perf-again`", + ) + val bugMessage = "Bug 0000000 - Run startup-profile benchmarks on additional Bitbar devices r?#releng-reviewers" + val precedingPush = listOf(RevisionDetail(1, 1, "1fd6", "author", bugMessage)) + + assertEquals(bugMessage, selectPreferredPushComment(listOf(retry), listOf(precedingPush))) + } + + @Test + fun `perf-again retry uses the immediately newer result in Treeherder response order`() { + val retry = RevisionDetail(1, 1, "9240", "author", "Pushed via `mach try perf-again`") + val bugMessage = "Bug 0000000 - Run startup-profile benchmarks on additional Bitbar devices" + val priorPush = listOf(RevisionDetail(1, 1, "1fd6", "author", bugMessage)) + val responseOrder = listOf(priorPush, listOf(retry)) + + assertEquals( + bugMessage, + selectPreferredPushComment(responseOrder[1], responseOrder.take(1).asReversed()), + ) + } + @Test fun `author search forwards selected project and rejects malformed emails`() = runTest { val projectViewModel = createViewModel(authorEmail = null, project = "mozilla-central") diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt new file mode 100644 index 0000000..cce9dc8 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt @@ -0,0 +1,53 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +class PushTimeFormatterTest { + + private val zoneId = ZoneId.of("UTC") + private val locale = Locale.US + private val nowMillis = Instant.parse("2026-01-03T12:00:00Z").toEpochMilli() + + @Test + fun `formats a push from today with a relative label`() { + assertEquals( + "Today at 09:05", + formatRelativePushTime( + pushTimestampSeconds = Instant.parse("2026-01-03T09:05:45Z").epochSecond, + nowMillis = nowMillis, + zoneId = zoneId, + locale = locale, + ), + ) + } + + @Test + fun `formats a push from yesterday with a relative label`() { + assertEquals( + "Yesterday at 09:05", + formatRelativePushTime( + pushTimestampSeconds = Instant.parse("2026-01-02T09:05:45Z").epochSecond, + nowMillis = nowMillis, + zoneId = zoneId, + locale = locale, + ), + ) + } + + @Test + fun `formats older pushes with a date`() { + assertEquals( + "Jan 1 at 09:05", + formatRelativePushTime( + pushTimestampSeconds = Instant.parse("2026-01-01T09:05:45Z").epochSecond, + nowMillis = nowMillis, + zoneId = zoneId, + locale = locale, + ), + ) + } +} diff --git a/doc/imported-app-icons.md b/doc/imported-app-icons.md new file mode 100644 index 0000000..23be711 --- /dev/null +++ b/doc/imported-app-icons.md @@ -0,0 +1,14 @@ +# Imported app icon sources + +The following launcher-icon assets were copied into TryFox from the local Firefox Android checkout. + +| TryFox resource | Source path | +| --- | --- | +| `ic_fenix_debug_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/debug/res/drawable/ic_launcher_foreground.xml` | +| `ic_fenix_nightly_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/nightly/res/drawable/ic_launcher_foreground.xml` | +| `ic_fenix_beta_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/beta/res/drawable/ic_launcher_foreground.xml` | +| `ic_focus_debug_foreground.png` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png` | +| `ic_focus_nightly_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/nightly/res/drawable/ic_launcher_foreground.xml` | +| `ic_focus_beta_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/focusBeta/res/drawable-v24/ic_launcher_foreground.xml` | + +Focus Debug uses its copied transparent foreground directly. From 581a3598adcbba34a5e3c84a07a7447feba8667a Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 00:17:43 +0200 Subject: [PATCH 06/24] Polish search result job presentation --- .../mozilla/tryfox/ui/composables/AppIcon.kt | 39 ++++++++++++---- .../tryfox/ui/screens/ProfileScreen.kt | 19 +++++++- .../res/drawable/ic_focus_beta_foreground.xml | 3 -- .../drawable/ic_focus_debug_foreground_v2.png | Bin 0 -> 66536 bytes .../tryfox/ui/screens/JobNameFormatterTest.kt | 43 ++++++++++++++++++ doc/imported-app-icons.md | 2 +- 6 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 app/src/main/res/drawable/ic_focus_debug_foreground_v2.png create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt index 50e9d17..ab8b03e 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt @@ -1,10 +1,14 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -19,6 +23,8 @@ import org.mozilla.tryfox.util.FOCUS_NIGHTLY import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER +private const val PADDED_FOREGROUND_ICON_SCALE = 1.8f + @Composable fun AppIcon( appName: String, @@ -41,11 +47,17 @@ fun AppIcon( } appName == FENIX_RELEASE -> R.drawable.ic_firefox to R.string.app_icon_firefox_description appName == FOCUS -> { - (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground else R.drawable.ic_focus) to + (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground_v2 else R.drawable.ic_focus) to + R.string.app_icon_focus_description + } + appName == FOCUS_NIGHTLY -> { + (if (useSearchResultVariant) R.drawable.ic_focus_nightly_foreground else R.drawable.ic_focus) to + R.string.app_icon_focus_description + } + appName == FOCUS_BETA -> { + (if (useSearchResultVariant) R.drawable.ic_focus_beta_foreground else R.drawable.ic_focus) to R.string.app_icon_focus_description } - appName == FOCUS_NIGHTLY -> R.drawable.ic_focus_nightly_foreground to R.string.app_icon_focus_description - appName == FOCUS_BETA -> R.drawable.ic_focus_beta_foreground to R.string.app_icon_focus_description appName == FOCUS_RELEASE -> R.drawable.ic_focus to R.string.app_icon_focus_description else -> { println("Titouan - Error - $appName") @@ -54,11 +66,22 @@ fun AppIcon( } if (iconResId != null && contentDescResId != null) { - Image( - painter = painterResource(id = iconResId), - contentDescription = stringResource(id = contentDescResId), - modifier = modifier, - ) + val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_BETA, FOCUS_BETA) + if (isPaddedSearchResultForeground) { + Box(modifier = modifier.clipToBounds()) { + Image( + painter = painterResource(id = iconResId), + contentDescription = stringResource(id = contentDescResId), + modifier = Modifier.fillMaxSize().scale(PADDED_FOREGROUND_ICON_SCALE), + ) + } + } else { + Image( + painter = painterResource(id = iconResId), + contentDescription = stringResource(id = contentDescResId), + modifier = modifier, + ) + } Spacer(modifier = Modifier.width(8.dp)) } } 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 5b71bad..691c415 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 @@ -95,6 +95,23 @@ private fun formatAppNameForDisplay(appName: String): String { } } +private val signingApkJobNamePattern = Regex( + pattern = "signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-firebase)?", + option = RegexOption.IGNORE_CASE, +) + +internal fun formatJobNameForDisplay(jobName: String): String { + val match = signingApkJobNamePattern.matchEntire(jobName.trim()) ?: return jobName + val appName = when (match.groupValues[1].lowercase(Locale.ROOT)) { + FENIX -> "Fenix" + FOCUS -> "Focus" + else -> return jobName + } + val channel = match.groupValues[2].lowercase(Locale.ROOT) + val firebaseSuffix = if (match.groupValues[3].isNotEmpty()) " (firebase)" else "" + return "$appName $channel$firebaseSuffix" +} + internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String { val normalizedJobName = jobName.lowercase(Locale.ROOT) return when { @@ -437,7 +454,7 @@ private fun CompactApkRow(job: JobDetailsUiModel, profileViewModel: ProfileViewM useSearchResultVariant = true, ) Text( - text = job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }, + text = job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), diff --git a/app/src/main/res/drawable/ic_focus_beta_foreground.xml b/app/src/main/res/drawable/ic_focus_beta_foreground.xml index 96508c7..84b58b6 100644 --- a/app/src/main/res/drawable/ic_focus_beta_foreground.xml +++ b/app/src/main/res/drawable/ic_focus_beta_foreground.xml @@ -12,9 +12,6 @@ android:scaleY="0.15398437" android:translateX="14.58" android:translateY="14.58"> - diff --git a/app/src/main/res/drawable/ic_focus_debug_foreground_v2.png b/app/src/main/res/drawable/ic_focus_debug_foreground_v2.png new file mode 100644 index 0000000000000000000000000000000000000000..9718bf4d7bfae5413113d0721de2d90c767dd70c GIT binary patch literal 66536 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

k@PJ-~`R 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 G5BOgoRX-O1 literal 0 HcmV?d00001 diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt new file mode 100644 index 0000000..f4f6595 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt @@ -0,0 +1,43 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class JobNameFormatterTest { + + @Test + fun `formats every signing APK app and channel`() { + val cases = mapOf( + "signing-apk-fenix-debug" to "Fenix debug", + "signing-apk-fenix-nightly" to "Fenix nightly", + "signing-apk-fenix-beta" to "Fenix beta", + "signing-apk-fenix-release" to "Fenix release", + "signing-apk-focus-debug" to "Focus debug", + "signing-apk-focus-nightly" to "Focus nightly", + "signing-apk-focus-beta" to "Focus beta", + "signing-apk-focus-release" to "Focus release", + ) + + cases.forEach { (jobName, expectedDisplayName) -> + assertEquals(expectedDisplayName, formatJobNameForDisplay(jobName)) + } + } + + @Test + fun `formats Firebase signing APK jobs`() { + assertEquals("Fenix nightly (firebase)", formatJobNameForDisplay("signing-apk-fenix-nightly-firebase")) + assertEquals("Focus beta (firebase)", formatJobNameForDisplay("signing-apk-focus-beta-firebase")) + } + + @Test + fun `preserves job names outside the signing APK naming convention`() { + assertEquals("Build Fenix for arm64-v8a", formatJobNameForDisplay("Build Fenix for arm64-v8a")) + assertEquals("signing-apk-fenix-esr", formatJobNameForDisplay("signing-apk-fenix-esr")) + assertEquals("signing-apk-fenix-nightly-firebase-extra", formatJobNameForDisplay("signing-apk-fenix-nightly-firebase-extra")) + } + + @Test + fun `formats signing APK job names without case sensitivity`() { + assertEquals("Focus release", formatJobNameForDisplay("SIGNING-APK-FOCUS-RELEASE")) + } +} diff --git a/doc/imported-app-icons.md b/doc/imported-app-icons.md index 23be711..1d06308 100644 --- a/doc/imported-app-icons.md +++ b/doc/imported-app-icons.md @@ -7,7 +7,7 @@ The following launcher-icon assets were copied into TryFox from the local Firefo | `ic_fenix_debug_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/debug/res/drawable/ic_launcher_foreground.xml` | | `ic_fenix_nightly_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/nightly/res/drawable/ic_launcher_foreground.xml` | | `ic_fenix_beta_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/beta/res/drawable/ic_launcher_foreground.xml` | -| `ic_focus_debug_foreground.png` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png` | +| `ic_focus_debug_foreground_v2.png` | Derived from `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png` with an enlarged foreground and DEV lettering; the source banner height is preserved. | | `ic_focus_nightly_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/nightly/res/drawable/ic_launcher_foreground.xml` | | `ic_focus_beta_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/focusBeta/res/drawable-v24/ic_launcher_foreground.xml` | From f5065938818408ee943daf48dcf9b40214e68e39 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 00:57:46 +0200 Subject: [PATCH 07/24] Refine unified search controls --- .../tryfox/ui/composables/ProjectSelector.kt | 78 +++++++++ .../tryfox/ui/screens/ProfileScreen.kt | 162 ++++++++---------- .../tryfox/ui/screens/TreeherderApksScreen.kt | 105 ++++-------- app/src/main/res/values/strings.xml | 2 +- 4 files changed, 183 insertions(+), 164 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt new file mode 100644 index 0000000..a17243c --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt @@ -0,0 +1,78 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun ProjectSelector( + projects: List, + selectedProject: String, + projectLabel: (String) -> String, + onProjectSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val selectedIndex = projects.indexOf(selectedProject).coerceAtLeast(0) + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(24.dp)) + .padding(4.dp) + .testTag("unified_search_project_input"), + ) { + val segmentWidth = maxWidth / projects.size + val indicatorOffset by animateDpAsState( + targetValue = segmentWidth * selectedIndex, + animationSpec = tween(durationMillis = 220), + label = "project selector indicator offset", + ) + + Box( + modifier = Modifier + .offset(x = indicatorOffset) + .width(segmentWidth) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(20.dp)), + ) + Row(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + projects.forEach { project -> + TextButton( + onClick = { onProjectSelected(project) }, + modifier = Modifier + .width(segmentWidth) + .fillMaxHeight() + .testTag("unified_search_project_$project"), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Text( + text = projectLabel(project), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} 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 691c415..ffed8dd 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 @@ -3,11 +3,9 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -28,20 +26,15 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar @@ -50,9 +43,7 @@ import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -62,6 +53,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R @@ -71,6 +63,7 @@ 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.ProjectSelector import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel @@ -95,6 +88,16 @@ private fun formatAppNameForDisplay(appName: String): String { } } +private fun projectDisplayName(project: String): String { + return when (project) { + "try" -> "try" + "mozilla-central" -> "central" + "mozilla-beta" -> "beta" + "mozilla-release" -> "release" + else -> project + } +} + private val signingApkJobNamePattern = Regex( pattern = "signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-firebase)?", option = RegexOption.IGNORE_CASE, @@ -138,7 +141,7 @@ private fun ProfileSearchButton( onClick = onClick, enabled = enabled, modifier = modifier.testTag("profile_search_button"), - shape = RoundedCornerShape(topStart = 0.dp, bottomStart = 0.dp, topEnd = 8.dp, bottomEnd = 8.dp), + shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), contentPadding = PaddingValues(0.dp), ) { @@ -158,7 +161,7 @@ private fun ProfileSearchButton( } } -@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun UserSearchCard( email: String, @@ -171,90 +174,69 @@ private fun UserSearchCard( ) { val keyboardController = LocalSoftwareKeyboardController.current val projects = listOf("try", "mozilla-central", "mozilla-beta", "mozilla-release") - var projectMenuExpanded by remember { mutableStateOf(false) } - - androidx.compose.material3.Card( + Column( modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + ProjectSelector( + projects = projects, + selectedProject = project, + projectLabel = ::projectDisplayName, + onProjectSelected = onProjectChange, + modifier = Modifier.height(52.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - ExposedDropdownMenuBox( - expanded = projectMenuExpanded, - onExpandedChange = { projectMenuExpanded = !projectMenuExpanded }, - ) { - TextField( - value = project, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(id = R.string.treeherder_apks_project_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = projectMenuExpanded) }, - modifier = Modifier - .menuAnchor(MenuAnchorType.PrimaryNotEditable) - .fillMaxWidth() - .testTag("unified_search_project_input"), - ) - ExposedDropdownMenu( - expanded = projectMenuExpanded, - onDismissRequest = { projectMenuExpanded = false }, - ) { - projects.forEach { candidate -> - DropdownMenuItem( - text = { Text(candidate) }, - onClick = { - onProjectChange(candidate) - projectMenuExpanded = false - }, - ) - } - } - } - Row( + OutlinedTextField( + value = email, + onValueChange = onEmailChange, + placeholder = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = email, - onValueChange = onEmailChange, - label = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, - modifier = Modifier - .weight(1f) - .testTag("profile_email_input"), - singleLine = true, - shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp), - trailingIcon = { - if (email.isNotEmpty()) { - IconButton( - onClick = { onEmailChange("") }, - modifier = Modifier.testTag("profile_email_clear_button"), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.profile_screen_clear_email_description), - ) - } + .weight(1f) + .height(52.dp) + .testTag("profile_email_input"), + singleLine = true, + shape = RoundedCornerShape(20.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (email.isNotEmpty()) { + IconButton( + onClick = { onEmailChange("") }, + modifier = Modifier.testTag("profile_email_clear_button"), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.profile_screen_clear_email_description), + ) } - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { - onSearchClick() // Perform the original search action - keyboardController?.hide() - }), - ) - ProfileSearchButton( - onClick = { - onSearchClick() // Perform the original search action - keyboardController?.hide() - }, - enabled = !isLoading && email.isNotBlank(), - isLoading = isLoading, - modifier = Modifier.fillMaxHeight().padding(top = 8.dp), - ) - } + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions(onSearch = { + onSearchClick() + keyboardController?.hide() + }), + ) + ProfileSearchButton( + onClick = { + onSearchClick() + keyboardController?.hide() + }, + enabled = !isLoading && email.isNotBlank(), + isLoading = isLoading, + modifier = Modifier.size(52.dp), + ) } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt index 3852330..145ca63 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt @@ -2,10 +2,8 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -22,21 +20,16 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults import androidx.compose.material3.TopAppBar @@ -56,6 +49,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -65,6 +59,7 @@ import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.AppCard import org.mozilla.tryfox.ui.composables.BinButton import org.mozilla.tryfox.ui.composables.ErrorState +import org.mozilla.tryfox.ui.composables.ProjectSelector import org.mozilla.tryfox.ui.composables.PushCommentCard // Project name mappings @@ -74,7 +69,6 @@ private val projectDisplayToActualMap = mapOf( "beta" to "mozilla-beta", "release" to "mozilla-release", ) -private val projectActualToDisplayMap = projectDisplayToActualMap.entries.associate { (k, v) -> v to k } internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" internal const val TREEHERDER_RESULTS_HEADER_TAG = "treeherder_results_header" @@ -279,71 +273,36 @@ fun SearchSection( isLoading: Boolean, ) { val projectDisplayOptions = projectDisplayToActualMap.keys.toList() - var expanded by remember { mutableStateOf(false) } - - Card( - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ProjectSelector( + projects = projectDisplayOptions, + selectedProject = projectDisplayToActualMap.entries.first { it.value == selectedProject }.key, + projectLabel = { it }, + onProjectSelected = { displayProject -> onProjectSelected(projectDisplayToActualMap.getValue(displayProject)) }, + modifier = Modifier.height(52.dp), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.fillMaxWidth(), - ) { - TextField( - value = projectActualToDisplayMap[selectedProject] ?: selectedProject, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(id = R.string.treeherder_apks_project_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - projectDisplayOptions.forEach { displayKey -> - DropdownMenuItem( - text = { Text(displayKey) }, - onClick = { - onProjectSelected(projectDisplayToActualMap[displayKey] ?: displayKey) - expanded = false - }, - ) - } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = revision, - onValueChange = onRevisionChange, - label = { Text(stringResource(id = R.string.treeherder_apks_revision_label)) }, - placeholder = { Text(stringResource(id = R.string.treeherder_apks_revision_placeholder)) }, - modifier = Modifier.weight(1f).fillMaxHeight(), - singleLine = true, - shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp), // Matched ProfileScreen - colors = OutlinedTextFieldDefaults.colors(), - ) - - SearchButton( // Using the same SearchButton as ProfileScreen - onClick = onSearchClick, - enabled = !isLoading && revision.isNotBlank(), - isLoading = isLoading, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxHeight(), - ) - } + OutlinedTextField( + value = revision, + onValueChange = onRevisionChange, + placeholder = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, + modifier = Modifier.weight(1f).height(52.dp).testTag("profile_email_input"), + singleLine = true, + shape = RoundedCornerShape(20.dp), + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + colors = OutlinedTextFieldDefaults.colors(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email), + ) + SearchButton( + onClick = onSearchClick, + enabled = !isLoading && revision.isNotBlank(), + isLoading = isLoading, + modifier = Modifier.size(52.dp).testTag("profile_search_button"), + ) } } } @@ -362,7 +321,7 @@ fun SearchButton( // This is the local SearchButton onClick = onClick, enabled = enabled, modifier = modifier, - shape = RoundedCornerShape(topStart = 0.dp, bottomStart = 0.dp, topEnd = 12.dp, bottomEnd = 12.dp), // Shape from Treeherder + shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), contentPadding = PaddingValues(horizontal = 0.dp), ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6f8436..41c8a3c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,7 +55,7 @@ Compatible APKs No compatible APKs found for this job. Search builds - Email or revision + Revision number or email… Search builds Enter an email or revision and tap search. Clear email field From 03b8b2c0d0b0995b11b6653a95c5677a9514c342 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 01:09:35 +0200 Subject: [PATCH 08/24] Polish search navigation and selector --- app/src/main/java/org/mozilla/tryfox/MainActivity.kt | 6 +++++- .../org/mozilla/tryfox/ui/composables/ProjectSelector.kt | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 0445c50..79ea540 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -188,7 +188,11 @@ class MainActivity : ComponentActivity() { deepLinkRevision = null, onNavigateUp = { localNavController.popBackStack() }, onSearchEmail = { project, email -> - localNavController.navigate(AppRoutes.createTreeherderSearchRoute(project, email)) + localNavController.navigate(AppRoutes.createTreeherderSearchRoute(project, email)) { + popUpTo(NavScreen.TreeherderSearch.route) { + inclusive = true + } + } }, ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt index a17243c..64b08db 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt @@ -3,6 +3,7 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -32,6 +33,11 @@ fun ProjectSelector( modifier: Modifier = Modifier, ) { val selectedIndex = projects.indexOf(selectedProject).coerceAtLeast(0) + val selectedContainerColor = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } BoxWithConstraints( modifier = modifier .fillMaxWidth() @@ -51,7 +57,7 @@ fun ProjectSelector( .offset(x = indicatorOffset) .width(segmentWidth) .fillMaxHeight() - .background(MaterialTheme.colorScheme.secondaryContainer, RoundedCornerShape(20.dp)), + .background(selectedContainerColor, RoundedCornerShape(20.dp)), ) Row(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { projects.forEach { project -> From b71fc8d88495c0b71b0aade2a3b8c52c0c945823 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 12:51:27 +0200 Subject: [PATCH 09/24] Add animated progress button --- .../ui/composables/ProgressButtonTest.kt | 449 ++++++++++++++++ .../download/worker/ApkDownloadWorker.kt | 3 +- .../tryfox/ui/composables/DownloadButton.kt | 111 ++-- .../tryfox/ui/composables/EndingAnimation.kt | 15 + .../tryfox/ui/composables/ProgressButton.kt | 504 ++++++++++++++++++ .../ui/composables/ProgressButtonSemantics.kt | 22 + .../tryfox/ui/composables/TryFoxCard.kt | 1 + .../tryfox/ui/screens/HistoryScreen.kt | 1 + .../tryfox/ui/screens/ProfileScreen.kt | 1 + 9 files changed, 1042 insertions(+), 65 deletions(-) create mode 100644 app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt new file mode 100644 index 0000000..d05c567 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt @@ -0,0 +1,449 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import kotlin.math.abs + +class ProgressButtonTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun showsIdleTextWhenNotLoading() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = false, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Create file").assertIsDisplayed() + composeRule.onAllNodesWithText("Loading").assertCountEquals(0) + } + + @Test + fun showsLoadingTextWhenLoading() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = true, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Loading").assertIsDisplayed() + composeRule.onAllNodesWithText("Create file").assertCountEquals(0) + } + + @Test + fun buttonDisabledWhenNotEnabled() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = false, + enabled = false, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Create file").assertIsNotEnabled() + } + + @Test + fun buttonClickInvokesCallback() { + var clicks = 0 + + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = false, + onClick = { clicks++ }, + modifier = Modifier, + ) + } + + composeRule.onNode(hasClickAction()).performClick() + composeRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun determinateProgressStillShowsLoadingText() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = true, + progress = 0.5f, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Loading").assertIsDisplayed() + } + + @Test + fun indicatorColorTransitionsOnlyAfterCompletionSweep() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + val indicatorColor = Color.Blue + val trackEndColor = Color.Green + + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = loadingState.value, + indicatorColor = indicatorColor, + trackEndColor = trackEndColor, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.None, + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + assertColorClose(indicatorColor, button.indicatorColor()) + + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + assertColorClose(indicatorColor, button.indicatorColor(), tolerance = 0.1f) + + composeRule.runOnIdle { loadingState.value = false } + + composeRule.mainClock.advanceTimeBy(200L) + composeRule.waitForIdle() + assertColorClose(indicatorColor, button.indicatorColor(), tolerance = 0.3f) + + composeRule.mainClock.advanceTimeBy(400L) + composeRule.waitForIdle() + assertColorClose(trackEndColor, button.indicatorColor(), tolerance = 0.1f) + + composeRule.mainClock.autoAdvance = true + } + + @Test + fun borderAlphaHoldsDuringConstantDelayThenFades() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = loadingState.value, + indicatorColor = Color.Blue, + trackEndColor = Color.Red, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.Constant(duration = 400f), + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + + composeRule.runOnIdle { loadingState.value = false } + + composeRule.mainClock.advanceTimeBy(200L) + composeRule.waitForIdle() + assertFloatEquals(1f, button.borderAlpha()) + + composeRule.mainClock.advanceTimeBy(400L) + composeRule.waitForIdle() + assertFloatEquals(1f, button.borderAlpha()) + + var finalAlpha = 1f + repeat(15) { + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + finalAlpha = button.borderAlpha() + if (finalAlpha <= 0.1f) return@repeat + } + assertFloatEquals(0f, finalAlpha, tolerance = 0.1f) + + composeRule.mainClock.autoAdvance = true + } + + @Test + fun noneEndingShowsIdleTextImmediatelyAfterCompletionSweep() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Install", + loadingText = "Download", + isLoading = loadingState.value, + progress = 0.6f, + indicatorColor = Color.Green, + trackEndColor = Color.Green, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.None, + onClick = {}, + ) + } + + composeRule.onNodeWithText("Download").assertIsDisplayed() + composeRule.runOnIdle { loadingState.value = false } + composeRule.mainClock.advanceTimeBy(250L) + composeRule.waitForIdle() + + composeRule.onNodeWithText("Install").assertIsDisplayed() + composeRule.onAllNodesWithText("Download").assertCountEquals(0) + composeRule.mainClock.autoAdvance = true + } + + @Test + fun progressFractionMatchesDeterminateProgress() { + val progressState = 0.65f + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = true, + progress = progressState, + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + assertFloatEquals(progressState, button.progressFraction()) + assertEquals( + ProgressBarRangeInfo(progressState, 0f..1f), + button.progressRangeInfo(), + ) + } + + @Test + fun determinateProgressLargeIncreaseAnimatesSmoothly() { + composeRule.mainClock.autoAdvance = false + val progressState = mutableStateOf(0.1f) + + composeRule.setContent { + ProgressButton( + text = "Upload", + isLoading = true, + progress = progressState.value, + onClick = {}, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.mainClock.advanceTimeBy(2_000L) + composeRule.waitForIdle() + assertFloatEquals(0.1f, button.progressFraction()) + + composeRule.runOnIdle { progressState.value = 0.9f } + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + val intermediateProgress = button.progressFraction() + assertTrue( + "Expected an intermediate value, but was $intermediateProgress", + intermediateProgress > 0.1f && intermediateProgress < 0.9f, + ) + + composeRule.mainClock.advanceTimeBy(2_000L) + composeRule.waitForIdle() + assertFloatEquals(0.9f, button.progressFraction()) + composeRule.mainClock.autoAdvance = true + } + + @Test + fun rotatingDeterminateProgressMovesWhileItsArcGrows() { + composeRule.mainClock.autoAdvance = false + val progressState = mutableStateOf(0.2f) + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Upload", + isLoading = loadingState.value, + progress = progressState.value, + determinateProgressAnimation = DeterminateProgressAnimation.Rotating, + onClick = {}, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.mainClock.advanceTimeBy(2_000L) + composeRule.waitForIdle() + val initialStart = button.progressStartFraction() + assertFloatEquals(0.2f, button.progressFraction()) + + composeRule.runOnIdle { progressState.value = 0.8f } + composeRule.mainClock.advanceTimeBy(200L) + composeRule.waitForIdle() + + assertTrue("Expected the arc to rotate", button.progressStartFraction() != initialStart) + assertTrue("Expected the arc to grow smoothly", button.progressFraction() in 0.2f..0.8f) + + val startBeforeCompletion = button.progressStartFraction() + composeRule.runOnIdle { loadingState.value = false } + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + assertTrue( + "Expected the leading edge to keep rotating while completing", + button.progressStartFraction() != startBeforeCompletion, + ) + composeRule.mainClock.autoAdvance = true + } + + @Test + fun indeterminateProgressSemanticsExposed() { + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = true, + progress = null, + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + assertEquals(ProgressBarRangeInfo.Indeterminate, button.progressRangeInfo()) + } + + @Test + fun pulseEndingProducesBeatPattern() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = loadingState.value, + indicatorColor = Color.Blue, + trackEndColor = Color.Magenta, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.Pulse( + beatDuration = 200f, + beats = 2, + delayBetweenBeats = 100f, + ), + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + + composeRule.runOnIdle { loadingState.value = false } + + composeRule.mainClock.advanceTimeBy(200L) // sweep + composeRule.mainClock.advanceTimeBy(300L) // indicator color animation + composeRule.mainClock.advanceTimeBy(100L) // initial delay + + var dropDetected = false + repeat(6) { + composeRule.mainClock.advanceTimeBy(50L) + composeRule.waitForIdle() + val alpha = button.borderAlpha() + if (alpha < 0.92f) { + dropDetected = true + return@repeat + } + } + assertTrue("Expected drop during pulse", dropDetected) + + var restoredToFull = false + repeat(4) { + composeRule.mainClock.advanceTimeBy(50L) + composeRule.waitForIdle() + val alpha = button.borderAlpha() + if (alpha >= 0.95f) { + restoredToFull = true + return@repeat + } + } + assertTrue("Expected alpha to return to full", restoredToFull) + + composeRule.mainClock.advanceTimeBy(100L) // delay between beats + + var secondDropDetected = false + repeat(6) { + composeRule.mainClock.advanceTimeBy(50L) + composeRule.waitForIdle() + val alpha = button.borderAlpha() + if (alpha < 0.92f) { + secondDropDetected = true + return@repeat + } + } + assertTrue("Expected drop on second beat", secondDropDetected) + + composeRule.mainClock.autoAdvance = true + } + + private fun SemanticsNodeInteraction.indicatorColor(): Color = + fetchSemanticsNode().config[IndicatorColorKey] + + private fun SemanticsNodeInteraction.borderAlpha(): Float = + fetchSemanticsNode().config[BorderAlphaKey] + + private fun SemanticsNodeInteraction.progressFraction(): Float = + fetchSemanticsNode().config[ProgressFractionKey] + + private fun SemanticsNodeInteraction.progressStartFraction(): Float = + fetchSemanticsNode().config[ProgressStartFractionKey] + + private fun SemanticsNodeInteraction.progressRangeInfo(): ProgressBarRangeInfo = + fetchSemanticsNode().config[SemanticsProperties.ProgressBarRangeInfo] + + private fun assertColorClose(expected: Color, actual: Color, tolerance: Float = 0.1f) { + val distance = abs(expected.red - actual.red) + + abs(expected.green - actual.green) + + abs(expected.blue - actual.blue) + + abs(expected.alpha - actual.alpha) + assertTrue( + "Color mismatch. Expected $expected, got $actual (distance $distance)", + distance <= tolerance, + ) + } + + private fun assertFloatEquals(expected: Float, actual: Float, tolerance: Float = 0.001f) { + assertTrue( + "Expected $expected, got $actual (tolerance $tolerance)", + abs(expected - actual) <= tolerance, + ) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt index c7c6b54..b404f01 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt @@ -61,12 +61,13 @@ class ApkDownloadWorker( val progressPercent = if (totalBytes > 0) ((bytesDownloaded * 100) / totalBytes).toInt() else -1 val now = System.currentTimeMillis() + val elapsedSinceLastUpdate = now - lastProgressUpdateAt val shouldPublish = totalBytes <= 0 || lastProgressPercent < 0 || bytesDownloaded == totalBytes || progressPercent >= lastProgressPercent + MIN_PROGRESS_PERCENT_STEP || - now - lastProgressUpdateAt >= PROGRESS_UPDATE_INTERVAL_MS + elapsedSinceLastUpdate >= PROGRESS_UPDATE_INTERVAL_MS if (shouldPublish) { lastProgressUpdateAt = now lastProgressPercent = progressPercent 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 a9bb1f7..929d5b4 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 @@ -1,82 +1,65 @@ package org.mozilla.tryfox.ui.composables -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text +import android.util.Log +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag // Added import import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState import java.io.File +private const val TAG = "DownloadButton" + @Composable fun DownloadButton( downloadState: DownloadState, onDownloadClick: () -> Unit, onInstallClick: (File) -> Unit, modifier: Modifier = Modifier, + inProgressText: String? = null, + determinateProgressAnimation: DeterminateProgressAnimation = DeterminateProgressAnimation.Rotating, ) { - when (downloadState) { - is DownloadState.Downloaded -> { - Button( - onClick = { onInstallClick(downloadState.file) }, - modifier = modifier.testTag("action_button_install_ready"), // Tag for Install state - ) { - Text(stringResource(id = R.string.download_button_install)) - } - } - is DownloadState.InProgress -> { - val animatedProgress = - if (downloadState.isIndeterminate) { - 0f - } else { - animateFloatAsState( - targetValue = downloadState.progress, - animationSpec = tween(durationMillis = 250, easing = LinearEasing), - label = "downloadProgress", - ).value - } - Button( - onClick = {}, - enabled = false, - modifier = modifier.testTag("action_button_downloading"), // Tag for Downloading state - ) { - if (downloadState.isIndeterminate) { - CircularProgressIndicator( - modifier = Modifier - .size(ButtonDefaults.IconSize) - .testTag("progress_indicator_indeterminate"), // Tag for indeterminate progress - strokeWidth = 2.dp, - ) - } else { - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = Modifier - .size(ButtonDefaults.IconSize) - .testTag("progress_indicator_determinate"), // Tag for determinate progress - strokeWidth = 2.dp, - ) - } - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(id = R.string.download_button_downloading)) - } - } - is DownloadState.NotDownloaded, is DownloadState.DownloadFailed -> { - Button( - onClick = onDownloadClick, - modifier = modifier.testTag("action_button_download_initial"), // Tag for Download state - ) { - Text(stringResource(id = R.string.download_button_download)) - } - } + val inProgressState = downloadState as? DownloadState.InProgress + val downloadedState = downloadState as? DownloadState.Downloaded + val defaultText = stringResource(id = R.string.download_button_downloading) + val colorScheme = MaterialTheme.colorScheme + val isDownloading = inProgressState != null + + LaunchedEffect(downloadState) { + Log.d(TAG, "downloadState changed: $downloadState") } + + ProgressButton( + onClick = { + downloadedState?.let { onInstallClick(it.file) } ?: onDownloadClick() + }, + enabled = true, + isLoading = isDownloading, + progress = inProgressState + ?.progress + ?.takeUnless { inProgressState.isIndeterminate }, + text = if (downloadedState == null) { + stringResource(id = R.string.download_button_download) + } else { + stringResource(id = R.string.download_button_install) + }, + loadingText = inProgressText ?: defaultText, + determinateProgressAnimation = determinateProgressAnimation, + // Keep the fill stable across every state; the lighter progress ring is + // deliberately distinct from the primary button background. + trackColor = colorScheme.onPrimary.copy(alpha = 0.28f), + indicatorColor = colorScheme.primaryContainer, + trackEndColor = colorScheme.primaryContainer, + endingAnimation = EndingAnimation.None, + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary, + modifier = modifier, + semanticsTag = when { + downloadedState != null -> "action_button_install_ready" + inProgressState != null -> "action_button_downloading" + else -> "action_button_download_initial" + }, + ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt new file mode 100644 index 0000000..9dcccf3 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt @@ -0,0 +1,15 @@ +package org.mozilla.tryfox.ui.composables + +sealed class EndingAnimation { + data object None : EndingAnimation() + + data class Pulse( + val beatDuration: Float, + val beats: Int, + val delayBetweenBeats: Float, + ) : EndingAnimation() + + data class Constant( + val duration: Float, + ) : EndingAnimation() +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt new file mode 100644 index 0000000..4cf21cf --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt @@ -0,0 +1,504 @@ +package org.mozilla.tryfox.ui.composables + +import android.util.Log +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +private const val TAG = "ProgressButton" + +@Composable +fun ProgressButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean, + progress: Float? = null, + loadingText: String = text, + determinateProgressAnimation: DeterminateProgressAnimation = DeterminateProgressAnimation.Static, + segmentLengthFraction: Float = 0.18f, + enabled: Boolean = true, + trackColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f), + indicatorColor: Color = MaterialTheme.colorScheme.primary, + trackEndColor: Color = MaterialTheme.colorScheme.primary, + containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + shape: Shape = ButtonDefaults.shape, + strokeWidth: Dp = 6.dp, + fullCycleMillis: Int = 1800, + ignoreClicksDuringClosingAnimation: Boolean = true, + completionSweepMillis: Float = 500f, + endingAnimation: EndingAnimation = EndingAnimation.Constant(duration = 1000f), + semanticsTag: String = PROGRESS_BUTTON_TAG, +) { + val baseSegmentFraction = segmentLengthFraction.coerceIn(0f, 1f) + val clampedProgress = progress?.coerceIn(0f, 1f) + val isActiveLoading = isLoading + val resolvedContainerColor = if (enabled) containerColor else disabledContainerColor + val resolvedContentColor = if (enabled) contentColor else disabledContentColor + + val infiniteTransition = rememberInfiniteTransition(label = "borderTransition") + val animatedFraction by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = fullCycleMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "borderSweep", + ) + + var isCompleting by remember { mutableStateOf(false) } + val completionSegment = remember { Animatable(initialValue = 0f) } + val determinateProgress = remember { Animatable(initialValue = 0f) } + val borderAlpha = remember { Animatable(initialValue = if (isActiveLoading) 1f else 0f) } + val indicatorColorAnim = remember { + Animatable( + initialValue = indicatorColor, + typeConverter = ColorVectorConverter, + ) + } + var previousActiveLoading by remember { mutableStateOf(isActiveLoading) } + var lastProgressValue by remember { mutableStateOf(clampedProgress) } + var completionRequest by remember { mutableStateOf(null) } + var completionWasDeterminate by remember { mutableStateOf(false) } + + LaunchedEffect(clampedProgress) { + clampedProgress ?: return@LaunchedEffect + Log.d( + TAG, + "[$semanticsTag] progress retarget: ${determinateProgress.value} -> $clampedProgress", + ) + determinateProgress.animateTo( + targetValue = clampedProgress, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + + LaunchedEffect(isActiveLoading, clampedProgress, indicatorColor, baseSegmentFraction) { + val wasActive = previousActiveLoading + val previousProgress = lastProgressValue + previousActiveLoading = isActiveLoading + lastProgressValue = clampedProgress + Log.d( + TAG, + "[$semanticsTag] state effect: wasActive=$wasActive isActiveLoading=$isActiveLoading " + + "previousProgress=$previousProgress clampedProgress=$clampedProgress isCompleting=$isCompleting", + ) + + if (isActiveLoading) { + completionRequest = null + if (isCompleting) { + completionSegment.stop() + isCompleting = false + } + indicatorColorAnim.stop() + indicatorColorAnim.snapTo(indicatorColor) + if (!wasActive || borderAlpha.value < 1f) { + borderAlpha.stop() + borderAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 180, easing = LinearEasing), + ) + } + } else { + if (wasActive) { + val initialFraction = if (previousProgress == null) { + baseSegmentFraction + } else { + determinateProgress.value + }.coerceIn(0f, 1f) + Log.d( + TAG, + "[$semanticsTag] loading ended -> requesting completion sweep " + + "from initialFraction=$initialFraction wasDeterminate=${previousProgress != null}", + ) + completionRequest = CompletionParams( + initialFraction = initialFraction, + wasDeterminate = previousProgress != null, + ) + } else if (!isCompleting) { + indicatorColorAnim.stop() + indicatorColorAnim.snapTo(indicatorColor) + if (borderAlpha.value != 0f) { + borderAlpha.stop() + borderAlpha.snapTo(0f) + } + } + } + } + + LaunchedEffect(completionRequest, trackEndColor, completionSweepMillis, indicatorColor, endingAnimation) { + val request = completionRequest ?: return@LaunchedEffect + + isCompleting = true + completionWasDeterminate = request.wasDeterminate + Log.d( + TAG, + "[$semanticsTag] completion started: initialFraction=${request.initialFraction} " + + "wasDeterminate=${request.wasDeterminate}", + ) + + completionSegment.stop() + completionSegment.snapTo(request.initialFraction) + borderAlpha.stop() + borderAlpha.snapTo(1f) + + val sweepDuration = completionSweepMillis.coerceAtLeast(0f).roundToInt().coerceAtLeast(1) + + try { + if (request.initialFraction < 1f && sweepDuration > 0) { + completionSegment.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = sweepDuration, + easing = LinearEasing, + ), + ) + } else { + completionSegment.snapTo(1f) + } + Log.d(TAG, "[$semanticsTag] completion sweep reached full border") + + if (indicatorColorAnim.value != trackEndColor) { + indicatorColorAnim.animateTo( + targetValue = trackEndColor, + animationSpec = tween(durationMillis = 300, easing = LinearEasing), + ) + } + + when (endingAnimation) { + EndingAnimation.None -> { + borderAlpha.snapTo(0f) + } + is EndingAnimation.Constant -> { + val delayMillis = endingAnimation.duration.coerceAtLeast(0f).roundToInt() + if (delayMillis > 0) { + delay(delayMillis.toLong()) + } + borderAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + } + is EndingAnimation.Pulse -> { + val beatCount = endingAnimation.beats.coerceAtLeast(0) + val beatDuration = endingAnimation.beatDuration.coerceAtLeast(0f) + val halfDuration = (beatDuration / 2f).coerceAtLeast(1f) + val delayBetween = endingAnimation.delayBetweenBeats.coerceAtLeast(0f) + if (delayBetween > 0f) { + delay(delayBetween.roundToInt().toLong()) + } + repeat(beatCount) { beatIndex -> + borderAlpha.animateTo( + targetValue = 0.7f, + animationSpec = tween( + durationMillis = halfDuration.roundToInt(), + easing = LinearEasing, + ), + ) + borderAlpha.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = halfDuration.roundToInt(), + easing = LinearEasing, + ), + ) + if (delayBetween > 0f && beatIndex != beatCount - 1) { + delay(delayBetween.roundToInt().toLong()) + } + } + if (delayBetween > 0f) { + delay(delayBetween.roundToInt().toLong()) + } + borderAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + } + } + } finally { + completionSegment.snapTo(0f) + if (!borderAlpha.isRunning) { + borderAlpha.snapTo(0f) + } + indicatorColorAnim.stop() + indicatorColorAnim.snapTo(indicatorColor) + completionWasDeterminate = false + isCompleting = false + completionRequest = null + Log.d(TAG, "[$semanticsTag] completion finished, back to idle") + } + } + + val showLoadingText = isActiveLoading || isCompleting || borderAlpha.value > 0.01f + + val progressFractionValue = when { + isCompleting -> completionSegment.value + clampedProgress != null -> determinateProgress.value + else -> baseSegmentFraction + }.coerceIn(0f, 1f) + + val progressRangeInfo = if (clampedProgress != null || isCompleting) { + ProgressBarRangeInfo(progressFractionValue, 0f..1f) + } else { + ProgressBarRangeInfo.Indeterminate + } + + val textStyle = MaterialTheme.typography.labelLarge + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val horizontalPadding = remember(layoutDirection) { + ButtonDefaults.ContentPadding.calculateLeftPadding(layoutDirection) + + ButtonDefaults.ContentPadding.calculateRightPadding(layoutDirection) + } + val idleTextWidthPx = remember(text, textStyle) { + textMeasurer.measure( + text = AnnotatedString(text), + style = textStyle, + ).size.width.toFloat() + } + val loadingTextWidthPx = remember(loadingText, textStyle) { + textMeasurer.measure( + text = AnnotatedString(loadingText), + style = textStyle, + ).size.width.toFloat() + } + val targetWidth = remember(showLoadingText, idleTextWidthPx, loadingTextWidthPx, horizontalPadding, density) { + val contentWidthPx = if (showLoadingText) loadingTextWidthPx else idleTextWidthPx + val contentWidthDp = with(density) { contentWidthPx.toDp() } + (contentWidthDp + horizontalPadding).coerceAtLeast(ButtonDefaults.MinWidth) + } + val animatedWidthPx by animateFloatAsState( + targetValue = with(density) { targetWidth.toPx() }, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), + label = "buttonWidth", + ) + val animatedWidth = with(density) { animatedWidthPx.toDp() } + + Surface( + onClick = { + if (isCompleting && ignoreClicksDuringClosingAnimation) return@Surface + onClick() + }, + enabled = enabled, + shape = shape, + color = resolvedContainerColor, + contentColor = resolvedContentColor, + modifier = modifier + .width(animatedWidth) + .semantics { + testTag = semanticsTag + progressFractionSemantics = progressFractionValue + borderAlphaSemantics = borderAlpha.value + indicatorColorSemantics = indicatorColorAnim.value + isCompletingSemantics = isCompleting + progressStartFractionSemantics = progressStartFraction( + isCompleting = isCompleting, + completionWasDeterminate = completionWasDeterminate, + hasDeterminateProgress = clampedProgress != null, + determinateProgressAnimation = determinateProgressAnimation, + spinnerFraction = animatedFraction, + ) + progressBarRangeInfo = progressRangeInfo + } + .animateContentSize( + animationSpec = tween(durationMillis = 250, easing = LinearEasing), + alignment = Alignment.Center, + ), + ) { + Box( + modifier = Modifier + .defaultMinSize( + minWidth = ButtonDefaults.MinWidth, + minHeight = ButtonDefaults.MinHeight, + ) + .drawWithContent { + drawContent() + + val strokeWidthPx = strokeWidth.toPx() + if (size.width <= 0f || size.height <= 0f) { + return@drawWithContent + } + + val borderOutline = shape.createOutline(size, layoutDirection, this) + if (borderOutline !is Outline.Rounded) { + return@drawWithContent + } + val borderPath = Path().apply { + addRoundRect( + roundRect = borderOutline.roundRect, + direction = Path.Direction.Clockwise, + ) + } + + val pathMeasure = PathMeasure().apply { setPath(borderPath, true) } + val pathLength = pathMeasure.length + if (pathLength <= 0f) return@drawWithContent + + val alpha = borderAlpha.value + if (alpha <= 0f) return@drawWithContent + + val shouldRender = isActiveLoading || isCompleting || alpha > 0f + if (!shouldRender) return@drawWithContent + + val activeSegmentFraction = progressFractionValue + if (activeSegmentFraction <= 0f) return@drawWithContent + + val normalizedSpinnerFraction = run { + val normalized = animatedFraction % 1f + if (normalized < 0f) normalized + 1f else normalized + } + val normalizedStartFraction = progressStartFraction( + isCompleting = isCompleting, + completionWasDeterminate = completionWasDeterminate, + hasDeterminateProgress = clampedProgress != null, + determinateProgressAnimation = determinateProgressAnimation, + spinnerFraction = normalizedSpinnerFraction, + ) + val startDistance = normalizedStartFraction * pathLength + val endDistance = startDistance + activeSegmentFraction * pathLength + + val currentTrackColor = trackColor + val currentIndicatorColor = indicatorColorAnim.value + + drawPath( + path = borderPath, + color = currentTrackColor.copy(alpha = currentTrackColor.alpha * alpha), + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Butt), + ) + + val indicatorPath = Path() + pathMeasure.getSegment( + startDistance, + endDistance.coerceAtMost(pathLength), + indicatorPath, + true, + ) + if (endDistance > pathLength) { + val wrapPath = Path() + pathMeasure.getSegment(0f, endDistance - pathLength, wrapPath, true) + indicatorPath.addPath(wrapPath) + } + + drawPath( + path = indicatorPath, + color = currentIndicatorColor.copy(alpha = currentIndicatorColor.alpha * alpha), + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + ) + }, + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier.padding(ButtonDefaults.ContentPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (showLoadingText) loadingText else text, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + } + } + } +} + +/** Defines how a determinate progress arc moves around the button border. */ +enum class DeterminateProgressAnimation { + /** The progress arc begins at the same fixed point on the border. */ + Static, + + /** The progress arc rotates continuously while its length follows the current progress. */ + Rotating, +} + +private fun progressStartFraction( + isCompleting: Boolean, + completionWasDeterminate: Boolean, + hasDeterminateProgress: Boolean, + determinateProgressAnimation: DeterminateProgressAnimation, + spinnerFraction: Float, +): Float { + if ( + isCompleting && + completionWasDeterminate && + determinateProgressAnimation == DeterminateProgressAnimation.Static + ) { + return 0f + } + if (hasDeterminateProgress && determinateProgressAnimation == DeterminateProgressAnimation.Static) return 0f + return spinnerFraction % 1f +} + +private data class CompletionParams( + val initialFraction: Float, + val wasDeterminate: Boolean, +) + +private val ColorVectorConverter = TwoWayConverter( + convertToVector = { color -> + AnimationVector4D(color.red, color.green, color.blue, color.alpha) + }, + convertFromVector = { vector -> + Color(vector.v1, vector.v2, vector.v3, vector.v4) + }, +) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt new file mode 100644 index 0000000..f849cf7 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt @@ -0,0 +1,22 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver + +const val PROGRESS_BUTTON_TAG = "IndeterminateProgressButton" + +val ProgressFractionKey = SemanticsPropertyKey("ProgressFraction") +var SemanticsPropertyReceiver.progressFractionSemantics by ProgressFractionKey + +val BorderAlphaKey = SemanticsPropertyKey("BorderAlpha") +var SemanticsPropertyReceiver.borderAlphaSemantics by BorderAlphaKey + +val IndicatorColorKey = SemanticsPropertyKey("IndicatorColor") +var SemanticsPropertyReceiver.indicatorColorSemantics by IndicatorColorKey + +val IsCompletingKey = SemanticsPropertyKey("IsCompleting") +var SemanticsPropertyReceiver.isCompletingSemantics by IsCompletingKey + +val ProgressStartFractionKey = SemanticsPropertyKey("ProgressStartFraction") +var SemanticsPropertyReceiver.progressStartFractionSemantics by ProgressStartFractionKey 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 index 0e889f7..fb14998 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt @@ -60,6 +60,7 @@ fun TryFoxCard( val downloadedFile = (latestApk.downloadState as? DownloadState.Downloaded)?.file downloadedFile?.let { onInstallClick(it) } }, + inProgressText = stringResource(id = R.string.download_button_download), ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt index 33c66b5..0637ca1 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt @@ -260,6 +260,7 @@ private fun HistoryCard( downloadState = historyItem.downloadState, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + inProgressText = stringResource(id = R.string.download_button_download), ) } } 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 ffed8dd..7a07b3a 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 @@ -448,6 +448,7 @@ private fun CompactApkRow(job: JobDetailsUiModel, profileViewModel: ProfileViewM onDownloadClick = { profileViewModel.downloadArtifact(it) }, onInstallClick = profileViewModel::installApk, modifier = Modifier.width(112.dp), + inProgressText = stringResource(id = R.string.download_button_download), ) } } From beca879f347a23011e8cfebd0d5c3ff3dbbf7bfc Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 14:14:36 +0200 Subject: [PATCH 10/24] Add unified search history --- .../tryfox/data/FakeUserDataRepository.kt | 17 +- .../ui/screens/TreeherderApksScreenTest.kt | 83 ++++++++++ .../java/org/mozilla/tryfox/MainActivity.kt | 34 +++- .../org/mozilla/tryfox/TryFoxViewModel.kt | 14 ++ .../mozilla/tryfox/data/SearchHistoryEntry.kt | 55 +++++++ .../repositories/DefaultUserDataRepository.kt | 46 +++++- .../data/repositories/UserDataRepository.kt | 3 + .../java/org/mozilla/tryfox/di/AppModule.kt | 2 + .../tryfox/ui/screens/ProfileViewModel.kt | 4 +- .../ui/screens/SearchHistoryViewModel.kt | 26 +++ .../tryfox/ui/screens/TreeherderApksScreen.kt | 149 ++++++++++++++++-- app/src/main/res/values/strings.xml | 1 + .../mozilla/tryfox/data/SearchHistoryTest.kt | 47 ++++++ .../data/managers/FakeUserDataRepository.kt | 17 +- 14 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt index 22cc462..1d4f426 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt @@ -2,6 +2,9 @@ package org.mozilla.tryfox.data import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity @@ -12,11 +15,22 @@ class FakeUserDataRepository : UserDataRepository { private val _lastSearchedEmailFlow = MutableStateFlow("") override val lastSearchedEmailFlow: Flow = _lastSearchedEmailFlow + private val _searchHistoryFlow = MutableStateFlow>(emptyList()) + override val searchHistoryFlow: Flow> = _searchHistoryFlow private val _lanReceiveIdentityFlow = MutableStateFlow(null) override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow override suspend fun saveLastSearchedEmail(email: String) { - _lastSearchedEmailFlow.value = email + recordSearch("try", email) + } + + override suspend fun recordSearch(project: String, query: String, searchedAt: Long) { + val queryType = if ('@' in query) SearchHistoryQueryType.EMAIL else SearchHistoryQueryType.REVISION + _searchHistoryFlow.value = SearchHistory.record( + _searchHistoryFlow.value, + SearchHistoryEntry(project, query, queryType, searchedAt), + ) + _lastSearchedEmailFlow.value = SearchHistory.latestEmail(_searchHistoryFlow.value) } override suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) { @@ -26,5 +40,6 @@ class FakeUserDataRepository : UserDataRepository { // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" + _searchHistoryFlow.value = emptyList() } } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt index 5feba89..c685039 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt @@ -1,5 +1,8 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -7,8 +10,11 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.delay +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -24,6 +30,8 @@ import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.RevisionDetail import org.mozilla.tryfox.data.RevisionMeta import org.mozilla.tryfox.data.RevisionResult +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.TreeherderJobsResponse import org.mozilla.tryfox.data.TreeherderRevisionResponse import org.mozilla.tryfox.data.repositories.TreeherderRepository @@ -84,6 +92,81 @@ class TreeherderApksScreenTest { .assertIsDisplayed() } + @Test + fun searchHistory_filtersSuggestions_andSelectsAnEntry() { + val emailEntry = SearchHistoryEntry( + project = "mozilla-central", + query = "person@mozilla.org", + queryType = SearchHistoryQueryType.EMAIL, + searchedAt = 2L, + ) + var query by mutableStateOf("") + var selectedEntry: SearchHistoryEntry? = null + + composeTestRule.setContent { + TryFoxTheme { + SearchSection( + selectedProject = "try", + onProjectSelected = {}, + revision = query, + onRevisionChange = { query = it }, + onSearchClick = {}, + isLoading = false, + searchHistory = listOf( + emailEntry, + SearchHistoryEntry("try", "abc123", SearchHistoryQueryType.REVISION, 1L), + ), + onHistoryItemSelected = { selectedEntry = it }, + ) + } + } + + composeTestRule.onNodeWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertIsDisplayed() + composeTestRule.onNodeWithText("Recent searches").assertIsDisplayed() + composeTestRule.onNodeWithText("central").assertIsDisplayed() + + composeTestRule.onNodeWithText("person@mozilla.org").performClick() + composeTestRule.runOnIdle { + assertEquals(emailEntry, selectedEntry) + } + + composeTestRule.onNodeWithTag("profile_email_input").performTextInput("no-match") + composeTestRule.onAllNodesWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertCountEquals(0) + } + + @Test + fun selectingHistoryEntry_hidesHistoryBeforeShowingSearchResults() { + val viewModel = TryFoxViewModel( + fenixRepository = DelayedTreeherderRepository(targetJobName = "signing-apk-focus-nightly"), + downloadFileRepository = FakeDownloadFileRepository(), + cacheManager = FakeCacheManager(), + intentManager = FakeIntentManager(), + historyRepository = FakeHistoryRepository(), + project = null, + revision = null, + supportedAbis = listOf("arm64-v8a"), + infoLogger = { _, _ -> 0 }, + ) + + composeTestRule.setContent { + TryFoxTheme { + TryFoxMainScreen( + tryFoxViewModel = viewModel, + deepLinkProject = null, + deepLinkRevision = null, + onNavigateUp = {}, + searchHistory = listOf( + SearchHistoryEntry("try", "abc123", SearchHistoryQueryType.REVISION, 1L), + ), + ) + } + } + + composeTestRule.onNodeWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag("treeherder_search_history_0").performClick() + composeTestRule.onAllNodesWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertCountEquals(0) + } + private class DelayedTreeherderRepository( private val targetJobName: String, ) : TreeherderRepository { diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 79ea540..84b71d6 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge 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.setValue @@ -26,6 +27,7 @@ import org.mozilla.tryfox.ui.screens.ProfileScreen import org.mozilla.tryfox.ui.screens.QrCodeScannerScreen import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopScreen import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryScreen +import org.mozilla.tryfox.ui.screens.SearchHistoryViewModel import org.mozilla.tryfox.ui.screens.SearchQuery import org.mozilla.tryfox.ui.screens.SearchQueryClassifier import org.mozilla.tryfox.ui.screens.TryFoxMainScreen @@ -118,6 +120,7 @@ class MainActivity : ComponentActivity() { @Suppress("LongMethod") @Composable fun AppNavigation() { + val appSearchHistoryViewModel: SearchHistoryViewModel = koinViewModel() val localNavController = rememberNavController() this@MainActivity.navController = localNavController @@ -150,9 +153,7 @@ class MainActivity : ComponentActivity() { composable(NavScreen.QrScanner.route) { QrCodeScannerScreen( onNavigateUp = { localNavController.popBackStack() }, - onQrCodeScanned = { rawValue -> - routeDeepLink(rawValue, popQrScanner = true) - }, + onQrCodeScanned = { rawValue -> routeDeepLink(rawValue, popQrScanner = true) }, ) } composable(NavScreen.ReceiveFromDesktop.route) { @@ -181,6 +182,7 @@ class MainActivity : ComponentActivity() { ) } composable(NavScreen.TreeherderSearch.route) { + val searchHistory by appSearchHistoryViewModel.searchHistory.collectAsState() // mainActivityViewModel is already injected and passed as a parameter TryFoxMainScreen( tryFoxViewModel = koinViewModel(), @@ -194,6 +196,8 @@ class MainActivity : ComponentActivity() { } } }, + searchHistory = searchHistory, + onSearchSucceeded = appSearchHistoryViewModel::recordSuccessfulSearch, ) } composable( @@ -220,6 +224,7 @@ class MainActivity : ComponentActivity() { deepLinkProject = project, deepLinkRevision = query, onNavigateUp = { localNavController.popBackStack() }, + onSearchSucceeded = appSearchHistoryViewModel::recordSuccessfulSearch, ) null -> TryFoxMainScreen( tryFoxViewModel = koinViewModel { parametersOf(project, query) }, @@ -263,8 +268,28 @@ class MainActivity : ComponentActivity() { } private fun routeDeepLink(rawValue: String?, popQrScanner: Boolean): Boolean { - val route = AppDeepLinkRouteMapper.routeFor(rawValue) ?: return false + when (val destination = AppDeepLinkParser.parse(rawValue)) { + is AppDeepLinkDestination.TreeherderSearch -> { + navigateToDeepLinkRoute( + AppRoutes.createTreeherderSearchRoute(destination.project, destination.revision), + popQrScanner, + ) + return true + } + + is AppDeepLinkDestination.Profile -> { + navigateToDeepLinkRoute( + AppRoutes.createTreeherderSearchRoute(destination.project, destination.email), + popQrScanner, + ) + return true + } + + null -> return false + } + } + private fun navigateToDeepLinkRoute(route: String, popQrScanner: Boolean) { navController.navigate(route) { launchSingleTop = true if (popQrScanner) { @@ -273,6 +298,5 @@ class MainActivity : ComponentActivity() { } } } - return true } } diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 038fc0a..c37f3bc 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager @@ -124,6 +126,9 @@ class TryFoxViewModel( var selectedJobs by mutableStateOf>(emptyList()) private set + var successfulSearch by mutableStateOf(null) + private set + val isLoadingJobArtifacts = mutableStateMapOf() var onInstallApk: ((File) -> Unit)? = null @@ -193,6 +198,7 @@ class TryFoxViewModel( } fun searchJobsAndArtifacts() { + successfulSearch = null if (revision.isBlank()) { errorMessage = "Please enter a revision to search." return @@ -397,6 +403,14 @@ class TryFoxViewModel( } selectedJobs = selectedJobs.filter { it.artifacts.isNotEmpty() } + if (selectedJobs.isNotEmpty()) { + successfulSearch = SearchHistoryEntry( + project = selectedProject, + query = revision, + queryType = SearchHistoryQueryType.REVISION, + searchedAt = currentTimeMillisProvider(), + ) + } infoLogger( TAG, "searchJobsAndArtifacts: finished in ${elapsedRealtimeProvider() - loadStartMs} ms with ${selectedJobs.size} job(s) shown", diff --git a/app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt b/app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt new file mode 100644 index 0000000..3f5abdb --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt @@ -0,0 +1,55 @@ +package org.mozilla.tryfox.data + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchHistoryEntry( + val project: String, + val query: String, + val queryType: SearchHistoryQueryType, + val searchedAt: Long, +) + +@Serializable +enum class SearchHistoryQueryType { + EMAIL, + REVISION, +} + +object SearchHistory { + const val MAX_ENTRIES = 15 + + fun record(entries: List, entry: SearchHistoryEntry): List { + val normalizedEntry = entry.copy(project = entry.project.trim(), query = entry.query.trim()) + return listOf(normalizedEntry) + entries.filterNot { existing -> + existing.project.equals(normalizedEntry.project, ignoreCase = true) && + existing.query.equals(normalizedEntry.query, ignoreCase = true) + }.take(MAX_ENTRIES - 1) + } + + fun displayOrder(entries: List): List { + val newestEmail = entries + .asSequence() + .filter { it.queryType == SearchHistoryQueryType.EMAIL } + .maxByOrNull(SearchHistoryEntry::searchedAt) + return listOfNotNull(newestEmail) + entries + .filterNot { it == newestEmail } + .sortedByDescending(SearchHistoryEntry::searchedAt) + } + + fun latestEmail(entries: List): String = + entries.filter { it.queryType == SearchHistoryQueryType.EMAIL } + .maxByOrNull(SearchHistoryEntry::searchedAt) + ?.query + .orEmpty() + + fun legacyEmailEntry(email: String): SearchHistoryEntry? = + email.trim().takeIf(String::isNotBlank)?.let { + SearchHistoryEntry( + project = "try", + query = it, + queryType = SearchHistoryQueryType.EMAIL, + searchedAt = 0L, + ) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt index 8c42bde..0907398 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt @@ -8,6 +8,12 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.lan.LanReceiveIdentity /** @@ -19,15 +25,24 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos private object PreferenceKeys { val USER_EMAIL = stringPreferencesKey("user_email_preference_key") + val SEARCH_HISTORY = stringPreferencesKey("search_history_preference_key") val LAN_DEVICE_ID = stringPreferencesKey("lan_device_id") val LAN_DEVICE_NAME = stringPreferencesKey("lan_device_name") val LAN_SHARED_SECRET = stringPreferencesKey("lan_shared_secret") } - override val lastSearchedEmailFlow: Flow = appContext.dataStore.data - .map { preferences -> - preferences[PreferenceKeys.USER_EMAIL] ?: "" + override val searchHistoryFlow: Flow> = appContext.dataStore.data.map { preferences -> + val storedHistory = preferences[PreferenceKeys.SEARCH_HISTORY] + ?.let(::decodeSearchHistory) + .orEmpty() + if (storedHistory.isNotEmpty()) { + storedHistory + } else { + listOfNotNull(SearchHistory.legacyEmailEntry(preferences[PreferenceKeys.USER_EMAIL].orEmpty())) } + } + + override val lastSearchedEmailFlow: Flow = searchHistoryFlow.map(SearchHistory::latestEmail) override val lanReceiveIdentityFlow: Flow = appContext.dataStore.data .map { preferences -> @@ -46,8 +61,24 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos } override suspend fun saveLastSearchedEmail(email: String) { + recordSearch(project = "try", query = email) + } + + override suspend fun recordSearch(project: String, query: String, searchedAt: Long) { + val normalizedQuery = query.trim() + val queryType = if ('@' in normalizedQuery) SearchHistoryQueryType.EMAIL else SearchHistoryQueryType.REVISION + val entry = SearchHistoryEntry(project.trim(), normalizedQuery, queryType, searchedAt) appContext.dataStore.edit { preferences -> - preferences[PreferenceKeys.USER_EMAIL] = email + val existingEntries = preferences[PreferenceKeys.SEARCH_HISTORY] + ?.let(::decodeSearchHistory) + .orEmpty() + .ifEmpty { + listOfNotNull(SearchHistory.legacyEmailEntry(preferences[PreferenceKeys.USER_EMAIL].orEmpty())) + } + preferences[PreferenceKeys.SEARCH_HISTORY] = json.encodeToString(SearchHistory.record(existingEntries, entry)) + if (queryType == SearchHistoryQueryType.EMAIL) { + preferences[PreferenceKeys.USER_EMAIL] = normalizedQuery + } } } @@ -58,4 +89,11 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos preferences[PreferenceKeys.LAN_SHARED_SECRET] = identity.sharedSecret } } + + private fun decodeSearchHistory(serializedHistory: String): List = + runCatching { json.decodeFromString>(serializedHistory) }.getOrDefault(emptyList()) + + private companion object { + val json = Json { ignoreUnknownKeys = true } + } } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt index 1a87c79..cb32aae 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.data.repositories import kotlinx.coroutines.flow.Flow +import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.lan.LanReceiveIdentity /** @@ -12,6 +13,7 @@ interface UserDataRepository { * A flow that emits the last searched email. */ val lastSearchedEmailFlow: Flow + val searchHistoryFlow: Flow> val lanReceiveIdentityFlow: Flow /** @@ -19,5 +21,6 @@ interface UserDataRepository { * @param email The email to save. */ suspend fun saveLastSearchedEmail(email: String) + suspend fun recordSearch(project: String, query: String, searchedAt: Long = System.currentTimeMillis()) suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) } 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 bef83d8..ffd4182 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -56,6 +56,7 @@ import org.mozilla.tryfox.ui.screens.HomeViewModel import org.mozilla.tryfox.ui.screens.ProfileViewModel import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopViewModel import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryViewModel +import org.mozilla.tryfox.ui.screens.SearchHistoryViewModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE @@ -199,6 +200,7 @@ val viewModelModule = module { viewModel { HistoryViewModel(get(), get(), get(), get(), get(named("IODispatcher"))) } viewModel { ReceiveFromDesktopViewModel(get()) } viewModel { ReceiveMessageHistoryViewModel(get(), get(named("IODispatcher"))) } + viewModel { SearchHistoryViewModel(get()) } viewModel { val releaseRepositories = listOf( get(named(FENIX)), 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 7639afd..c5a65ea 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 @@ -197,7 +197,6 @@ class ProfileViewModel( return } viewModelScope.launch { - userDataRepository.saveLastSearchedEmail(emailToSearch) _isLoading.value = true _errorMessage.value = null _pushes.value = emptyList() @@ -280,6 +279,9 @@ class ProfileViewModel( _pushes.value = pushesWithJobsAndArtifacts syncLoadedStateDownloadStates() + if (pushesWithJobsAndArtifacts.isNotEmpty()) { + userDataRepository.recordSearch(_selectedProject.value, emailToSearch) + } logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } if (failedPushCount.get() > 0) { _errorMessage.value = "Some pushes could not be loaded." diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt new file mode 100644 index 0000000..1e2aa2b --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt @@ -0,0 +1,26 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.repositories.UserDataRepository + +class SearchHistoryViewModel( + private val userDataRepository: UserDataRepository, +) : ViewModel() { + val searchHistory: StateFlow> = userDataRepository.searchHistoryFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + fun recordSuccessfulSearch(project: String, query: String) { + viewModelScope.launch { + runCatching { userDataRepository.recordSearch(project, query) } + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt index 145ca63..c2b5b6d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt @@ -1,5 +1,7 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -7,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -14,6 +17,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -21,6 +25,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -42,19 +47,24 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import org.mozilla.tryfox.R import org.mozilla.tryfox.TryFoxViewModel +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.AppCard import org.mozilla.tryfox.ui.composables.BinButton @@ -72,6 +82,7 @@ private val projectDisplayToActualMap = mapOf( internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" internal const val TREEHERDER_RESULTS_HEADER_TAG = "treeherder_results_header" +internal const val TREEHERDER_SEARCH_HISTORY_TAG = "treeherder_search_history" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,6 +92,8 @@ fun SearchScreen( deepLinkRevision: String?, onNavigateUp: () -> Unit, onSearchEmail: (project: String, email: String) -> Unit = { _, _ -> }, + searchHistory: List = emptyList(), + onSearchSucceeded: (project: String, query: String) -> Unit = { _, _ -> }, ) { val cacheState by tryFoxViewModel.cacheState.collectAsState() val isDownloading by tryFoxViewModel.isDownloadingAnyFile.collectAsState() @@ -91,6 +104,24 @@ fun SearchScreen( ?.let { "Enter a valid email address or a revision without @." }, ) } + var hasSubmittedSearch by rememberSaveable(deepLinkRevision) { mutableStateOf(deepLinkRevision != null) } + var lastRecordedSearchKey by rememberSaveable { mutableStateOf(null) } + + fun submitSearch(project: String, query: String) { + when (val searchQuery = SearchQueryClassifier.classify(query).getOrNull()) { + is SearchQuery.Email -> { + queryValidationError = null + hasSubmittedSearch = true + onSearchEmail(project, searchQuery.value) + } + is SearchQuery.Revision -> { + queryValidationError = null + hasSubmittedSearch = true + tryFoxViewModel.searchJobsAndArtifacts() + } + null -> queryValidationError = "Enter a valid email address or a revision without @." + } + } LaunchedEffect(Unit) { tryFoxViewModel.checkCacheStatus() @@ -120,6 +151,16 @@ fun SearchScreen( } } + LaunchedEffect(tryFoxViewModel.successfulSearch) { + tryFoxViewModel.successfulSearch?.let { search -> + val searchKey = "${search.project}:${search.query}" + if (lastRecordedSearchKey != searchKey) { + onSearchSucceeded(search.project, search.query) + lastRecordedSearchKey = searchKey + } + } + } + val binButtonEnabled = !isDownloading && cacheState == CacheManagementState.IdleNonEmpty Scaffold( @@ -176,19 +217,15 @@ fun SearchScreen( tryFoxViewModel.updateRevision(it) }, onSearchClick = { - when (val query = SearchQueryClassifier.classify(tryFoxViewModel.revision).getOrNull()) { - is SearchQuery.Email -> { - queryValidationError = null - onSearchEmail(tryFoxViewModel.selectedProject, query.value) - } - is SearchQuery.Revision -> { - queryValidationError = null - tryFoxViewModel.searchJobsAndArtifacts() - } - null -> queryValidationError = "Enter a valid email address or a revision without @." - } + submitSearch(tryFoxViewModel.selectedProject, tryFoxViewModel.revision) }, isLoading = tryFoxViewModel.isLoading, + searchHistory = if (hasSubmittedSearch) emptyList() else SearchHistory.displayOrder(searchHistory), + onHistoryItemSelected = { entry -> + tryFoxViewModel.updateSelectedProject(entry.project) + tryFoxViewModel.updateRevision(entry.query) + submitSearch(entry.project, entry.query) + }, ) } @@ -260,7 +297,17 @@ fun TryFoxMainScreen( deepLinkRevision: String?, onNavigateUp: () -> Unit, onSearchEmail: (project: String, email: String) -> Unit = { _, _ -> }, -) = SearchScreen(tryFoxViewModel, deepLinkProject, deepLinkRevision, onNavigateUp, onSearchEmail) + searchHistory: List = emptyList(), + onSearchSucceeded: (project: String, query: String) -> Unit = { _, _ -> }, +) = SearchScreen( + tryFoxViewModel, + deepLinkProject, + deepLinkRevision, + onNavigateUp, + onSearchEmail, + searchHistory, + onSearchSucceeded, +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -271,6 +318,8 @@ fun SearchSection( onRevisionChange: (String) -> Unit, onSearchClick: () -> Unit, isLoading: Boolean, + searchHistory: List = emptyList(), + onHistoryItemSelected: (SearchHistoryEntry) -> Unit = {}, ) { val projectDisplayOptions = projectDisplayToActualMap.keys.toList() Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -290,12 +339,16 @@ fun SearchSection( value = revision, onValueChange = onRevisionChange, placeholder = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, - modifier = Modifier.weight(1f).height(52.dp).testTag("profile_email_input"), + modifier = Modifier.weight(1f).heightIn(min = 56.dp).testTag("profile_email_input"), singleLine = true, shape = RoundedCornerShape(20.dp), leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, colors = OutlinedTextFieldDefaults.colors(), - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Email), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Search, + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { onSearchClick() }), ) SearchButton( onClick = onSearchClick, @@ -304,6 +357,74 @@ fun SearchSection( modifier = Modifier.size(52.dp).testTag("profile_search_button"), ) } + SearchHistoryPanel( + entries = searchHistory.filter { it.query.contains(revision.trim(), ignoreCase = true) }, + onEntryClick = onHistoryItemSelected, + ) + } +} + +@Composable +private fun SearchHistoryPanel( + entries: List, + onEntryClick: (SearchHistoryEntry) -> Unit, +) { + if (entries.isEmpty()) return + + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(TREEHERDER_SEARCH_HISTORY_TAG), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column { + Text( + text = stringResource(R.string.search_history_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + entries.forEachIndexed { index, entry -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { onEntryClick(entry) } + .testTag("treeherder_search_history_$index") + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = entry.query, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = projectDisplayToActualMap.entries.firstOrNull { it.value == entry.project }?.key ?: entry.project, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 41c8a3c..905264c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ TryFox Search builds History + Recent searches Scan QR code Receive from desktop Fetching latest nightly builds... diff --git a/app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt b/app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt new file mode 100644 index 0000000..4ba2dd4 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt @@ -0,0 +1,47 @@ +package org.mozilla.tryfox.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SearchHistoryTest { + + @Test + fun `records newest first, deduplicates normalized project and query, and limits entries`() { + val initialEntries = (1..15).map { index -> + SearchHistoryEntry("try", "revision-$index", SearchHistoryQueryType.REVISION, index.toLong()) + } + + val updatedEntries = SearchHistory.record( + entries = initialEntries, + entry = SearchHistoryEntry("TRY", " Revision-5 ", SearchHistoryQueryType.REVISION, 20L), + ) + + assertEquals(15, updatedEntries.size) + assertEquals("Revision-5", updatedEntries.first().query) + assertEquals("revision-15", updatedEntries.last().query) + } + + @Test + fun `places the latest email before newer revision entries`() { + val entries = listOf( + SearchHistoryEntry("try", "revision", SearchHistoryQueryType.REVISION, 30L), + SearchHistoryEntry("mozilla-central", "older@mozilla.org", SearchHistoryQueryType.EMAIL, 20L), + SearchHistoryEntry("try", "old-revision", SearchHistoryQueryType.REVISION, 10L), + ) + + assertEquals( + listOf("older@mozilla.org", "revision", "old-revision"), + SearchHistory.displayOrder(entries).map(SearchHistoryEntry::query), + ) + assertEquals("older@mozilla.org", SearchHistory.latestEmail(entries)) + } + + @Test + fun `creates a legacy email entry only when history is empty`() { + val legacyEntry = SearchHistory.legacyEmailEntry("person@mozilla.org") + + assertEquals("person@mozilla.org", legacyEntry?.query) + assertEquals(SearchHistoryQueryType.EMAIL, legacyEntry?.queryType) + assertEquals(null, SearchHistory.legacyEmailEntry(" ")) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt index 0ff690d..f8826cc 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt @@ -2,6 +2,9 @@ package org.mozilla.tryfox.data.managers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity @@ -12,11 +15,22 @@ class FakeUserDataRepository : UserDataRepository { private val _lastSearchedEmailFlow = MutableStateFlow("") override val lastSearchedEmailFlow: Flow = _lastSearchedEmailFlow + private val _searchHistoryFlow = MutableStateFlow>(emptyList()) + override val searchHistoryFlow: Flow> = _searchHistoryFlow private val _lanReceiveIdentityFlow = MutableStateFlow(null) override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow override suspend fun saveLastSearchedEmail(email: String) { - _lastSearchedEmailFlow.value = email + recordSearch("try", email) + } + + override suspend fun recordSearch(project: String, query: String, searchedAt: Long) { + val queryType = if ('@' in query) SearchHistoryQueryType.EMAIL else SearchHistoryQueryType.REVISION + _searchHistoryFlow.value = SearchHistory.record( + _searchHistoryFlow.value, + SearchHistoryEntry(project, query, queryType, searchedAt), + ) + _lastSearchedEmailFlow.value = SearchHistory.latestEmail(_searchHistoryFlow.value) } override suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) { @@ -26,5 +40,6 @@ class FakeUserDataRepository : UserDataRepository { // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" + _searchHistoryFlow.value = emptyList() } } From c0de3a74b99a71003072fff1ae293cea8c1c0e8c Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 14:27:04 +0200 Subject: [PATCH 11/24] Polish search result labels and icons --- .../java/org/mozilla/tryfox/ui/composables/AppIcon.kt | 2 +- .../org/mozilla/tryfox/ui/screens/ProfileScreen.kt | 10 +++++++--- .../mozilla/tryfox/ui/screens/JobNameFormatterTest.kt | 6 ++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt index ab8b03e..2340740 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt @@ -66,7 +66,7 @@ fun AppIcon( } if (iconResId != null && contentDescResId != null) { - val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_BETA, FOCUS_BETA) + val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA) if (isPaddedSearchResultForeground) { Box(modifier = modifier.clipToBounds()) { Image( 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 7a07b3a..937d6bd 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 @@ -99,7 +99,7 @@ private fun projectDisplayName(project: String): String { } private val signingApkJobNamePattern = Regex( - pattern = "signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-firebase)?", + pattern = "signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-(firebase|simulation))?", option = RegexOption.IGNORE_CASE, ) @@ -111,8 +111,12 @@ internal fun formatJobNameForDisplay(jobName: String): String { else -> return jobName } val channel = match.groupValues[2].lowercase(Locale.ROOT) - val firebaseSuffix = if (match.groupValues[3].isNotEmpty()) " (firebase)" else "" - return "$appName $channel$firebaseSuffix" + val variantSuffix = when (match.groupValues[4].lowercase(Locale.ROOT)) { + "firebase" -> " (firebase)" + "simulation" -> " (perftests)" + else -> "" + } + return "$appName $channel$variantSuffix" } internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String { diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt index f4f6595..bed79c3 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt @@ -29,6 +29,12 @@ class JobNameFormatterTest { assertEquals("Focus beta (firebase)", formatJobNameForDisplay("signing-apk-focus-beta-firebase")) } + @Test + fun `formats simulation signing APK jobs as perftests`() { + assertEquals("Fenix nightly (perftests)", formatJobNameForDisplay("signing-apk-fenix-nightly-simulation")) + assertEquals("Focus beta (perftests)", formatJobNameForDisplay("SIGNING-APK-FOCUS-BETA-SIMULATION")) + } + @Test fun `preserves job names outside the signing APK naming convention`() { assertEquals("Build Fenix for arm64-v8a", formatJobNameForDisplay("Build Fenix for arm64-v8a")) From 4a7009e2d15914a33d708db480449a30b20aaf67 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 14:39:20 +0200 Subject: [PATCH 12/24] Unify search result and input presentation --- .../ui/screens/TreeherderApksScreenTest.kt | 10 +- .../mozilla/tryfox/ui/composables/AppCard.kt | 178 ----------------- .../tryfox/ui/screens/ProfileScreen.kt | 182 +++++++++++------- .../tryfox/ui/screens/TreeherderApksScreen.kt | 128 ++---------- app/src/main/res/values/strings.xml | 11 +- 5 files changed, 140 insertions(+), 369 deletions(-) delete mode 100644 app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt index c685039..c51ea53 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt @@ -46,6 +46,7 @@ class TreeherderApksScreenTest { @Test fun treeherderScreen_showsLoaderUntilSearchCompletes_thenDisplaysResults() { val targetJobName = "signing-apk-focus-nightly" + val targetDisplayName = "Focus nightly" val viewModel = TryFoxViewModel( fenixRepository = DelayedTreeherderRepository(targetJobName = targetJobName), downloadFileRepository = FakeDownloadFileRepository(), @@ -76,19 +77,19 @@ class TreeherderApksScreenTest { composeTestRule.onNodeWithTag(TREEHERDER_LOADING_STATE_TAG, useUnmergedTree = true) .assertIsDisplayed() - composeTestRule.onAllNodesWithText(targetJobName, substring = false, useUnmergedTree = true) + composeTestRule.onAllNodesWithText(targetDisplayName, substring = false, useUnmergedTree = true) .assertCountEquals(0) composeTestRule.waitUntil(timeoutMillis = 5_000) { - composeTestRule.onAllNodesWithTag(TREEHERDER_RESULTS_HEADER_TAG, useUnmergedTree = true) + composeTestRule.onAllNodesWithTag("revision_search_push_ed209aa2136b241686ff20489c5cb622348e2ecf", useUnmergedTree = true) .fetchSemanticsNodes().isNotEmpty() } composeTestRule.onAllNodesWithTag(TREEHERDER_LOADING_STATE_TAG, useUnmergedTree = true) .assertCountEquals(0) - composeTestRule.onNodeWithTag(TREEHERDER_RESULTS_HEADER_TAG, useUnmergedTree = true) + composeTestRule.onNodeWithText(targetDisplayName, substring = false, useUnmergedTree = true) .assertIsDisplayed() - composeTestRule.onNodeWithText(targetJobName, substring = false, useUnmergedTree = true) + composeTestRule.onNodeWithTag("revision_search_push_ed209aa2136b241686ff20489c5cb622348e2ecf", useUnmergedTree = true) .assertIsDisplayed() } @@ -131,6 +132,7 @@ class TreeherderApksScreenTest { } composeTestRule.onNodeWithTag("profile_email_input").performTextInput("no-match") + composeTestRule.onNodeWithTag("profile_email_clear_button").assertIsDisplayed() composeTestRule.onAllNodesWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertCountEquals(0) } 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 deleted file mode 100644 index a86d796..0000000 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt +++ /dev/null @@ -1,178 +0,0 @@ -package org.mozilla.tryfox.ui.composables - -import androidx.compose.foundation.clickable -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.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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 -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.mozilla.tryfox.R -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 - -@Composable -fun AppCard( - job: JobDetailsUiModel, - viewModel: TryFoxViewModel, -) { - val jobArtifacts = job.artifacts - val (supportedArtifacts, unsupportedArtifacts) = remember(jobArtifacts) { - jobArtifacts.partition { it.abi.isSupported } - } - - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp), - ) { - AppIcon(appName = job.appName, modifier = Modifier.size(24.dp)) - Text( - text = job.jobName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 12.dp), - ) { - AssistChip( - onClick = { /* No action needed */ }, - label = { Text(job.jobSymbol, style = MaterialTheme.typography.labelSmall) }, - colors = AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer, - ), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(id = R.string.app_card_task_id, job.taskId), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (viewModel.isLoadingJobArtifacts[job.taskId] == true && job.artifacts.isEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - Spacer(modifier = Modifier.width(8.dp)) - Text( - stringResource(id = R.string.app_card_loading_artifacts), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else if (job.artifacts.isEmpty() && viewModel.isLoadingJobArtifacts[job.taskId] == false) { - Text( - stringResource(id = R.string.app_card_no_apks_found), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } else { - if (supportedArtifacts.isNotEmpty()) { - Text( - text = stringResource(id = R.string.app_card_supported_apks_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - supportedArtifacts.forEach { artifactUiModel -> - DisplayArtifactCard( - artifact = artifactUiModel, - viewModel = viewModel, - ) - } - } - } - - if (unsupportedArtifacts.isNotEmpty()) { - var isExpanded by remember { mutableStateOf(false) } - val topPadding = if (supportedArtifacts.isNotEmpty()) 12.dp else 0.dp - Spacer(modifier = Modifier.padding(top = topPadding)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { isExpanded = !isExpanded } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.app_card_unsupported_apks_title, unsupportedArtifacts.size), - style = MaterialTheme.typography.titleMedium, - ) - Icon( - imageVector = if (isExpanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, - contentDescription = if (isExpanded) stringResource(id = R.string.app_card_collapse_description) else stringResource(id = R.string.app_card_expand_description), - ) - } - if (isExpanded) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(top = 8.dp), - ) { - unsupportedArtifacts.forEach { artifactUiModel -> - DisplayArtifactCard( - artifact = artifactUiModel, - viewModel = viewModel, - ) - } - } - } - } - } - } - } -} - -@Composable -private fun DisplayArtifactCard( - artifact: ArtifactUiModel, - viewModel: TryFoxViewModel, -) { - if (artifact.downloadState is DownloadState.DownloadFailed) { - 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)) - } - - ArtifactCard( - downloadState = artifact.downloadState, - abi = artifact.abi, - onDownloadClick = { - viewModel.downloadArtifact(artifact) - }, - onInstallClick = { viewModel.installApk(it) }, - ) -} 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 937d6bd..3eed2be 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -65,6 +66,7 @@ import org.mozilla.tryfox.ui.composables.DownloadButton import org.mozilla.tryfox.ui.composables.ErrorState import org.mozilla.tryfox.ui.composables.ProjectSelector import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment +import org.mozilla.tryfox.ui.models.ArtifactUiModel import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel import org.mozilla.tryfox.util.FENIX @@ -135,7 +137,7 @@ internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String } @Composable -private fun ProfileSearchButton( +internal fun SearchSubmitButton( onClick: () -> Unit, enabled: Boolean, isLoading: Boolean, @@ -176,7 +178,6 @@ private fun UserSearchCard( isLoading: Boolean, modifier: Modifier = Modifier, ) { - val keyboardController = LocalSoftwareKeyboardController.current val projects = listOf("try", "mozilla-central", "mozilla-beta", "mozilla-release") Column( modifier = modifier.fillMaxWidth(), @@ -189,59 +190,82 @@ private fun UserSearchCard( onProjectSelected = onProjectChange, modifier = Modifier.height(52.dp), ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = email, - onValueChange = onEmailChange, - placeholder = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, - modifier = Modifier - .weight(1f) - .height(52.dp) - .testTag("profile_email_input"), - singleLine = true, - shape = RoundedCornerShape(20.dp), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - ) - }, - trailingIcon = { - if (email.isNotEmpty()) { - IconButton( - onClick = { onEmailChange("") }, - modifier = Modifier.testTag("profile_email_clear_button"), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.profile_screen_clear_email_description), - ) - } + SearchInputRow( + query = email, + onQueryChange = onEmailChange, + onSearchClick = onSearchClick, + isLoading = isLoading, + ) + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun SearchInputRow( + query: String, + onQueryChange: (String) -> Unit, + onSearchClick: () -> Unit, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { + Text( + text = stringResource(id = R.string.profile_screen_user_email_label), + maxLines = 1, + ) + }, + modifier = Modifier + .weight(1f) + .heightIn(min = 56.dp) + .testTag("profile_email_input"), + singleLine = true, + shape = RoundedCornerShape(20.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton( + onClick = { onQueryChange("") }, + modifier = Modifier.testTag("profile_email_clear_button"), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.profile_screen_clear_email_description), + ) } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Search, - ), - keyboardActions = KeyboardActions(onSearch = { - onSearchClick() - keyboardController?.hide() - }), - ) - ProfileSearchButton( - onClick = { - onSearchClick() - keyboardController?.hide() - }, - enabled = !isLoading && email.isNotBlank(), - isLoading = isLoading, - modifier = Modifier.size(52.dp), - ) - } + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions(onSearch = { + onSearchClick() + keyboardController?.hide() + }), + ) + SearchSubmitButton( + onClick = { + onSearchClick() + keyboardController?.hide() + }, + enabled = !isLoading && query.isNotBlank(), + isLoading = isLoading, + modifier = Modifier.size(52.dp), + ) } } @@ -371,7 +395,12 @@ fun ProfileScreen( ) } items(pushes, key = { push -> push.revision ?: push.pushComment }) { push -> - EmailPushCard(push = push, profileViewModel = profileViewModel) + PushResultCard( + push = push, + onDownloadClick = profileViewModel::downloadArtifact, + onInstallClick = profileViewModel::installApk, + testTag = "email_search_push_${push.revision}", + ) } } } @@ -397,12 +426,18 @@ fun ProfileScreen( } @Composable -private fun EmailPushCard(push: PushUiModel, profileViewModel: ProfileViewModel) { +internal fun PushResultCard( + push: PushUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (java.io.File) -> Unit, + testTag: String, +) { val commitTitle = remember(push.pushComment) { push.pushComment.lineSequence().firstOrNull().orEmpty().trim() + .ifBlank { "Revision ${push.revision?.take(12).orEmpty()}" } } Card( - modifier = Modifier.fillMaxWidth().testTag("email_search_push_${push.revision}"), + modifier = Modifier.fillMaxWidth().testTag(testTag), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Column(modifier = Modifier.padding(16.dp)) { @@ -411,23 +446,36 @@ private fun EmailPushCard(push: PushUiModel, profileViewModel: ProfileViewModel) style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) - Text( - text = "${formatRelativePushTime(push.pushTimestamp)} · ${push.author}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) + if (push.pushTimestamp > 0L || push.author.isNotBlank()) { + Text( + text = listOfNotNull( + formatRelativePushTime(push.pushTimestamp).takeIf { push.pushTimestamp > 0L }, + push.author.takeIf(String::isNotBlank), + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + } HorizontalDivider(modifier = Modifier.padding(top = 14.dp)) push.jobs.forEachIndexed { index, job -> if (index > 0) HorizontalDivider() - CompactApkRow(job = job, profileViewModel = profileViewModel) + CompactApkRow( + job = job, + onDownloadClick = onDownloadClick, + onInstallClick = onInstallClick, + ) } } } } @Composable -private fun CompactApkRow(job: JobDetailsUiModel, profileViewModel: ProfileViewModel) { +private fun CompactApkRow( + job: JobDetailsUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (java.io.File) -> Unit, +) { val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } Row( @@ -449,8 +497,8 @@ private fun CompactApkRow(job: JobDetailsUiModel, profileViewModel: ProfileViewM apk?.let { DownloadButton( downloadState = it.downloadState, - onDownloadClick = { profileViewModel.downloadArtifact(it) }, - onInstallClick = profileViewModel::installApk, + onDownloadClick = { onDownloadClick(it) }, + onInstallClick = onInstallClick, modifier = Modifier.width(112.dp), inProgressText = stringResource(id = R.string.download_button_download), ) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt index c2b5b6d..1640561 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt @@ -13,14 +13,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.History -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 @@ -30,8 +26,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -56,8 +50,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -66,11 +58,9 @@ import org.mozilla.tryfox.TryFoxViewModel import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.ui.composables.AppCard import org.mozilla.tryfox.ui.composables.BinButton -import org.mozilla.tryfox.ui.composables.ErrorState import org.mozilla.tryfox.ui.composables.ProjectSelector -import org.mozilla.tryfox.ui.composables.PushCommentCard +import org.mozilla.tryfox.ui.models.PushUiModel // Project name mappings private val projectDisplayToActualMap = mapOf( @@ -81,7 +71,6 @@ private val projectDisplayToActualMap = mapOf( ) internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" -internal const val TREEHERDER_RESULTS_HEADER_TAG = "treeherder_results_header" internal const val TREEHERDER_SEARCH_HISTORY_TAG = "treeherder_search_history" @OptIn(ExperimentalMaterial3Api::class) @@ -238,52 +227,25 @@ fun SearchScreen( queryValidationError?.let { item { ErrorState(errorMessage = it) } } - tryFoxViewModel.relevantPushComment?.let { comment -> - val pushTimestamp = tryFoxViewModel.relevantPushTimestamp - if ((comment.isNotBlank() || tryFoxViewModel.relevantPushAuthor != null) && pushTimestamp != null) { - item { - PushCommentCard( - comment = comment, - author = tryFoxViewModel.relevantPushAuthor, - revision = tryFoxViewModel.revision, - pushTimestamp = pushTimestamp, - ) - } - } - } - if (tryFoxViewModel.isLoading) { item { LoadingState(candidateCount = tryFoxViewModel.isLoadingJobArtifacts.size) } } else if (tryFoxViewModel.selectedJobs.isNotEmpty()) { item { - Text( - text = stringResource(id = R.string.treeherder_apks_jobs_found_message, tryFoxViewModel.selectedJobs.size), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(bottom = 8.dp) - .testTag(TREEHERDER_RESULTS_HEADER_TAG), + PushResultCard( + push = PushUiModel( + pushComment = tryFoxViewModel.relevantPushComment.orEmpty(), + author = tryFoxViewModel.relevantPushAuthor.orEmpty(), + jobs = tryFoxViewModel.selectedJobs, + revision = tryFoxViewModel.revision, + pushTimestamp = tryFoxViewModel.relevantPushTimestamp ?: 0L, + ), + onDownloadClick = tryFoxViewModel::downloadArtifact, + onInstallClick = tryFoxViewModel::installApk, + testTag = "revision_search_push_${tryFoxViewModel.revision}", ) } - - items(tryFoxViewModel.selectedJobs, key = { it.taskId }) { job -> - AppCard(job = job, viewModel = tryFoxViewModel) - } - } else if (!tryFoxViewModel.isLoading && tryFoxViewModel.errorMessage == null && (tryFoxViewModel.relevantPushComment != null || tryFoxViewModel.relevantPushAuthor != null)) { - // Slightly adjusted logic to account for author possibly being present even if comment is not - if (tryFoxViewModel.relevantPushComment?.isNotBlank() == true || tryFoxViewModel.relevantPushAuthor != null) { - // This case should ideally be handled by the PushCommentCard itself not rendering if both are empty/null - } else { - item { - Text( - stringResource(id = R.string.treeherder_apks_no_jobs_found), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(16.dp), - ) - } - } } } } @@ -330,33 +292,12 @@ fun SearchSection( onProjectSelected = { displayProject -> onProjectSelected(projectDisplayToActualMap.getValue(displayProject)) }, modifier = Modifier.height(52.dp), ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = revision, - onValueChange = onRevisionChange, - placeholder = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, - modifier = Modifier.weight(1f).heightIn(min = 56.dp).testTag("profile_email_input"), - singleLine = true, - shape = RoundedCornerShape(20.dp), - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, - colors = OutlinedTextFieldDefaults.colors(), - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Search, - ), - keyboardActions = androidx.compose.foundation.text.KeyboardActions(onSearch = { onSearchClick() }), - ) - SearchButton( - onClick = onSearchClick, - enabled = !isLoading && revision.isNotBlank(), - isLoading = isLoading, - modifier = Modifier.size(52.dp).testTag("profile_search_button"), - ) - } + SearchInputRow( + query = revision, + onQueryChange = onRevisionChange, + onSearchClick = onSearchClick, + isLoading = isLoading, + ) SearchHistoryPanel( entries = searchHistory.filter { it.query.contains(revision.trim(), ignoreCase = true) }, onEntryClick = onHistoryItemSelected, @@ -428,39 +369,6 @@ private fun SearchHistoryPanel( } } -// Re-using the SearchButton from ProfileScreen implies it's either moved to a common composables location or defined here. -// For now, assuming it's defined in this file or accessible. If it was meant to be the ProfileScreen.SearchButton, -// this would need refactoring to a common composable. The current `SearchButton` defined below seems tailored for this screen. -@Composable -fun SearchButton( // This is the local SearchButton - onClick: () -> Unit, - enabled: Boolean, - isLoading: Boolean, - modifier: Modifier = Modifier, -) { - Button( - onClick = onClick, - enabled = enabled, - modifier = modifier, - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(horizontal = 0.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - Icons.Default.Search, - contentDescription = stringResource(id = R.string.treeherder_apks_search_button_description), // Specific description - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - } -} - @Composable fun LoadingState(candidateCount: Int) { Card( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 905264c..8d3cb9f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,19 +13,10 @@ Project Email or revision Email address or revision... - Found %1$d job(s) matching criteria: - No jobs found matching the specified criteria for this push. Search Searching for jobs and artifacts... Unknown Warning: Unsupported ABI - Task ID: %1$s - Loading artifacts for this job... - No APKs found for this job. - APKs supported by your device: - APKs unsupported by your device (%1$d) - Collapse - Expand Download failed: %1$s Firefox Nightly Icon Firefox Icon @@ -56,7 +47,7 @@ Compatible APKs No compatible APKs found for this job. Search builds - Revision number or email… + Revision or email Search builds Enter an email or revision and tap search. Clear email field From 4a18503f358d9b0b0da7d87feb4b66c261c38234 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 14:54:19 +0200 Subject: [PATCH 13/24] Animate search result editing --- .../tryfox/ui/screens/ProfileScreen.kt | 50 ++++- .../tryfox/ui/screens/ProfileViewModel.kt | 1 + .../tryfox/ui/screens/TreeherderApksScreen.kt | 183 +++++++++++------- 3 files changed, 166 insertions(+), 68 deletions(-) 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 3eed2be..4bfa672 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 @@ -1,5 +1,10 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,12 +47,17 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState 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.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource @@ -59,6 +69,7 @@ import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.AppIcon import org.mozilla.tryfox.ui.composables.BinButton @@ -176,6 +187,7 @@ private fun UserSearchCard( onProjectChange: (String) -> Unit, onSearchClick: () -> Unit, isLoading: Boolean, + onSearchFieldFocusChanged: (Boolean) -> Unit = {}, modifier: Modifier = Modifier, ) { val projects = listOf("try", "mozilla-central", "mozilla-beta", "mozilla-release") @@ -195,6 +207,7 @@ private fun UserSearchCard( onQueryChange = onEmailChange, onSearchClick = onSearchClick, isLoading = isLoading, + onFocusChanged = onSearchFieldFocusChanged, ) } } @@ -206,6 +219,7 @@ internal fun SearchInputRow( onQueryChange: (String) -> Unit, onSearchClick: () -> Unit, isLoading: Boolean, + onFocusChanged: (Boolean) -> Unit = {}, modifier: Modifier = Modifier, ) { val keyboardController = LocalSoftwareKeyboardController.current @@ -226,6 +240,7 @@ internal fun SearchInputRow( modifier = Modifier .weight(1f) .heightIn(min = 56.dp) + .onFocusChanged { onFocusChanged(it.isFocused) } .testTag("profile_email_input"), singleLine = true, shape = RoundedCornerShape(20.dp), @@ -290,6 +305,14 @@ fun ProfileScreen( val isLoading by profileViewModel.isLoading.collectAsState() val errorMessage by profileViewModel.errorMessage.collectAsState() val cacheState by profileViewModel.cacheState.collectAsState() + val searchHistory by profileViewModel.searchHistory.collectAsState(initial = emptyList()) + var displayedQuery by rememberSaveable { mutableStateOf("") } + var isSearchFieldFocused by remember { mutableStateOf(false) } + + LaunchedEffect(pushes) { + if (pushes.isNotEmpty()) displayedQuery = authorEmail + } + val isEditingDisplayedSearch = pushes.isNotEmpty() && isSearchFieldFocused && authorEmail != displayedQuery val isDownloading = remember(pushes) { pushes.any { push -> @@ -351,7 +374,10 @@ fun ProfileScreen( ) { UserSearchCard( email = authorEmail, - onEmailChange = { profileViewModel.updateAuthorEmail(it) }, + onEmailChange = { + isSearchFieldFocused = true + profileViewModel.updateAuthorEmail(it) + }, project = selectedProject, onProjectChange = { profileViewModel.updateSelectedProject(it) }, onSearchClick = { @@ -362,6 +388,22 @@ fun ProfileScreen( } }, isLoading = isLoading && pushes.isEmpty(), + onSearchFieldFocusChanged = { isSearchFieldFocused = it }, + ) + + SearchHistoryPanel( + entries = SearchHistory.displayOrder(searchHistory) + .filter { it.query.contains(authorEmail.trim(), ignoreCase = true) }, + visible = isEditingDisplayedSearch, + onEntryClick = { entry -> + profileViewModel.updateSelectedProject(entry.project) + profileViewModel.updateAuthorEmail(entry.query) + when (val query = SearchQueryClassifier.classify(entry.query).getOrNull()) { + is SearchQuery.Email -> profileViewModel.searchByAuthor() + is SearchQuery.Revision -> onSearchRevision(entry.project, query.value) + null -> profileViewModel.showInvalidQueryError() + } + }, ) when { @@ -379,6 +421,11 @@ fun ProfileScreen( } } pushes.isNotEmpty() -> { + AnimatedVisibility( + visible = !isEditingDisplayedSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { LazyColumn( contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -403,6 +450,7 @@ fun ProfileScreen( ) } } + } } errorMessage != null -> { ErrorState(errorMessage = errorMessage!!) 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 c5a65ea..99b29d8 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 @@ -112,6 +112,7 @@ class ProfileViewModel( private val pendingAutoInstallDownloads = mutableSetOf() val cacheState: StateFlow = cacheManager.cacheState + val searchHistory = userDataRepository.searchHistoryFlow private val deviceSupportedAbis: List by lazy { runCatching { Build.SUPPORTED_ABIS.toList() }.getOrDefault(emptyList()) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt index 1640561..a3c0760 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt @@ -1,5 +1,10 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -94,18 +99,28 @@ fun SearchScreen( ) } var hasSubmittedSearch by rememberSaveable(deepLinkRevision) { mutableStateOf(deepLinkRevision != null) } + var displayedQuery by rememberSaveable(deepLinkRevision) { mutableStateOf(deepLinkRevision.orEmpty()) } + var isSearchFieldFocused by remember { mutableStateOf(false) } var lastRecordedSearchKey by rememberSaveable { mutableStateOf(null) } + val isEditingDisplayedSearch = hasSubmittedSearch && + isSearchFieldFocused && + tryFoxViewModel.revision != displayedQuery + val showSearchHistory = !hasSubmittedSearch || isEditingDisplayedSearch + val showCurrentSearch = !isEditingDisplayedSearch + fun submitSearch(project: String, query: String) { when (val searchQuery = SearchQueryClassifier.classify(query).getOrNull()) { is SearchQuery.Email -> { queryValidationError = null hasSubmittedSearch = true + displayedQuery = query onSearchEmail(project, searchQuery.value) } is SearchQuery.Revision -> { queryValidationError = null hasSubmittedSearch = true + displayedQuery = query tryFoxViewModel.searchJobsAndArtifacts() } null -> queryValidationError = "Enter a valid email address or a revision without @." @@ -203,13 +218,18 @@ fun SearchScreen( revision = tryFoxViewModel.revision, onRevisionChange = { queryValidationError = null + // A text edit is unambiguously an editing interaction, including + // the trailing clear action whose focus callback can be delayed. + isSearchFieldFocused = true tryFoxViewModel.updateRevision(it) }, onSearchClick = { submitSearch(tryFoxViewModel.selectedProject, tryFoxViewModel.revision) }, isLoading = tryFoxViewModel.isLoading, - searchHistory = if (hasSubmittedSearch) emptyList() else SearchHistory.displayOrder(searchHistory), + showSearchHistory = showSearchHistory, + onSearchFieldFocusChanged = { isSearchFieldFocused = it }, + searchHistory = SearchHistory.displayOrder(searchHistory), onHistoryItemSelected = { entry -> tryFoxViewModel.updateSelectedProject(entry.project) tryFoxViewModel.updateRevision(entry.query) @@ -221,7 +241,15 @@ fun SearchScreen( tryFoxViewModel.errorMessage?.let { // TODO: Consider creating a specific string resource for \"Download failed\" if it's a common prefix for user-facing errors. if (tryFoxViewModel.selectedJobs.isEmpty() || !it.startsWith("Download failed")) { - item { ErrorState(errorMessage = it) } + item { + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ErrorState(errorMessage = it) + } + } } } @@ -229,22 +257,34 @@ fun SearchScreen( if (tryFoxViewModel.isLoading) { item { - LoadingState(candidateCount = tryFoxViewModel.isLoadingJobArtifacts.size) + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LoadingState(candidateCount = tryFoxViewModel.isLoadingJobArtifacts.size) + } } } else if (tryFoxViewModel.selectedJobs.isNotEmpty()) { item { - PushResultCard( - push = PushUiModel( - pushComment = tryFoxViewModel.relevantPushComment.orEmpty(), - author = tryFoxViewModel.relevantPushAuthor.orEmpty(), - jobs = tryFoxViewModel.selectedJobs, - revision = tryFoxViewModel.revision, - pushTimestamp = tryFoxViewModel.relevantPushTimestamp ?: 0L, - ), - onDownloadClick = tryFoxViewModel::downloadArtifact, - onInstallClick = tryFoxViewModel::installApk, - testTag = "revision_search_push_${tryFoxViewModel.revision}", - ) + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PushResultCard( + push = PushUiModel( + pushComment = tryFoxViewModel.relevantPushComment.orEmpty(), + author = tryFoxViewModel.relevantPushAuthor.orEmpty(), + jobs = tryFoxViewModel.selectedJobs, + revision = tryFoxViewModel.revision, + pushTimestamp = tryFoxViewModel.relevantPushTimestamp ?: 0L, + ), + onDownloadClick = tryFoxViewModel::downloadArtifact, + onInstallClick = tryFoxViewModel::installApk, + testTag = "revision_search_push_${tryFoxViewModel.revision}", + ) + } } } } @@ -280,6 +320,8 @@ fun SearchSection( onRevisionChange: (String) -> Unit, onSearchClick: () -> Unit, isLoading: Boolean, + showSearchHistory: Boolean = true, + onSearchFieldFocusChanged: (Boolean) -> Unit = {}, searchHistory: List = emptyList(), onHistoryItemSelected: (SearchHistoryEntry) -> Unit = {}, ) { @@ -297,72 +339,79 @@ fun SearchSection( onQueryChange = onRevisionChange, onSearchClick = onSearchClick, isLoading = isLoading, + onFocusChanged = onSearchFieldFocusChanged, ) SearchHistoryPanel( entries = searchHistory.filter { it.query.contains(revision.trim(), ignoreCase = true) }, + visible = showSearchHistory, onEntryClick = onHistoryItemSelected, ) } } @Composable -private fun SearchHistoryPanel( +internal fun SearchHistoryPanel( entries: List, + visible: Boolean, onEntryClick: (SearchHistoryEntry) -> Unit, ) { - if (entries.isEmpty()) return - - Card( - modifier = Modifier - .fillMaxWidth() - .testTag(TREEHERDER_SEARCH_HISTORY_TAG), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + AnimatedVisibility( + visible = visible && entries.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), ) { - Column { - Text( - text = stringResource(R.string.search_history_title), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - ) - entries.forEachIndexed { index, entry -> - if (index > 0) { - HorizontalDivider( - modifier = Modifier.padding(horizontal = 16.dp), - color = MaterialTheme.colorScheme.outlineVariant, - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .clip(RoundedCornerShape(12.dp)) - .clickable { onEntryClick(entry) } - .testTag("treeherder_search_history_$index") - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Default.History, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = entry.query, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) - Text( - text = projectDisplayToActualMap.entries.firstOrNull { it.value == entry.project }?.key ?: entry.project, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(TREEHERDER_SEARCH_HISTORY_TAG), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column { + Text( + text = stringResource(R.string.search_history_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + entries.forEachIndexed { index, entry -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + Row( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { onEntryClick(entry) } + .testTag("treeherder_search_history_$index") + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = entry.query, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = projectDisplayToActualMap.entries.firstOrNull { it.value == entry.project }?.key ?: entry.project, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } } } } From 282186316eb1215aacba5103b44164ce3c42e46c Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 15:30:56 +0200 Subject: [PATCH 14/24] Unify search result flow --- .../tryfox/MainActivityDeeplinkTest.kt | 4 +- .../tryfox/ui/screens/ProfileScreenTest.kt | 138 ----- ...rApksScreenTest.kt => SearchScreenTest.kt} | 6 +- .../main/java/org/mozilla/tryfox/AppRoutes.kt | 5 - .../java/org/mozilla/tryfox/MainActivity.kt | 69 +-- .../mozilla/tryfox/UnifiedSearchViewModel.kt | 8 +- .../java/org/mozilla/tryfox/di/AppModule.kt | 4 +- .../tryfox/ui/screens/ProfileScreen.kt | 555 ------------------ .../tryfox/ui/screens/ProfileViewModel.kt | 117 +++- .../tryfox/ui/screens/SearchResultCard.kt | 88 +++ ...reeherderApksScreen.kt => SearchScreen.kt} | 195 +++--- .../tryfox/ui/screens/ProfileViewModelTest.kt | 392 ------------- 12 files changed, 307 insertions(+), 1274 deletions(-) delete mode 100644 app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt rename app/src/androidTest/java/org/mozilla/tryfox/ui/screens/{TreeherderApksScreenTest.kt => SearchScreenTest.kt} (99%) delete mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt rename app/src/main/java/org/mozilla/tryfox/ui/screens/{TreeherderApksScreen.kt => SearchScreen.kt} (73%) delete mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt index 382a0d9..4f6a32c 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt @@ -82,7 +82,7 @@ class MainActivityDeeplinkTest { } @Test - fun testDeeplink_withAuthorEmail_populatesProfileScreen() { + fun testDeeplink_withAuthorEmail_populatesSearchScreen() { val email = "tthibaud@mozilla.com" val encodedEmail = "tthibaud%40mozilla.com" val deeplinkUri = @@ -118,7 +118,7 @@ class MainActivityDeeplinkTest { } @Test - fun testTryfoxScheme_withAuthorEmail_populatesProfileScreen() { + fun testTryfoxScheme_withAuthorEmail_populatesSearchScreen() { val email = "tthibaud@mozilla.com" val encodedEmail = "tthibaud%40mozilla.com" val deeplinkUri = 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 deleted file mode 100644 index 759b388..0000000 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mozilla.tryfox.data.FakeApkDownloadCoordinator -import org.mozilla.tryfox.data.FakeCacheManager -import org.mozilla.tryfox.data.FakeHistoryRepository -import org.mozilla.tryfox.data.FakeIntentManager -import org.mozilla.tryfox.data.FakeTreeherderRepository -import org.mozilla.tryfox.data.FakeUserDataRepository -import org.mozilla.tryfox.data.managers.CacheManager -import org.mozilla.tryfox.data.repositories.UserDataRepository - -@RunWith(AndroidJUnit4::class) -class ProfileScreenTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private val fenixRepository = FakeTreeherderRepository() - private val userDataRepository: UserDataRepository = FakeUserDataRepository() - private val cacheManager: CacheManager = FakeCacheManager() - private val intentManager = FakeIntentManager() - private val historyRepository = FakeHistoryRepository() - private val emailInputTag = "profile_email_input" - private val emailClearButtonTag = "profile_email_clear_button" - private val searchButtonTag = "profile_search_button" - - private val downloadButtonInitialTag = "action_button_download_initial" - private val downloadButtonLoadingTag = "action_button_downloading" - private val downloadButtonInstallTag = "action_button_install_ready" - - private val longTimeoutMillis = 1_000L - - @Test - fun searchPushesAndCheckDownloadAndInstallStates() { - val profileViewModel = ProfileViewModel( - fenixRepository = fenixRepository, - downloadCoordinator = FakeApkDownloadCoordinator(), - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - authorEmail = null, - ) - - composeTestRule.setContent { - ProfileScreen( - profileViewModel = profileViewModel, - onNavigateUp = { }, - ) - } - - val emailFieldNode = composeTestRule.onNodeWithTag(emailInputTag).fetchSemanticsNode() - if (emailFieldNode.config[SemanticsProperties.EditableText].text.isNotEmpty()) { - composeTestRule.onNodeWithTag(emailClearButtonTag).performClick() - } - - composeTestRule.onNodeWithTag(emailInputTag).performTextInput("example@mozilla.com") - composeTestRule.onNodeWithTag(searchButtonTag).performClick() - - composeTestRule.waitUntil("Wait for at least one download button", longTimeoutMillis) { - composeTestRule.onAllNodesWithTag(downloadButtonInitialTag, useUnmergedTree = true) - .fetchSemanticsNodes().isNotEmpty() - } - - val searchResultsHeading = composeTestRule - .onAllNodesWithTag("email_search_results_heading", useUnmergedTree = true) - .fetchSemanticsNodes() - assertTrue( - "Expected an email-search result count heading", - searchResultsHeading.isNotEmpty(), - ) - composeTestRule.onNodeWithTag("email_search_results_heading").assert(hasText("1 push found")) - composeTestRule.onNodeWithText("Build Fenix for arm64-v8a").assert(hasText("Build Fenix for arm64-v8a")) - - val pushCards = composeTestRule - .onAllNodesWithTag("email_search_push_fakerevision123", useUnmergedTree = true) - .fetchSemanticsNodes() - assertTrue( - "Expected a compact push card to be rendered for the Try push entry", - pushCards.isNotEmpty(), - ) - - composeTestRule.onNodeWithTag(downloadButtonInitialTag, useUnmergedTree = true) - .performClick() - - composeTestRule.waitUntil("Download button enters loading state", longTimeoutMillis) { - composeTestRule.onAllNodesWithTag(downloadButtonLoadingTag, useUnmergedTree = true) - .fetchSemanticsNodes().isNotEmpty() - } - - composeTestRule.waitUntil("Download button enters install state", longTimeoutMillis) { - composeTestRule.onAllNodesWithTag(downloadButtonInstallTag, useUnmergedTree = true) - .fetchSemanticsNodes().isNotEmpty() - } - - assertTrue( - "APK file should have been captured by onInstallApk callback", - intentManager.wasInstallApkCalled, - ) - } - - @Test - fun test_profileScreen_displays_initial_authorEmail_in_searchField() { - val initialEmail = "initial@example.com" - val profileViewModelWithEmail = ProfileViewModel( - fenixRepository = fenixRepository, - downloadCoordinator = FakeApkDownloadCoordinator(), - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - authorEmail = initialEmail, - ) - - composeTestRule.setContent { - ProfileScreen( - profileViewModel = profileViewModelWithEmail, - onNavigateUp = { }, - ) - } - - composeTestRule.onNodeWithTag(emailInputTag).assert(hasText(initialEmail)) - } -} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt similarity index 99% rename from app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt rename to app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt index c51ea53..38d0b16 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt @@ -38,7 +38,7 @@ import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.ui.theme.TryFoxTheme @RunWith(AndroidJUnit4::class) -class TreeherderApksScreenTest { +class SearchScreenTest { @get:Rule val composeTestRule = createComposeRule() @@ -61,7 +61,7 @@ class TreeherderApksScreenTest { composeTestRule.setContent { TryFoxTheme { - TryFoxMainScreen( + SearchScreen( tryFoxViewModel = viewModel, deepLinkProject = null, deepLinkRevision = null, @@ -152,7 +152,7 @@ class TreeherderApksScreenTest { composeTestRule.setContent { TryFoxTheme { - TryFoxMainScreen( + SearchScreen( tryFoxViewModel = viewModel, deepLinkProject = null, deepLinkRevision = null, diff --git a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt index 1df7cf0..58b9de3 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt @@ -10,16 +10,11 @@ object AppRoutes { const val QR_SCANNER = "qr_scanner" const val TREEHERDER_SEARCH = "treeherder_search" const val TREEHERDER_SEARCH_WITH_ARGS = "treeherder_search/{project}/{query}" - const val PROFILE_BY_EMAIL = "profile_by_email?email={email}" fun createTreeherderSearchRoute(project: String, query: String): String { return "treeherder_search/${encode(project)}/${encode(query)}" } - fun createProfileByEmailRoute(email: String): String { - return "profile_by_email?email=${encode(email)}" - } - private fun encode(value: String): String { return URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20") } diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 84b71d6..036a28c 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -23,14 +23,12 @@ import org.koin.core.parameter.parametersOf import org.mozilla.tryfox.EXTRA_RECEIVE_FROM_DESKTOP_START_REQUESTED import org.mozilla.tryfox.ui.screens.HistoryScreen import org.mozilla.tryfox.ui.screens.HomeScreen -import org.mozilla.tryfox.ui.screens.ProfileScreen import org.mozilla.tryfox.ui.screens.QrCodeScannerScreen import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopScreen import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryScreen import org.mozilla.tryfox.ui.screens.SearchHistoryViewModel -import org.mozilla.tryfox.ui.screens.SearchQuery -import org.mozilla.tryfox.ui.screens.SearchQueryClassifier -import org.mozilla.tryfox.ui.screens.TryFoxMainScreen +import org.mozilla.tryfox.ui.screens.SearchScreen +import org.mozilla.tryfox.ui.screens.SearchViewModel import org.mozilla.tryfox.ui.theme.TryFoxTheme /** @@ -77,13 +75,6 @@ sealed class NavScreen(val route: String) { query = revision, ) } - - /** - * Represents the Profile screen filtered by email. - */ - data object ProfileByEmail : NavScreen(AppRoutes.PROFILE_BY_EMAIL) { - fun createRoute(email: String) = AppRoutes.createProfileByEmailRoute(email) - } } /** @@ -184,20 +175,12 @@ class MainActivity : ComponentActivity() { composable(NavScreen.TreeherderSearch.route) { val searchHistory by appSearchHistoryViewModel.searchHistory.collectAsState() // mainActivityViewModel is already injected and passed as a parameter - TryFoxMainScreen( - tryFoxViewModel = koinViewModel(), + SearchScreen( + searchViewModel = koinViewModel { parametersOf("", "try") }, deepLinkProject = null, - deepLinkRevision = null, + deepLinkQuery = null, onNavigateUp = { localNavController.popBackStack() }, - onSearchEmail = { project, email -> - localNavController.navigate(AppRoutes.createTreeherderSearchRoute(project, email)) { - popUpTo(NavScreen.TreeherderSearch.route) { - inclusive = true - } - } - }, searchHistory = searchHistory, - onSearchSucceeded = appSearchHistoryViewModel::recordSuccessfulSearch, ) } composable( @@ -209,43 +192,13 @@ class MainActivity : ComponentActivity() { ) { backStackEntry -> val project = backStackEntry.arguments?.getString("project") val query = backStackEntry.arguments?.getString("query")?.let(Uri::decode).orEmpty() - when (SearchQueryClassifier.classify(query).getOrNull()) { - is SearchQuery.Email -> ProfileScreen( - onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel { parametersOf(query, project) }, - onSearchRevision = { selectedProject, revision -> - localNavController.navigate( - AppRoutes.createTreeherderSearchRoute(selectedProject, revision), - ) - }, - ) - is SearchQuery.Revision -> TryFoxMainScreen( - tryFoxViewModel = koinViewModel { parametersOf(project, query) }, - deepLinkProject = project, - deepLinkRevision = query, - onNavigateUp = { localNavController.popBackStack() }, - onSearchSucceeded = appSearchHistoryViewModel::recordSuccessfulSearch, - ) - null -> TryFoxMainScreen( - tryFoxViewModel = koinViewModel { parametersOf(project, query) }, - deepLinkProject = project, - deepLinkRevision = query, - onNavigateUp = { localNavController.popBackStack() }, - ) - } - } - composable( - route = NavScreen.ProfileByEmail.route, - arguments = listOf(navArgument("email") { type = NavType.StringType }), - ) { backStackEntry -> - val email = backStackEntry.arguments?.getString("email")?.let(Uri::decode) - - ProfileScreen( + val searchHistory by appSearchHistoryViewModel.searchHistory.collectAsState() + SearchScreen( + searchViewModel = koinViewModel { parametersOf("", project) }, + deepLinkProject = project, + deepLinkQuery = query, onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel { parametersOf(email, "try") }, - onSearchRevision = { project, revision -> - localNavController.navigate(AppRoutes.createTreeherderSearchRoute(project, revision)) - }, + searchHistory = searchHistory, ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt b/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt index 6966d6a..9e682d4 100644 --- a/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt @@ -1,7 +1,9 @@ package org.mozilla.tryfox +import org.mozilla.tryfox.ui.screens.SearchViewModel + /** - * Canonical name for the build-search state holder. The implementation remains in - * [TryFoxViewModel] while its long-lived download/cache API is migrated incrementally. + * Compatibility name for integrations which adopted the initial unified-search proposal. + * Both names resolve to the one shared state holder. */ -typealias UnifiedSearchViewModel = TryFoxViewModel +typealias UnifiedSearchViewModel = SearchViewModel 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 ffd4182..7205147 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -53,10 +53,10 @@ import org.mozilla.tryfox.network.MozillaArchivesApiService import org.mozilla.tryfox.network.TreeherderApiService import org.mozilla.tryfox.ui.screens.HistoryViewModel import org.mozilla.tryfox.ui.screens.HomeViewModel -import org.mozilla.tryfox.ui.screens.ProfileViewModel import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopViewModel import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryViewModel import org.mozilla.tryfox.ui.screens.SearchHistoryViewModel +import org.mozilla.tryfox.ui.screens.SearchViewModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE @@ -221,7 +221,7 @@ val viewModelModule = module { ) } viewModel { params -> - ProfileViewModel(get(), get(), get(), get(), get(), get(), params.getOrNull(), project = params.getOrNull() ?: "try") + SearchViewModel(get(), get(), get(), get(), get(), get(), params.getOrNull(), project = params.getOrNull() ?: "try") } } 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 deleted file mode 100644 index 4bfa672..0000000 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt +++ /dev/null @@ -1,555 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -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.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTooltipState -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.Hyphens -import androidx.compose.ui.unit.dp -import org.mozilla.tryfox.R -import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.SearchHistory -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.ProjectSelector -import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment -import org.mozilla.tryfox.ui.models.ArtifactUiModel -import org.mozilla.tryfox.ui.models.JobDetailsUiModel -import org.mozilla.tryfox.ui.models.PushUiModel -import org.mozilla.tryfox.util.FENIX -import org.mozilla.tryfox.util.FENIX_BETA -import org.mozilla.tryfox.util.FENIX_NIGHTLY -import org.mozilla.tryfox.util.FENIX_RELEASE -import org.mozilla.tryfox.util.FOCUS -import org.mozilla.tryfox.util.FOCUS_BETA -import org.mozilla.tryfox.util.FOCUS_NIGHTLY -import org.mozilla.tryfox.util.FOCUS_RELEASE -import java.util.Locale - -// Helper function to format app name for display -private fun formatAppNameForDisplay(appName: String): String { - return when (appName.lowercase(Locale.getDefault())) { - FENIX_NIGHTLY -> "Fenix Nightly" - FENIX -> "Fenix" - FOCUS -> "Focus Nightly" - FOCUS_RELEASE -> "Focus Release" - else -> appName.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } -} - -private fun projectDisplayName(project: String): String { - return when (project) { - "try" -> "try" - "mozilla-central" -> "central" - "mozilla-beta" -> "beta" - "mozilla-release" -> "release" - else -> project - } -} - -private val signingApkJobNamePattern = Regex( - pattern = "signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-(firebase|simulation))?", - option = RegexOption.IGNORE_CASE, -) - -internal fun formatJobNameForDisplay(jobName: String): String { - val match = signingApkJobNamePattern.matchEntire(jobName.trim()) ?: return jobName - val appName = when (match.groupValues[1].lowercase(Locale.ROOT)) { - FENIX -> "Fenix" - FOCUS -> "Focus" - else -> return jobName - } - val channel = match.groupValues[2].lowercase(Locale.ROOT) - val variantSuffix = when (match.groupValues[4].lowercase(Locale.ROOT)) { - "firebase" -> " (firebase)" - "simulation" -> " (perftests)" - else -> "" - } - return "$appName $channel$variantSuffix" -} - -internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String { - val normalizedJobName = jobName.lowercase(Locale.ROOT) - return when { - "focus-debug" in normalizedJobName -> FOCUS - "focus-nightly" in normalizedJobName -> FOCUS_NIGHTLY - "focus-beta" in normalizedJobName -> FOCUS_BETA - "focus" in normalizedJobName -> FOCUS - "fenix-debug" in normalizedJobName -> FENIX - "fenix-nightly" in normalizedJobName -> FENIX_NIGHTLY - "fenix-release" in normalizedJobName -> FENIX_RELEASE - "fenix-beta" in normalizedJobName -> FENIX_BETA - else -> fallbackAppName - } -} - -@Composable -internal fun SearchSubmitButton( - onClick: () -> Unit, - enabled: Boolean, - isLoading: Boolean, - modifier: Modifier = Modifier, -) { - Button( - onClick = onClick, - enabled = enabled, - modifier = modifier.testTag("profile_search_button"), - shape = RoundedCornerShape(24.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(0.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - Icons.Default.Search, - contentDescription = stringResource(id = R.string.profile_screen_search_button_description), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(horizontal = 12.dp), - ) - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun UserSearchCard( - email: String, - onEmailChange: (String) -> Unit, - project: String, - onProjectChange: (String) -> Unit, - onSearchClick: () -> Unit, - isLoading: Boolean, - onSearchFieldFocusChanged: (Boolean) -> Unit = {}, - modifier: Modifier = Modifier, -) { - val projects = listOf("try", "mozilla-central", "mozilla-beta", "mozilla-release") - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - ProjectSelector( - projects = projects, - selectedProject = project, - projectLabel = ::projectDisplayName, - onProjectSelected = onProjectChange, - modifier = Modifier.height(52.dp), - ) - SearchInputRow( - query = email, - onQueryChange = onEmailChange, - onSearchClick = onSearchClick, - isLoading = isLoading, - onFocusChanged = onSearchFieldFocusChanged, - ) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -internal fun SearchInputRow( - query: String, - onQueryChange: (String) -> Unit, - onSearchClick: () -> Unit, - isLoading: Boolean, - onFocusChanged: (Boolean) -> Unit = {}, - modifier: Modifier = Modifier, -) { - val keyboardController = LocalSoftwareKeyboardController.current - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - placeholder = { - Text( - text = stringResource(id = R.string.profile_screen_user_email_label), - maxLines = 1, - ) - }, - modifier = Modifier - .weight(1f) - .heightIn(min = 56.dp) - .onFocusChanged { onFocusChanged(it.isFocused) } - .testTag("profile_email_input"), - singleLine = true, - shape = RoundedCornerShape(20.dp), - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = null, - ) - }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton( - onClick = { onQueryChange("") }, - modifier = Modifier.testTag("profile_email_clear_button"), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.profile_screen_clear_email_description), - ) - } - } - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Email, - imeAction = ImeAction.Search, - ), - keyboardActions = KeyboardActions(onSearch = { - onSearchClick() - keyboardController?.hide() - }), - ) - SearchSubmitButton( - onClick = { - onSearchClick() - keyboardController?.hide() - }, - enabled = !isLoading && query.isNotBlank(), - isLoading = isLoading, - modifier = Modifier.size(52.dp), - ) - } -} - -/** - * Composable function for the Profile screen, which allows users to search for pushes by author email. - * - * @param modifier The modifier to be applied to the component. - * @param onNavigateUp Callback to navigate back to the previous screen. - * @param profileViewModel The ViewModel for the Profile screen. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ProfileScreen( - modifier: Modifier = Modifier, - onNavigateUp: () -> Unit, - profileViewModel: ProfileViewModel, - onSearchRevision: (project: String, revision: String) -> Unit = { _, _ -> }, -) { - val authorEmail by profileViewModel.authorEmail.collectAsState() - val selectedProject by profileViewModel.selectedProject.collectAsState() - val pushes by profileViewModel.pushes.collectAsState() - val isLoading by profileViewModel.isLoading.collectAsState() - val errorMessage by profileViewModel.errorMessage.collectAsState() - val cacheState by profileViewModel.cacheState.collectAsState() - val searchHistory by profileViewModel.searchHistory.collectAsState(initial = emptyList()) - var displayedQuery by rememberSaveable { mutableStateOf("") } - var isSearchFieldFocused by remember { mutableStateOf(false) } - - LaunchedEffect(pushes) { - if (pushes.isNotEmpty()) displayedQuery = authorEmail - } - val isEditingDisplayedSearch = pushes.isNotEmpty() && isSearchFieldFocused && authorEmail != displayedQuery - - val isDownloading = remember(pushes) { - pushes.any { push -> - push.jobs.any { job -> - job.artifacts.any { artifact -> - artifact.downloadState is DownloadState.InProgress - } - } - } - } - - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text(stringResource(id = R.string.profile_screen_title)) }, - navigationIcon = { - IconButton(onClick = onNavigateUp) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = R.string.common_back_button_description), - ) - } - }, - actions = { - val tooltipState = rememberTooltipState() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(id = R.string.bin_button_tooltip_clear_downloaded_apks)) - } - }, - state = tooltipState, - ) { - BinButton( - cacheState = cacheState, - onConfirm = { profileViewModel.clearAppCache() }, - enabled = !isDownloading && cacheState == CacheManagementState.IdleNonEmpty, - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp) - .padding(top = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - UserSearchCard( - email = authorEmail, - onEmailChange = { - isSearchFieldFocused = true - profileViewModel.updateAuthorEmail(it) - }, - project = selectedProject, - onProjectChange = { profileViewModel.updateSelectedProject(it) }, - onSearchClick = { - when (val query = SearchQueryClassifier.classify(authorEmail).getOrNull()) { - is SearchQuery.Email -> profileViewModel.searchByAuthor() - is SearchQuery.Revision -> onSearchRevision(selectedProject, query.value) - null -> profileViewModel.showInvalidQueryError() - } - }, - isLoading = isLoading && pushes.isEmpty(), - onSearchFieldFocusChanged = { isSearchFieldFocused = it }, - ) - - SearchHistoryPanel( - entries = SearchHistory.displayOrder(searchHistory) - .filter { it.query.contains(authorEmail.trim(), ignoreCase = true) }, - visible = isEditingDisplayedSearch, - onEntryClick = { entry -> - profileViewModel.updateSelectedProject(entry.project) - profileViewModel.updateAuthorEmail(entry.query) - when (val query = SearchQueryClassifier.classify(entry.query).getOrNull()) { - is SearchQuery.Email -> profileViewModel.searchByAuthor() - is SearchQuery.Revision -> onSearchRevision(entry.project, query.value) - null -> profileViewModel.showInvalidQueryError() - } - }, - ) - - when { - isLoading && pushes.isEmpty() && authorEmail.isNotBlank() -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Text( - text = stringResource(id = R.string.profile_screen_loading_pushes), - modifier = Modifier.padding(top = 8.dp), - ) - } - } - pushes.isNotEmpty() -> { - AnimatedVisibility( - visible = !isEditingDisplayedSearch, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LazyColumn( - contentPadding = PaddingValues(bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - errorMessage?.let { message -> - item { ErrorState(errorMessage = message) } - } - item { - Text( - text = pluralStringResource(R.plurals.profile_screen_pushes_found, pushes.size, pushes.size), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.testTag("email_search_results_heading"), - ) - } - items(pushes, key = { push -> push.revision ?: push.pushComment }) { push -> - PushResultCard( - push = push, - onDownloadClick = profileViewModel::downloadArtifact, - onInstallClick = profileViewModel::installApk, - testTag = "email_search_push_${push.revision}", - ) - } - } - } - } - errorMessage != null -> { - ErrorState(errorMessage = errorMessage!!) - } - !isLoading && errorMessage == null && pushes.isEmpty() -> { - Box( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - contentAlignment = Alignment.Center, - ) { - val message = if (authorEmail.isBlank()) { - stringResource(id = R.string.profile_screen_no_pushes_enter_email) - } else { - stringResource(id = R.string.profile_screen_no_pushes_found) - } - Text(message) - } - } - } - } - } -} - -@Composable -internal fun PushResultCard( - push: PushUiModel, - onDownloadClick: (ArtifactUiModel) -> Unit, - onInstallClick: (java.io.File) -> Unit, - testTag: String, -) { - val commitTitle = remember(push.pushComment) { - push.pushComment.lineSequence().firstOrNull().orEmpty().trim() - .ifBlank { "Revision ${push.revision?.take(12).orEmpty()}" } - } - Card( - modifier = Modifier.fillMaxWidth().testTag(testTag), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = rememberLinkedPushComment(commitTitle), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - if (push.pushTimestamp > 0L || push.author.isNotBlank()) { - Text( - text = listOfNotNull( - formatRelativePushTime(push.pushTimestamp).takeIf { push.pushTimestamp > 0L }, - push.author.takeIf(String::isNotBlank), - ).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - } - HorizontalDivider(modifier = Modifier.padding(top = 14.dp)) - push.jobs.forEachIndexed { index, job -> - if (index > 0) HorizontalDivider() - CompactApkRow( - job = job, - onDownloadClick = onDownloadClick, - onInstallClick = onInstallClick, - ) - } - } - } -} - -@Composable -private fun CompactApkRow( - job: JobDetailsUiModel, - onDownloadClick: (ArtifactUiModel) -> Unit, - onInstallClick: (java.io.File) -> Unit, -) { - val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } - val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - AppIcon( - appName = appIconName, - modifier = Modifier.size(34.dp), - useSearchResultVariant = true, - ) - Text( - text = job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), - style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - apk?.let { - DownloadButton( - downloadState = it.downloadState, - onDownloadClick = { onDownloadClick(it) }, - onInstallClick = onInstallClick, - modifier = Modifier.width(112.dp), - inProgressText = stringResource(id = R.string.download_button_download), - ) - } - } -} 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 99b29d8..30a55a8 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 @@ -70,7 +70,12 @@ internal fun isPerfAgainRetry(revisions: List): Boolean { * @param intentManager The manager for handling intents, such as APK installation. * @param authorEmail The initial author email to search for, can be null. */ -class ProfileViewModel( +/** + * State holder for every Treeherder search. The input is classified once at submission + * time; from that point on the presentation only consumes [pushes], regardless of whether + * the repository request was made by revision or by author email. + */ +class SearchViewModel( private val fenixRepository: TreeherderRepository, private val userDataRepository: UserDataRepository, private val cacheManager: CacheManager, @@ -88,7 +93,7 @@ class ProfileViewModel( ) companion object { - private const val TAG = "ProfileViewModel" + private const val TAG = "SearchViewModel" private val apkJobNameHints = listOf("signing-apk", "android-apk", "apk-focus", "apk-fenix", "apk-reference-browser", "apk-geckoview") private val nonAndroidPlatformHints = listOf("ios", "mac", "macos", "macosx", "win", "windows", "linux", "desktop") private val androidProductHints = listOf("focus", "fenix", "reference-browser", "geckoview", "android") @@ -97,6 +102,9 @@ class ProfileViewModel( private val _authorEmail = MutableStateFlow(authorEmail ?: "") val authorEmail: StateFlow = _authorEmail.asStateFlow() + /** The shared search field value. Kept as an alias during the email API migration. */ + val query: StateFlow = _authorEmail.asStateFlow() + private val _selectedProject = MutableStateFlow(project) val selectedProject: StateFlow = _selectedProject.asStateFlow() @@ -119,7 +127,7 @@ class ProfileViewModel( } init { - logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel for email: $authorEmail" } + logcat(LogPriority.DEBUG, TAG) { "Initializing SearchViewModel for query: $authorEmail" } downloadCoordinator.downloads .onEach { persistedDownloads -> downloadStates.value = persistedDownloads @@ -146,7 +154,7 @@ class ProfileViewModel( }.launchIn(viewModelScope) if (authorEmail != null) { - searchByAuthor() + submitSearch() } else { loadLastSearchedEmail() } @@ -171,6 +179,8 @@ class ProfileViewModel( _errorMessage.value = null } + fun updateQuery(query: String) = updateAuthorEmail(query) + fun updateSelectedProject(project: String) { _selectedProject.value = project } @@ -181,10 +191,109 @@ class ProfileViewModel( searchByAuthor() } + /** Applies either kind of deep link to the same model and executes the matching request. */ + fun setQueryFromDeepLinkAndSearch(project: String?, query: String) { + _selectedProject.value = project ?: "try" + _authorEmail.value = query + submitSearch() + } + fun showInvalidQueryError() { _errorMessage.value = "Enter a valid email address or a revision without @." } + /** + * The sole search entry point used by the shared screen. Query kind changes only the + * Treeherder operation; loading, errors, results and artifact actions stay shared. + */ + fun submitSearch() { + when (val parsed = SearchQueryClassifier.classify(_authorEmail.value).getOrNull()) { + is SearchQuery.Email -> searchByAuthor() + is SearchQuery.Revision -> searchByRevision(parsed.value) + null -> showInvalidQueryError() + } + } + + private fun searchByRevision(revision: String) { + if (revision.isBlank()) { + _errorMessage.value = "Please enter a revision to search." + return + } + viewModelScope.launch { + _isLoading.value = true + _errorMessage.value = null + _pushes.value = emptyList() + when (val pushResult = fenixRepository.getPushByRevision(_selectedProject.value, revision)) { + is NetworkResult.Success -> { + val push = pushResult.data.results.firstOrNull() + if (push == null) { + _errorMessage.value = "No push found for project: ${_selectedProject.value}, revision: $revision" + } else { + val jobsResult = fenixRepository.getJobsForPush(push.id) + if (jobsResult is NetworkResult.Success) { + val jobs = jobsResult.data.results + .filter(::isAndroidApkCandidate) + .filter { it.jobName.contains("signing-apk", ignoreCase = true) } + .ifEmpty { jobsResult.data.results.filter(::isAndroidApkCandidate) } + .map { job -> + val artifacts = fetchArtifacts(job.taskId) + if (artifacts.artifacts.isEmpty()) null else JobDetailsUiModel( + appName = job.appName, + jobName = job.jobName, + jobSymbol = job.jobSymbol, + taskId = job.taskId, + isSignedBuild = job.isSignedBuild, + isTest = job.isTest, + artifacts = artifacts.artifacts, + ) + }.filterNotNull() + if (jobs.isEmpty()) { + _errorMessage.value = "No signed builds found for this revision." + } else { + val precedingRevisions = if (isPerfAgainRetry(push.revisions)) { + when ( + val authorPushes = fenixRepository.getPushesByAuthor( + _selectedProject.value, + push.author, + ) + ) { + is NetworkResult.Success -> { + val pushIndex = authorPushes.data.results.indexOfFirst { it.id == push.id } + authorPushes.data.results + .take(pushIndex.coerceAtLeast(0)) + .asReversed() + .map { it.revisions } + } + is NetworkResult.Error -> emptyList() + } + } else { + emptyList() + } + _pushes.value = listOf( + PushUiModel( + pushComment = selectPreferredPushComment(push.revisions, precedingRevisions), + author = push.author, + jobs = jobs, + revision = push.revision, + pushTimestamp = push.pushTimestamp, + ), + ) + syncLoadedStateDownloadStates() + userDataRepository.recordSearch(_selectedProject.value, revision) + } + } else { + _errorMessage.value = "Error fetching jobs: ${(jobsResult as NetworkResult.Error).message}" + } + } + } + is NetworkResult.Error -> { + _errorMessage.value = "Error fetching revision details for ${_selectedProject.value}: ${pushResult.message}" + } + } + _isLoading.value = false + } + } + fun searchByAuthor() { val emailToSearch = _authorEmail.value logcat(TAG) { "searchByAuthor called for email: $emailToSearch" } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt new file mode 100644 index 0000000..23a0b46 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt @@ -0,0 +1,88 @@ +package org.mozilla.tryfox.ui.screens + +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.R +import org.mozilla.tryfox.ui.composables.AppIcon +import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment +import org.mozilla.tryfox.ui.models.ArtifactUiModel +import org.mozilla.tryfox.ui.models.JobDetailsUiModel +import org.mozilla.tryfox.ui.models.PushUiModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_NIGHTLY +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_NIGHTLY +import org.mozilla.tryfox.util.FOCUS_RELEASE +import java.io.File +import java.util.Locale + +@Composable +internal fun PushResultCard(push: PushUiModel, onDownloadClick: (ArtifactUiModel) -> Unit, onInstallClick: (File) -> Unit, testTag: String) { + val commitTitle = remember(push.pushComment) { push.pushComment.lineSequence().firstOrNull().orEmpty().trim().ifBlank { "Revision ${push.revision?.take(12).orEmpty()}" } } + Card(modifier = Modifier.fillMaxWidth().testTag(testTag), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + Text(rememberLinkedPushComment(commitTitle), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (push.pushTimestamp > 0L || push.author.isNotBlank()) { + Text(listOfNotNull(formatRelativePushTime(push.pushTimestamp).takeIf { push.pushTimestamp > 0L }, push.author.takeIf(String::isNotBlank)).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp)) + } + HorizontalDivider(modifier = Modifier.padding(top = 14.dp)) + push.jobs.forEachIndexed { index, job -> + if (index > 0) HorizontalDivider() + CompactApkRow(job, onDownloadClick, onInstallClick) + } + } + } +} + +@Composable +private fun CompactApkRow(job: JobDetailsUiModel, onDownloadClick: (ArtifactUiModel) -> Unit, onInstallClick: (File) -> Unit) { + val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } + val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + AppIcon(appName = appIconName, modifier = Modifier.size(34.dp), useSearchResultVariant = true) + Text(job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + apk?.let { DownloadButton(downloadState = it.downloadState, onDownloadClick = { onDownloadClick(it) }, onInstallClick = onInstallClick, modifier = Modifier.width(112.dp), inProgressText = stringResource(id = R.string.download_button_download)) } + } +} + +private fun formatAppNameForDisplay(appName: String): String = when (appName.lowercase(Locale.getDefault())) { + FENIX_NIGHTLY -> "Fenix Nightly"; FENIX -> "Fenix"; FOCUS -> "Focus Nightly"; FOCUS_RELEASE -> "Focus Release" + else -> appName.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } +} + +private val signingApkJobNamePattern = Regex("signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-(firebase|simulation))?", RegexOption.IGNORE_CASE) +internal fun formatJobNameForDisplay(jobName: String): String { + val match = signingApkJobNamePattern.matchEntire(jobName.trim()) ?: return jobName + val appName = when (match.groupValues[1].lowercase(Locale.ROOT)) { FENIX -> "Fenix"; FOCUS -> "Focus"; else -> return jobName } + val suffix = when (match.groupValues[4].lowercase(Locale.ROOT)) { "firebase" -> " (firebase)"; "simulation" -> " (perftests)"; else -> "" } + return "$appName ${match.groupValues[2].lowercase(Locale.ROOT)}$suffix" +} +internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String = when { + "focus-debug" in jobName.lowercase(Locale.ROOT) -> FOCUS; "focus-nightly" in jobName.lowercase(Locale.ROOT) -> FOCUS_NIGHTLY; "focus-beta" in jobName.lowercase(Locale.ROOT) -> FOCUS_BETA; "focus" in jobName.lowercase(Locale.ROOT) -> FOCUS + "fenix-debug" in jobName.lowercase(Locale.ROOT) -> FENIX; "fenix-nightly" in jobName.lowercase(Locale.ROOT) -> FENIX_NIGHTLY; "fenix-release" in jobName.lowercase(Locale.ROOT) -> FENIX_RELEASE; "fenix-beta" in jobName.lowercase(Locale.ROOT) -> FENIX_BETA + else -> fallbackAppName +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt similarity index 73% rename from app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt rename to app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt index a3c0760..835c6b5 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -18,10 +18,17 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.History +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 @@ -40,7 +47,6 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -49,23 +55,23 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import org.mozilla.tryfox.R -import org.mozilla.tryfox.TryFoxViewModel import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.BinButton import org.mozilla.tryfox.ui.composables.ProjectSelector -import org.mozilla.tryfox.ui.models.PushUiModel // Project name mappings private val projectDisplayToActualMap = mapOf( @@ -81,88 +87,49 @@ internal const val TREEHERDER_SEARCH_HISTORY_TAG = "treeherder_search_history" @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( - tryFoxViewModel: TryFoxViewModel, + searchViewModel: SearchViewModel, deepLinkProject: String?, - deepLinkRevision: String?, + deepLinkQuery: String?, onNavigateUp: () -> Unit, - onSearchEmail: (project: String, email: String) -> Unit = { _, _ -> }, searchHistory: List = emptyList(), - onSearchSucceeded: (project: String, query: String) -> Unit = { _, _ -> }, ) { - val cacheState by tryFoxViewModel.cacheState.collectAsState() - val isDownloading by tryFoxViewModel.isDownloadingAnyFile.collectAsState() - val lifecycleOwner = LocalLifecycleOwner.current - var queryValidationError by remember(deepLinkRevision) { + val cacheState by searchViewModel.cacheState.collectAsState() + val query by searchViewModel.query.collectAsState() + val selectedProject by searchViewModel.selectedProject.collectAsState() + val isLoading by searchViewModel.isLoading.collectAsState() + val errorMessage by searchViewModel.errorMessage.collectAsState() + val pushes by searchViewModel.pushes.collectAsState() + val isDownloading = pushes.any { push -> push.jobs.any { job -> job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } } } + var queryValidationError by remember(deepLinkQuery) { mutableStateOf( - deepLinkRevision?.takeIf { SearchQueryClassifier.classify(it).isFailure } + deepLinkQuery?.takeIf { SearchQueryClassifier.classify(it).isFailure } ?.let { "Enter a valid email address or a revision without @." }, ) } - var hasSubmittedSearch by rememberSaveable(deepLinkRevision) { mutableStateOf(deepLinkRevision != null) } - var displayedQuery by rememberSaveable(deepLinkRevision) { mutableStateOf(deepLinkRevision.orEmpty()) } + var hasSubmittedSearch by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery != null) } + var displayedQuery by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery.orEmpty()) } var isSearchFieldFocused by remember { mutableStateOf(false) } - var lastRecordedSearchKey by rememberSaveable { mutableStateOf(null) } val isEditingDisplayedSearch = hasSubmittedSearch && isSearchFieldFocused && - tryFoxViewModel.revision != displayedQuery + query != displayedQuery val showSearchHistory = !hasSubmittedSearch || isEditingDisplayedSearch val showCurrentSearch = !isEditingDisplayedSearch - fun submitSearch(project: String, query: String) { - when (val searchQuery = SearchQueryClassifier.classify(query).getOrNull()) { - is SearchQuery.Email -> { + fun submitSearch(queryToSubmit: String) { + when (SearchQueryClassifier.classify(queryToSubmit).getOrNull()) { + is SearchQuery.Email, is SearchQuery.Revision -> { queryValidationError = null hasSubmittedSearch = true - displayedQuery = query - onSearchEmail(project, searchQuery.value) - } - is SearchQuery.Revision -> { - queryValidationError = null - hasSubmittedSearch = true - displayedQuery = query - tryFoxViewModel.searchJobsAndArtifacts() + displayedQuery = queryToSubmit + searchViewModel.submitSearch() } null -> queryValidationError = "Enter a valid email address or a revision without @." } } - LaunchedEffect(Unit) { - tryFoxViewModel.checkCacheStatus() - } - - DisposableEffect(lifecycleOwner, tryFoxViewModel) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - tryFoxViewModel.checkCacheStatus() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LaunchedEffect(deepLinkProject, deepLinkRevision) { - val revisionQuery = SearchQueryClassifier.classify(deepLinkRevision.orEmpty()).getOrNull() as? SearchQuery.Revision - if (revisionQuery != null) { - val resolvedProject = deepLinkProject ?: "try" - val projectChanged = tryFoxViewModel.selectedProject != resolvedProject - val revisionChanged = tryFoxViewModel.revision != revisionQuery.value - if (projectChanged || revisionChanged) { - tryFoxViewModel.setRevisionFromDeepLinkAndSearch(resolvedProject, revisionQuery.value) - } - } - } - - LaunchedEffect(tryFoxViewModel.successfulSearch) { - tryFoxViewModel.successfulSearch?.let { search -> - val searchKey = "${search.project}:${search.query}" - if (lastRecordedSearchKey != searchKey) { - onSearchSucceeded(search.project, search.query) - lastRecordedSearchKey = searchKey - } - } + LaunchedEffect(deepLinkProject, deepLinkQuery) { + deepLinkQuery?.let { searchViewModel.setQueryFromDeepLinkAndSearch(deepLinkProject, it) } } val binButtonEnabled = !isDownloading && cacheState == CacheManagementState.IdleNonEmpty @@ -190,7 +157,7 @@ fun SearchScreen( ) { BinButton( cacheState = cacheState, - onConfirm = { tryFoxViewModel.clearAppCache() }, + onConfirm = { searchViewModel.clearAppCache() }, enabled = binButtonEnabled, ) } @@ -213,34 +180,34 @@ fun SearchScreen( ) { item { SearchSection( - selectedProject = tryFoxViewModel.selectedProject, - onProjectSelected = { actualProjectValue -> tryFoxViewModel.updateSelectedProject(actualProjectValue) }, - revision = tryFoxViewModel.revision, + selectedProject = selectedProject, + onProjectSelected = searchViewModel::updateSelectedProject, + revision = query, onRevisionChange = { queryValidationError = null // A text edit is unambiguously an editing interaction, including // the trailing clear action whose focus callback can be delayed. isSearchFieldFocused = true - tryFoxViewModel.updateRevision(it) + searchViewModel.updateQuery(it) }, onSearchClick = { - submitSearch(tryFoxViewModel.selectedProject, tryFoxViewModel.revision) + submitSearch(query) }, - isLoading = tryFoxViewModel.isLoading, + isLoading = isLoading, showSearchHistory = showSearchHistory, onSearchFieldFocusChanged = { isSearchFieldFocused = it }, searchHistory = SearchHistory.displayOrder(searchHistory), onHistoryItemSelected = { entry -> - tryFoxViewModel.updateSelectedProject(entry.project) - tryFoxViewModel.updateRevision(entry.query) - submitSearch(entry.project, entry.query) + searchViewModel.updateSelectedProject(entry.project) + searchViewModel.updateQuery(entry.query) + submitSearch(entry.query) }, ) } - tryFoxViewModel.errorMessage?.let { + errorMessage?.let { // TODO: Consider creating a specific string resource for \"Download failed\" if it's a common prefix for user-facing errors. - if (tryFoxViewModel.selectedJobs.isEmpty() || !it.startsWith("Download failed")) { + if (pushes.isEmpty() || !it.startsWith("Download failed")) { item { AnimatedVisibility( visible = showCurrentSearch, @@ -255,34 +222,28 @@ fun SearchScreen( queryValidationError?.let { item { ErrorState(errorMessage = it) } } - if (tryFoxViewModel.isLoading) { + if (isLoading) { item { AnimatedVisibility( visible = showCurrentSearch, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - LoadingState(candidateCount = tryFoxViewModel.isLoadingJobArtifacts.size) + LoadingState(candidateCount = 0) } } - } else if (tryFoxViewModel.selectedJobs.isNotEmpty()) { - item { + } else if (pushes.isNotEmpty()) { + items(pushes.size) { index -> AnimatedVisibility( visible = showCurrentSearch, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { PushResultCard( - push = PushUiModel( - pushComment = tryFoxViewModel.relevantPushComment.orEmpty(), - author = tryFoxViewModel.relevantPushAuthor.orEmpty(), - jobs = tryFoxViewModel.selectedJobs, - revision = tryFoxViewModel.revision, - pushTimestamp = tryFoxViewModel.relevantPushTimestamp ?: 0L, - ), - onDownloadClick = tryFoxViewModel::downloadArtifact, - onInstallClick = tryFoxViewModel::installApk, - testTag = "revision_search_push_${tryFoxViewModel.revision}", + push = pushes[index], + onDownloadClick = searchViewModel::downloadArtifact, + onInstallClick = searchViewModel::installApk, + testTag = "search_push_${pushes[index].revision}", ) } } @@ -291,26 +252,6 @@ fun SearchScreen( } } -/** Backwards-compatible name retained for callers and existing UI tests. */ -@Composable -fun TryFoxMainScreen( - tryFoxViewModel: TryFoxViewModel, - deepLinkProject: String?, - deepLinkRevision: String?, - onNavigateUp: () -> Unit, - onSearchEmail: (project: String, email: String) -> Unit = { _, _ -> }, - searchHistory: List = emptyList(), - onSearchSucceeded: (project: String, query: String) -> Unit = { _, _ -> }, -) = SearchScreen( - tryFoxViewModel, - deepLinkProject, - deepLinkRevision, - onNavigateUp, - onSearchEmail, - searchHistory, - onSearchSucceeded, -) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchSection( @@ -418,6 +359,36 @@ internal fun SearchHistoryPanel( } } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun SearchInputRow( + query: String, + onQueryChange: (String) -> Unit, + onSearchClick: () -> Unit, + isLoading: Boolean, + onFocusChanged: (Boolean) -> Unit = {}, +) { + val keyboardController = LocalSoftwareKeyboardController.current + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(stringResource(R.string.profile_screen_user_email_label), maxLines = 1) }, + modifier = Modifier.weight(1f).heightIn(min = 56.dp).onFocusChanged { onFocusChanged(it.isFocused) }.testTag("search_query_input"), + singleLine = true, + shape = RoundedCornerShape(20.dp), + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { if (query.isNotEmpty()) IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.profile_screen_clear_email_description)) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearchClick(); keyboardController?.hide() }), + ) + Button(onClick = { onSearchClick(); keyboardController?.hide() }, enabled = !isLoading && query.isNotBlank(), modifier = Modifier.size(52.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), contentPadding = PaddingValues(0.dp)) { + if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary) + else Icon(Icons.Default.Search, contentDescription = stringResource(R.string.profile_screen_search_button_description)) + } + } +} + @Composable fun LoadingState(candidateCount: Int) { Card( 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 deleted file mode 100644 index 484d019..0000000 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt +++ /dev/null @@ -1,392 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -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.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension -import org.junit.jupiter.api.io.TempDir -import org.mozilla.tryfox.data.Artifact -import org.mozilla.tryfox.data.ArtifactsResponse -import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.FakeHistoryRepository -import org.mozilla.tryfox.data.JobDetails -import org.mozilla.tryfox.data.NetworkResult -import org.mozilla.tryfox.data.RevisionDetail -import org.mozilla.tryfox.data.RevisionMeta -import org.mozilla.tryfox.data.RevisionResult -import org.mozilla.tryfox.data.TreeherderJobsResponse -import org.mozilla.tryfox.data.TreeherderRevisionResponse -import org.mozilla.tryfox.data.managers.FakeCacheManager -import org.mozilla.tryfox.data.managers.FakeIntentManager -import org.mozilla.tryfox.data.managers.FakeUserDataRepository -import org.mozilla.tryfox.data.repositories.TreeherderRepository -import org.mozilla.tryfox.download.ApkDownloadCoordinator -import org.mozilla.tryfox.download.ApkDownloadRequest -import org.mozilla.tryfox.download.model.DownloadStatus -import org.mozilla.tryfox.download.model.PersistedDownloadState -import org.mozilla.tryfox.util.TREEHERDER -import java.io.File - -@ExperimentalCoroutinesApi -class ProfileViewModelTest { - - @JvmField - @RegisterExtension - val mainCoroutineRule = MainCoroutineRule() - - private lateinit var viewModel: ProfileViewModel - private lateinit var cacheManager: FakeCacheManager - private lateinit var downloadCoordinator: FakeApkDownloadCoordinator - - private lateinit var fenixRepository: FakeTreeherderRepository - - private val userDataRepository = FakeUserDataRepository() - private val intentManager = FakeIntentManager() - private val historyRepository = FakeHistoryRepository() - - @TempDir - lateinit var tempCacheDir: File - - @BeforeEach - fun setUp() = runTest { - cacheManager = FakeCacheManager(tempCacheDir) - fenixRepository = FakeTreeherderRepository() - downloadCoordinator = FakeApkDownloadCoordinator() - stubProfileSearch() - viewModel = ProfileViewModel( - fenixRepository = fenixRepository, - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - downloadCoordinator = downloadCoordinator, - authorEmail = "test@example.com", - ) - advanceUntilIdle() - } - - @AfterEach - fun tearDown() { - cacheManager.reset() - } - - @Test - fun `updateAuthorEmail should update the authorEmail state`() = runTest { - val viewModel = createViewModel(authorEmail = null) - val newEmail = "test@example.com" - - viewModel.authorEmail.test { - assertEquals("", awaitItem()) - - viewModel.updateAuthorEmail(newEmail) - - assertEquals(newEmail, awaitItem()) - } - } - - @Test - fun `prefers bracketed Bug commit messages over generated try syntax`() { - val generatedTryMessage = "Perf selections= Queries=[ \"\" ]" - val bugMessage = "[Bug 0000000](https://bugzilla.mozilla.org/show_bug.cgi?id=0000000) - Run benchmarks" - val revisions = listOf( - RevisionDetail(1, 1, "try", "author", generatedTryMessage), - RevisionDetail(1, 1, "bug", "author", bugMessage), - ) - - assertEquals(bugMessage, selectPreferredPushComment(revisions)) - } - - @Test - fun `perf-again retry inherits the nearest preceding Bug commit message`() { - val retry = RevisionDetail( - 1, - 1, - "9240aec3d50cab971bbbacab005f3eee0aeb9eb1", - "author", - "Perf selections= Queries=[ \"\" ]\n\nPushed via `mach try perf-again`", - ) - val bugMessage = "Bug 0000000 - Run startup-profile benchmarks on additional Bitbar devices r?#releng-reviewers" - val precedingPush = listOf(RevisionDetail(1, 1, "1fd6", "author", bugMessage)) - - assertEquals(bugMessage, selectPreferredPushComment(listOf(retry), listOf(precedingPush))) - } - - @Test - fun `perf-again retry uses the immediately newer result in Treeherder response order`() { - val retry = RevisionDetail(1, 1, "9240", "author", "Pushed via `mach try perf-again`") - val bugMessage = "Bug 0000000 - Run startup-profile benchmarks on additional Bitbar devices" - val priorPush = listOf(RevisionDetail(1, 1, "1fd6", "author", bugMessage)) - val responseOrder = listOf(priorPush, listOf(retry)) - - assertEquals( - bugMessage, - selectPreferredPushComment(responseOrder[1], responseOrder.take(1).asReversed()), - ) - } - - @Test - fun `author search forwards selected project and rejects malformed emails`() = runTest { - val projectViewModel = createViewModel(authorEmail = null, project = "mozilla-central") - fenixRepository.lastAuthorProject = null - projectViewModel.updateAuthorEmail("not-an-email@") - projectViewModel.searchByAuthor() - assertEquals(null, fenixRepository.lastAuthorProject) - - projectViewModel.updateAuthorEmail("test@example.com") - projectViewModel.searchByAuthor() - advanceUntilIdle() - assertEquals("mozilla-central", fenixRepository.lastAuthorProject) - } - - @Test - fun `downloadArtifact should enqueue WorkManager work and reflect persisted progress`() = runTest { - val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() - val outputFile = File(cacheManager.getCacheDir(TREEHERDER), "task-123/${artifact.name.substringAfterLast('/')}") - - viewModel.downloadArtifact(artifact) - advanceUntilIdle() - - assertEquals(1, downloadCoordinator.enqueuedRequests.size) - val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() - assertEquals(artifact.uniqueKey, enqueuedRequest.uniqueKey) - assertEquals(artifact.downloadUrl, enqueuedRequest.downloadUrl) - assertEquals(outputFile.absolutePath, enqueuedRequest.outputPath) - assertEquals(TREEHERDER, enqueuedRequest.appName) - - val inProgressArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() - assertTrue(inProgressArtifact.downloadState is DownloadState.InProgress) - - downloadCoordinator.emit( - PersistedDownloadState( - uniqueKey = artifact.uniqueKey, - downloadUrl = artifact.downloadUrl, - outputPath = outputFile.absolutePath, - appName = TREEHERDER, - fileName = artifact.name.substringAfterLast('/'), - cacheRelativePath = "$TREEHERDER/task-123/${artifact.name.substringAfterLast('/')}", - status = DownloadStatus.RUNNING, - bytesDownloaded = 25, - totalBytes = 100, - workId = "work-1", - ), - ) - advanceUntilIdle() - - val runningArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() - val runningState = runningArtifact.downloadState as DownloadState.InProgress - assertEquals(0.25f, runningState.progress) - assertFalse(runningState.isIndeterminate) - - outputFile.parentFile?.mkdirs() - outputFile.writeText("fake apk") - downloadCoordinator.emit( - PersistedDownloadState( - uniqueKey = artifact.uniqueKey, - downloadUrl = artifact.downloadUrl, - outputPath = outputFile.absolutePath, - appName = TREEHERDER, - fileName = artifact.name.substringAfterLast('/'), - cacheRelativePath = "$TREEHERDER/task-123/${artifact.name.substringAfterLast('/')}", - status = DownloadStatus.SUCCEEDED, - bytesDownloaded = 100, - totalBytes = 100, - workId = "work-1", - ), - ) - advanceUntilIdle() - - val downloadedArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() - val downloadedState = downloadedArtifact.downloadState as DownloadState.Downloaded - assertEquals(outputFile.absolutePath, downloadedState.file.absolutePath) - assertTrue(intentManager.wasInstallApkCalled) - } - - @Test - fun `downloadArtifact should map persisted failures to download failed state`() = runTest { - val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() - val failureMessage = "network failed" - - viewModel.downloadArtifact(artifact) - advanceUntilIdle() - - downloadCoordinator.emit( - PersistedDownloadState( - uniqueKey = artifact.uniqueKey, - downloadUrl = artifact.downloadUrl, - outputPath = File( - cacheManager.getCacheDir(TREEHERDER), - "task-123/${artifact.name.substringAfterLast('/')}", - ).absolutePath, - appName = TREEHERDER, - fileName = artifact.name.substringAfterLast('/'), - cacheRelativePath = "$TREEHERDER/task-123/${artifact.name.substringAfterLast('/')}", - status = DownloadStatus.FAILED, - errorMessage = failureMessage, - workId = "work-1", - ), - ) - advanceUntilIdle() - - val failedArtifact = viewModel.pushes.value.single().jobs.single().artifacts.single() - val failedState = failedArtifact.downloadState as DownloadState.DownloadFailed - assertEquals(failureMessage, failedState.message) - } - - private fun createViewModel(authorEmail: String?, project: String = "try"): ProfileViewModel = - ProfileViewModel( - fenixRepository = fenixRepository, - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - downloadCoordinator = downloadCoordinator, - authorEmail = authorEmail, - project = project, - ) - - private fun stubProfileSearch() { - val email = "test@example.com" - fenixRepository.pushesByAuthorResult = - NetworkResult.Success( - TreeherderRevisionResponse( - meta = RevisionMeta(revision = null, count = 1, repository = "try"), - results = listOf( - RevisionResult( - id = 1, - revision = "abc123", - author = email, - revisions = listOf( - RevisionDetail( - resultSetId = 1, - repositoryId = 1, - revision = "abc123", - author = email, - comments = "Bug 123", - ), - ), - revisionCount = 1, - pushTimestamp = 1_700_000_000, - repositoryId = 1, - ), - ), - ), - ) - fenixRepository.jobsForPushResult = - NetworkResult.Success( - TreeherderJobsResponse( - results = listOf( - JobDetails( - appName = "Fenix Nightly", - jobName = "Fenix Android APK", - jobSymbol = "Bs", - taskId = "task-123", - ), - ), - ), - ) - fenixRepository.artifactsForTaskResult = - NetworkResult.Success( - ArtifactsResponse( - artifacts = listOf( - Artifact( - storageType = "s3", - name = "public/target.apk", - expires = "2099-01-01T00:00:00Z", - contentType = "application/vnd.android.package-archive", - ), - ), - ), - ) - } - - private class FakeTreeherderRepository : TreeherderRepository { - var lastAuthorProject: String? = null - var pushesByAuthorResult: NetworkResult = - NetworkResult.Error("Not stubbed") - var jobsForPushResult: NetworkResult = - NetworkResult.Error("Not stubbed") - var artifactsForTaskResult: NetworkResult = - NetworkResult.Error("Not stubbed") - - override suspend fun getPushByRevision( - project: String, - revision: String, - ): NetworkResult = pushesByAuthorResult - - override suspend fun getPushesByAuthor(author: String): NetworkResult = - pushesByAuthorResult - - override suspend fun getPushesByAuthor( - project: String, - author: String, - ): NetworkResult { - lastAuthorProject = project - return pushesByAuthorResult - } - - override suspend fun getJobsForPush(pushId: Int): NetworkResult = - jobsForPushResult - - override suspend fun getJobsForPushPage( - pushId: Int, - page: Int, - count: Int, - ): NetworkResult = jobsForPushResult - - override suspend fun getArtifactsForTask(taskId: String): NetworkResult = - artifactsForTaskResult - } - - private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { - private val _downloads = MutableStateFlow>(emptyMap()) - val enqueuedRequests = mutableListOf() - - override val downloads = _downloads.asStateFlow() - - override fun enqueue(request: ApkDownloadRequest): String { - enqueuedRequests += request - _downloads.value = _downloads.value + ( - request.uniqueKey to PersistedDownloadState( - uniqueKey = request.uniqueKey, - downloadUrl = request.downloadUrl, - outputPath = request.outputPath, - appName = request.appName, - fileName = request.fileName, - cacheRelativePath = request.cacheRelativePath, - status = DownloadStatus.QUEUED, - workId = request.uniqueKey, - ) - ) - return request.uniqueKey - } - - override fun retry(request: ApkDownloadRequest): String = enqueue(request) - - override fun cancel(uniqueKey: String) { - _downloads.value[uniqueKey]?.let { current -> - _downloads.value = _downloads.value + ( - uniqueKey to current.copy( - status = DownloadStatus.CANCELED, - updatedAt = System.currentTimeMillis(), - ) - ) - } - } - - override fun observe(uniqueKey: String) = downloads.map { it[uniqueKey] } - - fun emit(state: PersistedDownloadState) { - _downloads.value = _downloads.value + (state.uniqueKey to state) - } - } -} From b74869526bb2f16bf30fa59ed5cf2ecca2f406bc Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 16:22:43 +0200 Subject: [PATCH 15/24] Manage unified search APK installs --- app/src/main/AndroidManifest.xml | 10 +- .../java/org/mozilla/tryfox/MainActivity.kt | 24 ++ .../java/org/mozilla/tryfox/di/AppModule.kt | 2 + .../tryfox/install/ApkInstallCoordinator.kt | 238 ++++++++++++++++++ .../tryfox/install/InstallResultReceiver.kt | 12 + .../mozilla/tryfox/install/InstallState.kt | 15 ++ .../tryfox/ui/composables/DownloadButton.kt | 25 +- .../tryfox/ui/screens/ProfileViewModel.kt | 40 ++- .../tryfox/ui/screens/SearchResultCard.kt | 56 ++++- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 33 ++- app/src/main/res/values/strings.xml | 6 + 11 files changed, 428 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/install/InstallState.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4808ec..2944161 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,8 +15,10 @@ + + @@ -74,12 +76,8 @@ - - - - + android:name=".install.InstallResultReceiver" + android:exported="false" /> + pendingUninstallOperationId?.let { operationId -> + installCoordinator.onUninstallResult(operationId, result.resultCode == RESULT_OK) + } + pendingUninstallOperationId = null + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launch { + installCoordinator.uninstallRequests.collect { request -> + pendingUninstallOperationId = request.operationId + uninstallLauncher.launch( + Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply { + data = Uri.fromParts("package", request.packageName, null) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + }, + ) + } + } enableEdgeToEdge() setContent { TryFoxTheme { 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 7205147..d2d03e2 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -43,6 +43,7 @@ import org.mozilla.tryfox.download.ApkDownloadStore import org.mozilla.tryfox.download.DefaultApkDownloadCoordinator import org.mozilla.tryfox.download.DefaultApkDownloadStore import org.mozilla.tryfox.download.DownloadNotificationFactory +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.lan.DefaultLanMessageHistoryRepository import org.mozilla.tryfox.lan.LanMessageHistoryRepository import org.mozilla.tryfox.lan.LanReceiveIdentityManager @@ -171,6 +172,7 @@ val repositoryModule = module { ) } single { DefaultIntentManager(androidContext()) } + single { ApkInstallCoordinator(androidContext()) } single { DefaultApkDownloadStore(androidContext(), get(named("IODispatcher"))) } single { DownloadNotificationFactory(androidContext()) } single { WorkManager.getInstance(androidContext()) } diff --git a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt new file mode 100644 index 0000000..64f6fd2 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt @@ -0,0 +1,238 @@ +package org.mozilla.tryfox.install + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.logcat +import java.io.File +import java.util.concurrent.atomic.AtomicInteger + +/** Owns PackageInstaller sessions started from unified search. */ +@Suppress("NestedBlockDepth", "TooManyFunctions") +class ApkInstallCoordinator(private val context: Context) { + private data class Operation(val artifactKey: String, val file: File, val packageName: String) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val packageInstaller = context.packageManager.packageInstaller + private val requestCodes = AtomicInteger(10_000) + private val operations = mutableMapOf() + private var activeOperationId: String? = null + + private val _states = MutableStateFlow>(emptyMap()) + val states: StateFlow> = _states.asStateFlow() + private val _uninstallRequests = MutableSharedFlow(extraBufferCapacity = 1) + val uninstallRequests: SharedFlow = _uninstallRequests.asSharedFlow() + private val _successfulInstalls = MutableSharedFlow(extraBufferCapacity = 1) + val successfulInstalls: SharedFlow = _successfulInstalls.asSharedFlow() + + fun install(artifactKey: String, file: File) { + if (activeOperationId != null) return + activeOperationId = artifactKey + _states.value = _states.value + (artifactKey to InstallState.Installing) + scope.launch { prepareAndInstall(artifactKey, file) } + } + + fun cancelConflict(artifactKey: String) { + if (activeOperationId == artifactKey) activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Idle) + } + + fun confirmUninstallAndRetry(artifactKey: String) { + val operation = operations[artifactKey] ?: return + _states.value = _states.value + (artifactKey to InstallState.Uninstalling) + _uninstallRequests.tryEmit(UninstallRequest(artifactKey, operation.packageName)) + } + + fun onUninstallResult(artifactKey: String, succeeded: Boolean) { + val operation = operations[artifactKey] ?: return + if (!succeeded || isInstalled(operation.packageName)) { + logcat(LogPriority.WARN, TAG) { + "Uninstall failed artifactKey=$artifactKey package=${operation.packageName} " + + "activitySucceeded=$succeeded packageStillInstalled=${isInstalled(operation.packageName)}" + } + fail(artifactKey, "Uninstall was canceled or did not complete.") + return + } + _states.value = _states.value + (artifactKey to InstallState.Installing) + scope.launch { commit(operation) } + } + + fun openInstalledApp(packageName: String) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent == null) { + logcat(LogPriority.WARN, TAG) { "No launch activity for $packageName" } + return + } + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launchIntent) + } + + fun onInstallResult(intent: Intent) { + val artifactKey = intent.getStringExtra(EXTRA_ARTIFACT_KEY) ?: return + val operation = operations[artifactKey] ?: return + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE).orEmpty() + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + @Suppress("DEPRECATION") + val confirmationIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + if (confirmationIntent == null) { + fail(artifactKey, "Android did not provide an installation confirmation screen.") + } else { + confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(confirmationIntent) + } + } + + PackageInstaller.STATUS_SUCCESS -> succeed(artifactKey) + PackageInstaller.STATUS_FAILURE_CONFLICT -> { + if (statusMessage.contains(SHARED_USER_SIGNATURE_FAILURE)) { + logcat(LogPriority.WARN, TAG) { + "Shared-user signature conflict artifactKey=$artifactKey package=${operation.packageName} " + + "message=$statusMessage" + } + fail(artifactKey, SHARED_USER_SIGNATURE_USER_MESSAGE) + return + } + val conflictingPackage = intent.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) ?: operation.packageName + logcat(LogPriority.WARN, TAG) { + "Install conflict artifactKey=$artifactKey package=${operation.packageName} " + + "conflictingPackage=$conflictingPackage message=$statusMessage" + } + conflict(artifactKey, conflictingPackage) + } + else -> { + if (statusMessage.contains("VERSION_DOWNGRADE", ignoreCase = true) && isInstalled(operation.packageName)) { + conflict(artifactKey, operation.packageName) + } else { + logcat(LogPriority.WARN, TAG) { "Install failed status=$status message=$statusMessage" } + fail(artifactKey, userMessage(status)) + } + } + } + } + + private fun prepareAndInstall(artifactKey: String, file: File) { + if (!file.isFile) { + fail(artifactKey, "The downloaded APK is no longer available.") + return + } + val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, 0) + val packageName = archive?.packageName + if (packageName == null) { + fail(artifactKey, "The downloaded file is not a valid APK.") + return + } + val operation = Operation(artifactKey, file, packageName) + operations[artifactKey] = operation + val incomingVersion = archive.let(PackageInfoCompat::getLongVersionCode) + val installedVersion = installedVersion(packageName) + if (installedVersion != null && installedVersion > incomingVersion) { + conflict(artifactKey, packageName) + return + } + commit(operation) + } + + private fun commit(operation: Operation) { + var sessionId: Int? = null + try { + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(operation.packageName) + setSize(operation.file.length()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setPackageSource(PackageInstaller.PACKAGE_SOURCE_DOWNLOADED_FILE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED) + } + } + sessionId = packageInstaller.createSession(params) + packageInstaller.openSession(sessionId).use { session -> + operation.file.inputStream().use { input -> + session.openWrite("base.apk", 0, operation.file.length()).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + session.commit(statusReceiver(operation.artifactKey)) + } + } catch (e: Exception) { + sessionId?.let(packageInstaller::abandonSession) + logcat(LogPriority.ERROR, TAG) { "Could not create install session: ${e.message}" } + fail(operation.artifactKey, "Could not start installation.") + } + } + + private fun statusReceiver(artifactKey: String) = PendingIntent.getBroadcast( + context, + requestCodes.incrementAndGet(), + Intent(context, InstallResultReceiver::class.java).putExtra(EXTRA_ARTIFACT_KEY, artifactKey), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ).intentSender + + private fun conflict(artifactKey: String, packageName: String) { + _states.value = _states.value + (artifactKey to InstallState.Conflict(packageName)) + } + + private fun succeed(artifactKey: String) { + val packageName = operations[artifactKey]?.packageName ?: return + activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Installed(packageName)) + _successfulInstalls.tryEmit(artifactKey) + } + + private fun fail(artifactKey: String, message: String) { + val packageName = operations[artifactKey]?.packageName + logcat(LogPriority.ERROR, TAG) { + "Install failed artifactKey=$artifactKey package=$packageName message=$message" + } + activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Failed(message)) + } + + private fun installedVersion(packageName: String): Long? = try { + PackageInfoCompat.getLongVersionCode(context.packageManager.getPackageInfo(packageName, 0)) + } catch (_: PackageManager.NameNotFoundException) { + null + } + + private fun isInstalled(packageName: String) = installedVersion(packageName) != null + + private fun userMessage(status: Int) = when (status) { + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "This APK is not compatible with this device." + PackageInstaller.STATUS_FAILURE_INVALID -> "Android rejected this APK as invalid." + PackageInstaller.STATUS_FAILURE_STORAGE -> "There is not enough storage to install this APK." + PackageInstaller.STATUS_FAILURE_BLOCKED -> "Android blocked this installation." + PackageInstaller.STATUS_FAILURE_ABORTED -> "Installation was canceled." + PackageInstaller.STATUS_FAILURE_TIMEOUT -> "Installation timed out." + else -> "Android could not install this APK." + } + + private companion object { + const val TAG = "ApkInstallCoordinator" + const val EXTRA_ARTIFACT_KEY = "org.mozilla.tryfox.install.ARTIFACT_KEY" + const val SHARED_USER_SIGNATURE_FAILURE = "INSTALL_FAILED_SHARED_USER_INCOMPATIBLE" + const val SHARED_USER_SIGNATURE_USER_MESSAGE = + "This build is signed differently from an installed Firefox app. Android cannot install them together. " + + "Uninstall the conflicting Firefox app and its local data, then try again." + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt new file mode 100644 index 0000000..34b02ee --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt @@ -0,0 +1,12 @@ +package org.mozilla.tryfox.install + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.koin.core.context.GlobalContext + +class InstallResultReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + GlobalContext.get().get().onInstallResult(intent) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt b/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt new file mode 100644 index 0000000..4196ee4 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt @@ -0,0 +1,15 @@ +package org.mozilla.tryfox.install + +sealed interface InstallState { + data object Idle : InstallState + data object Installing : InstallState + data class Conflict(val packageName: String) : InstallState + data object Uninstalling : InstallState + data class Installed(val packageName: String) : InstallState + data class Failed(val message: String) : InstallState +} + +data class UninstallRequest( + val operationId: String, + val packageName: String, +) 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 929d5b4..cd37fa4 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 @@ -8,10 +8,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState import java.io.File private const val TAG = "DownloadButton" +@Suppress("LongParameterList", "CyclomaticComplexMethod") @Composable fun DownloadButton( downloadState: DownloadState, @@ -20,11 +22,16 @@ fun DownloadButton( modifier: Modifier = Modifier, inProgressText: String? = null, determinateProgressAnimation: DeterminateProgressAnimation = DeterminateProgressAnimation.Rotating, + installState: InstallState = InstallState.Idle, + installDisabled: Boolean = false, + onOpenClick: ((String) -> Unit)? = null, ) { val inProgressState = downloadState as? DownloadState.InProgress val downloadedState = downloadState as? DownloadState.Downloaded val defaultText = stringResource(id = R.string.download_button_downloading) val colorScheme = MaterialTheme.colorScheme + val isInstalling = installState is InstallState.Installing || installState is InstallState.Uninstalling + val isInstalled = installState is InstallState.Installed val isDownloading = inProgressState != null LaunchedEffect(downloadState) { @@ -33,19 +40,23 @@ fun DownloadButton( ProgressButton( onClick = { - downloadedState?.let { onInstallClick(it.file) } ?: onDownloadClick() + (installState as? InstallState.Installed)?.let { installed -> + onOpenClick?.invoke(installed.packageName) + } ?: downloadedState?.let { onInstallClick(it.file) } ?: onDownloadClick() }, - enabled = true, - isLoading = isDownloading, - progress = inProgressState + enabled = !installDisabled, + isLoading = isDownloading || isInstalling, + progress = if (isInstalling) null else inProgressState ?.progress ?.takeUnless { inProgressState.isIndeterminate }, - text = if (downloadedState == null) { + text = if (isInstalled) { + stringResource(id = R.string.download_button_open) + } else if (downloadedState == null) { stringResource(id = R.string.download_button_download) } else { stringResource(id = R.string.download_button_install) }, - loadingText = inProgressText ?: defaultText, + loadingText = if (isInstalling) stringResource(id = R.string.download_button_installing) else inProgressText ?: defaultText, determinateProgressAnimation = determinateProgressAnimation, // Keep the fill stable across every state; the lighter progress ring is // deliberately distinct from the primary button background. @@ -57,6 +68,8 @@ fun DownloadButton( contentColor = colorScheme.onPrimary, modifier = modifier, semanticsTag = when { + isInstalled -> "action_button_installed" + isInstalling -> "action_button_installing" downloadedState != null -> "action_button_install_ready" inProgressState != null -> "action_button_downloading" else -> "action_button_download_initial" 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 30a55a8..6374a85 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 @@ -19,7 +19,6 @@ import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.RevisionDetail import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager -import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.HistoryRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.data.repositories.UserDataRepository @@ -27,6 +26,8 @@ import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -79,9 +80,9 @@ class SearchViewModel( private val fenixRepository: TreeherderRepository, private val userDataRepository: UserDataRepository, private val cacheManager: CacheManager, - private val intentManager: IntentManager, private val historyRepository: HistoryRepository, private val downloadCoordinator: ApkDownloadCoordinator, + private val installCoordinator: ApkInstallCoordinator, authorEmail: String?, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, project: String = "try", @@ -121,6 +122,7 @@ class SearchViewModel( val cacheState: StateFlow = cacheManager.cacheState val searchHistory = userDataRepository.searchHistoryFlow + val installStates: StateFlow> = installCoordinator.states private val deviceSupportedAbis: List by lazy { runCatching { Build.SUPPORTED_ABIS.toList() }.getOrDefault(emptyList()) @@ -135,6 +137,18 @@ class SearchViewModel( } .launchIn(viewModelScope) + installCoordinator.successfulInstalls + .onEach { artifactKey -> + findArtifact(artifactKey)?.let { downloadedArtifact -> + try { + updateInstallTimestamp(downloadedArtifact) + } catch (_: Exception) { + // History is best-effort; installation has already succeeded. + } + } + } + .launchIn(viewModelScope) + cacheManager.cacheState.onEach { state -> if (state is CacheManagementState.IdleEmpty) { val updatedPushes = _pushes.value.map { @@ -577,20 +591,26 @@ class SearchViewModel( } } - fun installApk(file: File) { + fun installArtifact(artifactUiModel: ArtifactUiModel) { + val downloadState = artifactUiModel.downloadState as? DownloadState.Downloaded ?: return + installCoordinator.install(artifactUiModel.uniqueKey, downloadState.file) + } + + fun cancelInstallConflict(artifactKey: String) = installCoordinator.cancelConflict(artifactKey) + + fun confirmUninstallAndRetry(artifactKey: String) = installCoordinator.confirmUninstallAndRetry(artifactKey) + + fun openInstalledApp(packageName: String) = installCoordinator.openInstalledApp(packageName) + + private fun installApk(file: File) { val downloadedArtifact = findDownloadedArtifact(file) if (downloadedArtifact == null) { - intentManager.installApk(file) + installCoordinator.install(file.absolutePath, file) return } viewModelScope.launch { - try { - updateInstallTimestamp(downloadedArtifact) - } catch (_: Exception) { - // History is best-effort; never block installation. - } - intentManager.installApk(file) + installCoordinator.install(downloadedArtifact.artifact.uniqueKey, file) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt index 23a0b46..a8c0a82 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.composables.AppIcon import org.mozilla.tryfox.ui.composables.DownloadButton import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment @@ -36,11 +37,19 @@ import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_NIGHTLY import org.mozilla.tryfox.util.FOCUS_RELEASE -import java.io.File import java.util.Locale +@Suppress("LongParameterList") @Composable -internal fun PushResultCard(push: PushUiModel, onDownloadClick: (ArtifactUiModel) -> Unit, onInstallClick: (File) -> Unit, testTag: String) { +internal fun PushResultCard( + push: PushUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (ArtifactUiModel) -> Unit, + onOpenClick: (String) -> Unit, + installStates: Map, + activeInstallKey: String?, + testTag: String, +) { val commitTitle = remember(push.pushComment) { push.pushComment.lineSequence().firstOrNull().orEmpty().trim().ifBlank { "Revision ${push.revision?.take(12).orEmpty()}" } } Card(modifier = Modifier.fillMaxWidth().testTag(testTag), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) { Column(modifier = Modifier.padding(16.dp)) { @@ -51,21 +60,50 @@ internal fun PushResultCard(push: PushUiModel, onDownloadClick: (ArtifactUiModel HorizontalDivider(modifier = Modifier.padding(top = 14.dp)) push.jobs.forEachIndexed { index, job -> if (index > 0) HorizontalDivider() - CompactApkRow(job, onDownloadClick, onInstallClick) + CompactApkRow(job, onDownloadClick, onInstallClick, onOpenClick, installStates, activeInstallKey) } } } } @Composable -private fun CompactApkRow(job: JobDetailsUiModel, onDownloadClick: (ArtifactUiModel) -> Unit, onInstallClick: (File) -> Unit) { +private fun CompactApkRow( + job: JobDetailsUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (ArtifactUiModel) -> Unit, + onOpenClick: (String) -> Unit, + installStates: Map, + activeInstallKey: String?, +) { val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { - AppIcon(appName = appIconName, modifier = Modifier.size(34.dp), useSearchResultVariant = true) - Text(job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Spacer(Modifier.width(8.dp)) - apk?.let { DownloadButton(downloadState = it.downloadState, onDownloadClick = { onDownloadClick(it) }, onInstallClick = onInstallClick, modifier = Modifier.width(112.dp), inProgressText = stringResource(id = R.string.download_button_download)) } + val installState = apk?.let { installStates[it.uniqueKey] ?: InstallState.Idle } + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(appName = appIconName, modifier = Modifier.size(34.dp), useSearchResultVariant = true) + Text(job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + apk?.let { artifact -> + DownloadButton( + downloadState = artifact.downloadState, + onDownloadClick = { onDownloadClick(artifact) }, + onInstallClick = { onInstallClick(artifact) }, + modifier = Modifier.width(112.dp), + inProgressText = stringResource(id = R.string.download_button_download), + installState = installState ?: InstallState.Idle, + installDisabled = activeInstallKey != null && activeInstallKey != artifact.uniqueKey, + onOpenClick = onOpenClick, + ) + } + } + (installState as? InstallState.Failed)?.let { failure -> + Text( + text = failure.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 42.dp, top = 6.dp), + ) + } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt index 835c6b5..c45c2ec 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -69,6 +70,7 @@ import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.BinButton import org.mozilla.tryfox.ui.composables.ProjectSelector @@ -99,6 +101,11 @@ fun SearchScreen( val isLoading by searchViewModel.isLoading.collectAsState() val errorMessage by searchViewModel.errorMessage.collectAsState() val pushes by searchViewModel.pushes.collectAsState() + val installStates by searchViewModel.installStates.collectAsState() + val activeInstallKey = installStates.entries.firstOrNull { (_, state) -> + state is InstallState.Installing || state is InstallState.Uninstalling || state is InstallState.Conflict + }?.key + val installConflict = installStates.entries.firstOrNull { (_, state) -> state is InstallState.Conflict } val isDownloading = pushes.any { push -> push.jobs.any { job -> job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } } } var queryValidationError by remember(deepLinkQuery) { mutableStateOf( @@ -110,6 +117,25 @@ fun SearchScreen( var displayedQuery by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery.orEmpty()) } var isSearchFieldFocused by remember { mutableStateOf(false) } + installConflict?.let { (artifactKey, state) -> + val conflict = state as InstallState.Conflict + AlertDialog( + onDismissRequest = { searchViewModel.cancelInstallConflict(artifactKey) }, + title = { Text(stringResource(id = R.string.install_conflict_title)) }, + text = { Text(stringResource(R.string.install_conflict_message, conflict.packageName)) }, + confirmButton = { + Button(onClick = { searchViewModel.confirmUninstallAndRetry(artifactKey) }) { + Text(stringResource(id = R.string.install_conflict_confirm)) + } + }, + dismissButton = { + Button(onClick = { searchViewModel.cancelInstallConflict(artifactKey) }) { + Text(stringResource(id = R.string.install_conflict_cancel)) + } + }, + ) + } + val isEditingDisplayedSearch = hasSubmittedSearch && isSearchFieldFocused && query != displayedQuery @@ -132,7 +158,7 @@ fun SearchScreen( deepLinkQuery?.let { searchViewModel.setQueryFromDeepLinkAndSearch(deepLinkProject, it) } } - val binButtonEnabled = !isDownloading && cacheState == CacheManagementState.IdleNonEmpty + val binButtonEnabled = !isDownloading && activeInstallKey == null && cacheState == CacheManagementState.IdleNonEmpty Scaffold( modifier = Modifier.fillMaxSize(), @@ -242,7 +268,10 @@ fun SearchScreen( PushResultCard( push = pushes[index], onDownloadClick = searchViewModel::downloadArtifact, - onInstallClick = searchViewModel::installApk, + onInstallClick = searchViewModel::installArtifact, + onOpenClick = searchViewModel::openInstalledApp, + installStates = installStates, + activeInstallKey = activeInstallKey, testTag = "search_push_${pushes[index].revision}", ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d3cb9f..76a955e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -28,6 +28,12 @@ Clear Cache Clear downloaded apks Install + Installing… + Open + Replace installed app? + An incompatible or newer version of %1$s is installed. Uninstalling it deletes that app’s local data before this build is installed. + Uninstall and install + Cancel Downloading Download No APK details available. From 21c60ec15946f4beb9190884e0cb07c1009f73f7 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 16:58:15 +0200 Subject: [PATCH 16/24] Use managed installer across app --- .../java/org/mozilla/tryfox/MainActivity.kt | 26 +++++++++++++ .../java/org/mozilla/tryfox/di/AppModule.kt | 3 +- .../tryfox/ui/composables/ArchiveGroupCard.kt | 28 ++++++++++--- .../tryfox/ui/composables/DownloadButton.kt | 15 +++++-- .../tryfox/ui/composables/TryFoxCard.kt | 34 ++++++++++------ .../tryfox/ui/screens/HistoryScreen.kt | 12 ++++++ .../tryfox/ui/screens/HistoryViewModel.kt | 39 +++++++++++++++---- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 17 ++++++-- .../tryfox/ui/screens/HomeViewModel.kt | 16 +++++++- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 21 ---------- .../tryfox/ui/screens/SwipeableTryFoxCard.kt | 7 +++- .../tryfox/ui/screens/TryFoxCardComponent.kt | 7 +++- 12 files changed, 169 insertions(+), 56 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 23aba23..c884c5e 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -7,12 +7,16 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text 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.setValue +import androidx.compose.ui.res.stringResource import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.NavType @@ -26,6 +30,7 @@ import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import org.mozilla.tryfox.EXTRA_RECEIVE_FROM_DESKTOP_START_REQUESTED import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.screens.HistoryScreen import org.mozilla.tryfox.ui.screens.HomeScreen import org.mozilla.tryfox.ui.screens.QrCodeScannerScreen @@ -136,9 +141,30 @@ class MainActivity : ComponentActivity() { @Composable fun AppNavigation() { val appSearchHistoryViewModel: SearchHistoryViewModel = koinViewModel() + val installStates by installCoordinator.states.collectAsState() + val installConflict = installStates.entries.firstOrNull { (_, state) -> state is InstallState.Conflict } val localNavController = rememberNavController() this@MainActivity.navController = localNavController + installConflict?.let { (artifactKey, state) -> + val conflict = state as InstallState.Conflict + AlertDialog( + onDismissRequest = { installCoordinator.cancelConflict(artifactKey) }, + title = { Text(stringResource(id = R.string.install_conflict_title)) }, + text = { Text(stringResource(R.string.install_conflict_message, conflict.packageName)) }, + confirmButton = { + Button(onClick = { installCoordinator.confirmUninstallAndRetry(artifactKey) }) { + Text(stringResource(id = R.string.install_conflict_confirm)) + } + }, + dismissButton = { + Button(onClick = { installCoordinator.cancelConflict(artifactKey) }) { + Text(stringResource(id = R.string.install_conflict_cancel)) + } + }, + ) + } + LaunchedEffect(localNavController) { routeDeepLink(intent) } 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 d2d03e2..db381b1 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -199,7 +199,7 @@ val viewModelModule = module { params.getOrNull(), ) } - viewModel { HistoryViewModel(get(), get(), get(), get(), get(named("IODispatcher"))) } + viewModel { HistoryViewModel(get(), get(), get(), get(), get(), get(named("IODispatcher"))) } viewModel { ReceiveFromDesktopViewModel(get()) } viewModel { ReceiveMessageHistoryViewModel(get(), get(named("IODispatcher"))) } viewModel { SearchHistoryViewModel(get()) } @@ -219,6 +219,7 @@ val viewModelModule = module { get(), get(), get(), + get(), get(named("IODispatcher")), ) } 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 3975c81..1686e90 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 @@ -65,6 +65,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import org.mozilla.tryfox.R +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption @@ -76,7 +77,6 @@ import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.FeatureFlags import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.parseDateToLocalDate -import java.io.File private object ArchiveGroupCardTokens { val CardPaddingTop = 4.dp @@ -93,7 +93,7 @@ fun ArchiveGroupCard( modifier: Modifier = Modifier, apks: List, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, onOpenAppClick: () -> Unit, onUninstallClick: () -> Unit, appState: AppState?, @@ -110,6 +110,8 @@ fun ArchiveGroupCard( pendingBuildOptions: List = emptyList(), onBuildSelected: (String) -> Unit = {}, onDismissBuildPicker: () -> Unit = {}, + installStates: Map = emptyMap(), + onOpenInstalledApp: (String) -> Unit = {}, ) { if (pendingBuildOptions.isNotEmpty()) { NightlyBuildPickerDialog( @@ -184,6 +186,8 @@ fun ArchiveGroupCard( onInstallClick, onUninstallClick, appState, + installStates, + onOpenInstalledApp, ) } @@ -433,9 +437,11 @@ private fun ReleaseVersionSelector( private fun ArchiveGroupAbiSelector( apks: List, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, onUninstallClick: () -> Unit, appState: AppState?, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, ) { val firstSupportedIndex = apks.indexOfFirst { it.abi.isSupported }.takeIf { it != -1 } ?: 0 var selectedIndex by remember { mutableStateOf(firstSupportedIndex) } @@ -486,6 +492,8 @@ private fun ArchiveGroupAbiSelector( Spacer(Modifier.height(ArchiveGroupCardTokens.SpacerHeight)) } + val selectedApk = apks[selectedIndex] + val installState = installStates[selectedApk.uniqueKey] ?: InstallState.Idle Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (appState?.isInstalled == true) { Button( @@ -499,11 +507,21 @@ private fun ArchiveGroupAbiSelector( } } - val selectedApk = apks[selectedIndex] DownloadButton( downloadState = selectedApk.downloadState, onDownloadClick = { onDownloadClick(selectedApk) }, - onInstallClick = { file -> onInstallClick(file) }, + onInstallClick = { onInstallClick(selectedApk) }, + installState = installState, + onOpenClick = onOpenInstalledApp, + debugLabel = "home:${selectedApk.uniqueKey}", + ) + } + (installState as? InstallState.Failed)?.let { failure -> + Text( + text = failure.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), ) } } 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 cd37fa4..6e82b2f 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 @@ -25,6 +25,7 @@ fun DownloadButton( installState: InstallState = InstallState.Idle, installDisabled: Boolean = false, onOpenClick: ((String) -> Unit)? = null, + debugLabel: String = "action_button", ) { val inProgressState = downloadState as? DownloadState.InProgress val downloadedState = downloadState as? DownloadState.Downloaded @@ -34,8 +35,12 @@ fun DownloadButton( val isInstalled = installState is InstallState.Installed val isDownloading = inProgressState != null - LaunchedEffect(downloadState) { - Log.d(TAG, "downloadState changed: $downloadState") + LaunchedEffect(downloadState, installState, installDisabled, debugLabel) { + Log.d( + TAG, + "[$debugLabel] state download=${downloadState.javaClass.simpleName} install=${installState.javaClass.simpleName} " + + "installDisabled=$installDisabled", + ) } ProgressButton( @@ -56,7 +61,11 @@ fun DownloadButton( } else { stringResource(id = R.string.download_button_install) }, - loadingText = if (isInstalling) stringResource(id = R.string.download_button_installing) else inProgressText ?: defaultText, + loadingText = if (isInstalling || isInstalled) { + stringResource(id = R.string.download_button_installing) + } else { + inProgressText ?: defaultText + }, determinateProgressAnimation = determinateProgressAnimation, // Keep the fill stable across every state; the lighter progress ring is // deliberately distinct from the primary button background. 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 index fb14998..4b47caa 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt @@ -17,19 +17,20 @@ 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.install.InstallState 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, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, ) { val latestApk = (app.apks as? ApksResult.Success)?.apks?.firstOrNull() ?: return @@ -39,13 +40,15 @@ fun TryFoxCard( containerColor = MaterialTheme.customColors.tryFoxCardBackground, ), ) { - Row( + val installState = installStates[latestApk.uniqueKey] ?: InstallState.Idle + Column { + 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), @@ -53,15 +56,24 @@ fun TryFoxCard( ) } Spacer(modifier = Modifier.width(4.dp)) - DownloadButton( + DownloadButton( downloadState = latestApk.downloadState, onDownloadClick = { onDownloadClick(latestApk) }, - onInstallClick = { - val downloadedFile = (latestApk.downloadState as? DownloadState.Downloaded)?.file - downloadedFile?.let { onInstallClick(it) } - }, + onInstallClick = { onInstallClick(latestApk) }, inProgressText = stringResource(id = R.string.download_button_download), - ) + installState = installState, + onOpenClick = onOpenInstalledApp, + debugLabel = "home:${latestApk.uniqueKey}", + ) + } + (installState as? InstallState.Failed)?.let { failure -> + Text( + text = failure.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt index 0637ca1..d0ef293 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt @@ -57,6 +57,7 @@ import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.composables.AppIcon import org.mozilla.tryfox.ui.composables.DownloadButton import org.mozilla.tryfox.ui.composables.ErrorState @@ -77,6 +78,7 @@ fun HistoryScreen( historyViewModel: HistoryViewModel, ) { val historyItems by historyViewModel.historyItems.collectAsState() + val installStates by historyViewModel.installStates.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(historyItems.size) { @@ -156,6 +158,8 @@ fun HistoryScreen( onRevisionClick = onNavigateToTreeherderRevision, onDownloadClick = { historyViewModel.download(historyItem) }, onInstallClick = { file -> historyViewModel.install(historyItem, file) }, + installState = installStates[historyItem.entry.uniqueKey] ?: InstallState.Idle, + onOpenClick = historyViewModel::openInstalledApp, onDeleteClick = { historyViewModel.delete(historyItem) }, ) } @@ -170,6 +174,8 @@ private fun HistoryCard( onRevisionClick: (project: String, revision: String) -> Unit, onDownloadClick: () -> Unit, onInstallClick: (java.io.File) -> Unit, + installState: InstallState, + onOpenClick: (String) -> Unit, onDeleteClick: () -> Unit, ) { val entry = historyItem.entry @@ -261,8 +267,14 @@ private fun HistoryCard( onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, inProgressText = stringResource(id = R.string.download_button_download), + installState = installState, + onOpenClick = onOpenClick, ) } + + (installState as? InstallState.Failed)?.let { failure -> + ErrorState(errorMessage = failure.message) + } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt index a5a5f4d..8b4bc58 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt @@ -23,6 +23,8 @@ import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.models.HistoryItemUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -32,6 +34,7 @@ class HistoryViewModel( private val downloadCoordinator: ApkDownloadCoordinator, private val cacheManager: CacheManager, private val intentManager: IntentManager, + private val installCoordinator: ApkInstallCoordinator? = null, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, ) : ViewModel() { @@ -45,6 +48,8 @@ class HistoryViewModel( private val _historyItems = MutableStateFlow>(emptyList()) val historyItems: StateFlow> = _historyItems.asStateFlow() + val installStates: StateFlow> = + installCoordinator?.states ?: MutableStateFlow(emptyMap()) init { logcat(LogPriority.DEBUG, TAG) { "init" } @@ -59,6 +64,20 @@ class HistoryViewModel( } .launchIn(viewModelScope) + installCoordinator?.successfulInstalls + ?.onEach { artifactKey -> + _historyItems.value.firstOrNull { it.entry.uniqueKey == artifactKey }?.let { historyItem -> + try { + historyRepository.upsertHistoryEntry( + historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), + ) + } catch (_: Exception) { + // History is best-effort; the install has already succeeded. + } + } + } + ?.launchIn(viewModelScope) + historyRepository.historyEntries .combine(cacheManager.cacheState) { entries, _ -> entries } .combine(cacheRefreshEvents) { entries, _ -> entries } @@ -124,18 +143,22 @@ class HistoryViewModel( } fun install(historyItem: HistoryItemUiModel, file: File) { - viewModelScope.launch { - try { - historyRepository.upsertHistoryEntry( - historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), - ) - } catch (_: Exception) { - // History is best-effort; never block installation. + installCoordinator?.install(historyItem.entry.uniqueKey, file) ?: run { + viewModelScope.launch { + try { + historyRepository.upsertHistoryEntry( + historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), + ) + } catch (_: Exception) { + // History is best-effort; never block installation. + } + intentManager.installApk(file) } - intentManager.installApk(file) } } + fun openInstalledApp(packageName: String) = installCoordinator?.openInstalledApp(packageName) + fun delete(historyItem: HistoryItemUiModel) { val uniqueKey = historyItem.entry.uniqueKey viewModelScope.launch(ioDispatcher) { 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 777d268..77da96f 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 @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.datetime.LocalDate import org.mozilla.tryfox.R +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.ArchiveGroupCard @@ -56,7 +57,6 @@ 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.util.parseDateToMillis -import java.io.File /** * Composable function for the Home screen, which displays a list of available apps and allows users to interact with them. @@ -78,6 +78,7 @@ fun HomeScreen( ) { val screenState by homeViewModel.homeScreenState.collectAsState() val isRefreshing by homeViewModel.isRefreshing.collectAsState() + val installStates by homeViewModel.installStates.collectAsState() val pullRefreshState = rememberPullRefreshState(isRefreshing, { homeViewModel.refreshData() }) LaunchedEffect(Unit) { @@ -193,7 +194,9 @@ fun HomeScreen( AppComponent( app = app, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = { homeViewModel.installApk(it) }, + onInstallClick = homeViewModel::installHomeApk, + installStates = installStates, + onOpenInstalledApp = homeViewModel::openInstalledApp, onOpenAppClick = { homeViewModel.openApp(it) }, onUninstallClick = { homeViewModel.uninstallApp(it) }, onDateSelected = { appName, date -> @@ -222,7 +225,9 @@ fun HomeScreen( modifier = Modifier.align(Alignment.TopCenter), tryFoxApp = tryFoxApp, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = { homeViewModel.installApk(it) }, + onInstallClick = homeViewModel::installHomeApk, + installStates = installStates, + onOpenInstalledApp = homeViewModel::openInstalledApp, onDismiss = { homeViewModel.dismissTryFoxCard() }, onTryFoxCardHeightChange = { tryFoxCardHeight = it }, ) @@ -279,7 +284,9 @@ private fun TopBarActionIcon( fun AppComponent( app: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, onOpenAppClick: (String) -> Unit, onUninstallClick: (String) -> Unit, onDateSelected: (String, LocalDate) -> Unit, @@ -311,6 +318,8 @@ fun AppComponent( appState = appState, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + installStates = installStates, + onOpenInstalledApp = onOpenInstalledApp, onOpenAppClick = { appState?.packageName?.let { onOpenAppClick(it) 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 7f50853..a4c0af4 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 @@ -27,6 +27,8 @@ import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel @@ -60,6 +62,7 @@ class HomeViewModel( private val mozillaPackageManager: MozillaPackageManager, private val cacheManager: CacheManager, private val intentManager: IntentManager, + private val installCoordinator: ApkInstallCoordinator? = null, private val ioDispatcher: CoroutineDispatcher, private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), ) : ViewModel() { @@ -69,6 +72,8 @@ class HomeViewModel( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + val installStates: StateFlow> = + installCoordinator?.states ?: MutableStateFlow(emptyMap()) private val downloadStates = MutableStateFlow>(emptyMap()) init { @@ -322,7 +327,12 @@ class HomeViewModel( } fun installApk(file: File) { - intentManager.installApk(file) + installCoordinator?.install(file.absolutePath, file) ?: intentManager.installApk(file) + } + + fun installHomeApk(apkInfo: ApkUiModel) { + val file = File(apkInfo.apkDir, apkInfo.fileName) + installCoordinator?.install(apkInfo.uniqueKey, file) ?: intentManager.installApk(file) } fun uninstallApp(packageName: String) { @@ -504,6 +514,10 @@ class HomeViewModel( mozillaPackageManager.launchApp(app) } + fun openInstalledApp(packageName: String) { + installCoordinator?.openInstalledApp(packageName) ?: mozillaPackageManager.launchApp(packageName) + } + fun dismissTryFoxCard() { _homeScreenState.update { currentState -> if (currentState !is HomeScreenState.Loaded) return@update currentState diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt index c45c2ec..ecc3f1c 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -105,7 +104,6 @@ fun SearchScreen( val activeInstallKey = installStates.entries.firstOrNull { (_, state) -> state is InstallState.Installing || state is InstallState.Uninstalling || state is InstallState.Conflict }?.key - val installConflict = installStates.entries.firstOrNull { (_, state) -> state is InstallState.Conflict } val isDownloading = pushes.any { push -> push.jobs.any { job -> job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } } } var queryValidationError by remember(deepLinkQuery) { mutableStateOf( @@ -117,25 +115,6 @@ fun SearchScreen( var displayedQuery by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery.orEmpty()) } var isSearchFieldFocused by remember { mutableStateOf(false) } - installConflict?.let { (artifactKey, state) -> - val conflict = state as InstallState.Conflict - AlertDialog( - onDismissRequest = { searchViewModel.cancelInstallConflict(artifactKey) }, - title = { Text(stringResource(id = R.string.install_conflict_title)) }, - text = { Text(stringResource(R.string.install_conflict_message, conflict.packageName)) }, - confirmButton = { - Button(onClick = { searchViewModel.confirmUninstallAndRetry(artifactKey) }) { - Text(stringResource(id = R.string.install_conflict_confirm)) - } - }, - dismissButton = { - Button(onClick = { searchViewModel.cancelInstallConflict(artifactKey) }) { - Text(stringResource(id = R.string.install_conflict_cancel)) - } - }, - ) - } - val isEditingDisplayedSearch = hasSubmittedSearch && isSearchFieldFocused && query != displayedQuery 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 index 3122dd1..0cd66e6 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt @@ -20,6 +20,7 @@ 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.install.InstallState import org.mozilla.tryfox.ui.composables.TryFoxCard import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.AppUiModel @@ -31,7 +32,9 @@ fun SwipeableTryFoxCard( modifier: Modifier = Modifier, tryFoxApp: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (java.io.File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, onDismiss: () -> Unit, onTryFoxCardHeightChange: (Dp) -> Unit, ) { @@ -81,6 +84,8 @@ fun SwipeableTryFoxCard( app = tryFoxApp, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + installStates = installStates, + onOpenInstalledApp = onOpenInstalledApp, ) }, ) 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 index 4d8db1b..53053d4 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt @@ -3,6 +3,7 @@ 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.install.InstallState import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.AppUiModel @@ -11,7 +12,9 @@ fun TryFoxCardComponent( modifier: Modifier = Modifier, tryFoxApp: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (java.io.File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, onDismiss: () -> Unit, onTryFoxCardHeightChange: (Dp) -> Unit, ) { @@ -20,6 +23,8 @@ fun TryFoxCardComponent( tryFoxApp = tryFoxApp, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + installStates = installStates, + onOpenInstalledApp = onOpenInstalledApp, onDismiss = onDismiss, onTryFoxCardHeightChange = onTryFoxCardHeightChange, ) From a4f5d2138e765ce6587c7207ccf9c0a1c480f502 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 16:22:18 +0200 Subject: [PATCH 17/24] Cache home screen release data --- .../repositories/HomeDataCacheRepository.kt | 79 ++++++ .../java/org/mozilla/tryfox/di/AppModule.kt | 4 + .../tryfox/ui/screens/HomeViewModel.kt | 246 ++++++++++++++---- .../HomeDataCacheRepositoryTest.kt | 82 ++++++ .../tryfox/ui/screens/HomeViewModelTest.kt | 188 +++++++++++++ 5 files changed, 555 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt new file mode 100644 index 0000000..07e21a6 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt @@ -0,0 +1,79 @@ +package org.mozilla.tryfox.data.repositories + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +/** Durable snapshot of successful home-screen release responses. */ +interface HomeDataCacheRepository { + suspend fun read(): HomeDataSnapshot? + suspend fun write(snapshot: HomeDataSnapshot) +} + +object EmptyHomeDataCacheRepository : HomeDataCacheRepository { + override suspend fun read(): HomeDataSnapshot? = null + override suspend fun write(snapshot: HomeDataSnapshot) = Unit +} + +@Serializable +data class HomeDataSnapshot( + val version: Int, + val apps: List, +) { + companion object { + const val CURRENT_VERSION = 1 + } +} + +@Serializable +data class CachedHomeApp( + val appName: String, + val apks: List, + val selectedReleaseVersion: String? = null, + val availableReleaseVersions: List = emptyList(), +) + +@Serializable +data class CachedHomeApk( + val originalString: String, + val rawDateString: String?, + val appName: String, + val version: String, + val abiName: String, + val fullUrl: String, + val fileName: String, +) + +class DefaultHomeDataCacheRepository( + context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val json: Json = Json { ignoreUnknownKeys = true }, +) : HomeDataCacheRepository { + private val cacheFile = File(context.filesDir, "home-data-cache-v1.json") + + override suspend fun read(): HomeDataSnapshot? = withContext(ioDispatcher) { + runCatching { + if (!cacheFile.exists()) return@runCatching null + json.decodeFromString(cacheFile.readText()) + .takeIf { it.version == HomeDataSnapshot.CURRENT_VERSION } + }.getOrNull() + } + + override suspend fun write(snapshot: HomeDataSnapshot) = withContext(ioDispatcher) { + runCatching { + cacheFile.parentFile?.mkdirs() + val temporaryFile = File(cacheFile.parentFile, "${cacheFile.name}.tmp") + temporaryFile.writeText(json.encodeToString(snapshot)) + if (!temporaryFile.renameTo(cacheFile)) { + temporaryFile.delete() + } + } + Unit + } +} 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 db381b1..6caa9f3 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -22,6 +22,7 @@ import org.mozilla.tryfox.data.managers.DefaultIntentManager import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.DefaultDownloadFileRepository import org.mozilla.tryfox.data.repositories.DefaultHistoryRepository +import org.mozilla.tryfox.data.repositories.DefaultHomeDataCacheRepository import org.mozilla.tryfox.data.repositories.DefaultMozillaArchiveRepository import org.mozilla.tryfox.data.repositories.DefaultTreeherderRepository import org.mozilla.tryfox.data.repositories.DefaultUserDataRepository @@ -32,6 +33,7 @@ import org.mozilla.tryfox.data.repositories.FenixReleaseRepository import org.mozilla.tryfox.data.repositories.FocusNightlyRepository import org.mozilla.tryfox.data.repositories.FocusReleaseRepository import org.mozilla.tryfox.data.repositories.HistoryRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository import org.mozilla.tryfox.data.repositories.MozillaArchiveRepository import org.mozilla.tryfox.data.repositories.ReferenceBrowserReleaseRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository @@ -154,6 +156,7 @@ val repositoryModule = module { single { DefaultTreeherderRepository(get()) } single { DefaultMozillaArchiveRepository(get()) } single { DefaultUserDataRepository(androidContext()) } + single { DefaultHomeDataCacheRepository(androidContext(), get(named("IODispatcher"))) } single { DefaultHistoryRepository(androidContext(), get(named("IODispatcher"))) } single { DefaultLanMessageHistoryRepository( @@ -221,6 +224,7 @@ val viewModelModule = module { get(), get(), get(named("IODispatcher")), + get(), ) } viewModel { params -> 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 a4c0af4..a6d8350 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 @@ -11,6 +11,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -20,7 +22,12 @@ import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.IntentManager +import org.mozilla.tryfox.data.repositories.CachedHomeApk +import org.mozilla.tryfox.data.repositories.CachedHomeApp import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository +import org.mozilla.tryfox.data.repositories.EmptyHomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.HomeDataSnapshot import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator @@ -64,6 +71,7 @@ class HomeViewModel( private val intentManager: IntentManager, private val installCoordinator: ApkInstallCoordinator? = null, private val ioDispatcher: CoroutineDispatcher, + private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), ) : ViewModel() { @@ -75,6 +83,15 @@ class HomeViewModel( val installStates: StateFlow> = installCoordinator?.states ?: MutableStateFlow(emptyMap()) private val downloadStates = MutableStateFlow>(emptyMap()) + private var currentAppsByName: Map = emptyMap() + private var cachedAppsByName: Map = emptyMap() + private var initialLoadStarted = false + private var tryFoxCardDismissed = false + private val appMutationVersions = mutableMapOf() + private val appsLock = Any() + private val refreshMutex = Mutex() + private val refreshStateMutex = Mutex() + private var activeRefreshes = 0 init { downloadCoordinator.downloads @@ -121,25 +138,77 @@ class HomeViewModel( } fun initialLoad() { - viewModelScope.launch(ioDispatcher) { - _homeScreenState.value = HomeScreenState.InitialLoading - _isRefreshing.value = true - cacheManager.checkCacheStatus() // Initial check - fetchData() - _isRefreshing.value = false - } + if (initialLoadStarted) return + initialLoadStarted = true + launchRefresh(hydrateCache = true) } fun refreshData() { + launchRefresh(hydrateCache = false) + } + + private fun launchRefresh(hydrateCache: Boolean) { viewModelScope.launch(ioDispatcher) { - _isRefreshing.value = true - fetchData() - _isRefreshing.value = false + markRefreshStarted() + try { + refreshMutex.withLock { + if (hydrateCache) { + cacheManager.checkCacheStatus() + hydrateCachedData() + } + fetchData() + } + } finally { + markRefreshFinished() + } + } + } + + private suspend fun markRefreshStarted() = refreshStateMutex.withLock { + activeRefreshes += 1 + _isRefreshing.value = true + } + + private suspend fun markRefreshFinished() = refreshStateMutex.withLock { + activeRefreshes -= 1 + _isRefreshing.value = activeRefreshes > 0 + } + + private suspend fun hydrateCachedData() { + val snapshot = homeDataCacheRepository.read() ?: return + val appInfoMap = appInfoMap() + val cachedApps = snapshot.apps.associate { cachedApp -> + cachedApp.appName to cachedApp.toAppUiModel(appInfoMap[cachedApp.appName]) } + if (cachedApps.isEmpty()) return + + synchronized(appsLock) { + cachedAppsByName = cachedApps + currentAppsByName = initialApps(appInfoMap) + cachedApps + } + publishCurrentApps() } private suspend fun fetchData() { - val appInfoMap = mapOf( + val appInfoMap = appInfoMap() + val mutationVersionsAtStart = synchronized(appsLock) { appMutationVersions.toMap() } + if (_homeScreenState.value !is HomeScreenState.Loaded) { + synchronized(appsLock) { + currentAppsByName = initialApps(appInfoMap) + } + publishCurrentApps() + } + + val fetchedApps = releaseRepositories.associate { repository -> + repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) + } + applyFetchedApps(fetchedApps, mutationVersionsAtStart) + publishCurrentApps() + persistSuccessfulApps() + } + + private fun appInfoMap(): Map { + return mapOf( FENIX to mozillaPackageManager.fenix, FENIX_RELEASE to mozillaPackageManager.fenixRelease, FENIX_BETA to mozillaPackageManager.fenixBeta, @@ -148,48 +217,110 @@ class HomeViewModel( REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, TRYFOX to mozillaPackageManager.tryfox, ) + } - _homeScreenState.update { - val currentCacheState = cacheManager.cacheState.value - val initialApps = appInfoMap.mapValues { (appName, appState) -> - AppUiModel( - name = appName, - packageName = appState.packageName, - installedVersion = appState.version, - installedVersionCode = appState.versionCode, - installedDate = appState.formattedInstallDate, - installingPackageName = appState.installingPackageName, - splitNames = appState.splitNames, - apks = ApksResult.Loading, - ) - } - HomeScreenState.Loaded( - apps = initialApps.filterNot { (key, _) -> key == TRYFOX }, - tryfoxApp = initialApps[TRYFOX], - cacheManagementState = currentCacheState, - isDownloadingAnyFile = false, + private fun initialApps(appInfoMap: Map): Map = + appInfoMap.mapValues { (appName, appState) -> + AppUiModel( + name = appName, + packageName = appState.packageName, + installedVersion = appState.version, + installedVersionCode = appState.versionCode, + installedDate = appState.formattedInstallDate, + installingPackageName = appState.installingPackageName, + splitNames = appState.splitNames, + apks = ApksResult.Loading, ) } - val newApps = releaseRepositories.associate { repository -> - repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) - } + private fun publishCurrentApps() { + val apps = synchronized(appsLock) { currentAppsByName } + val currentCacheState = cacheManager.cacheState.value + val tryFoxApp = apps[TRYFOX] + ?.takeIf { !tryFoxCardDismissed && it.newVersionAvailable } + _homeScreenState.value = HomeScreenState.Loaded( + apps = apps.filterNot { (key, _) -> key == TRYFOX }, + tryfoxApp = tryFoxApp, + cacheManagementState = currentCacheState, + isDownloadingAnyFile = false, + ).applyDownloadStates(downloadStates.value) + } - val tryFoxApp = newApps[TRYFOX]?.takeIf { it.newVersionAvailable } + private fun updateCurrentApp(appName: String, update: (AppUiModel) -> AppUiModel) { + synchronized(appsLock) { + val currentApp = currentAppsByName[appName] ?: return + currentAppsByName = currentAppsByName + (appName to update(currentApp)) + appMutationVersions[appName] = (appMutationVersions[appName] ?: 0) + 1 + } + } - _homeScreenState.update { - if (it is HomeScreenState.Loaded) { - val loadedState = it.copy( - apps = newApps.filterNot { (key, _) -> key == TRYFOX }, - tryfoxApp = tryFoxApp, - ) - loadedState.applyDownloadStates(downloadStates.value) - } else { - it + private fun applyFetchedApps( + fetchedApps: Map, + mutationVersionsAtStart: Map, + ) { + synchronized(appsLock) { + val mergedApps = fetchedApps.mapValues { (appName, fetchedApp) -> + val cachedApp = currentAppsByName[appName] + val changedWhileRefreshing = appMutationVersions[appName] != mutationVersionsAtStart[appName] + if (changedWhileRefreshing && cachedApp != null) { + cachedApp + } else if (fetchedApp.apks is ApksResult.Error && cachedApp?.apks is ApksResult.Success) { + cachedApp + } else { + fetchedApp + } } + currentAppsByName = currentAppsByName + mergedApps + cachedAppsByName = cachedAppsByName + fetchedApps.filterValues { it.apks is ApksResult.Success } } } + private suspend fun persistSuccessfulApps() { + val cachedApps = synchronized(appsLock) { cachedAppsByName } + val successfulApps = cachedApps.values.mapNotNull { app -> + val successfulApks = app.apks as? ApksResult.Success ?: return@mapNotNull null + CachedHomeApp( + appName = app.name, + apks = successfulApks.apks.map(::toCachedHomeApk), + selectedReleaseVersion = app.selectedReleaseVersion, + availableReleaseVersions = app.availableReleaseVersions, + ) + } + if (successfulApps.isNotEmpty()) { + homeDataCacheRepository.write( + HomeDataSnapshot(version = HomeDataSnapshot.CURRENT_VERSION, apps = successfulApps), + ) + } + } + + private fun CachedHomeApp.toAppUiModel(appState: AppState?): AppUiModel = AppUiModel( + name = appName, + packageName = appState?.packageName.orEmpty(), + installedVersion = appState?.version, + installedVersionCode = appState?.versionCode, + installedDate = appState?.formattedInstallDate, + installingPackageName = appState?.installingPackageName, + splitNames = appState?.splitNames.orEmpty(), + apks = ApksResult.Success(apks.map { it.toUiModel() }), + selectedReleaseVersion = selectedReleaseVersion, + availableReleaseVersions = availableReleaseVersions, + ) + + private fun CachedHomeApk.toUiModel(): ApkUiModel { + val parsed = MozillaArchiveApk(originalString, rawDateString, appName, version, abiName, fullUrl, fileName) + return convertParsedApksToUiModels(listOf(parsed)).single() + } + + private fun toCachedHomeApk(apk: ApkUiModel): CachedHomeApk = CachedHomeApk( + originalString = apk.originalString, + rawDateString = apk.uniqueKey.split('/').let { parts -> parts.getOrNull(1)?.takeIf { parts.size > 2 } }, + appName = apk.appName, + version = apk.version, + abiName = apk.abi.name.orEmpty(), + fullUrl = apk.url, + fileName = apk.fileName, + ) + private fun getLatestApks(apks: List): List { if (apks.isEmpty()) { return emptyList() @@ -372,6 +503,11 @@ class HomeViewModel( val builds = pendingBuildsByApp.remove(appName) ?: return val chosen = builds.filter { it.rawDateString == buildId } if (chosen.isEmpty()) return + val chosenApks = ApksResult.Success(convertParsedApksToUiModels(chosen)) + + updateCurrentApp(appName) { + it.copy(apks = chosenApks, pendingBuildOptions = emptyList()) + } _homeScreenState.update { state -> if (state !is HomeScreenState.Loaded) return@update state @@ -379,7 +515,7 @@ class HomeViewModel( state.copy( apps = state.apps + ( appName to app.copy( - apks = ApksResult.Success(convertParsedApksToUiModels(chosen)), + apks = chosenApks, pendingBuildOptions = emptyList(), ) ), @@ -390,6 +526,7 @@ class HomeViewModel( /** Dismisses the multi-build prompt, leaving the latest build (already shown) in place. */ fun onDismissBuildPicker(appName: String) { pendingBuildsByApp.remove(appName) + updateCurrentApp(appName) { app -> app.copy(pendingBuildOptions = emptyList()) } _homeScreenState.update { state -> if (state !is HomeScreenState.Loaded) return@update state val app = state.apps[appName] ?: return@update state @@ -422,6 +559,9 @@ class HomeViewModel( apks = ApksResult.Loading, selectedReleaseVersion = version, ) + updateCurrentApp(appName) { + it.copy(apks = ApksResult.Loading, selectedReleaseVersion = version) + } _homeScreenState.value = currentState.copy(apps = updatedApps) val newApksResult = repository.getReleasesForVersion(version).toApksResult(appName) @@ -433,6 +573,9 @@ class HomeViewModel( apks = newApksResult, selectedReleaseVersion = version, ) + updateCurrentApp(appName) { + it.copy(apks = newApksResult, selectedReleaseVersion = version) + } _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) syncLoadedStateDownloadStates() @@ -456,6 +599,13 @@ class HomeViewModel( apks = ApksResult.Loading, pendingBuildOptions = emptyList(), ) + updateCurrentApp(appName) { + it.copy( + userPickedDate = date, + apks = ApksResult.Loading, + pendingBuildOptions = emptyList(), + ) + } _homeScreenState.value = currentState.copy(apps = updatedApps) @@ -487,6 +637,13 @@ class HomeViewModel( val finalUpdatedApps = latestState.apps.toMutableMap() finalUpdatedApps[appName] = finalUpdatedApp + updateCurrentApp(appName) { + it.copy( + userPickedDate = date, + apks = newApksResult, + pendingBuildOptions = buildOptions, + ) + } _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) syncLoadedStateDownloadStates() } @@ -519,6 +676,7 @@ class HomeViewModel( } fun dismissTryFoxCard() { + tryFoxCardDismissed = true _homeScreenState.update { currentState -> if (currentState !is HomeScreenState.Loaded) return@update currentState currentState.copy(tryfoxApp = null) diff --git a/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt new file mode 100644 index 0000000..12454c8 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt @@ -0,0 +1,82 @@ +package org.mozilla.tryfox.data.repositories + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.File + +class HomeDataCacheRepositoryTest { + @TempDir + lateinit var filesDir: File + + private fun repository() = DefaultHomeDataCacheRepository( + context = mock { on { this.filesDir } doReturn filesDir }, + ioDispatcher = Dispatchers.Unconfined, + ) + + private fun snapshot(version: String = "1.0") = HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = "fenix", + apks = listOf( + CachedHomeApk( + originalString = "build", + rawDateString = "2026-07-31-10-00-00", + appName = "fenix", + version = version, + abiName = "arm64-v8a", + fullUrl = "https://example.test/fenix.apk", + fileName = "fenix.apk", + ), + ), + ), + ), + ) + + @Test + fun `read returns null when no snapshot exists`() = runTest { + assertNull(repository().read()) + } + + @Test + fun `write then read round trips a snapshot`() = runTest { + val repository = repository() + val snapshot = snapshot() + + repository.write(snapshot) + + assertEquals(snapshot, repository.read()) + } + + @Test + fun `write replaces the prior snapshot`() = runTest { + val repository = repository() + repository.write(snapshot("1.0")) + val replacement = snapshot("2.0") + + repository.write(replacement) + + assertEquals(replacement, repository.read()) + } + + @Test + fun `read ignores corrupt snapshots`() = runTest { + File(filesDir, "home-data-cache-v1.json").writeText("not json") + + assertNull(repository().read()) + } + + @Test + fun `read ignores snapshots without a schema version`() = runTest { + File(filesDir, "home-data-cache-v1.json").writeText("{\"apps\":[]}") + + assertNull(repository().read()) + } +} 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 ce54f01..5e1488d 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 @@ -1,10 +1,12 @@ package org.mozilla.tryfox.ui.screens +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime @@ -33,10 +35,15 @@ import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager +import org.mozilla.tryfox.data.repositories.CachedHomeApk +import org.mozilla.tryfox.data.repositories.CachedHomeApp +import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseRepository import org.mozilla.tryfox.data.repositories.FocusNightlyRepository import org.mozilla.tryfox.data.repositories.FocusReleaseRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.HomeDataSnapshot import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest @@ -181,6 +188,7 @@ class HomeViewModelTest { private fun createViewModel( releaseRepositories: List = emptyList(), mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), + homeDataCacheRepository: HomeDataCacheRepository = FakeHomeDataCacheRepository(), ) = HomeViewModel( releaseRepositories = releaseRepositories, downloadCoordinator = fakeDownloadCoordinator, @@ -188,9 +196,54 @@ class HomeViewModelTest { cacheManager = fakeCacheManager, intentManager = intentManager, ioDispatcher = mainCoroutineRule.testDispatcher, + homeDataCacheRepository = homeDataCacheRepository, supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) + private class FakeHomeDataCacheRepository( + var snapshot: HomeDataSnapshot? = null, + ) : HomeDataCacheRepository { + val writes = mutableListOf() + + override suspend fun read(): HomeDataSnapshot? = snapshot + + override suspend fun write(snapshot: HomeDataSnapshot) { + writes += snapshot + this.snapshot = snapshot + } + } + + private class CountingReleaseRepository( + override val appName: String, + private val result: NetworkResult>, + ) : ReleaseRepository { + var calls = 0 + + override suspend fun getLatestReleases(): NetworkResult> { + calls += 1 + return result + } + } + + private class BlockingDateReleaseRepository( + override val appName: String, + private val latestResult: NetworkResult>, + private val dateResult: NetworkResult>, + ) : DateAwareReleaseRepository { + val latestStarted = CompletableDeferred() + val unblockLatest = CompletableDeferred() + var latestCalls = 0 + + override suspend fun getLatestReleases(): NetworkResult> { + latestCalls += 1 + latestStarted.complete(Unit) + unblockLatest.await() + return latestResult + } + + override suspend fun getReleases(date: LocalDate?): NetworkResult> = dateResult + } + private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { private val _downloads = MutableStateFlow>(emptyMap()) val enqueuedRequests = mutableListOf() @@ -273,6 +326,141 @@ class HomeViewModelTest { ) } + @Test + fun `initialLoad hydrates cached data and retains it when refresh fails`() = runTest { + val cachedApk = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val cache = FakeHomeDataCacheRepository( + HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = testFenixAppName, + apks = listOf( + CachedHomeApk( + cachedApk.originalString, + cachedApk.rawDateString, + cachedApk.appName, + cachedApk.version, + cachedApk.abiName, + cachedApk.fullUrl, + cachedApk.fileName, + ), + ), + ), + ), + ), + ) + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Error("offline"), + ) + viewModel = createViewModel(listOf(repository), homeDataCacheRepository = cache) + + viewModel.initialLoad() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertTrue(state.apps[testFenixAppName]?.apks is ApksResult.Success) + assertEquals(1, repository.calls) + assertEquals(1, cache.writes.size) + } + + @Test + fun `initialLoad is idempotent after home view model has loaded`() = runTest { + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel(listOf(repository)) + + viewModel.initialLoad() + advanceUntilIdle() + viewModel.initialLoad() + advanceUntilIdle() + + assertEquals(1, repository.calls) + } + + @Test + fun `refresh waits for an in-flight load before starting another request`() = runTest { + val repository = BlockingDateReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel(listOf(repository)) + + viewModel.initialLoad() + runCurrent() + assertTrue(repository.latestStarted.isCompleted) + viewModel.refreshData() + runCurrent() + + assertEquals(1, repository.latestCalls) + assertTrue(viewModel.isRefreshing.value) + + repository.unblockLatest.complete(Unit) + advanceUntilIdle() + + assertEquals(2, repository.latestCalls) + assertFalse(viewModel.isRefreshing.value) + } + + @Test + fun `date selection is retained when cache refresh finishes later`() = runTest { + val cachedApk = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val selectedApk = createTestParsedNightlyApk( + testFenixAppName, + "2023-10-30-01-01-01", + testVersion, + testAbi, + ) + val cache = FakeHomeDataCacheRepository( + HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = testFenixAppName, + apks = listOf( + CachedHomeApk( + cachedApk.originalString, + cachedApk.rawDateString, + cachedApk.appName, + cachedApk.version, + cachedApk.abiName, + cachedApk.fullUrl, + cachedApk.fileName, + ), + ), + ), + ), + ), + ) + val repository = BlockingDateReleaseRepository( + testFenixAppName, + NetworkResult.Success(listOf(cachedApk)), + NetworkResult.Success(listOf(selectedApk)), + ) + viewModel = createViewModel(listOf(repository), homeDataCacheRepository = cache) + val selectedDate = LocalDate(2023, 10, 30) + + viewModel.initialLoad() + runCurrent() + assertTrue(repository.latestStarted.isCompleted) + + viewModel.onDateSelected(testFenixAppName, selectedDate) + runCurrent() + repository.unblockLatest.complete(Unit) + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(selectedDate, state.apps[testFenixAppName]?.userPickedDate) + assertEquals( + selectedApk.rawDateString?.formatApkDateForTest(), + (state.apps[testFenixAppName]?.apks as? ApksResult.Success)?.apks?.single()?.date, + ) + } + @Test fun `initialLoad success should update HomeScreenState to Loaded with data`() = runTest { val fenixParsed = From 9778a795fa2799ae388e388df81b3c408d1655b1 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 18:32:35 +0200 Subject: [PATCH 18/24] Show unsigned APKs in search results --- .../download/DefaultApkDownloadCoordinator.kt | 10 + .../download/worker/ApkDownloadWorker.kt | 41 +++- .../mozilla/tryfox/ui/models/PushUiModel.kt | 1 + .../tryfox/ui/screens/ProfileViewModel.kt | 194 +++++++++--------- .../tryfox/ui/screens/SearchResultCard.kt | 72 ++++++- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 6 +- app/src/main/res/values/strings.xml | 6 + 7 files changed, 224 insertions(+), 106 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt index 79af552..4ebe117 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.download import android.content.Context +import android.util.Log import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy @@ -16,6 +17,10 @@ class DefaultApkDownloadCoordinator( private val store: ApkDownloadStore = DefaultApkDownloadStore(context.applicationContext), private val workManager: WorkManager = WorkManager.getInstance(context.applicationContext), ) : ApkDownloadCoordinator { + private companion object { + const val TAG = "ApkDownloadCoordinator" + } + override val downloads: StateFlow> = store.downloads override fun enqueue(request: ApkDownloadRequest): String { @@ -33,6 +38,11 @@ class DefaultApkDownloadCoordinator( ), ) workManager.enqueueUniqueWork(request.uniqueKey, ExistingWorkPolicy.REPLACE, workRequest) + Log.d( + TAG, + "enqueued uniqueKey=${request.uniqueKey} workId=${workRequest.id} " + + "outputPath=${request.outputPath}", + ) return workRequest.id.toString() } diff --git a/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt index b404f01..87933c5 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.download.worker import android.content.Context +import android.util.Log import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.WorkerParameters @@ -31,6 +32,7 @@ class ApkDownloadWorker( override suspend fun doWork(): Result { val request = inputData.toRequest() ?: return Result.failure() + Log.d(TAG, "started uniqueKey=${request.uniqueKey} workerId=$id outputPath=${request.outputPath}") val startedAt = System.currentTimeMillis() var lastBytesDownloaded = 0L var lastTotalBytes = -1L @@ -63,11 +65,17 @@ class ApkDownloadWorker( val now = System.currentTimeMillis() val elapsedSinceLastUpdate = now - lastProgressUpdateAt val shouldPublish = - totalBytes <= 0 || + if (totalBytes <= 0) { + // Some Taskcluster artifact responses omit Content-Length. The + // progress UI is necessarily indeterminate, but publishing on + // every 4 KiB read overwhelms the state store and logcat. + lastProgressUpdateAt == 0L || elapsedSinceLastUpdate >= PROGRESS_UPDATE_INTERVAL_MS + } else { lastProgressPercent < 0 || - bytesDownloaded == totalBytes || - progressPercent >= lastProgressPercent + MIN_PROGRESS_PERCENT_STEP || - elapsedSinceLastUpdate >= PROGRESS_UPDATE_INTERVAL_MS + bytesDownloaded == totalBytes || + progressPercent >= lastProgressPercent + MIN_PROGRESS_PERCENT_STEP || + elapsedSinceLastUpdate >= PROGRESS_UPDATE_INTERVAL_MS + } if (shouldPublish) { lastProgressUpdateAt = now lastProgressPercent = progressPercent @@ -111,6 +119,11 @@ class ApkDownloadWorker( ), ) } else { + Log.d( + TAG, + "download completed uniqueKey=${request.uniqueKey} file=${downloadedFile.absolutePath} " + + "bytes=$lastBytesDownloaded total=$lastTotalBytes", + ) updateSuccess( request = request, startedAt = startedAt, @@ -128,6 +141,7 @@ class ApkDownloadWorker( } is NetworkResult.Error -> { + Log.e(TAG, "download failed uniqueKey=${request.uniqueKey}: ${result.message}") updateFailure( request = request, message = result.message, @@ -143,6 +157,7 @@ class ApkDownloadWorker( } } } catch (e: CancellationException) { + Log.w(TAG, "download cancelled uniqueKey=${request.uniqueKey}") updateCanceled(request = request, startedAt = startedAt) cacheManager.checkCacheStatus() throw e @@ -169,6 +184,7 @@ class ApkDownloadWorker( private fun updateFailure(request: ApkDownloadRequest, message: String?, startedAt: Long) { if (!isCurrentRequest(request)) return + Log.e(TAG, "recording failure uniqueKey=${request.uniqueKey}: $message") downloadStore.upsert( request.toPersistedState( status = DownloadStatus.FAILED, @@ -250,10 +266,18 @@ class ApkDownloadWorker( updatedAt = updatedAt, ) - private fun isCurrentRequest(request: ApkDownloadRequest): Boolean = - downloadStore.get(request.uniqueKey)?.let { persistedState -> - persistedState.workId == id.toString() && persistedState.status != DownloadStatus.CANCELED - } == true + private fun isCurrentRequest(request: ApkDownloadRequest): Boolean { + val persistedState = downloadStore.get(request.uniqueKey) + val isCurrent = persistedState?.workId == id.toString() && persistedState.status != DownloadStatus.CANCELED + if (!isCurrent) { + Log.w( + TAG, + "ignoring stale worker uniqueKey=${request.uniqueKey} workerId=$id " + + "storedWorkId=${persistedState?.workId} storedStatus=${persistedState?.status}", + ) + } + return isCurrent + } private fun Data.toRequest(): ApkDownloadRequest? { val uniqueKey = getString(KEY_UNIQUE_KEY) ?: return null @@ -274,6 +298,7 @@ class ApkDownloadWorker( } companion object { + private const val TAG = "ApkDownloadWorker" private const val PROGRESS_UPDATE_INTERVAL_MS = 500L private const val MIN_PROGRESS_PERCENT_STEP = 5 const val KEY_UNIQUE_KEY = "download_unique_key" diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt index a2ec752..58ed410 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt @@ -6,4 +6,5 @@ data class PushUiModel( val jobs: List, val revision: String?, val pushTimestamp: Long, + val unsignedJobs: List = emptyList(), ) 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 6374a85..8d5205d 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 @@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.data.DownloadState @@ -95,6 +97,7 @@ class SearchViewModel( companion object { private const val TAG = "SearchViewModel" + private const val MAX_PARALLEL_ARTIFACT_REQUESTS = 6 private val apkJobNameHints = listOf("signing-apk", "android-apk", "apk-focus", "apk-fenix", "apk-reference-browser", "apk-geckoview") private val nonAndroidPlatformHints = listOf("ios", "mac", "macos", "macosx", "win", "windows", "linux", "desktop") private val androidProductHints = listOf("focus", "fenix", "reference-browser", "geckoview", "android") @@ -118,7 +121,6 @@ class SearchViewModel( private val _pushes = MutableStateFlow>(emptyList()) val pushes: StateFlow> = _pushes.asStateFlow() private val downloadStates = MutableStateFlow>(emptyMap()) - private val pendingAutoInstallDownloads = mutableSetOf() val cacheState: StateFlow = cacheManager.cacheState val searchHistory = userDataRepository.searchHistoryFlow @@ -160,6 +162,13 @@ class SearchViewModel( }, ) }, + unsignedJobs = it.unsignedJobs.map { job -> + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy(downloadState = DownloadState.NotDownloaded) + }, + ) + }, ) } _pushes.value = updatedPushes @@ -245,24 +254,17 @@ class SearchViewModel( } else { val jobsResult = fenixRepository.getJobsForPush(push.id) if (jobsResult is NetworkResult.Success) { - val jobs = jobsResult.data.results - .filter(::isAndroidApkCandidate) - .filter { it.jobName.contains("signing-apk", ignoreCase = true) } - .ifEmpty { jobsResult.data.results.filter(::isAndroidApkCandidate) } - .map { job -> - val artifacts = fetchArtifacts(job.taskId) - if (artifacts.artifacts.isEmpty()) null else JobDetailsUiModel( - appName = job.appName, - jobName = job.jobName, - jobSymbol = job.jobSymbol, - taskId = job.taskId, - isSignedBuild = job.isSignedBuild, - isTest = job.isTest, - artifacts = artifacts.artifacts, - ) - }.filterNotNull() - if (jobs.isEmpty()) { - _errorMessage.value = "No signed builds found for this revision." + val (signedCandidates, unsignedCandidates) = selectJobsBySigning( + jobsResult.data.results.filter(::isAndroidApkCandidate), + ) + val jobs = mutableListOf() + for (job in signedCandidates + unsignedCandidates) { + loadJob(job)?.let(jobs::add) + } + val signedJobs = jobs.filter(JobDetailsUiModel::isSignedBuild) + val unsignedJobs = jobs.filterNot(JobDetailsUiModel::isSignedBuild) + if (signedJobs.isEmpty() && unsignedJobs.isEmpty()) { + _errorMessage.value = "No APK builds found for this revision." } else { val precedingRevisions = if (isPerfAgainRetry(push.revisions)) { when ( @@ -287,7 +289,8 @@ class SearchViewModel( PushUiModel( pushComment = selectPreferredPushComment(push.revisions, precedingRevisions), author = push.author, - jobs = jobs, + jobs = signedJobs, + unsignedJobs = unsignedJobs, revision = push.revision, pushTimestamp = push.pushTimestamp, ), @@ -325,6 +328,7 @@ class SearchViewModel( _errorMessage.value = null _pushes.value = emptyList() logcat(LogPriority.DEBUG, TAG) { "Starting search..." } + val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) when (val result = fenixRepository.getPushesByAuthor(_selectedProject.value, emailToSearch)) { is NetworkResult.Success -> { @@ -337,29 +341,21 @@ class SearchViewModel( async { val jobsResult = fenixRepository.getJobsForPush(pushResult.id) if (jobsResult is NetworkResult.Success) { - val candidates = jobsResult.data.results.filter(::isAndroidApkCandidate) - val filteredJobs = candidates.filter { it.jobName.contains("signing-apk", ignoreCase = true) } - .ifEmpty { candidates } - if (filteredJobs.isNotEmpty()) { - val jobsWithArtifacts = filteredJobs.map { jobDetails -> + val (signedCandidates, unsignedCandidates) = selectJobsBySigning( + jobsResult.data.results.filter(::isAndroidApkCandidate), + ) + val selectedJobs = signedCandidates + unsignedCandidates + if (selectedJobs.isNotEmpty()) { + val jobsWithArtifacts = selectedJobs.map { jobDetails -> async { - val artifactResult = fetchArtifacts(jobDetails.taskId) + val artifactResult = artifactSemaphore.withPermit { + fetchArtifacts(jobDetails.taskId) + } if (artifactResult.failed) { failedPushCount.incrementAndGet() } - val artifacts = artifactResult.artifacts - if (artifacts.isNotEmpty()) { - JobDetailsUiModel( - appName = jobDetails.appName, - jobName = jobDetails.jobName, - jobSymbol = jobDetails.jobSymbol, - taskId = jobDetails.taskId, - isSignedBuild = jobDetails.isSignedBuild, - isTest = jobDetails.isTest, - artifacts = artifacts, - ) - } else { - null + artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { artifacts -> + jobWithArtifacts(jobDetails, artifacts) } } }.awaitAll().filterNotNull() @@ -374,7 +370,8 @@ class SearchViewModel( .map { it.revisions }, ), author = pushResult.author, - jobs = jobsWithArtifacts, + jobs = jobsWithArtifacts.filter(JobDetailsUiModel::isSignedBuild), + unsignedJobs = jobsWithArtifacts.filterNot(JobDetailsUiModel::isSignedBuild), revision = pushResult.revision, pushTimestamp = pushResult.pushTimestamp, ) @@ -386,7 +383,7 @@ class SearchViewModel( } } else { logcat(LogPriority.VERBOSE, TAG) { - "No signed, non-test jobs for push ID: ${pushResult.id}" + "No eligible, non-test APK jobs for push ID: ${pushResult.id}" } null } @@ -410,8 +407,8 @@ class SearchViewModel( if (failedPushCount.get() > 0) { _errorMessage.value = "Some pushes could not be loaded." } else if (pushesWithJobsAndArtifacts.isEmpty()) { - _errorMessage.value = "No signed builds found for this author." - logcat(TAG) { "No signed builds found for author." } + _errorMessage.value = "No APK builds found for this author." + logcat(TAG) { "No APK builds found for author." } } } @@ -478,7 +475,7 @@ class SearchViewModel( } private fun isAndroidApkCandidate(job: org.mozilla.tryfox.data.JobDetails): Boolean { - if (job.isTest || !job.isSignedBuild) return false + if (job.isTest) return false val appName = job.appName.lowercase() val jobName = job.jobName.lowercase() val hasAndroidSource = jobName.contains("build-android") || @@ -487,13 +484,42 @@ class SearchViewModel( return apkJobNameHints.any(jobName::contains) || jobName.contains("apk") } + /** Keeps the existing signing-job preference while loading unsigned candidates as a separate group. */ + private fun selectJobsBySigning( + candidates: List, + ): Pair, List> { + val signedCandidates = candidates.filter { it.isSignedBuild } + val preferredSignedCandidates = signedCandidates + .filter { it.jobName.contains("signing-apk", ignoreCase = true) } + .ifEmpty { signedCandidates } + return preferredSignedCandidates to candidates.filterNot { it.isSignedBuild } + } + + private suspend fun loadJob(job: org.mozilla.tryfox.data.JobDetails): JobDetailsUiModel? { + val artifactResult = fetchArtifacts(job.taskId) + return artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { artifacts -> jobWithArtifacts(job, artifacts) } + } + + private fun jobWithArtifacts( + job: org.mozilla.tryfox.data.JobDetails, + artifacts: List, + ) = JobDetailsUiModel( + appName = job.appName, + jobName = job.jobName, + jobSymbol = job.jobSymbol, + taskId = job.taskId, + isSignedBuild = job.isSignedBuild, + isTest = job.isTest, + artifacts = artifacts, + ) + fun getDownloadedFile(artifactName: String, taskId: String): File? { if (taskId.isBlank()) return null val taskSpecificDir = File(cacheManager.getCacheDir(TREEHERDER), taskId) val outputFile = File(taskSpecificDir, artifactName) val exists = outputFile.exists() logcat( - LogPriority.DEBUG, + LogPriority.VERBOSE, TAG, ) { "getDownloadedFile artifactName=$artifactName, taskId=$taskId, " + @@ -571,13 +597,15 @@ class SearchViewModel( cacheRelativePath = "$TREEHERDER/$taskId/$artifactFileName", ) - pendingAutoInstallDownloads += artifactUiModel.uniqueKey try { - downloadCoordinator.enqueue(request) + val workId = downloadCoordinator.enqueue(request) + logcat(LogPriority.DEBUG, TAG) { + "Download enqueued uniqueKey=${artifactUiModel.uniqueKey} workId=$workId " + + "outputPath=${outputFile.absolutePath}" + } downloadStates.value = downloadCoordinator.downloads.value syncLoadedStateDownloadStates() } catch (e: Exception) { - pendingAutoInstallDownloads.remove(artifactUiModel.uniqueKey) logcat(LogPriority.ERROR, TAG) { "Failed to enqueue download for ${artifactUiModel.name}: ${e.message}" } @@ -602,18 +630,6 @@ class SearchViewModel( fun openInstalledApp(packageName: String) = installCoordinator.openInstalledApp(packageName) - private fun installApk(file: File) { - val downloadedArtifact = findDownloadedArtifact(file) - if (downloadedArtifact == null) { - installCoordinator.install(file.absolutePath, file) - return - } - - viewModelScope.launch { - installCoordinator.install(downloadedArtifact.artifact.uniqueKey, file) - } - } - private fun updateArtifactDownloadState( taskIdToUpdate: String, artifactNameToUpdate: String, @@ -638,6 +654,14 @@ class SearchViewModel( job.updateArtifact(artifactNameToUpdate, newState) } }, + unsignedJobs = + unsignedJobs.map { job: JobDetailsUiModel -> + if (job.taskId != taskId) { + job + } else { + job.updateArtifact(artifactNameToUpdate, newState) + } + }, ) private fun JobDetailsUiModel.updateArtifact( @@ -671,9 +695,21 @@ class SearchViewModel( }, ) }, + unsignedJobs = push.unsignedJobs.map { job -> + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy( + downloadState = resolveDownloadState( + artifactName = artifact.name.substringAfterLast('/'), + taskId = artifact.taskId, + uniqueKey = artifact.uniqueKey, + ), + ) + }, + ) + }, ) } - maybeAutoInstallCompletedDownloads(persistedDownloads) } private fun resolveDownloadState( @@ -709,43 +745,9 @@ class SearchViewModel( DownloadStatus.CANCELED -> DownloadState.NotDownloaded } - private fun maybeAutoInstallCompletedDownloads(persistedDownloads: Map) { - val completedKeys = pendingAutoInstallDownloads.filter { uniqueKey -> - persistedDownloads[uniqueKey]?.status == DownloadStatus.SUCCEEDED - } - - completedKeys.forEach { uniqueKey -> - val persistedDownload = persistedDownloads[uniqueKey] ?: return@forEach - val file = File(persistedDownload.outputPath) - if (file.exists()) { - logcat(TAG) { "Auto-installing completed download for uniqueKey=$uniqueKey" } - installApk(file) - } else { - logcat(LogPriority.WARN, TAG) { - "Download completed for uniqueKey=$uniqueKey but file is missing at ${file.absolutePath}" - } - } - } - - pendingAutoInstallDownloads.removeAll(completedKeys.toSet()) - pendingAutoInstallDownloads.removeAll( - persistedDownloads.filterValues { it.isTerminal }.keys, - ) - } - - private fun findDownloadedArtifact(file: File): DownloadedArtifact? = - _pushes.value.firstNotNullOfOrNull { push -> - push.jobs.firstNotNullOfOrNull { job -> - job.artifacts.firstOrNull { artifact -> - val downloadState = artifact.downloadState - downloadState is DownloadState.Downloaded && downloadState.file.absolutePath == file.absolutePath - }?.let { artifact -> DownloadedArtifact(push, job, artifact) } - } - } - private fun findArtifact(uniqueKey: String): DownloadedArtifact? = _pushes.value.firstNotNullOfOrNull { push -> - push.jobs.firstNotNullOfOrNull { job -> + (push.jobs + push.unsignedJobs).firstNotNullOfOrNull { job -> job.artifacts.firstOrNull { artifact -> artifact.uniqueKey == uniqueKey }?.let { artifact -> DownloadedArtifact(push, job, artifact) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt index a8c0a82..54b9437 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -7,17 +8,28 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.unit.dp @@ -62,6 +74,63 @@ internal fun PushResultCard( if (index > 0) HorizontalDivider() CompactApkRow(job, onDownloadClick, onInstallClick, onOpenClick, installStates, activeInstallKey) } + if (push.unsignedJobs.isNotEmpty()) { + UnsignedApksSection(push, onDownloadClick, onInstallClick, onOpenClick, installStates, activeInstallKey) + } + } + } +} + +@Composable +private fun UnsignedApksSection( + push: PushUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (ArtifactUiModel) -> Unit, + onOpenClick: (String) -> Unit, + installStates: Map, + activeInstallKey: String?, +) { + var expanded by rememberSaveable(push.revision) { mutableStateOf(false) } + val stateDescription = stringResource( + if (expanded) R.string.search_result_unsigned_apks_expanded else R.string.search_result_unsigned_apks_collapsed, + ) + + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .semantics { this.stateDescription = stateDescription } + .testTag("unsigned_apks_toggle_${push.revision}") + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = pluralStringResource( + R.plurals.search_result_unsigned_apks, + push.unsignedJobs.size, + push.unsignedJobs.size, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon(if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null) + } + if (expanded) { + Column(modifier = Modifier.padding(start = 16.dp)) { + push.unsignedJobs.forEach { job -> + HorizontalDivider() + CompactApkRow( + job = job, + onDownloadClick = onDownloadClick, + onInstallClick = onInstallClick, + onOpenClick = onOpenClick, + installStates = installStates, + activeInstallKey = activeInstallKey, + modifier = Modifier.testTag("unsigned_apk_row_${job.taskId}"), + ) + } } } } @@ -74,11 +143,12 @@ private fun CompactApkRow( onOpenClick: (String) -> Unit, installStates: Map, activeInstallKey: String?, + modifier: Modifier = Modifier, ) { val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } val installState = apk?.let { installStates[it.uniqueKey] ?: InstallState.Idle } - Column(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Column(modifier = modifier.fillMaxWidth().padding(vertical = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { AppIcon(appName = appIconName, modifier = Modifier.size(34.dp), useSearchResultVariant = true) Text(job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt index ecc3f1c..4d929c6 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -104,7 +104,11 @@ fun SearchScreen( val activeInstallKey = installStates.entries.firstOrNull { (_, state) -> state is InstallState.Installing || state is InstallState.Uninstalling || state is InstallState.Conflict }?.key - val isDownloading = pushes.any { push -> push.jobs.any { job -> job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } } } + val isDownloading = pushes.any { push -> + (push.jobs + push.unsignedJobs).any { job -> + job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } + } + } var queryValidationError by remember(deepLinkQuery) { mutableStateOf( deepLinkQuery?.takeIf { SearchQueryClassifier.classify(it).isFailure } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76a955e..16268d7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -52,6 +52,12 @@ Task ID: Compatible APKs No compatible APKs found for this job. + + %1$d unsigned apk + %1$d unsigned apks + + Unsigned APKs expanded + Unsigned APKs collapsed Search builds Revision or email Search builds From ced9f82726b572261fa72249e94059ca6758464f Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 19:33:52 +0200 Subject: [PATCH 19/24] Warn before unsigned APK downloads --- .../tryfox/ui/screens/SearchResultCard.kt | 28 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt index 54b9437..cde37cd 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt @@ -1,5 +1,10 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -117,8 +123,28 @@ private fun UnsignedApksSection( ) Icon(if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null) } - if (expanded) { + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { Column(modifier = Modifier.padding(start = 16.dp)) { + Row( + modifier = Modifier.padding(top = 12.dp, end = 8.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(R.string.search_result_unsigned_apks_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } push.unsignedJobs.forEach { job -> HorizontalDivider() CompactApkRow( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16268d7..ae0db9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,6 +58,7 @@ Unsigned APKs expanded Unsigned APKs collapsed + Those APKs are unsigned and might not be installable correctly. You can trigger a signing job on Treeherder, like `signing-apk-fenix-nightly`. Search builds Revision or email Search builds From a99db7828b6bb308e1193f461d01aaabb7f3a16d Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 19:46:50 +0200 Subject: [PATCH 20/24] Hide unsigned APKs with signed equivalents --- .../tryfox/ui/screens/ProfileViewModel.kt | 32 ++++++++-- .../ui/screens/UnsignedApkFilterTest.kt | 62 +++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt 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 8d5205d..6d5800d 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 @@ -64,6 +64,26 @@ internal fun isPerfAgainRetry(revisions: List): Boolean { return firstComment.contains("Pushed via `mach try perf-again`", ignoreCase = true) } +private val unsignedApkJobNamePattern = Regex("^build-apk-(.+)$", RegexOption.IGNORE_CASE) + +/** Removes an unsigned build only when its corresponding signed build has a displayable APK. */ +internal fun filterRedundantUnsignedApkJobs(jobs: List): List { + val availableSignedJobNames = jobs.asSequence() + .filter(JobDetailsUiModel::isSignedBuild) + .map { it.jobName.trim().lowercase() } + .toSet() + + return jobs.filter { job -> + if (job.isSignedBuild) return@filter true + val signedEquivalent = unsignedApkJobNamePattern.matchEntire(job.jobName.trim()) + ?.groupValues + ?.get(1) + ?.let { "signing-apk-$it" } + ?.lowercase() + signedEquivalent == null || signedEquivalent !in availableSignedJobNames + } +} + /** * ViewModel for the Profile screen, responsible for fetching pushes and artifacts by author, managing downloads, and handling user interactions. * @@ -261,8 +281,9 @@ class SearchViewModel( for (job in signedCandidates + unsignedCandidates) { loadJob(job)?.let(jobs::add) } - val signedJobs = jobs.filter(JobDetailsUiModel::isSignedBuild) - val unsignedJobs = jobs.filterNot(JobDetailsUiModel::isSignedBuild) + val visibleJobs = filterRedundantUnsignedApkJobs(jobs) + val signedJobs = visibleJobs.filter(JobDetailsUiModel::isSignedBuild) + val unsignedJobs = visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild) if (signedJobs.isEmpty() && unsignedJobs.isEmpty()) { _errorMessage.value = "No APK builds found for this revision." } else { @@ -360,7 +381,8 @@ class SearchViewModel( } }.awaitAll().filterNotNull() - if (jobsWithArtifacts.isNotEmpty()) { + val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) + if (visibleJobs.isNotEmpty()) { PushUiModel( pushComment = selectPreferredPushComment( revisions = pushResult.revisions, @@ -370,8 +392,8 @@ class SearchViewModel( .map { it.revisions }, ), author = pushResult.author, - jobs = jobsWithArtifacts.filter(JobDetailsUiModel::isSignedBuild), - unsignedJobs = jobsWithArtifacts.filterNot(JobDetailsUiModel::isSignedBuild), + jobs = visibleJobs.filter(JobDetailsUiModel::isSignedBuild), + unsignedJobs = visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild), revision = pushResult.revision, pushTimestamp = pushResult.pushTimestamp, ) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt new file mode 100644 index 0000000..7e5e9c2 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt @@ -0,0 +1,62 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.ui.models.JobDetailsUiModel + +class UnsignedApkFilterTest { + + @Test + fun `hides an unsigned build when its signed equivalent is available`() { + val signed = job("signing-apk-fenix-nightly", signed = true) + val unsigned = job("build-apk-fenix-nightly", signed = false) + + assertEquals(listOf(signed), filterRedundantUnsignedApkJobs(listOf(signed, unsigned))) + } + + @Test + fun `keeps an unsigned build when its signed equivalent is unavailable`() { + val unsigned = job("build-apk-fenix-nightly", signed = false) + + assertEquals(listOf(unsigned), filterRedundantUnsignedApkJobs(listOf(unsigned))) + } + + @Test + fun `keeps builds with a different signing variant`() { + val signedBeta = job("signing-apk-fenix-beta", signed = true) + val unsignedNightly = job("build-apk-fenix-nightly", signed = false) + + assertEquals( + listOf(signedBeta, unsignedNightly), + filterRedundantUnsignedApkJobs(listOf(signedBeta, unsignedNightly)), + ) + } + + @Test + fun `does not treat an unsigned signing-named job as an available signed equivalent`() { + val signingNamedButUnsigned = job("signing-apk-fenix-nightly", signed = false) + val unsigned = job("build-apk-fenix-nightly", signed = false) + + assertEquals( + listOf(signingNamedButUnsigned, unsigned), + filterRedundantUnsignedApkJobs(listOf(signingNamedButUnsigned, unsigned)), + ) + } + + @Test + fun `matches job names ignoring case and surrounding whitespace`() { + val signed = job(" signing-apk-fenix-nightly ", signed = true) + val unsigned = job("BUILD-APK-FENIX-NIGHTLY", signed = false) + + assertEquals(listOf(signed), filterRedundantUnsignedApkJobs(listOf(signed, unsigned))) + } + + private fun job(jobName: String, signed: Boolean) = JobDetailsUiModel( + appName = "fenix", + jobName = jobName, + jobSymbol = "B", + taskId = jobName, + isSignedBuild = signed, + isTest = false, + ) +} From 2e4937a4088824cc6f0dfaefe06bce2d13324195 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 31 Jul 2026 19:53:59 +0200 Subject: [PATCH 21/24] Keep search controls fixed above suggestions --- .../tryfox/ui/screens/ProfileViewModel.kt | 7 +- .../mozilla/tryfox/ui/screens/SearchQuery.kt | 2 +- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 127 +++++++++--------- 3 files changed, 65 insertions(+), 71 deletions(-) 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 6d5800d..d2e101a 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 @@ -241,10 +241,6 @@ class SearchViewModel( submitSearch() } - fun showInvalidQueryError() { - _errorMessage.value = "Enter a valid email address or a revision without @." - } - /** * The sole search entry point used by the shared screen. Query kind changes only the * Treeherder operation; loading, errors, results and artifact actions stay shared. @@ -253,7 +249,7 @@ class SearchViewModel( when (val parsed = SearchQueryClassifier.classify(_authorEmail.value).getOrNull()) { is SearchQuery.Email -> searchByAuthor() is SearchQuery.Revision -> searchByRevision(parsed.value) - null -> showInvalidQueryError() + null -> Unit } } @@ -341,7 +337,6 @@ class SearchViewModel( return } if (SearchQueryClassifier.classify(emailToSearch).getOrNull() !is SearchQuery.Email) { - _errorMessage.value = "Enter a valid email address." return } viewModelScope.launch { diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt index 420adec..3a89097 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt @@ -21,7 +21,7 @@ object SearchQueryClassifier { value.isBlank() -> Result.failure(IllegalArgumentException("Enter an email or revision.")) '@' !in value -> Result.success(SearchQuery.Revision(value)) emailPattern.matches(value) -> Result.success(SearchQuery.Email(value)) - else -> Result.failure(IllegalArgumentException("Enter a valid email address or a revision without @.")) + else -> Result.failure(IllegalArgumentException("Invalid search query.")) } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt index 4d929c6..31d685b 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -109,12 +109,6 @@ fun SearchScreen( job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } } } - var queryValidationError by remember(deepLinkQuery) { - mutableStateOf( - deepLinkQuery?.takeIf { SearchQueryClassifier.classify(it).isFailure } - ?.let { "Enter a valid email address or a revision without @." }, - ) - } var hasSubmittedSearch by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery != null) } var displayedQuery by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery.orEmpty()) } var isSearchFieldFocused by remember { mutableStateOf(false) } @@ -126,14 +120,10 @@ fun SearchScreen( val showCurrentSearch = !isEditingDisplayedSearch fun submitSearch(queryToSubmit: String) { - when (SearchQueryClassifier.classify(queryToSubmit).getOrNull()) { - is SearchQuery.Email, is SearchQuery.Revision -> { - queryValidationError = null - hasSubmittedSearch = true - displayedQuery = queryToSubmit - searchViewModel.submitSearch() - } - null -> queryValidationError = "Enter a valid email address or a revision without @." + if (SearchQueryClassifier.classify(queryToSubmit).isSuccess) { + hasSubmittedSearch = true + displayedQuery = queryToSubmit + searchViewModel.submitSearch() } } @@ -180,83 +170,92 @@ fun SearchScreen( ) }, ) { innerPadding -> - LazyColumn( + Column( modifier = Modifier .fillMaxSize() .padding(innerPadding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), ) { - item { + // Keep the controls available while the suggestions below scroll. + Column(modifier = Modifier.padding(16.dp)) { SearchSection( selectedProject = selectedProject, onProjectSelected = searchViewModel::updateSelectedProject, revision = query, onRevisionChange = { - queryValidationError = null // A text edit is unambiguously an editing interaction, including // the trailing clear action whose focus callback can be delayed. isSearchFieldFocused = true searchViewModel.updateQuery(it) }, - onSearchClick = { - submitSearch(query) - }, + onSearchClick = { submitSearch(query) }, isLoading = isLoading, - showSearchHistory = showSearchHistory, + showSearchHistory = false, onSearchFieldFocusChanged = { isSearchFieldFocused = it }, - searchHistory = SearchHistory.displayOrder(searchHistory), - onHistoryItemSelected = { entry -> - searchViewModel.updateSelectedProject(entry.project) - searchViewModel.updateQuery(entry.query) - submitSearch(entry.query) - }, ) } - errorMessage?.let { - // TODO: Consider creating a specific string resource for \"Download failed\" if it's a common prefix for user-facing errors. - if (pushes.isEmpty() || !it.startsWith("Download failed")) { + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (showSearchHistory) { + item { + SearchHistoryPanel( + entries = SearchHistory.displayOrder(searchHistory) + .filter { it.query.contains(query.trim(), ignoreCase = true) }, + visible = true, + onEntryClick = { entry -> + searchViewModel.updateSelectedProject(entry.project) + searchViewModel.updateQuery(entry.query) + submitSearch(entry.query) + }, + ) + } + } + + errorMessage?.let { + // TODO: Consider creating a specific string resource for \"Download failed\" if it's a common prefix for user-facing errors. + if (pushes.isEmpty() || !it.startsWith("Download failed")) { + item { + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ErrorState(errorMessage = it) + } + } + } + } + + if (isLoading) { item { AnimatedVisibility( visible = showCurrentSearch, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { - ErrorState(errorMessage = it) + LoadingState(candidateCount = 0) } } - } - } - - queryValidationError?.let { item { ErrorState(errorMessage = it) } } - - if (isLoading) { - item { - AnimatedVisibility( - visible = showCurrentSearch, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - LoadingState(candidateCount = 0) - } - } - } else if (pushes.isNotEmpty()) { - items(pushes.size) { index -> - AnimatedVisibility( - visible = showCurrentSearch, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - ) { - PushResultCard( - push = pushes[index], - onDownloadClick = searchViewModel::downloadArtifact, - onInstallClick = searchViewModel::installArtifact, - onOpenClick = searchViewModel::openInstalledApp, - installStates = installStates, - activeInstallKey = activeInstallKey, - testTag = "search_push_${pushes[index].revision}", - ) + } else if (pushes.isNotEmpty()) { + items(pushes.size) { index -> + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PushResultCard( + push = pushes[index], + onDownloadClick = searchViewModel::downloadArtifact, + onInstallClick = searchViewModel::installArtifact, + onOpenClick = searchViewModel::openInstalledApp, + installStates = installStates, + activeInstallKey = activeInstallKey, + testTag = "search_push_${pushes[index].revision}", + ) + } } } } From ed4ff42e0834c7631a6a401e3ef703783590cb4e Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Mon, 3 Aug 2026 11:53:16 +0200 Subject: [PATCH 22/24] Add recent project build search fallback --- README.md | 3 +- .../DefaultTreeherderRepository.kt | 10 +++ .../data/repositories/TreeherderRepository.kt | 8 ++ .../tryfox/network/TreeherderApiService.kt | 8 ++ .../tryfox/ui/composables/ProjectSelector.kt | 8 +- .../tryfox/ui/screens/ProfileViewModel.kt | 86 ++++++++++++++----- .../mozilla/tryfox/ui/screens/SearchQuery.kt | 5 +- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 42 +++++++-- app/src/main/res/values/strings.xml | 1 + .../ui/screens/SearchQueryClassifierTest.kt | 7 +- 10 files changed, 143 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 0eb7aed..0e4c6dc 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ Install the latest APK from [TryFox Releases](https://github.com/mozilla-mobile/ - `mozilla-central` (displayed as "central") - `mozilla-beta` (displayed as "beta") - `mozilla-release` (displayed as "release") + - `autoland` (displayed as "autoland") - **Search by Revision**: Enter a full revision hash to find associated builds for the selected project. - **Automated CI Data Fetching**: 1. Queries Treeherder API for push details using the selected project and revision. 2. Displays relevant push comment from the revision (often a Bugzilla link). 3. Fetches all jobs associated with the push. - 4. Filters for relevant, signed, non-test build jobs (jobs containing "B" and "s", excluding "t" in their symbols). + 4. Filters for relevant Android APK build jobs, including signed and unsigned builds, while excluding test jobs. 5. For each job, retrieves its artifacts from Taskcluster. - **Job and Artifact Display**: - Lists build jobs with their app icon (e.g., Fenix, Focus), job name, job symbol, and Task ID. diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt index 8fa5b62..ea1a188 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt @@ -37,6 +37,16 @@ class DefaultTreeherderRepository( return safeApiCall { treeherderApiService.getPushByAuthor(project = project, author = author) } } + override suspend fun getRecentPushes( + project: String, + count: Int, + offset: Int, + ): NetworkResult { + return safeApiCall { + treeherderApiService.getRecentPushes(project = project, count = count, offset = offset) + } + } + override suspend fun getJobsForPush(pushId: Int): NetworkResult { return safeApiCall { val pageSize = 2000 diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt index 646fe1d..71a6980 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt @@ -14,6 +14,14 @@ interface TreeherderRepository { /** Looks up an author's pushes in the selected Treeherder project. */ suspend fun getPushesByAuthor(project: String, author: String): NetworkResult = getPushesByAuthor(author) + + /** Gets the most recent pushes for a project, as shown by Treeherder without a query. */ + suspend fun getRecentPushes( + project: String, + count: Int = 10, + offset: Int = 0, + ): NetworkResult = + NetworkResult.Error("Recent-push lookup is not implemented.") suspend fun getJobsForPush(pushId: Int): NetworkResult suspend fun getJobsForPushPage( pushId: Int, diff --git a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt index 375aca8..d8df458 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -24,6 +24,14 @@ interface TreeherderApiService { @Query("author") author: String, ): TreeherderRevisionResponse + @GET("project/{project}/push/") + suspend fun getRecentPushes( + @Path("project") project: String, + @Query("full") full: Boolean = true, + @Query("count") count: Int = 10, + @Query("offset") offset: Int = 0, + ): TreeherderRevisionResponse + @GET("jobs/") suspend fun getJobsForPush( @Query("push_id") pushId: Int, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt index 64b08db..9a231a2 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -30,6 +31,7 @@ fun ProjectSelector( selectedProject: String, projectLabel: (String) -> String, onProjectSelected: (String) -> Unit, + enabled: Boolean = true, modifier: Modifier = Modifier, ) { val selectedIndex = projects.indexOf(selectedProject).coerceAtLeast(0) @@ -42,7 +44,7 @@ fun ProjectSelector( modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(24.dp)) - .padding(4.dp) + .padding(2.dp) .testTag("unified_search_project_input"), ) { val segmentWidth = maxWidth / projects.size @@ -63,18 +65,20 @@ fun ProjectSelector( projects.forEach { project -> TextButton( onClick = { onProjectSelected(project) }, + enabled = enabled, modifier = Modifier .width(segmentWidth) .fillMaxHeight() .testTag("unified_search_project_$project"), shape = RoundedCornerShape(20.dp), + contentPadding = PaddingValues(horizontal = 2.dp), colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), ) { Text( text = projectLabel(project), - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, ) } 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 d2e101a..a4d57d2 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 @@ -37,7 +37,6 @@ import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File -import java.util.concurrent.atomic.AtomicInteger private fun bugComment(revisions: List): String? { return revisions.firstOrNull { revision -> @@ -138,6 +137,9 @@ class SearchViewModel( private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + private val _warningMessage = MutableStateFlow(null) + val warningMessage: StateFlow = _warningMessage.asStateFlow() + private val _pushes = MutableStateFlow>(emptyList()) val pushes: StateFlow> = _pushes.asStateFlow() private val downloadStates = MutableStateFlow>(emptyMap()) @@ -196,9 +198,9 @@ class SearchViewModel( } }.launchIn(viewModelScope) - if (authorEmail != null) { - submitSearch() - } else { + // Screen navigation only supplies a prefill. Searches are submitted explicitly by + // the screen (including its intentional deep-link effect), never during creation. + if (authorEmail.isNullOrBlank()) { loadLastSearchedEmail() } } @@ -220,12 +222,14 @@ class SearchViewModel( logcat(LogPriority.DEBUG, TAG) { "Updating author email to: $email" } _authorEmail.value = email _errorMessage.value = null + _warningMessage.value = null } fun updateQuery(query: String) = updateAuthorEmail(query) fun updateSelectedProject(project: String) { _selectedProject.value = project + _warningMessage.value = null } fun setEmailFromDeepLinkAndSearch(project: String?, email: String) { @@ -249,6 +253,7 @@ class SearchViewModel( when (val parsed = SearchQueryClassifier.classify(_authorEmail.value).getOrNull()) { is SearchQuery.Email -> searchByAuthor() is SearchQuery.Revision -> searchByRevision(parsed.value) + SearchQuery.RecentPushes -> searchRecentPushes() null -> Unit } } @@ -261,6 +266,7 @@ class SearchViewModel( viewModelScope.launch { _isLoading.value = true _errorMessage.value = null + _warningMessage.value = null _pushes.value = emptyList() when (val pushResult = fenixRepository.getPushByRevision(_selectedProject.value, revision)) { is NetworkResult.Success -> { @@ -330,6 +336,7 @@ class SearchViewModel( fun searchByAuthor() { val emailToSearch = _authorEmail.value + val projectToSearch = _selectedProject.value logcat(TAG) { "searchByAuthor called for email: $emailToSearch" } if (emailToSearch.isBlank()) { _errorMessage.value = "Please enter an author email to search." @@ -339,20 +346,43 @@ class SearchViewModel( if (SearchQueryClassifier.classify(emailToSearch).getOrNull() !is SearchQuery.Email) { return } + searchPushes( + queryToRecord = emailToSearch, + project = projectToSearch, + ) { fenixRepository.getPushesByAuthor(projectToSearch, emailToSearch) } + } + + private fun searchRecentPushes() { + val projectToSearch = _selectedProject.value + searchPushes( + queryToRecord = null, + project = projectToSearch, + ) { fenixRepository.getRecentPushes(projectToSearch) } + } + + private fun searchPushes( + queryToRecord: String?, + project: String, + request: suspend () -> NetworkResult, + ) { viewModelScope.launch { _isLoading.value = true _errorMessage.value = null _pushes.value = emptyList() logcat(LogPriority.DEBUG, TAG) { "Starting search..." } val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) - - when (val result = fenixRepository.getPushesByAuthor(_selectedProject.value, emailToSearch)) { - is NetworkResult.Success -> { + var requestToRun = request + var retriedRecentPushes = false + var fetchedPushCount = 0 + + searchLoop@ while (true) { + when (val result = requestToRun()) { + is NetworkResult.Success -> { + fetchedPushCount += result.data.results.size logcat( LogPriority.DEBUG, TAG, - ) { "getPushesByAuthor success, processing ${result.data.results.size} pushes" } - val failedPushCount = AtomicInteger(0) + ) { "Push lookup succeeded; processing ${result.data.results.size} pushes" } val pushesWithJobsAndArtifacts = result.data.results.mapIndexed { pushIndex, pushResult -> async { val jobsResult = fenixRepository.getJobsForPush(pushResult.id) @@ -367,9 +397,6 @@ class SearchViewModel( val artifactResult = artifactSemaphore.withPermit { fetchArtifacts(jobDetails.taskId) } - if (artifactResult.failed) { - failedPushCount.incrementAndGet() - } artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { artifacts -> jobWithArtifacts(jobDetails, artifacts) } @@ -405,7 +432,6 @@ class SearchViewModel( null } } else { - failedPushCount.incrementAndGet() logcat(LogPriority.WARN, TAG) { "getJobsForPush failed for push ID: ${pushResult.id}: " + (jobsResult as NetworkResult.Error).message @@ -418,21 +444,37 @@ class SearchViewModel( _pushes.value = pushesWithJobsAndArtifacts syncLoadedStateDownloadStates() if (pushesWithJobsAndArtifacts.isNotEmpty()) { - userDataRepository.recordSearch(_selectedProject.value, emailToSearch) + queryToRecord?.let { userDataRepository.recordSearch(project, it) } } logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } - if (failedPushCount.get() > 0) { - _errorMessage.value = "Some pushes could not be loaded." - } else if (pushesWithJobsAndArtifacts.isEmpty()) { - _errorMessage.value = "No APK builds found for this author." - logcat(TAG) { "No APK builds found for author." } + if (queryToRecord == null && !retriedRecentPushes && pushesWithJobsAndArtifacts.isEmpty()) { + retriedRecentPushes = true + requestToRun = { + fenixRepository.getRecentPushes( + project = project, + count = 50, + offset = 10, + ) + } + logcat(TAG) { "No usable APKs in the newest 10 pushes; searching the next 50." } + continue@searchLoop + } + if (pushesWithJobsAndArtifacts.isEmpty()) { + _errorMessage.value = "No push was found with a job that produced an APK." + logcat(TAG) { _errorMessage.value.orEmpty() } + } else if (pushesWithJobsAndArtifacts.size < fetchedPushCount) { + _warningMessage.value = + "Showing ${pushesWithJobsAndArtifacts.size} of $fetchedPushCount fetched pushes. " + + "The remaining pushes did not contain a job that produced an APK." } } - is NetworkResult.Error -> { - logcat(LogPriority.ERROR, TAG) { "Error fetching pushes: ${result.message}" } - _errorMessage.value = "Error fetching pushes: ${result.message}" + is NetworkResult.Error -> { + logcat(LogPriority.ERROR, TAG) { "Error fetching pushes: ${result.message}" } + _errorMessage.value = "Error fetching pushes: ${result.message}" + } } + break@searchLoop } _isLoading.value = false } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt index 3a89097..8f99a40 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt @@ -6,6 +6,9 @@ sealed interface SearchQuery { data class Email(override val value: String) : SearchQuery data class Revision(override val value: String) : SearchQuery + data object RecentPushes : SearchQuery { + override val value = "" + } } /** @@ -18,7 +21,7 @@ object SearchQueryClassifier { fun classify(input: String): Result { val value = input.trim() return when { - value.isBlank() -> Result.failure(IllegalArgumentException("Enter an email or revision.")) + value.isBlank() -> Result.success(SearchQuery.RecentPushes) '@' !in value -> Result.success(SearchQuery.Revision(value)) emailPattern.matches(value) -> Result.success(SearchQuery.Email(value)) else -> Result.failure(IllegalArgumentException("Invalid search query.")) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt index 31d685b..2ca94bf 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -80,6 +80,7 @@ private val projectDisplayToActualMap = mapOf( "central" to "mozilla-central", "beta" to "mozilla-beta", "release" to "mozilla-release", + "autoland" to "autoland", ) internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" @@ -99,6 +100,7 @@ fun SearchScreen( val selectedProject by searchViewModel.selectedProject.collectAsState() val isLoading by searchViewModel.isLoading.collectAsState() val errorMessage by searchViewModel.errorMessage.collectAsState() + val warningMessage by searchViewModel.warningMessage.collectAsState() val pushes by searchViewModel.pushes.collectAsState() val installStates by searchViewModel.installStates.collectAsState() val activeInstallKey = installStates.entries.firstOrNull { (_, state) -> @@ -120,11 +122,9 @@ fun SearchScreen( val showCurrentSearch = !isEditingDisplayedSearch fun submitSearch(queryToSubmit: String) { - if (SearchQueryClassifier.classify(queryToSubmit).isSuccess) { - hasSubmittedSearch = true - displayedQuery = queryToSubmit - searchViewModel.submitSearch() - } + hasSubmittedSearch = true + displayedQuery = queryToSubmit + searchViewModel.submitSearch() } LaunchedEffect(deepLinkProject, deepLinkQuery) { @@ -229,6 +229,18 @@ fun SearchScreen( } } + warningMessage?.let { + item { + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + WarningState(warningMessage = it) + } + } + } + if (isLoading) { item { AnimatedVisibility( @@ -284,6 +296,7 @@ fun SearchSection( selectedProject = projectDisplayToActualMap.entries.first { it.value == selectedProject }.key, projectLabel = { it }, onProjectSelected = { displayProject -> onProjectSelected(projectDisplayToActualMap.getValue(displayProject)) }, + enabled = !isLoading, modifier = Modifier.height(52.dp), ) SearchInputRow( @@ -393,7 +406,7 @@ internal fun SearchInputRow( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { onSearchClick(); keyboardController?.hide() }), ) - Button(onClick = { onSearchClick(); keyboardController?.hide() }, enabled = !isLoading && query.isNotBlank(), modifier = Modifier.size(52.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), contentPadding = PaddingValues(0.dp)) { + Button(onClick = { onSearchClick(); keyboardController?.hide() }, enabled = !isLoading, modifier = Modifier.size(52.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), contentPadding = PaddingValues(0.dp)) { if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary) else Icon(Icons.Default.Search, contentDescription = stringResource(R.string.profile_screen_search_button_description)) } @@ -425,7 +438,7 @@ fun LoadingState(candidateCount: Int) { verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = "Loading signed APKs", + text = stringResource(R.string.search_loading_apk_builds), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) @@ -460,3 +473,18 @@ fun ErrorState(errorMessage: String) { ) } } + +@Composable +fun WarningState(warningMessage: String) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = warningMessage, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onTertiaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae0db9d..46bab63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ Email address or revision... Search Searching for jobs and artifacts... + Loading APK builds Unknown Warning: Unsupported ABI Download failed: %1$s diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt index d8ea7c3..76b157d 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt @@ -13,8 +13,11 @@ class SearchQueryClassifierTest { assertEquals(SearchQuery.Revision("abc123"), SearchQueryClassifier.classify(" abc123 ").getOrThrow()) } - @Test fun `rejects blank and malformed at input`() { - assertTrue(SearchQueryClassifier.classify(" ").isFailure) + @Test fun `classifies a blank query as recent pushes`() { + assertEquals(SearchQuery.RecentPushes, SearchQueryClassifier.classify(" ").getOrThrow()) + } + + @Test fun `rejects malformed email`() { assertTrue(SearchQueryClassifier.classify("person@mozilla").isFailure) } } From 632a82cd8cb254ede55f5b665d44070bf7925574 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Mon, 3 Aug 2026 11:59:39 +0200 Subject: [PATCH 23/24] Order APK jobs by variant and name --- .../tryfox/ui/screens/ProfileViewModel.kt | 29 +++++++-- .../tryfox/ui/screens/ApkJobOrderingTest.kt | 64 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt 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 a4d57d2..a402e29 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 @@ -37,6 +37,7 @@ import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File +import java.util.Locale private fun bugComment(revisions: List): String? { return revisions.firstOrNull { revision -> @@ -83,6 +84,26 @@ internal fun filterRedundantUnsignedApkJobs(jobs: List): List } } +/** Orders displayed APK jobs by variant group, then app and job name. */ +internal fun orderApkJobs(jobs: List): List = + jobs.sortedWith( + compareBy( + { job -> apkJobCategory(job) }, + { job -> job.appName.lowercase(Locale.ROOT) }, + { job -> job.jobName.lowercase(Locale.ROOT) }, + JobDetailsUiModel::taskId, + ), + ) + +private fun apkJobCategory(job: JobDetailsUiModel): Int { + val name = job.jobName.lowercase(Locale.ROOT) + return when { + "perftest" in name || "simulation" in name -> 2 + "firebase" in name -> 1 + else -> 0 + } +} + /** * ViewModel for the Profile screen, responsible for fetching pushes and artifacts by author, managing downloads, and handling user interactions. * @@ -284,8 +305,8 @@ class SearchViewModel( loadJob(job)?.let(jobs::add) } val visibleJobs = filterRedundantUnsignedApkJobs(jobs) - val signedJobs = visibleJobs.filter(JobDetailsUiModel::isSignedBuild) - val unsignedJobs = visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild) + val signedJobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)) + val unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)) if (signedJobs.isEmpty() && unsignedJobs.isEmpty()) { _errorMessage.value = "No APK builds found for this revision." } else { @@ -414,8 +435,8 @@ class SearchViewModel( .map { it.revisions }, ), author = pushResult.author, - jobs = visibleJobs.filter(JobDetailsUiModel::isSignedBuild), - unsignedJobs = visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild), + jobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)), + unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)), revision = pushResult.revision, pushTimestamp = pushResult.pushTimestamp, ) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt new file mode 100644 index 0000000..e351c77 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt @@ -0,0 +1,64 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.ui.models.JobDetailsUiModel + +class ApkJobOrderingTest { + + @Test + fun `orders regular jobs before firebase and perftest jobs`() { + val fenixRegular = job(appName = "fenix", jobName = "signing-apk-fenix-nightly") + val focusRegular = job(appName = "focus", jobName = "signing-apk-focus-nightly") + val fenixFirebase = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-firebase") + val focusFirebase = job(appName = "focus", jobName = "signing-apk-focus-nightly-firebase") + val fenixPerftest = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-simulation") + val focusPerftest = job(appName = "focus", jobName = "signing-apk-focus-nightly-perftest") + + assertEquals( + listOf(fenixRegular, focusRegular, fenixFirebase, focusFirebase, fenixPerftest, focusPerftest), + orderApkJobs( + listOf( + focusPerftest, + focusFirebase, + fenixRegular, + focusRegular, + fenixPerftest, + fenixFirebase, + ), + ), + ) + } + + @Test + fun `orders jobs alphabetically by app then job name within each variant group`() { + val fenixBeta = job(appName = "Fenix", jobName = "signing-apk-fenix-beta") + val fenixNightly = job(appName = "fenix", jobName = "signing-apk-fenix-nightly") + val focusNightly = job(appName = "focus", jobName = "signing-apk-focus-nightly") + + assertEquals( + listOf(fenixBeta, fenixNightly, focusNightly), + orderApkJobs(listOf(focusNightly, fenixNightly, fenixBeta)), + ) + } + + @Test + fun `treats a firebase perftest as a perftest`() { + val firebase = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-firebase") + val firebasePerftest = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-firebase-perftest") + + assertEquals( + listOf(firebase, firebasePerftest), + orderApkJobs(listOf(firebasePerftest, firebase)), + ) + } + + private fun job(appName: String, jobName: String) = JobDetailsUiModel( + appName = appName, + jobName = jobName, + jobSymbol = "B", + taskId = jobName, + isSignedBuild = true, + isTest = false, + ) +} From bd93be24b60ec6cd8b40be42940c63c80bbcc526 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Mon, 3 Aug 2026 12:22:07 +0200 Subject: [PATCH 24/24] Keep search field empty on open --- .../tryfox/ui/screens/ProfileViewModel.kt | 17 ----------------- 1 file changed, 17 deletions(-) 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 a402e29..499e8ac 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 @@ -8,7 +8,6 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -221,22 +220,6 @@ class SearchViewModel( // Screen navigation only supplies a prefill. Searches are submitted explicitly by // the screen (including its intentional deep-link effect), never during creation. - if (authorEmail.isNullOrBlank()) { - loadLastSearchedEmail() - } - } - - private fun loadLastSearchedEmail() { - viewModelScope.launch { - val lastEmail = userDataRepository.lastSearchedEmailFlow.first() - if (lastEmail.isNotBlank()) { - _authorEmail.value = lastEmail - logcat( - LogPriority.DEBUG, - TAG, - ) { "Initial author email loaded from storage: ${_authorEmail.value}" } - } - } } fun updateAuthorEmail(email: String) {