From 5ab417bf788a071c3d9c57cc6599f88e58322f13 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Sat, 11 Jul 2026 12:45:28 +0200 Subject: [PATCH 01/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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/47] 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) { From 909ea32e9263958f9ca50023dd4fcc60112391c7 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 4 Aug 2026 16:35:06 +0200 Subject: [PATCH 25/47] Redesign home app cards --- .../tryfox/ui/screens/HomeScreenTest.kt | 43 +-- .../data/DefaultMozillaPackageManager.kt | 6 + .../tryfox/data/MozillaPackageManager.kt | 3 + .../DefaultMozillaArchiveRepository.kt | 37 ++- .../FocusBetaReleaseRepository.kt | 21 ++ .../repositories/MozillaArchiveRepository.kt | 6 + .../java/org/mozilla/tryfox/di/AppModule.kt | 4 + .../tryfox/ui/composables/ArchiveGroupCard.kt | 4 +- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 311 ++++++++++++++++++ .../tryfox/ui/screens/HomeAppCardModel.kt | 35 ++ .../mozilla/tryfox/ui/screens/HomeScreen.kt | 115 +------ .../tryfox/ui/screens/HomeScreenState.kt | 1 + .../tryfox/ui/screens/HomeViewModel.kt | 10 + .../java/org/mozilla/tryfox/util/Consts.kt | 1 + app/src/main/res/values/strings.xml | 9 + .../data/FakeMozillaArchiveRepository.kt | 10 + .../data/MozillaArchiveRepositoryImplTest.kt | 16 + .../ui/screens/FakeMozillaPackageManager.kt | 4 + .../tryfox/ui/screens/HomeAppCardModelTest.kt | 46 +++ 19 files changed, 534 insertions(+), 148 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt index d9bd701..bf80574 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt @@ -19,45 +19,10 @@ class HomeScreenTest { val composeTestRule = createAndroidComposeRule() @Test - fun homeScreen_showsFenixNightlyCard() { - // --- Fenix (Nightly) Card --- - val fenixTitleTag = "app_title_text_fenix" - - // Assert Fenix text (found by tag) is displayed - composeTestRule.onNodeWithTag(fenixTitleTag, useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun homeScreen_showsFenixBetaCard() { - // --- Fenix Beta Card --- - val betaTitleTag = "app_title_text_fenix-beta" - - // Assert Beta text (found by tag) is displayed - composeTestRule.onNodeWithTag(betaTitleTag, useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun homeScreen_showsFenixReleaseCard() { - // --- Fenix Release Card --- - val releaseTitleTag = "app_title_text_fenix-release" - - // Assert Release text (found by tag) is displayed - composeTestRule.onNodeWithTag(releaseTitleTag, useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun homeScreen_showsAllThreeFenixVariants() { - // --- Fenix (Nightly) Card --- - val fenixTitleTag = "app_title_text_fenix" - composeTestRule.onNodeWithTag(fenixTitleTag, useUnmergedTree = true).assertIsDisplayed() - - // --- Fenix Beta Card --- - val betaTitleTag = "app_title_text_fenix-beta" - composeTestRule.onNodeWithTag(betaTitleTag, useUnmergedTree = true).assertIsDisplayed() - - // --- Fenix Release Card --- - val releaseTitleTag = "app_title_text_fenix-release" - composeTestRule.onNodeWithTag(releaseTitleTag, useUnmergedTree = true).assertIsDisplayed() + fun homeScreen_showsThreeGroupedAppCards() { + composeTestRule.onNodeWithTag("home_app_card_fenix", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("home_app_card_focus", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("home_app_card_referencebrowser", useUnmergedTree = true).assertIsDisplayed() } @Test diff --git a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt index c3f28dc..966d5d0 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt @@ -21,6 +21,8 @@ import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FENIX_RELEASE_PACKAGE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_BETA_PACKAGE import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.FOCUS_RELEASE_PACKAGE @@ -80,6 +82,7 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag FENIX_RELEASE_PACKAGE to FENIX_RELEASE, FENIX_BETA_PACKAGE to FENIX_BETA, FOCUS_NIGHTLY_PACKAGE to FOCUS, + FOCUS_BETA_PACKAGE to FOCUS_BETA, FOCUS_RELEASE_PACKAGE to FOCUS_RELEASE, REFERENCE_BROWSER_PACKAGE to REFERENCE_BROWSER, TRYFOX_PACKAGE to TRYFOX, @@ -100,6 +103,9 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag override val focusRelease: AppState get() = getAppState(FOCUS_RELEASE_PACKAGE) + override val focusBeta: AppState + get() = getAppState(FOCUS_BETA_PACKAGE) + override val referenceBrowser: AppState get() = getAppState(REFERENCE_BROWSER_PACKAGE) diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt index 1b73f5f..ad43393 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt @@ -33,6 +33,9 @@ interface MozillaPackageManager { */ val focusRelease: AppState + /** The [AppState] for Focus Beta. */ + val focusBeta: AppState + /** * The [AppState] for Reference Browser. */ diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt index 8d810b5..4fbc7f4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt @@ -15,6 +15,7 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA 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_RELEASE import retrofit2.HttpException @@ -71,11 +72,19 @@ class DefaultMozillaArchiveRepository( } override suspend fun getFocusReleaseBuilds(): NetworkResult> { + return getFocusReleaseBuilds(ReleaseType.Release) + } + + override suspend fun getFocusBetaBuilds(): NetworkResult> { + return getFocusReleaseBuilds(ReleaseType.Beta) + } + + private suspend fun getFocusReleaseBuilds(releaseType: ReleaseType): NetworkResult> { return try { val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL) val latestReleaseVersion = mozillaArchiveHtmlParser.parseFenixReleasesFromHtml( releasesHtml, - ReleaseType.Release, + releaseType, ) if (latestReleaseVersion.isEmpty()) { @@ -86,8 +95,8 @@ class DefaultMozillaArchiveRepository( version = latestReleaseVersion, archiveBaseUrl = RELEASES_FOCUS_BASE_URL, archiveAppName = FOCUS, - resultAppName = FOCUS_RELEASE, - releaseType = ReleaseType.Release, + resultAppName = if (releaseType == ReleaseType.Release) FOCUS_RELEASE else FOCUS_BETA, + releaseType = releaseType, ) } catch (e: Exception) { NetworkResult.Error("Failed to fetch or parse Focus releases: ${e.message}", e) @@ -110,9 +119,17 @@ class DefaultMozillaArchiveRepository( } override suspend fun getFocusReleaseVersions(): NetworkResult> { + return getFocusReleaseVersions(ReleaseType.Release) + } + + override suspend fun getFocusBetaVersions(): NetworkResult> { + return getFocusReleaseVersions(ReleaseType.Beta) + } + + private suspend fun getFocusReleaseVersions(releaseType: ReleaseType): NetworkResult> { return try { val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL) - val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, ReleaseType.Release) + val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType) if (releaseVersions.isEmpty()) { return NetworkResult.Error("No Focus release versions found", null) @@ -146,6 +163,14 @@ class DefaultMozillaArchiveRepository( } override suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> { + return getFocusReleaseBuildsForVersion(version, ReleaseType.Release) + } + + override suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> { + return getFocusReleaseBuildsForVersion(version, ReleaseType.Beta) + } + + private suspend fun getFocusReleaseBuildsForVersion(version: String, releaseType: ReleaseType): NetworkResult> { return try { if (version.isEmpty()) { return NetworkResult.Error("No version provided", null) @@ -155,8 +180,8 @@ class DefaultMozillaArchiveRepository( version = version, archiveBaseUrl = RELEASES_FOCUS_BASE_URL, archiveAppName = FOCUS, - resultAppName = FOCUS_RELEASE, - releaseType = ReleaseType.Release, + resultAppName = if (releaseType == ReleaseType.Release) FOCUS_RELEASE else FOCUS_BETA, + releaseType = releaseType, ) } catch (e: Exception) { NetworkResult.Error("Failed to fetch Focus release $version: ${e.message}", e) diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt new file mode 100644 index 0000000..b564d46 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt @@ -0,0 +1,21 @@ +package org.mozilla.tryfox.data.repositories + +import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.model.MozillaArchiveApk +import org.mozilla.tryfox.util.FOCUS_BETA + +/** Version-aware archive source for Focus Beta. */ +class FocusBetaReleaseRepository( + private val mozillaArchiveRepository: MozillaArchiveRepository, +) : VersionAwareReleaseRepository { + override val appName: String = FOCUS_BETA + + override suspend fun getLatestReleases(): NetworkResult> = + mozillaArchiveRepository.getFocusBetaBuilds() + + override suspend fun getAvailableReleaseVersions(): NetworkResult> = + mozillaArchiveRepository.getFocusBetaVersions() + + override suspend fun getReleasesForVersion(version: String): NetworkResult> = + mozillaArchiveRepository.getFocusBetaBuildsForVersion(version) +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt index 600513a..157e5d4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt @@ -32,7 +32,13 @@ interface MozillaArchiveRepository { suspend fun getFocusReleaseBuilds(): NetworkResult> + suspend fun getFocusBetaBuilds(): NetworkResult> + suspend fun getFocusReleaseVersions(): NetworkResult> + suspend fun getFocusBetaVersions(): NetworkResult> + suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> + + suspend fun getFocusBetaBuildsForVersion(version: String): 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 6caa9f3..b2191ae 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -31,6 +31,7 @@ import org.mozilla.tryfox.data.repositories.FenixBetaReleaseRepository 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.FocusBetaReleaseRepository import org.mozilla.tryfox.data.repositories.FocusReleaseRepository import org.mozilla.tryfox.data.repositories.HistoryRepository import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository @@ -64,6 +65,7 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA 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_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX @@ -185,6 +187,7 @@ val repositoryModule = module { single(named(FENIX_RELEASE)) { FenixReleaseReleaseRepository(get()) } single(named(FENIX_BETA)) { FenixBetaReleaseRepository(get()) } single(named(FOCUS)) { FocusNightlyRepository(get()) } + single(named(FOCUS_BETA)) { FocusBetaReleaseRepository(get()) } single(named(FOCUS_RELEASE)) { FocusReleaseRepository(get()) } single(named(REFERENCE_BROWSER)) { ReferenceBrowserReleaseRepository() } single(named(TRYFOX)) { TryFoxReleaseRepository(get()) } @@ -212,6 +215,7 @@ val viewModelModule = module { get(named(FENIX_RELEASE)), get(named(FENIX_BETA)), get(named(FOCUS)), + get(named(FOCUS_BETA)), get(named(FOCUS_RELEASE)), get(named(REFERENCE_BROWSER)), get(named(TRYFOX)), 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 1686e90..faabbfe 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 @@ -73,6 +73,7 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA 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_RELEASE import org.mozilla.tryfox.util.FeatureFlags import org.mozilla.tryfox.util.REFERENCE_BROWSER @@ -132,7 +133,7 @@ fun ArchiveGroupCard( val dateFromApk = firstApk?.date ?: "" // Release, beta and Focus release all pick a specific version from a dropdown. val hasReleaseVersionPicker = - appName == FENIX_RELEASE || appName == FOCUS_RELEASE || appName == FENIX_BETA + appName == FENIX_RELEASE || appName == FOCUS_RELEASE || appName == FENIX_BETA || appName == FOCUS_BETA val isDatePickerEnabled = appName != REFERENCE_BROWSER && !hasReleaseVersionPicker Column(modifier = Modifier.padding(ArchiveGroupCardTokens.ColumnPadding)) { @@ -571,6 +572,7 @@ private fun getFriendlyAppName(appName: String): String = FENIX_RELEASE -> stringResource(R.string.app_name_fenix_release) FENIX_BETA -> stringResource(R.string.app_name_fenix_beta) FOCUS -> stringResource(id = R.string.app_name_focus) + FOCUS_BETA -> stringResource(id = R.string.app_name_focus_beta) FOCUS_RELEASE -> stringResource(id = R.string.app_name_focus_release) REFERENCE_BROWSER -> stringResource(R.string.app_name_reference_browser) else -> appName diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt new file mode 100644 index 0000000..f5b2978 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -0,0 +1,311 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.CalendarToday +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +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.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState +import org.mozilla.tryfox.model.AppState +import org.mozilla.tryfox.ui.composables.AppIcon +import org.mozilla.tryfox.ui.composables.CurrentInstallState +import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.ui.models.NightlyBuildOption +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +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_RELEASE +import org.mozilla.tryfox.util.parseDateToLocalDate +import org.mozilla.tryfox.util.parseDateToMillis + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomeAppCard( + card: HomeAppCardUiModel, + installStates: Map, + onFlavorSelected: (String) -> Unit, + onDownloadClick: (ApkUiModel) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + onOpenInstalledApp: (String) -> Unit, + onDateSelected: (String, LocalDate) -> Unit, + dateValidator: (LocalDate) -> Boolean, + onReleaseVersionSelected: (String, String) -> Unit, + onBuildSelected: (String, String) -> Unit, + onDismissBuildPicker: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val app = card.selectedApp + val appState = app.toAppState() + val title = cardTitle(card.family) + val tag = card.family.name.lowercase() + val isNightly = app.name == FENIX || app.name == FOCUS + val isVersionSelectable = app.name in setOf(FENIX_RELEASE, FENIX_BETA, FOCUS_RELEASE, FOCUS_BETA) + val selectedApk = (app.apks as? ApksResult.Success)?.apks + ?.let { apks -> apks.firstOrNull { it.abi.isSupported } ?: apks.firstOrNull() } + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize() + .testTag("home_app_card_$tag"), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon( + appName = app.name, + modifier = Modifier + .size(60.dp) + .then( + if (appState?.isInstalled == true) { + Modifier + .clickable { onOpenInstalledApp(appState.packageName) } + .testTag("home_open_icon_$tag") + } else { + Modifier + }, + ), + ) + Column { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(cardSubtitle(card.family), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + CurrentInstallState( + appState = appState, + appDisplayName = title, + modifier = Modifier.testTag("home_install_status_$tag").padding(top = 8.dp), + ) + + if (card.family.appNames.size > 1) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + card.family.appNames.forEach { appName -> + FilterChip( + selected = appName == app.name, + onClick = { onFlavorSelected(appName) }, + label = { Text(flavorLabel(appName)) }, + modifier = Modifier.testTag("home_flavor_${tag}_${appName}"), + ) + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + when { + app.apks is ApksResult.Loading -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) + app.apks is ApksResult.Error -> Text(app.apks.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + selectedApk != null && isNightly -> NightlyDetails( + appName = app.name, + version = selectedApk.version, + date = selectedApk.date, + selectedDate = app.userPickedDate, + dateValidator = dateValidator, + onDateSelected = onDateSelected, + ) + selectedApk != null && isVersionSelectable -> ReleaseVersionDetails( + appName = app.name, + selectedVersion = app.selectedReleaseVersion ?: selectedApk.version, + versions = app.availableReleaseVersions, + onSelected = onReleaseVersionSelected, + ) + selectedApk != null -> Text(selectedApk.version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + else -> Text(stringResource(R.string.home_no_apks_available), style = MaterialTheme.typography.bodyMedium) + } + } + if (selectedApk != null || app.apks is ApksResult.Loading) { + Spacer(Modifier.width(12.dp)) + DownloadButton( + downloadState = selectedApk?.downloadState ?: DownloadState.NotDownloaded, + onDownloadClick = { selectedApk?.let(onDownloadClick) }, + onInstallClick = { selectedApk?.let(onInstallClick) }, + installState = selectedApk?.let { installStates[it.uniqueKey] } ?: InstallState.Idle, + installDisabled = selectedApk == null, + onOpenClick = onOpenInstalledApp, + debugLabel = "home-card:${selectedApk?.uniqueKey ?: app.name}", + modifier = Modifier + .testTag("home_primary_action_$tag") + .semantics { contentDescription = "Download $title" }, + ) + } + } + } + } + + if (app.pendingBuildOptions.isNotEmpty()) { + NightlyBuildPickerDialog( + options = app.pendingBuildOptions, + onSelect = { onBuildSelected(app.name, it) }, + onDismiss = { onDismissBuildPicker(app.name) }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NightlyDetails( + appName: String, + version: String, + date: String, + selectedDate: LocalDate?, + dateValidator: (LocalDate) -> Boolean, + onDateSelected: (String, LocalDate) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + Text(version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + AssistChip( + onClick = { showPicker = true }, + label = { Text(date.ifBlank { selectedDate?.toString().orEmpty() }) }, + leadingIcon = { Icon(Icons.Default.CalendarToday, null, Modifier.size(18.dp)) }, + modifier = Modifier.testTag("home_nightly_date_$appName"), + ) + if (showPicker) { + val initialDate = selectedDate ?: parseDateToLocalDate(date) ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val state = rememberDatePickerState( + initialSelectedDateMillis = initialDate.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds(), + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = dateValidator(Instant.fromEpochMilliseconds(utcTimeMillis).toLocalDateTime(TimeZone.UTC).date) + }, + ) + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { TextButton(onClick = { state.selectedDateMillis?.let { onDateSelected(appName, Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.currentSystemDefault()).date) }; showPicker = false }) { Text("OK") } }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } }, + ) { DatePicker(state = state) } + } +} + +@Composable +private fun ReleaseVersionDetails(appName: String, selectedVersion: String, versions: List, onSelected: (String, String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = Modifier.clickable(enabled = versions.isNotEmpty()) { expanded = true } + .testTag("home_release_version_$appName").padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedVersion, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select version") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + versions.forEach { version -> DropdownMenuItem(text = { Text(version) }, onClick = { expanded = false; onSelected(appName, version) }) } + } + } +} + +private fun AppUiModel.toAppState(): AppState? = installedVersion?.let { version -> + AppState( + name = name, + packageName = packageName, + version = version, + installDateMillis = installedDate?.let(::parseDateToMillis), + installingPackageName = installingPackageName, + versionCode = installedVersionCode, + splitNames = splitNames, + ) +} + +@Composable +private fun NightlyBuildPickerDialog( + options: List, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.home_select_build)) }, + text = { + Column { + options.forEach { option -> + Text( + option.label, + modifier = Modifier.fillMaxWidth().clickable { onSelect(option.id) }.padding(vertical = 12.dp), + ) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable private fun cardTitle(family: HomeAppFamily): String = when (family) { + HomeAppFamily.Fenix -> stringResource(R.string.home_app_title_fenix) + HomeAppFamily.Focus -> stringResource(R.string.home_app_title_focus) + HomeAppFamily.ReferenceBrowser -> stringResource(R.string.home_app_title_reference_browser) +} + +@Composable private fun cardSubtitle(family: HomeAppFamily): String = when (family) { + HomeAppFamily.Fenix -> stringResource(R.string.home_app_subtitle_fenix) + HomeAppFamily.Focus -> stringResource(R.string.home_app_subtitle_focus) + HomeAppFamily.ReferenceBrowser -> stringResource(R.string.home_app_subtitle_reference_browser) +} + +private fun flavorLabel(appName: String): String = when (appName) { + FENIX, FOCUS -> "Nightly" + FENIX_BETA, FOCUS_BETA -> "Beta" + else -> "Release" +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt new file mode 100644 index 0000000..9c1eaf3 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt @@ -0,0 +1,35 @@ +package org.mozilla.tryfox.ui.screens + +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +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_RELEASE +import org.mozilla.tryfox.util.REFERENCE_BROWSER + +/** The three product cards shown on Home. */ +enum class HomeAppFamily(val appNames: List, val defaultAppName: String) { + Fenix(listOf(FENIX_RELEASE, FENIX_BETA, FENIX), FENIX), + Focus(listOf(FOCUS_RELEASE, FOCUS_BETA, FOCUS), FOCUS_BETA), + ReferenceBrowser(listOf(REFERENCE_BROWSER), REFERENCE_BROWSER), +} + +data class HomeAppCardUiModel( + val family: HomeAppFamily, + val selectedAppName: String, + val appsByName: Map, +) { + val selectedApp: AppUiModel get() = appsByName.getValue(selectedAppName) +} + +internal fun homeAppCards( + apps: Map, + selectedAppNames: Map, +): List = HomeAppFamily.entries.mapNotNull { family -> + val familyApps = family.appNames.mapNotNull { name -> apps[name]?.let { name to it } }.toMap() + if (familyApps.isEmpty()) return@mapNotNull null + val selected = selectedAppNames[family].takeIf { it in familyApps } ?: family.defaultAppName + HomeAppCardUiModel(family, selected, familyApps) +} 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 77da96f..c075292 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt @@ -1,6 +1,5 @@ package org.mozilla.tryfox.ui.screens -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -46,17 +45,9 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics 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 import org.mozilla.tryfox.ui.composables.BinButton -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 /** * Composable function for the Home screen, which displays a list of available apps and allows users to interact with them. @@ -174,39 +165,37 @@ fun HomeScreen( is HomeScreenState.Loaded -> { val tryFoxApp = currentScreenState.tryfoxApp - val otherApps = currentScreenState.apps.values.toList() - - val targetSpacerHeight = if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp - val animatedSpacerHeight by animateDpAsState(targetValue = targetSpacerHeight, label = "tryFoxSpacerHeight") + val cards = homeAppCards( + apps = currentScreenState.apps, + selectedAppNames = currentScreenState.selectedAppNames, + ) LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - item { - Spacer(modifier = Modifier.height(animatedSpacerHeight)) - } + item { Spacer(modifier = Modifier.height(if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp)) } - items(otherApps) { app -> - AppComponent( - app = app, + items(cards, key = { it.family }) { card -> + HomeAppCard( + card = card, + onFlavorSelected = { appName -> + homeViewModel.selectHomeAppFlavor(card.family, appName) + }, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, onInstallClick = homeViewModel::installHomeApk, installStates = installStates, onOpenInstalledApp = homeViewModel::openInstalledApp, - onOpenAppClick = { homeViewModel.openApp(it) }, - onUninstallClick = { homeViewModel.uninstallApp(it) }, onDateSelected = { appName, date -> homeViewModel.onDateSelected( appName, date, ) }, - dateValidator = homeViewModel.getDateValidator(app.name), - onClearDate = { appName -> homeViewModel.onClearDate(appName) }, + dateValidator = homeViewModel.getDateValidator(card.selectedApp.name), onReleaseVersionSelected = { appName, version -> homeViewModel.onReleaseVersionSelected(appName, version) }, @@ -268,81 +257,3 @@ private fun TopBarActionIcon( icon() } } - -/** - * Composable function for displaying a single app component, which includes information about the app and actions that can be performed. - * - * @param app The UI model for the app. - * @param onDownloadClick Callback for when the download button is clicked. - * @param onInstallClick Callback for when the install button is clicked. - * @param onOpenAppClick Callback for when the open app button is clicked. - * @param onDateSelected Callback for when a date is selected in the date picker. - * @param dateValidator A function to validate the selectable dates in the date picker. - * @param onClearDate Callback for when the selected date is cleared. - */ -@Composable -fun AppComponent( - app: AppUiModel, - onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (ApkUiModel) -> Unit, - installStates: Map, - onOpenInstalledApp: (String) -> Unit, - onOpenAppClick: (String) -> Unit, - onUninstallClick: (String) -> Unit, - onDateSelected: (String, LocalDate) -> Unit, - dateValidator: (LocalDate) -> Boolean, - onClearDate: (String) -> Unit, - onReleaseVersionSelected: (String, String) -> Unit, - onBuildSelected: (String, String) -> Unit, - onDismissBuildPicker: (String) -> Unit, -) { - val apksResult = app.apks - - val appState = if (app.installedVersion != null) { - AppState( - name = app.name, - packageName = app.packageName, - version = app.installedVersion, - versionCode = app.installedVersionCode, - installDateMillis = app.installedDate?.let { parseDateToMillis(it) }, - installingPackageName = app.installingPackageName, - splitNames = app.splitNames, - ) - } else { - null - } - - ArchiveGroupCard( - modifier = Modifier.padding(vertical = 8.dp), - apks = (apksResult as? ApksResult.Success)?.apks ?: emptyList(), - appState = appState, - onDownloadClick = onDownloadClick, - onInstallClick = onInstallClick, - installStates = installStates, - onOpenInstalledApp = onOpenInstalledApp, - onOpenAppClick = { - appState?.packageName?.let { - onOpenAppClick(it) - } - }, - onUninstallClick = { - appState?.packageName?.let { - onUninstallClick(it) - } - }, - onDateSelected = { date -> onDateSelected(app.name, date) }, - userPickedDate = app.userPickedDate, - selectedReleaseVersion = app.selectedReleaseVersion, - availableReleaseVersions = app.availableReleaseVersions, - onReleaseVersionSelected = { version -> onReleaseVersionSelected(app.name, version) }, - appName = app.name, - errorMessage = (apksResult as? ApksResult.Error)?.message, - isLoading = apksResult is ApksResult.Loading, - dateValidator = dateValidator, - onClearDate = { onClearDate(app.name) }, - pendingBuildOptions = app.pendingBuildOptions, - onBuildSelected = { buildId -> onBuildSelected(app.name, buildId) }, - onDismissBuildPicker = { onDismissBuildPicker(app.name) }, - ) - Spacer(modifier = Modifier.height(16.dp)) -} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt index 4ffc996..d30ac05 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt @@ -20,5 +20,6 @@ sealed class HomeScreenState { val tryfoxApp: AppUiModel?, val cacheManagementState: CacheManagementState, val isDownloadingAnyFile: Boolean, + val selectedAppNames: Map = HomeAppFamily.entries.associateWith { it.defaultAppName }, ) : HomeScreenState() } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index a6d8350..6df38f1 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 @@ -48,6 +48,7 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA 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_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX @@ -147,6 +148,14 @@ class HomeViewModel( launchRefresh(hydrateCache = false) } + fun selectHomeAppFlavor(family: HomeAppFamily, appName: String) { + if (appName !in family.appNames) return + _homeScreenState.update { state -> + if (state !is HomeScreenState.Loaded) state + else state.copy(selectedAppNames = state.selectedAppNames + (family to appName)) + } + } + private fun launchRefresh(hydrateCache: Boolean) { viewModelScope.launch(ioDispatcher) { markRefreshStarted() @@ -214,6 +223,7 @@ class HomeViewModel( FENIX_BETA to mozillaPackageManager.fenixBeta, FOCUS to mozillaPackageManager.focus, FOCUS_RELEASE to mozillaPackageManager.focusRelease, + FOCUS_BETA to mozillaPackageManager.focusBeta, REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, TRYFOX to mozillaPackageManager.tryfox, ) 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 7a3e2f2..1591f59 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -16,6 +16,7 @@ const val FENIX_NIGHTLY_PACKAGE = "org.mozilla.fenix" const val FENIX_RELEASE_PACKAGE = "org.mozilla.firefox" const val FENIX_BETA_PACKAGE = "org.mozilla.firefox_beta" const val FOCUS_NIGHTLY_PACKAGE = "org.mozilla.focus.nightly" +const val FOCUS_BETA_PACKAGE = "org.mozilla.focus.beta" const val FOCUS_RELEASE_PACKAGE = "org.mozilla.focus" const val REFERENCE_BROWSER_PACKAGE = "org.mozilla.reference.browser" const val TRYFOX_PACKAGE = "org.mozilla.tryfox" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 46bab63..4e72d01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,6 +7,14 @@ Receive from desktop Fetching latest nightly builds... Loading initial app data... + Firefox Android + Fenix + Focus for Android + Privacy Browser + Reference Browser + GeckoView Reference + No APKs available + Select build An unknown error occurred. Back Search builds @@ -78,6 +86,7 @@ Fenix Beta Focus Nightly Focus Release + Focus Beta Reference Browser Unsupported ABI Clear date selection diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt index cff038b..ed2c413 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt @@ -16,6 +16,9 @@ class FakeMozillaArchiveRepository( private val focusReleases: NetworkResult> = NetworkResult.Success(emptyList()), private val focusReleaseVersions: NetworkResult> = NetworkResult.Success(emptyList()), private val focusReleasesByVersion: Map>> = emptyMap(), + private val focusBetaReleases: NetworkResult> = NetworkResult.Success(emptyList()), + private val focusBetaVersions: NetworkResult> = NetworkResult.Success(emptyList()), + private val focusBetaReleasesByVersion: Map>> = emptyMap(), ) : MozillaArchiveRepository { override suspend fun getFenixNightlyBuilds(date: LocalDate?): NetworkResult> { @@ -52,4 +55,11 @@ class FakeMozillaArchiveRepository( override suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> { return focusReleasesByVersion[version] ?: focusReleases } + + override suspend fun getFocusBetaBuilds(): NetworkResult> = focusBetaReleases + + override suspend fun getFocusBetaVersions(): NetworkResult> = focusBetaVersions + + override suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> = + focusBetaReleasesByVersion[version] ?: focusBetaReleases } diff --git a/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt b/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt index 51812bb..20606b9 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt @@ -222,4 +222,20 @@ class MozillaArchiveRepositoryImplTest { assertTrue(result is NetworkResult.Error) assertEquals("Failed to fetch or parse focus builds: $errorMessage", (result as NetworkResult.Error).message) } + + @Test + fun `getFocusBetaVersions returns beta versions only in newest-first order`() = runTest { + val releasesHtml = """ + 146.0/ + 147.0b2/ + 147.0b7/ + """.trimIndent() + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(DefaultMozillaArchiveRepository.RELEASES_FOCUS_BASE_URL))) + .thenReturn(releasesHtml) + + val result = repository.getFocusBetaVersions() + + assertTrue(result is NetworkResult.Success) + assertEquals(listOf("147.0b7", "147.0b2"), (result as NetworkResult.Success).data) + } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt index d724a54..a3e1f2f 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt @@ -8,6 +8,7 @@ import org.mozilla.tryfox.util.FENIX_BETA_PACKAGE import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE_PACKAGE import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE +import org.mozilla.tryfox.util.FOCUS_BETA_PACKAGE import org.mozilla.tryfox.util.FOCUS_RELEASE_PACKAGE import org.mozilla.tryfox.util.REFERENCE_BROWSER_PACKAGE import org.mozilla.tryfox.util.TRYFOX_PACKAGE @@ -31,6 +32,9 @@ class FakeMozillaPackageManager( override val focusRelease: AppState get() = apps[FOCUS_RELEASE_PACKAGE] ?: AppState("Focus Release", FOCUS_RELEASE_PACKAGE, null, null) + override val focusBeta: AppState + get() = apps[FOCUS_BETA_PACKAGE] ?: AppState("Focus Beta", FOCUS_BETA_PACKAGE, null, null) + override val referenceBrowser: AppState get() = apps[REFERENCE_BROWSER_PACKAGE] ?: AppState("Reference Browser", REFERENCE_BROWSER_PACKAGE, null, null) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt new file mode 100644 index 0000000..c666f36 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt @@ -0,0 +1,46 @@ +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.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +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_RELEASE +import org.mozilla.tryfox.util.REFERENCE_BROWSER + +class HomeAppCardModelTest { + @Test + fun `groups all home flavors into three cards with mockup defaults`() { + val apps = listOf( + FENIX, FENIX_BETA, FENIX_RELEASE, FOCUS, FOCUS_BETA, FOCUS_RELEASE, REFERENCE_BROWSER, + ).associateWith(::app) + + val cards = homeAppCards(apps, emptyMap()) + + assertEquals(3, cards.size) + assertEquals(FENIX, cards.first { it.family == HomeAppFamily.Fenix }.selectedAppName) + assertEquals(FOCUS_BETA, cards.first { it.family == HomeAppFamily.Focus }.selectedAppName) + assertEquals(REFERENCE_BROWSER, cards.first { it.family == HomeAppFamily.ReferenceBrowser }.selectedAppName) + } + + @Test + fun `retains an explicitly selected flavor`() { + val apps = listOf(FENIX, FENIX_BETA, FENIX_RELEASE).associateWith(::app) + + val card = homeAppCards(apps, mapOf(HomeAppFamily.Fenix to FENIX_RELEASE)).single() + + assertEquals(FENIX_RELEASE, card.selectedAppName) + } + + private fun app(name: String) = AppUiModel( + name = name, + packageName = name, + installedVersion = null, + installedDate = null, + apks = ApksResult.Loading, + ) +} From 9fb05f4a2654d9b2c4814369edccd3263b0e812a Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 4 Aug 2026 17:29:09 +0200 Subject: [PATCH 26/47] Show unsigned Roam builds and unknown app icons --- .../mozilla/tryfox/ui/composables/AppIcon.kt | 52 ++++++++-------- .../tryfox/ui/screens/ProfileViewModel.kt | 26 ++++---- app/src/main/res/drawable/unknown_app.xml | 62 +++++++++++++++++++ .../tryfox/ui/composables/AppIconTest.kt | 19 ++++++ .../ui/screens/AndroidApkCandidateTest.kt | 20 ++++++ 5 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 app/src/main/res/drawable/unknown_app.xml create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.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 2340740..23a46cb 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 @@ -31,7 +31,31 @@ fun AppIcon( modifier: Modifier = Modifier, useSearchResultVariant: Boolean = false, ) { - val (iconResId, contentDescResId) = when { + val (iconResId, contentDescResId) = appIconResources(appName, useSearchResultVariant) + val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA, FOCUS_NIGHTLY) + 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)) +} + +internal fun appIconResources( + appName: String, + useSearchResultVariant: Boolean, +): Pair = + when { appName == REFERENCE_BROWSER -> R.drawable.ic_reference_browser to R.string.app_icon_reference_browser_description appName == FENIX -> { (if (useSearchResultVariant) R.drawable.ic_fenix_debug_foreground else R.drawable.ic_fenix_nightly) to @@ -59,29 +83,5 @@ fun AppIcon( 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") - null to null - } + else -> R.drawable.unknown_app to R.string.app_icon_generic_description } - - if (iconResId != null && contentDescResId != null) { - val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, 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/ProfileViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt index 499e8ac..e76852a 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,19 @@ internal fun isPerfAgainRetry(revisions: List): Boolean { } private val unsignedApkJobNamePattern = Regex("^build-apk-(.+)$", RegexOption.IGNORE_CASE) +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", "roam", "android") + +internal fun isAndroidApkCandidate(job: org.mozilla.tryfox.data.JobDetails): Boolean { + if (job.isTest) 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") +} /** Removes an unsigned build only when its corresponding signed build has a displayable APK. */ internal fun filterRedundantUnsignedApkJobs(jobs: List): List { @@ -137,9 +150,6 @@ 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") } private val _authorEmail = MutableStateFlow(authorEmail ?: "") @@ -537,16 +547,6 @@ class SearchViewModel( } } - private fun isAndroidApkCandidate(job: org.mozilla.tryfox.data.JobDetails): Boolean { - if (job.isTest) 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") - } - /** Keeps the existing signing-job preference while loading unsigned candidates as a separate group. */ private fun selectJobsBySigning( candidates: List, diff --git a/app/src/main/res/drawable/unknown_app.xml b/app/src/main/res/drawable/unknown_app.xml new file mode 100644 index 0000000..21556b3 --- /dev/null +++ b/app/src/main/res/drawable/unknown_app.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt new file mode 100644 index 0000000..f695aae --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt @@ -0,0 +1,19 @@ +package org.mozilla.tryfox.ui.composables + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.R + +class AppIconTest { + + @Test + fun `uses the unknown app icon for an unrecognized app`() { + val (icon, description) = appIconResources( + appName = "roam", + useSearchResultVariant = true, + ) + + assertEquals(R.drawable.unknown_app, icon) + assertEquals(R.string.app_icon_generic_description, description) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.kt new file mode 100644 index 0000000..62caed1 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.kt @@ -0,0 +1,20 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.data.JobDetails + +class AndroidApkCandidateTest { + + @Test + fun `includes unsigned Roam APK build jobs`() { + val job = JobDetails( + appName = "roam", + jobName = "build-apk-roam-debug", + jobSymbol = "B", + taskId = "roam-task", + ) + + assertTrue(isAndroidApkCandidate(job)) + } +} From 425404d7ad974272ef939c45617d34c0d0fe57f5 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 4 Aug 2026 18:05:55 +0200 Subject: [PATCH 27/47] Show TryFox installer icon on installed status --- .../java/org/mozilla/tryfox/model/AppState.kt | 5 +++++ .../ui/composables/CurrentInstallState.kt | 9 +++++++++ app/src/main/res/drawable/ic_tryfox_black.png | Bin 0 -> 15970 bytes app/src/main/res/values/strings.xml | 1 + 4 files changed, 15 insertions(+) create mode 100644 app/src/main/res/drawable/ic_tryfox_black.png diff --git a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt index b9c4684..a44ad13 100644 --- a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt +++ b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt @@ -20,6 +20,10 @@ data class AppState( val isFromPlayStore: Boolean get() = installingPackageName == PLAY_STORE_PACKAGE + /** Whether the app was installed by TryFox. */ + val isFromTryFox: Boolean + get() = installingPackageName == TRYFOX_PACKAGE + val formattedInstallDate: String? get() = installDateMillis?.let { val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) @@ -28,5 +32,6 @@ data class AppState( companion object { const val PLAY_STORE_PACKAGE = "com.android.vending" + const val TRYFOX_PACKAGE = "org.mozilla.tryfox" } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt index 8289613..1319830 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt @@ -84,6 +84,15 @@ fun CurrentInstallState( ) } + if (appState != null && appState.isInstalled && appState.isFromTryFox) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tryfox_black), + contentDescription = stringResource(id = R.string.app_icon_tryfox_description), + modifier = Modifier.size(24.dp), + ) + } + if (appState != null && appState.isInstalled) { Spacer(modifier = Modifier.width(8.dp)) Text( diff --git a/app/src/main/res/drawable/ic_tryfox_black.png b/app/src/main/res/drawable/ic_tryfox_black.png new file mode 100644 index 0000000000000000000000000000000000000000..81d229e346f7a10dc3d861479f5714ad417ab523 GIT binary patch literal 15970 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 Icon Reference Browser Icon App Icon + TryFox Icon Confirm Clear Cache Clear Cache Clear downloaded apks From 6ecd93a88d0f0250c66de635ce53de310692598e Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 4 Aug 2026 18:31:40 +0200 Subject: [PATCH 28/47] Add installed Firefox Debug home flavor --- .../data/DefaultMozillaPackageManager.kt | 6 +++++ .../tryfox/data/MozillaPackageManager.kt | 3 +++ .../mozilla/tryfox/ui/composables/AppIcon.kt | 9 ++++++-- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 7 +++++- .../tryfox/ui/screens/HomeAppCardModel.kt | 13 ++++++++--- .../tryfox/ui/screens/HomeViewModel.kt | 4 ++++ .../java/org/mozilla/tryfox/util/Consts.kt | 3 +++ .../tryfox/ui/composables/AppIconTest.kt | 12 ++++++++++ .../ui/screens/FakeMozillaPackageManager.kt | 4 ++++ .../tryfox/ui/screens/HomeAppCardModelTest.kt | 22 +++++++++++++++++-- 10 files changed, 75 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt index 966d5d0..c4386e4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.callbackFlow import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_BETA_PACKAGE +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_NIGHTLY import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE @@ -81,6 +83,7 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag FENIX_NIGHTLY_PACKAGE to FENIX_NIGHTLY, FENIX_RELEASE_PACKAGE to FENIX_RELEASE, FENIX_BETA_PACKAGE to FENIX_BETA, + FENIX_DEBUG_PACKAGE to FENIX_DEBUG, FOCUS_NIGHTLY_PACKAGE to FOCUS, FOCUS_BETA_PACKAGE to FOCUS_BETA, FOCUS_RELEASE_PACKAGE to FOCUS_RELEASE, @@ -97,6 +100,9 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag override val fenixBeta: AppState get() = getAppState(FENIX_BETA_PACKAGE) + override val fenixDebug: AppState + get() = getAppState(FENIX_DEBUG_PACKAGE) + override val focus: AppState get() = getAppState(FOCUS_NIGHTLY_PACKAGE) diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt index ad43393..0dbb4cc 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt @@ -23,6 +23,9 @@ interface MozillaPackageManager { */ val fenixBeta: AppState + /** The [AppState] for Firefox Debug. */ + val fenixDebug: AppState + /** * The [AppState] for Focus Nightly. */ 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 23a46cb..293e37b 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 @@ -15,10 +15,12 @@ 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_DEBUG 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_DEBUG import org.mozilla.tryfox.util.FOCUS_NIGHTLY import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER @@ -32,8 +34,9 @@ fun AppIcon( useSearchResultVariant: Boolean = false, ) { val (iconResId, contentDescResId) = appIconResources(appName, useSearchResultVariant) - val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA, FOCUS_NIGHTLY) - if (isPaddedSearchResultForeground) { + val isPaddedForeground = appName == FENIX_DEBUG || + (useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA, FOCUS_NIGHTLY)) + if (isPaddedForeground) { Box(modifier = modifier.clipToBounds()) { Image( painter = painterResource(id = iconResId), @@ -69,6 +72,8 @@ internal fun appIconResources( (if (useSearchResultVariant) R.drawable.ic_fenix_beta_foreground else R.drawable.ic_firefox_beta) to R.string.app_icon_firefox_description } + appName == FENIX_DEBUG -> R.drawable.ic_fenix_debug_foreground to R.string.app_icon_firefox_description + appName == FOCUS_DEBUG -> R.drawable.ic_firefox to R.string.app_icon_firefox_description appName == FENIX_RELEASE -> R.drawable.ic_firefox to R.string.app_icon_firefox_description appName == FOCUS -> { (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground_v2 else R.drawable.ic_focus) to diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index f5b2978..54fccd3 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -64,9 +64,11 @@ import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG 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_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.parseDateToLocalDate import org.mozilla.tryfox.util.parseDateToMillis @@ -92,6 +94,7 @@ internal fun HomeAppCard( val title = cardTitle(card.family) val tag = card.family.name.lowercase() val isNightly = app.name == FENIX || app.name == FOCUS + val isDebug = app.name == FENIX_DEBUG || app.name == FOCUS_DEBUG val isVersionSelectable = app.name in setOf(FENIX_RELEASE, FENIX_BETA, FOCUS_RELEASE, FOCUS_BETA) val selectedApk = (app.apks as? ApksResult.Success)?.apks ?.let { apks -> apks.firstOrNull { it.abi.isSupported } ?: apks.firstOrNull() } @@ -156,6 +159,7 @@ internal fun HomeAppCard( ) { Column(modifier = Modifier.weight(1f)) { when { + isDebug -> Unit app.apks is ApksResult.Loading -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) app.apks is ApksResult.Error -> Text(app.apks.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) selectedApk != null && isNightly -> NightlyDetails( @@ -176,7 +180,7 @@ internal fun HomeAppCard( else -> Text(stringResource(R.string.home_no_apks_available), style = MaterialTheme.typography.bodyMedium) } } - if (selectedApk != null || app.apks is ApksResult.Loading) { + if (!isDebug && (selectedApk != null || app.apks is ApksResult.Loading)) { Spacer(Modifier.width(12.dp)) DownloadButton( downloadState = selectedApk?.downloadState ?: DownloadState.NotDownloaded, @@ -307,5 +311,6 @@ private fun NightlyBuildPickerDialog( private fun flavorLabel(appName: String): String = when (appName) { FENIX, FOCUS -> "Nightly" FENIX_BETA, FOCUS_BETA -> "Beta" + FENIX_DEBUG, FOCUS_DEBUG -> "Debug" else -> "Release" } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt index 9c1eaf3..d206677 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt @@ -3,16 +3,18 @@ package org.mozilla.tryfox.ui.screens import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG 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_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER /** The three product cards shown on Home. */ enum class HomeAppFamily(val appNames: List, val defaultAppName: String) { - Fenix(listOf(FENIX_RELEASE, FENIX_BETA, FENIX), FENIX), - Focus(listOf(FOCUS_RELEASE, FOCUS_BETA, FOCUS), FOCUS_BETA), + Fenix(listOf(FENIX_RELEASE, FENIX_BETA, FENIX, FENIX_DEBUG), FENIX), + Focus(listOf(FOCUS_RELEASE, FOCUS_BETA, FOCUS, FOCUS_DEBUG), FOCUS_BETA), ReferenceBrowser(listOf(REFERENCE_BROWSER), REFERENCE_BROWSER), } @@ -28,8 +30,13 @@ internal fun homeAppCards( apps: Map, selectedAppNames: Map, ): List = HomeAppFamily.entries.mapNotNull { family -> - val familyApps = family.appNames.mapNotNull { name -> apps[name]?.let { name to it } }.toMap() + val familyApps = family.appNames.mapNotNull { name -> + apps[name]?.takeUnless { name.isDebugFlavor && it.installedVersion == null }?.let { name to it } + }.toMap() if (familyApps.isEmpty()) return@mapNotNull null val selected = selectedAppNames[family].takeIf { it in familyApps } ?: family.defaultAppName HomeAppCardUiModel(family, selected, familyApps) } + +private val String.isDebugFlavor: Boolean + get() = this == FENIX_DEBUG || this == FOCUS_DEBUG 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 6df38f1..d15a6c2 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 @@ -46,9 +46,11 @@ import org.mozilla.tryfox.ui.models.NightlyBuildOption import org.mozilla.tryfox.ui.models.newVersionAvailable import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG 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_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX @@ -221,9 +223,11 @@ class HomeViewModel( FENIX to mozillaPackageManager.fenix, FENIX_RELEASE to mozillaPackageManager.fenixRelease, FENIX_BETA to mozillaPackageManager.fenixBeta, + FENIX_DEBUG to mozillaPackageManager.fenixDebug, FOCUS to mozillaPackageManager.focus, FOCUS_RELEASE to mozillaPackageManager.focusRelease, FOCUS_BETA to mozillaPackageManager.focusBeta, + FOCUS_DEBUG to mozillaPackageManager.fenixDebug, REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, TRYFOX to mozillaPackageManager.tryfox, ) 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 1591f59..e795522 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -3,11 +3,13 @@ package org.mozilla.tryfox.util const val FENIX = "fenix" const val FENIX_RELEASE = "fenix-release" const val FENIX_BETA = "fenix-beta" +const val FENIX_DEBUG = "fenix-debug" 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 FOCUS_DEBUG = "focus-debug" const val REFERENCE_BROWSER = "reference-browser" const val TREEHERDER = "treeherder" const val TRYFOX = "TryFox" @@ -15,6 +17,7 @@ const val TRYFOX = "TryFox" const val FENIX_NIGHTLY_PACKAGE = "org.mozilla.fenix" const val FENIX_RELEASE_PACKAGE = "org.mozilla.firefox" const val FENIX_BETA_PACKAGE = "org.mozilla.firefox_beta" +const val FENIX_DEBUG_PACKAGE = "org.mozilla.fenix.debug" const val FOCUS_NIGHTLY_PACKAGE = "org.mozilla.focus.nightly" const val FOCUS_BETA_PACKAGE = "org.mozilla.focus.beta" const val FOCUS_RELEASE_PACKAGE = "org.mozilla.focus" diff --git a/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt index f695aae..1879bd9 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt @@ -3,9 +3,21 @@ package org.mozilla.tryfox.ui.composables import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mozilla.tryfox.R +import org.mozilla.tryfox.util.FENIX_DEBUG class AppIconTest { + @Test + fun `uses the Fenix Debug icon for the Fenix Debug flavor`() { + val (icon, description) = appIconResources( + appName = FENIX_DEBUG, + useSearchResultVariant = false, + ) + + assertEquals(R.drawable.ic_fenix_debug_foreground, icon) + assertEquals(R.string.app_icon_firefox_description, description) + } + @Test fun `uses the unknown app icon for an unrecognized app`() { val (icon, description) = appIconResources( diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt index a3e1f2f..68f500d 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.emptyFlow import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.util.FENIX_BETA_PACKAGE +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE_PACKAGE import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE @@ -26,6 +27,9 @@ class FakeMozillaPackageManager( override val fenixBeta: AppState get() = apps[FENIX_BETA_PACKAGE] ?: AppState("Firefox Beta", FENIX_BETA_PACKAGE, null, null) + override val fenixDebug: AppState + get() = apps[FENIX_DEBUG_PACKAGE] ?: AppState("Firefox Debug", FENIX_DEBUG_PACKAGE, null, null) + override val focus: AppState get() = apps[FOCUS_NIGHTLY_PACKAGE] ?: AppState("Focus Nightly", FOCUS_NIGHTLY_PACKAGE, null, null) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt index c666f36..5680aeb 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt @@ -6,9 +6,11 @@ import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG 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_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER @@ -36,10 +38,26 @@ class HomeAppCardModelTest { assertEquals(FENIX_RELEASE, card.selectedAppName) } - private fun app(name: String) = AppUiModel( + @Test + fun `only shows Debug flavors when Firefox Debug is installed`() { + val apps = listOf(FENIX, FENIX_BETA, FENIX_RELEASE, FENIX_DEBUG, FOCUS, FOCUS_BETA, FOCUS_RELEASE, FOCUS_DEBUG) + .associateWith(::app) + + val cardsWithoutDebug = homeAppCards(apps, emptyMap()) + assertEquals(false, FENIX_DEBUG in cardsWithoutDebug.first { it.family == HomeAppFamily.Fenix }.appsByName) + assertEquals(false, FOCUS_DEBUG in cardsWithoutDebug.first { it.family == HomeAppFamily.Focus }.appsByName) + + val appsWithDebug = apps + (FENIX_DEBUG to app(FENIX_DEBUG, installedVersion = "1.0")) + + (FOCUS_DEBUG to app(FOCUS_DEBUG, installedVersion = "1.0")) + val cardsWithDebug = homeAppCards(appsWithDebug, emptyMap()) + assertEquals(true, FENIX_DEBUG in cardsWithDebug.first { it.family == HomeAppFamily.Fenix }.appsByName) + assertEquals(true, FOCUS_DEBUG in cardsWithDebug.first { it.family == HomeAppFamily.Focus }.appsByName) + } + + private fun app(name: String, installedVersion: String? = null) = AppUiModel( name = name, packageName = name, - installedVersion = null, + installedVersion = installedVersion, installedDate = null, apks = ApksResult.Loading, ) From ff0f2cd6240ad975bfdee6dbdc8ad506dff81d4b Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 4 Aug 2026 18:48:03 +0200 Subject: [PATCH 29/47] Improve installed app source and flavor icons --- .../java/org/mozilla/tryfox/model/AppState.kt | 4 +++ .../mozilla/tryfox/ui/composables/AppIcon.kt | 13 ++++----- .../ui/composables/CurrentInstallState.kt | 15 +++++++++- app/src/main/res/drawable/ic_usb_c_cable.xml | 23 +++++++++++++++ .../org/mozilla/tryfox/model/AppStateTest.kt | 28 +++++++++++++++++++ .../tryfox/ui/composables/AppIconTest.kt | 23 +++++++++++++++ 6 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/ic_usb_c_cable.xml create mode 100644 app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt diff --git a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt index a44ad13..517aa11 100644 --- a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt +++ b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt @@ -24,6 +24,10 @@ data class AppState( val isFromTryFox: Boolean get() = installingPackageName == TRYFOX_PACKAGE + /** Whether the app was installed from a source other than Play Store or TryFox. */ + val isSideloaded: Boolean + get() = isInstalled && !isFromPlayStore && !isFromTryFox + val formattedInstallDate: String? get() = installDateMillis?.let { val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) 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 293e37b..5497ddf 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 @@ -34,7 +34,8 @@ fun AppIcon( useSearchResultVariant: Boolean = false, ) { val (iconResId, contentDescResId) = appIconResources(appName, useSearchResultVariant) - val isPaddedForeground = appName == FENIX_DEBUG || + val isPaddedForeground = appName in setOf(FENIX_DEBUG, FOCUS_NIGHTLY, FOCUS_BETA) || + (appName == FOCUS && !useSearchResultVariant) || (useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA, FOCUS_NIGHTLY)) if (isPaddedForeground) { Box(modifier = modifier.clipToBounds()) { @@ -73,19 +74,17 @@ internal fun appIconResources( R.string.app_icon_firefox_description } appName == FENIX_DEBUG -> R.drawable.ic_fenix_debug_foreground to R.string.app_icon_firefox_description - appName == FOCUS_DEBUG -> R.drawable.ic_firefox to R.string.app_icon_firefox_description + appName == FOCUS_DEBUG -> R.drawable.ic_focus_debug_foreground_v2 to R.string.app_icon_focus_description appName == FENIX_RELEASE -> R.drawable.ic_firefox to R.string.app_icon_firefox_description appName == FOCUS -> { - (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground_v2 else R.drawable.ic_focus) to + (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground_v2 else R.drawable.ic_focus_nightly_foreground) 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 + R.drawable.ic_focus_nightly_foreground 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 + 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 -> R.drawable.unknown_app to R.string.app_icon_generic_description diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt index 1319830..0e1617c 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt @@ -89,7 +89,20 @@ fun CurrentInstallState( Icon( painter = painterResource(id = R.drawable.ic_tryfox_black), contentDescription = stringResource(id = R.string.app_icon_tryfox_description), - modifier = Modifier.size(24.dp), + modifier = Modifier + .size(24.dp) + .clickable { showMetadataSheet = true }, + ) + } + + if (appState != null && appState.isSideloaded) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_usb_c_cable), + contentDescription = stringResource(id = R.string.install_metadata_source_sideloaded), + modifier = Modifier + .size(24.dp) + .clickable { showMetadataSheet = true }, ) } diff --git a/app/src/main/res/drawable/ic_usb_c_cable.xml b/app/src/main/res/drawable/ic_usb_c_cable.xml new file mode 100644 index 0000000..5d5f15c --- /dev/null +++ b/app/src/main/res/drawable/ic_usb_c_cable.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt b/app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt new file mode 100644 index 0000000..8e40f35 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt @@ -0,0 +1,28 @@ +package org.mozilla.tryfox.model + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AppStateTest { + @Test + fun `identifies installed apps from unknown sources as sideloaded`() { + val sideloadedApp = appState(installer = "com.google.android.packageinstaller") + + assertTrue(sideloadedApp.isSideloaded) + } + + @Test + fun `does not classify Play Store or TryFox installs as sideloaded`() { + assertFalse(appState(AppState.PLAY_STORE_PACKAGE).isSideloaded) + assertFalse(appState(AppState.TRYFOX_PACKAGE).isSideloaded) + } + + private fun appState(installer: String?) = AppState( + name = "Firefox", + packageName = "org.mozilla.firefox", + version = "1.0", + installDateMillis = 0L, + installingPackageName = installer, + ) +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt index 1879bd9..3b95392 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt @@ -4,6 +4,11 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mozilla.tryfox.R import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG +import org.mozilla.tryfox.util.FOCUS_NIGHTLY +import org.mozilla.tryfox.util.FOCUS_RELEASE class AppIconTest { @@ -18,6 +23,24 @@ class AppIconTest { assertEquals(R.string.app_icon_firefox_description, description) } + @Test + fun `uses matching Home icons for Focus flavors`() { + val expectedIcons = mapOf( + FOCUS_RELEASE to R.drawable.ic_focus, + FOCUS_BETA to R.drawable.ic_focus_beta_foreground, + FOCUS to R.drawable.ic_focus_nightly_foreground, + FOCUS_NIGHTLY to R.drawable.ic_focus_nightly_foreground, + FOCUS_DEBUG to R.drawable.ic_focus_debug_foreground_v2, + ) + + expectedIcons.forEach { (appName, expectedIcon) -> + val (icon, description) = appIconResources(appName, useSearchResultVariant = false) + + assertEquals(expectedIcon, icon, "Unexpected icon for $appName") + assertEquals(R.string.app_icon_focus_description, description) + } + } + @Test fun `uses the unknown app icon for an unrecognized app`() { val (icon, description) = appIconResources( From 52374bfebcd010bb742bb2e63c76fd2792adf0f5 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 4 Aug 2026 19:04:08 +0200 Subject: [PATCH 30/47] Show real commit titles for Try pushes --- .../org/mozilla/tryfox/TryFoxViewModel.kt | 4 +- .../tryfox/ui/screens/ProfileViewModel.kt | 34 ++++--- .../ui/screens/PushCommentSelectionTest.kt | 88 +++++++++++++++++++ 3 files changed, 105 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index c37f3bc..f83223c 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -37,7 +37,7 @@ 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.needsPrecedingRealCommit import org.mozilla.tryfox.ui.screens.selectPreferredPushComment import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -222,7 +222,7 @@ class TryFoxViewModel( val firstPushResult = pushData.results.firstOrNull() if (firstPushResult != null) { - val precedingPushRevisions = if (isPerfAgainRetry(firstPushResult.revisions)) { + val precedingPushRevisions = if (needsPrecedingRealCommit(firstPushResult.revisions)) { when (val authorPushes = fenixRepository.getPushesByAuthor(selectedProject, firstPushResult.author)) { is NetworkResult.Success -> { val pushIndex = authorPushes.data.results.indexOfFirst { it.id == firstPushResult.id } 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 e76852a..f709080 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 @@ -38,30 +38,26 @@ import org.mozilla.tryfox.util.TREEHERDER import java.io.File import java.util.Locale -private fun bugComment(revisions: List): String? { - return revisions.firstOrNull { revision -> - revision.comments.trimStart().startsWith("Bug ", ignoreCase = true) || - revision.comments.trimStart().startsWith("[Bug ", ignoreCase = true) +private fun RevisionDetail.isTryTriggeringCommit(): Boolean = + comments.trimStart().startsWith("Fuzzy query=", ignoreCase = true) || + comments.contains("Pushed via `mach try", ignoreCase = true) + +private fun firstRealCommitComment(revisions: List): String? = + revisions.firstOrNull { revision -> + revision.comments.isNotBlank() && !revision.isTryTriggeringCommit() }?.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 - } -} +): String = + firstRealCommitComment(revisions) + ?: precedingPushRevisions.firstNotNullOfOrNull(::firstRealCommitComment) + ?: revisions.firstOrNull()?.comments.orEmpty().ifBlank { "No comment" } -internal fun isPerfAgainRetry(revisions: List): Boolean { - val firstComment = revisions.firstOrNull()?.comments.orEmpty() - return firstComment.contains("Pushed via `mach try perf-again`", ignoreCase = true) -} +/** True when a Try-generated push needs an earlier push to provide a useful title. */ +internal fun needsPrecedingRealCommit(revisions: List): Boolean = + firstRealCommitComment(revisions) == null && revisions.any(RevisionDetail::isTryTriggeringCommit) private val unsignedApkJobNamePattern = Regex("^build-apk-(.+)$", RegexOption.IGNORE_CASE) private val apkJobNameHints = listOf("signing-apk", "android-apk", "apk-focus", "apk-fenix", "apk-reference-browser", "apk-geckoview") @@ -303,7 +299,7 @@ class SearchViewModel( if (signedJobs.isEmpty() && unsignedJobs.isEmpty()) { _errorMessage.value = "No APK builds found for this revision." } else { - val precedingRevisions = if (isPerfAgainRetry(push.revisions)) { + val precedingRevisions = if (needsPrecedingRealCommit(push.revisions)) { when ( val authorPushes = fenixRepository.getPushesByAuthor( _selectedProject.value, diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt new file mode 100644 index 0000000..b57671c --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt @@ -0,0 +1,88 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.data.RevisionDetail + +class PushCommentSelectionTest { + + @Test + fun `uses the first real commit after a fuzzy Try trigger`() { + assertEquals( + "Bug 123 - Make the search result title useful", + selectPreferredPushComment( + revisions = listOf( + revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`"), + revision("Bug 123 - Make the search result title useful"), + ), + ), + ) + } + + @Test + fun `uses the first real commit even when it does not start with Bug`() { + assertEquals( + "Update Fenix dependencies", + selectPreferredPushComment( + revisions = listOf( + revision("Pushed via `mach try chooser`"), + revision("Update Fenix dependencies"), + revision("Bug 456 - A later commit"), + ), + ), + ) + } + + @Test + fun `keeps a Try trigger title when no real commit is available`() { + val triggerComment = "Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`" + + assertEquals( + triggerComment, + selectPreferredPushComment(revisions = listOf(revision(triggerComment))), + ) + } + + @Test + fun `uses the preceding real commit when a fuzzy push contains no real commit`() { + assertEquals( + "Update Fenix dependencies", + selectPreferredPushComment( + revisions = listOf(revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`")), + precedingPushRevisions = listOf( + listOf( + revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`"), + revision("Update Fenix dependencies"), + ), + ), + ), + ) + } + + @Test + fun `identifies trigger-only pushes that need a preceding commit`() { + assertTrue( + needsPrecedingRealCommit( + listOf(revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`")), + ), + ) + assertFalse( + needsPrecedingRealCommit( + listOf( + revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`"), + revision("Update Fenix dependencies"), + ), + ), + ) + } + + private fun revision(comments: String) = RevisionDetail( + resultSetId = 1, + repositoryId = 4, + revision = "abc123", + author = "tcampbell@mozilla.com", + comments = comments, + ) +} From 6d66c839a264c2b8f6f2211431469e4861e6671c Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Wed, 5 Aug 2026 15:03:29 +0200 Subject: [PATCH 31/47] Show installed Fenix Debug Try revision on Home --- .../tryfox/ui/screens/HomeAppCardTest.kt | 81 +++++++++++ .../java/org/mozilla/tryfox/MainActivity.kt | 5 + .../mozilla/tryfox/data/InstalledTryBuild.kt | 14 ++ .../InstalledTryBuildRepository.kt | 48 +++++++ .../java/org/mozilla/tryfox/di/AppModule.kt | 8 +- .../download/worker/ApkDownloadWorker.kt | 7 + .../tryfox/install/ApkInstallCoordinator.kt | 112 +++++++++++++-- .../tryfox/install/InstallResultReceiver.kt | 17 ++- .../tryfox/install/TryBuildProvenance.kt | 7 + .../mozilla/tryfox/ui/models/AppUiModel.kt | 2 + .../mozilla/tryfox/ui/models/PushUiModel.kt | 2 + .../tryfox/ui/screens/HistoryViewModel.kt | 11 +- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 37 ++++- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 2 + .../tryfox/ui/screens/HomeViewModel.kt | 52 +++++++ .../tryfox/ui/screens/ProfileViewModel.kt | 25 +++- app/src/main/res/values/strings.xml | 1 + .../install/ApkInstallCoordinatorTest.kt | 74 ++++++++++ .../ui/screens/FakeMozillaPackageManager.kt | 2 +- .../tryfox/ui/screens/HomeViewModelTest.kt | 71 ++++++++++ .../tryfox/ui/screens/SearchViewModelTest.kt | 129 ++++++++++++++++++ 21 files changed, 678 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt new file mode 100644 index 0000000..7261801 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt @@ -0,0 +1,81 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.ui.theme.TryFoxTheme +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE + +@RunWith(AndroidJUnit4::class) +class HomeAppCardTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun matchingFenixDebugTryBuildShowsCommitAndOpensItsRevision() { + val build = InstalledTryBuild( + packageName = FENIX_DEBUG_PACKAGE, + project = "mozilla-central", + revision = "abcdef123456", + commitMessage = "Fix the Fenix Debug build\n\nIgnored commit description", + versionName = "145.0a1", + versionCode = 42L, + ) + var openedProject: String? = null + var openedRevision: String? = null + + composeTestRule.setContent { + TryFoxTheme { + HomeAppCard( + card = HomeAppCardUiModel( + family = HomeAppFamily.Fenix, + selectedAppName = FENIX_DEBUG, + appsByName = mapOf( + FENIX_DEBUG to AppUiModel( + name = FENIX_DEBUG, + packageName = FENIX_DEBUG_PACKAGE, + installedVersion = "145.0a1", + installedVersionCode = 42L, + installedDate = "2026-08-05", + installedTryBuild = build, + apks = ApksResult.Loading, + ), + ), + ), + installStates = emptyMap(), + onFlavorSelected = {}, + onDownloadClick = {}, + onInstallClick = {}, + onOpenInstalledApp = {}, + onOpenTryBuild = { project, revision -> + openedProject = project + openedRevision = revision + }, + onDateSelected = { _, _ -> }, + dateValidator = { true }, + onReleaseVersionSelected = { _, _ -> }, + onBuildSelected = { _, _ -> }, + onDismissBuildPicker = {}, + ) + } + } + + composeTestRule.onNodeWithText("Fix the Fenix Debug build").assertIsDisplayed() + composeTestRule.onNodeWithTag("home_try_build_revision").performClick() + + assertEquals("mozilla-central", openedProject) + assertEquals("abcdef123456", openedRevision) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index c884c5e..46e50e7 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -177,6 +177,11 @@ class MainActivity : ComponentActivity() { onNavigateToQrScanner = { localNavController.navigate(NavScreen.QrScanner.route) }, onNavigateToReceiveFromDesktop = { localNavController.navigate(NavScreen.ReceiveFromDesktop.route) }, onNavigateToHistory = { localNavController.navigate(NavScreen.History.route) }, + onNavigateToTryBuild = { project, revision -> + localNavController.navigate( + NavScreen.TreeherderSearchWithArgs.createRoute(project, revision), + ) + }, homeViewModel = koinViewModel(), ) } diff --git a/app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt b/app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt new file mode 100644 index 0000000..dbc8450 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt @@ -0,0 +1,14 @@ +package org.mozilla.tryfox.data + +import kotlinx.serialization.Serializable + +/** Provenance for the Try build currently known to be installed on the device. */ +@Serializable +data class InstalledTryBuild( + val packageName: String, + val project: String, + val revision: String, + val commitMessage: String, + val versionName: String?, + val versionCode: Long, +) diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt new file mode 100644 index 0000000..40abe06 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt @@ -0,0 +1,48 @@ +package org.mozilla.tryfox.data.repositories + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.mozilla.tryfox.data.InstalledTryBuild + +interface InstalledTryBuildRepository { + val installedTryBuild: Flow + + suspend fun save(build: InstalledTryBuild) +} + +object EmptyInstalledTryBuildRepository : InstalledTryBuildRepository { + override val installedTryBuild: Flow = flowOf(null) + + override suspend fun save(build: InstalledTryBuild) = Unit +} + +class DefaultInstalledTryBuildRepository(private val appContext: Context) : InstalledTryBuildRepository { + private val Context.dataStore: DataStore by preferencesDataStore(name = "installed_try_build") + + override val installedTryBuild: Flow = appContext.dataStore.data.map { preferences -> + preferences[INSTALLED_TRY_BUILD]?.let { encoded -> + runCatching { json.decodeFromString(encoded) }.getOrNull() + } + } + + override suspend fun save(build: InstalledTryBuild) { + appContext.dataStore.edit { preferences -> + preferences[INSTALLED_TRY_BUILD] = json.encodeToString(build) + } + } + + private companion object { + val INSTALLED_TRY_BUILD = stringPreferencesKey("installed_try_build") + val json = Json { ignoreUnknownKeys = true } + } +} 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 b2191ae..1f73323 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -23,6 +23,7 @@ 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.DefaultInstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.DefaultMozillaArchiveRepository import org.mozilla.tryfox.data.repositories.DefaultTreeherderRepository import org.mozilla.tryfox.data.repositories.DefaultUserDataRepository @@ -30,11 +31,12 @@ import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.FenixBetaReleaseRepository 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.FocusBetaReleaseRepository +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.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.MozillaArchiveRepository import org.mozilla.tryfox.data.repositories.ReferenceBrowserReleaseRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository @@ -160,6 +162,7 @@ val repositoryModule = module { single { DefaultUserDataRepository(androidContext()) } single { DefaultHomeDataCacheRepository(androidContext(), get(named("IODispatcher"))) } single { DefaultHistoryRepository(androidContext(), get(named("IODispatcher"))) } + single { DefaultInstalledTryBuildRepository(androidContext()) } single { DefaultLanMessageHistoryRepository( androidContext(), @@ -177,7 +180,7 @@ val repositoryModule = module { ) } single { DefaultIntentManager(androidContext()) } - single { ApkInstallCoordinator(androidContext()) } + single { ApkInstallCoordinator(androidContext(), get()) } single { DefaultApkDownloadStore(androidContext(), get(named("IODispatcher"))) } single { DownloadNotificationFactory(androidContext()) } single { WorkManager.getInstance(androidContext()) } @@ -229,6 +232,7 @@ val viewModelModule = module { get(), get(named("IODispatcher")), get(), + get(), ) } viewModel { params -> 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 87933c5..98a43ba 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 @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import androidx.work.CoroutineWorker import androidx.work.Data +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import androidx.work.workDataOf import kotlinx.coroutines.CancellationException @@ -30,6 +31,12 @@ class ApkDownloadWorker( private val downloadStore: ApkDownloadStore by inject() private val notificationFactory: DownloadNotificationFactory by inject() + /** Required before [doWork] when the request is scheduled as expedited work. */ + override suspend fun getForegroundInfo(): ForegroundInfo { + val request = inputData.toRequest() + return notificationFactory.createForegroundInfo(request?.appName.orEmpty()) + } + override suspend fun doWork(): Result { val request = inputData.toRequest() ?: return Result.failure() Log.d(TAG, "started uniqueKey=${request.uniqueKey} workerId=$id outputPath=${request.outputPath}") diff --git a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt index 64f6fd2..0491125 100644 --- a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt +++ b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import androidx.core.content.pm.PackageInfoCompat import kotlinx.coroutines.CoroutineScope @@ -19,13 +20,26 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import logcat.LogPriority import logcat.logcat +import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE 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) +class ApkInstallCoordinator( + private val context: Context, + private val installedTryBuildRepository: InstalledTryBuildRepository, +) { + private data class Operation( + val artifactKey: String, + val file: File, + val packageName: String, + val versionName: String?, + val versionCode: Long, + val provenance: TryBuildProvenance?, + ) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val packageInstaller = context.packageManager.packageInstaller @@ -40,11 +54,11 @@ class ApkInstallCoordinator(private val context: Context) { private val _successfulInstalls = MutableSharedFlow(extraBufferCapacity = 1) val successfulInstalls: SharedFlow = _successfulInstalls.asSharedFlow() - fun install(artifactKey: String, file: File) { + fun install(artifactKey: String, file: File, provenance: TryBuildProvenance? = null) { if (activeOperationId != null) return activeOperationId = artifactKey _states.value = _states.value + (artifactKey to InstallState.Installing) - scope.launch { prepareAndInstall(artifactKey, file) } + scope.launch { prepareAndInstall(artifactKey, file, provenance) } } fun cancelConflict(artifactKey: String) { @@ -83,11 +97,17 @@ class ApkInstallCoordinator(private val context: Context) { context.startActivity(launchIntent) } - fun onInstallResult(intent: Intent) { + suspend fun onInstallResult(intent: Intent) { val artifactKey = intent.getStringExtra(EXTRA_ARTIFACT_KEY) ?: return - val operation = operations[artifactKey] ?: return + val inMemoryOperation = operations[artifactKey] val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) val statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE).orEmpty() + if (status == PackageInstaller.STATUS_SUCCESS) { + val operation = inMemoryOperation ?: intent.toResultOperation(artifactKey) ?: return + succeed(artifactKey, operation) + return + } + val operation = inMemoryOperation ?: return when (status) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { @Suppress("DEPRECATION") @@ -100,7 +120,6 @@ class ApkInstallCoordinator(private val context: Context) { } } - PackageInstaller.STATUS_SUCCESS -> succeed(artifactKey) PackageInstaller.STATUS_FAILURE_CONFLICT -> { if (statusMessage.contains(SHARED_USER_SIGNATURE_FAILURE)) { logcat(LogPriority.WARN, TAG) { @@ -128,7 +147,7 @@ class ApkInstallCoordinator(private val context: Context) { } } - private fun prepareAndInstall(artifactKey: String, file: File) { + private fun prepareAndInstall(artifactKey: String, file: File, provenance: TryBuildProvenance?) { if (!file.isFile) { fail(artifactKey, "The downloaded APK is no longer available.") return @@ -139,9 +158,9 @@ class ApkInstallCoordinator(private val context: Context) { 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 operation = Operation(artifactKey, file, packageName, archive.versionName, incomingVersion, provenance) + operations[artifactKey] = operation val installedVersion = installedVersion(packageName) if (installedVersion != null && installedVersion > incomingVersion) { conflict(artifactKey, packageName) @@ -171,7 +190,7 @@ class ApkInstallCoordinator(private val context: Context) { session.fsync(output) } } - session.commit(statusReceiver(operation.artifactKey)) + session.commit(statusReceiver(operation)) } } catch (e: Exception) { sessionId?.let(packageInstaller::abandonSession) @@ -180,10 +199,24 @@ class ApkInstallCoordinator(private val context: Context) { } } - private fun statusReceiver(artifactKey: String) = PendingIntent.getBroadcast( + private fun statusReceiver(operation: Operation) = PendingIntent.getBroadcast( context, requestCodes.incrementAndGet(), - Intent(context, InstallResultReceiver::class.java).putExtra(EXTRA_ARTIFACT_KEY, artifactKey), + Intent(context, InstallResultReceiver::class.java) + .setData( + Uri.Builder() + .scheme("tryfox") + .authority("install-result") + .appendPath(operation.artifactKey) + .build(), + ) + .putExtra(EXTRA_ARTIFACT_KEY, operation.artifactKey) + .putExtra(EXTRA_PACKAGE_NAME, operation.packageName) + .putExtra(EXTRA_VERSION_NAME, operation.versionName) + .putExtra(EXTRA_VERSION_CODE, operation.versionCode) + .putExtra(EXTRA_PROJECT, operation.provenance?.project) + .putExtra(EXTRA_REVISION, operation.provenance?.revision) + .putExtra(EXTRA_COMMIT_MESSAGE, operation.provenance?.commitMessage), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, ).intentSender @@ -191,14 +224,56 @@ class ApkInstallCoordinator(private val context: Context) { _states.value = _states.value + (artifactKey to InstallState.Conflict(packageName)) } - private fun succeed(artifactKey: String) { - val packageName = operations[artifactKey]?.packageName ?: return + private suspend fun succeed(artifactKey: String, operation: Operation) { + val packageName = operation.packageName + operation.provenance + ?.takeIf { packageName == FENIX_DEBUG_PACKAGE } + ?.let { provenance -> + try { + installedTryBuildRepository.save( + InstalledTryBuild( + packageName = packageName, + project = provenance.project, + revision = provenance.revision, + commitMessage = provenance.commitMessage, + versionName = operation.versionName, + versionCode = operation.versionCode, + ), + ) + } catch (exception: Exception) { + logcat(LogPriority.ERROR, TAG) { + "Could not persist Try build provenance for package=$packageName: ${exception.message}" + } + } + } activeOperationId = null operations.remove(artifactKey) _states.value = _states.value + (artifactKey to InstallState.Installed(packageName)) _successfulInstalls.tryEmit(artifactKey) } + private fun Intent.toResultOperation(artifactKey: String): Operation? { + val packageName = getStringExtra(EXTRA_PACKAGE_NAME)?.takeIf(String::isNotBlank) ?: return null + val versionCode = getLongExtra(EXTRA_VERSION_CODE, UNKNOWN_VERSION_CODE) + if (versionCode == UNKNOWN_VERSION_CODE) return null + val project = getStringExtra(EXTRA_PROJECT) + val revision = getStringExtra(EXTRA_REVISION) + val commitMessage = getStringExtra(EXTRA_COMMIT_MESSAGE) + val provenance = if (project != null && revision != null && commitMessage != null) { + TryBuildProvenance(project, revision, commitMessage) + } else { + null + } + return Operation( + artifactKey = artifactKey, + file = File(""), + packageName = packageName, + versionName = getStringExtra(EXTRA_VERSION_NAME), + versionCode = versionCode, + provenance = provenance, + ) + } + private fun fail(artifactKey: String, message: String) { val packageName = operations[artifactKey]?.packageName logcat(LogPriority.ERROR, TAG) { @@ -230,6 +305,13 @@ class ApkInstallCoordinator(private val context: Context) { private companion object { const val TAG = "ApkInstallCoordinator" const val EXTRA_ARTIFACT_KEY = "org.mozilla.tryfox.install.ARTIFACT_KEY" + const val EXTRA_PACKAGE_NAME = "org.mozilla.tryfox.install.PACKAGE_NAME" + const val EXTRA_VERSION_NAME = "org.mozilla.tryfox.install.VERSION_NAME" + const val EXTRA_VERSION_CODE = "org.mozilla.tryfox.install.VERSION_CODE" + const val EXTRA_PROJECT = "org.mozilla.tryfox.install.PROJECT" + const val EXTRA_REVISION = "org.mozilla.tryfox.install.REVISION" + const val EXTRA_COMMIT_MESSAGE = "org.mozilla.tryfox.install.COMMIT_MESSAGE" + const val UNKNOWN_VERSION_CODE = Long.MIN_VALUE 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. " + diff --git a/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt index 34b02ee..b3ad853 100644 --- a/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt @@ -3,10 +3,25 @@ package org.mozilla.tryfox.install import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.koin.core.context.GlobalContext class InstallResultReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - GlobalContext.get().get().onInstallResult(intent) + val pendingResult = goAsync() + receiverScope.launch { + try { + GlobalContext.get().get().onInstallResult(intent) + } finally { + pendingResult.finish() + } + } + } + + private companion object { + val receiverScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } } diff --git a/app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt b/app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt new file mode 100644 index 0000000..f0c9a31 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt @@ -0,0 +1,7 @@ +package org.mozilla.tryfox.install + +data class TryBuildProvenance( + val project: String, + val revision: String, + val commitMessage: String, +) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt index eed0ea8..0e3a82a 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.models import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.util.Version sealed class ApksResult { @@ -26,6 +27,7 @@ data class AppUiModel( val installedDate: String?, val installingPackageName: String? = null, val splitNames: List = emptyList(), + val installedTryBuild: InstalledTryBuild? = null, val apks: ApksResult, val userPickedDate: LocalDate? = null, val selectedReleaseVersion: String? = null, 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 58ed410..f901af5 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 @@ -1,6 +1,8 @@ package org.mozilla.tryfox.ui.models data class PushUiModel( + /** Treeherder project that produced this push and its artifacts. */ + val project: String, val pushComment: String, val author: String, val jobs: List, 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 8b4bc58..42aa8f0 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 @@ -25,6 +25,7 @@ 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.install.TryBuildProvenance import org.mozilla.tryfox.ui.models.HistoryItemUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -143,7 +144,15 @@ class HistoryViewModel( } fun install(historyItem: HistoryItemUiModel, file: File) { - installCoordinator?.install(historyItem.entry.uniqueKey, file) ?: run { + installCoordinator?.install( + historyItem.entry.uniqueKey, + file, + TryBuildProvenance( + project = historyItem.entry.project, + revision = historyItem.entry.revision, + commitMessage = historyItem.entry.commitMessage, + ), + ) ?: run { viewModelScope.launch { try { historyRepository.upsertHistoryEntry( diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index 54fccd3..8a19f02 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -2,6 +2,7 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,8 +16,8 @@ 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.CalendarToday -import androidx.compose.material3.AssistChip import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -39,6 +40,7 @@ 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.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription @@ -82,6 +84,7 @@ internal fun HomeAppCard( onDownloadClick: (ApkUiModel) -> Unit, onInstallClick: (ApkUiModel) -> Unit, onOpenInstalledApp: (String) -> Unit, + onOpenTryBuild: (String, String) -> Unit, onDateSelected: (String, LocalDate) -> Unit, dateValidator: (LocalDate) -> Boolean, onReleaseVersionSelected: (String, String) -> Unit, @@ -146,7 +149,7 @@ internal fun HomeAppCard( selected = appName == app.name, onClick = { onFlavorSelected(appName) }, label = { Text(flavorLabel(appName)) }, - modifier = Modifier.testTag("home_flavor_${tag}_${appName}"), + modifier = Modifier.testTag("home_flavor_${tag}_$appName"), ) } } @@ -196,6 +199,34 @@ internal fun HomeAppCard( ) } } + + app.installedTryBuild?.let { build -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { onOpenTryBuild(build.project, build.revision) } + .semantics { contentDescription = "Open Try build revision ${build.revision}" } + .testTag("home_try_build_revision") + .padding(12.dp), + ) { + Text( + text = stringResource( + R.string.home_try_build_revision_label, + build.revision.take(SHORT_REVISION_LENGTH), + ), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = build.commitMessage.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(), + modifier = Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } } } @@ -208,6 +239,8 @@ internal fun HomeAppCard( } } +private const val SHORT_REVISION_LENGTH = 12 + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun NightlyDetails( 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 c075292..d2d67a1 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 @@ -65,6 +65,7 @@ fun HomeScreen( onNavigateToQrScanner: () -> Unit, onNavigateToReceiveFromDesktop: () -> Unit, onNavigateToHistory: () -> Unit, + onNavigateToTryBuild: (String, String) -> Unit = { _, _ -> }, homeViewModel: HomeViewModel = viewModel(), ) { val screenState by homeViewModel.homeScreenState.collectAsState() @@ -189,6 +190,7 @@ fun HomeScreen( onInstallClick = homeViewModel::installHomeApk, installStates = installStates, onOpenInstalledApp = homeViewModel::openInstalledApp, + onOpenTryBuild = onNavigateToTryBuild, onDateSelected = { appName, date -> homeViewModel.onDateSelected( appName, 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 d15a6c2..8c63e0e 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.todayIn import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.CacheManager @@ -26,8 +27,10 @@ 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.EmptyInstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository import org.mozilla.tryfox.data.repositories.HomeDataSnapshot +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator @@ -75,6 +78,7 @@ class HomeViewModel( private val installCoordinator: ApkInstallCoordinator? = null, private val ioDispatcher: CoroutineDispatcher, private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, + private val installedTryBuildRepository: InstalledTryBuildRepository = EmptyInstalledTryBuildRepository, private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), ) : ViewModel() { @@ -88,6 +92,7 @@ class HomeViewModel( private val downloadStates = MutableStateFlow>(emptyMap()) private var currentAppsByName: Map = emptyMap() private var cachedAppsByName: Map = emptyMap() + private var installedTryBuild: InstalledTryBuild? = null private var initialLoadStarted = false private var tryFoxCardDismissed = false private val appMutationVersions = mutableMapOf() @@ -127,6 +132,8 @@ class HomeViewModel( installedDate = appState.formattedInstallDate, installingPackageName = appState.installingPackageName, splitNames = appState.splitNames, + installedTryBuild = app.name.takeIf { it == FENIX_DEBUG } + ?.let { matchingInstalledTryBuild(appState) }, ) } else { app @@ -138,6 +145,30 @@ class HomeViewModel( } } }.launchIn(viewModelScope) + + installedTryBuildRepository.installedTryBuild + .onEach { build -> + installedTryBuild = build + _homeScreenState.update { currentState -> + if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.copy( + apps = currentState.apps.mapValues { (_, app) -> + if (app.name == FENIX_DEBUG) { + app.copy( + installedTryBuild = matchingInstalledTryBuild( + app.packageName, + app.installedVersion, + app.installedVersionCode, + ), + ) + } else { + app + } + }, + ) + } + } + .launchIn(viewModelScope) } fun initialLoad() { @@ -243,6 +274,8 @@ class HomeViewModel( installedDate = appState.formattedInstallDate, installingPackageName = appState.installingPackageName, splitNames = appState.splitNames, + installedTryBuild = appName.takeIf { it == FENIX_DEBUG } + ?.let { matchingInstalledTryBuild(appState) }, apks = ApksResult.Loading, ) } @@ -315,6 +348,7 @@ class HomeViewModel( installedDate = appState?.formattedInstallDate, installingPackageName = appState?.installingPackageName, splitNames = appState?.splitNames.orEmpty(), + installedTryBuild = appState?.takeIf { appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), apks = ApksResult.Success(apks.map { it.toUiModel() }), selectedReleaseVersion = selectedReleaseVersion, availableReleaseVersions = availableReleaseVersions, @@ -389,6 +423,7 @@ class HomeViewModel( installedDate = appState?.formattedInstallDate, installingPackageName = appState?.installingPackageName, splitNames = appState?.splitNames ?: emptyList(), + installedTryBuild = appState?.takeIf { repository.appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), apks = apksResult, selectedReleaseVersion = selectedReleaseVersion, availableReleaseVersions = availableReleaseVersions, @@ -689,6 +724,23 @@ class HomeViewModel( installCoordinator?.openInstalledApp(packageName) ?: mozillaPackageManager.launchApp(packageName) } + private fun matchingInstalledTryBuild(appState: AppState): InstalledTryBuild? = + matchingInstalledTryBuild(appState.packageName, appState.version, appState.versionCode) + + private fun matchingInstalledTryBuild( + packageName: String, + versionName: String?, + versionCode: Long?, + ): InstalledTryBuild? { + val build = installedTryBuild ?: return null + if (versionName == null || build.versionName == null) return null + return build.takeIf { + packageName == it.packageName && + versionName == it.versionName && + versionCode == it.versionCode + } + } + fun dismissTryFoxCard() { tryFoxCardDismissed = true _homeScreenState.update { currentState -> 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 f709080..078fa32 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 @@ -29,6 +29,7 @@ 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.install.TryBuildProvenance import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -273,16 +274,17 @@ class SearchViewModel( _errorMessage.value = "Please enter a revision to search." return } + val projectToSearch = _selectedProject.value viewModelScope.launch { _isLoading.value = true _errorMessage.value = null _warningMessage.value = null _pushes.value = emptyList() - when (val pushResult = fenixRepository.getPushByRevision(_selectedProject.value, revision)) { + when (val pushResult = fenixRepository.getPushByRevision(projectToSearch, revision)) { is NetworkResult.Success -> { val push = pushResult.data.results.firstOrNull() if (push == null) { - _errorMessage.value = "No push found for project: ${_selectedProject.value}, revision: $revision" + _errorMessage.value = "No push found for project: $projectToSearch, revision: $revision" } else { val jobsResult = fenixRepository.getJobsForPush(push.id) if (jobsResult is NetworkResult.Success) { @@ -302,7 +304,7 @@ class SearchViewModel( val precedingRevisions = if (needsPrecedingRealCommit(push.revisions)) { when ( val authorPushes = fenixRepository.getPushesByAuthor( - _selectedProject.value, + projectToSearch, push.author, ) ) { @@ -320,6 +322,7 @@ class SearchViewModel( } _pushes.value = listOf( PushUiModel( + project = projectToSearch, pushComment = selectPreferredPushComment(push.revisions, precedingRevisions), author = push.author, jobs = signedJobs, @@ -329,7 +332,7 @@ class SearchViewModel( ), ) syncLoadedStateDownloadStates() - userDataRepository.recordSearch(_selectedProject.value, revision) + userDataRepository.recordSearch(projectToSearch, revision) } } else { _errorMessage.value = "Error fetching jobs: ${(jobsResult as NetworkResult.Error).message}" @@ -337,7 +340,7 @@ class SearchViewModel( } } is NetworkResult.Error -> { - _errorMessage.value = "Error fetching revision details for ${_selectedProject.value}: ${pushResult.message}" + _errorMessage.value = "Error fetching revision details for $projectToSearch: ${pushResult.message}" } } _isLoading.value = false @@ -416,6 +419,7 @@ class SearchViewModel( val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) if (visibleJobs.isNotEmpty()) { PushUiModel( + project = project, pushComment = selectPreferredPushComment( revisions = pushResult.revisions, precedingPushRevisions = result.data.results @@ -680,7 +684,14 @@ class SearchViewModel( fun installArtifact(artifactUiModel: ArtifactUiModel) { val downloadState = artifactUiModel.downloadState as? DownloadState.Downloaded ?: return - installCoordinator.install(artifactUiModel.uniqueKey, downloadState.file) + val provenance = findArtifact(artifactUiModel.uniqueKey)?.let { downloadedArtifact -> + TryBuildProvenance( + project = downloadedArtifact.push.project, + revision = downloadedArtifact.push.revision ?: return@let null, + commitMessage = downloadedArtifact.push.pushComment, + ) + } + installCoordinator.install(artifactUiModel.uniqueKey, downloadState.file, provenance) } fun cancelInstallConflict(artifactKey: String) = installCoordinator.cancelConflict(artifactKey) @@ -844,7 +855,7 @@ class SearchViewModel( ): TreeherderInstallHistoryEntry { val artifactFileName = artifact.name.substringAfterLast('/') return TreeherderInstallHistoryEntry( - project = "try", + project = push.project, revision = push.revision ?: "unknown_revision", commitMessage = push.pushComment, author = push.author, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea655a4..29f11c0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ Reference Browser GeckoView Reference No APKs available + Try revision · %1$s Select build An unknown error occurred. Back diff --git a/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt b/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt new file mode 100644 index 0000000..bab2058 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt @@ -0,0 +1,74 @@ +package org.mozilla.tryfox.install + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE + +class ApkInstallCoordinatorTest { + + @Test + fun `successful result after process recreation persists Try build provenance`() = runTest { + val packageInstaller = mock() + val packageManager = mock() + val context = mock() + whenever(context.packageManager).thenReturn(packageManager) + whenever(packageManager.packageInstaller).thenReturn(packageInstaller) + val repository = RecordingInstalledTryBuildRepository() + val coordinator = ApkInstallCoordinator(context, repository) + val resultIntent = mock() + val extras = mapOf( + "org.mozilla.tryfox.install.ARTIFACT_KEY" to "artifact-key", + "org.mozilla.tryfox.install.PACKAGE_NAME" to FENIX_DEBUG_PACKAGE, + "org.mozilla.tryfox.install.VERSION_NAME" to "128.0a1", + "org.mozilla.tryfox.install.PROJECT" to "mobile", + "org.mozilla.tryfox.install.REVISION" to "abc123", + "org.mozilla.tryfox.install.COMMIT_MESSAGE" to "Fix the Fenix Debug build", + ) + whenever(resultIntent.getStringExtra(any())).thenAnswer { invocation -> + extras[invocation.getArgument(0)] + } + whenever(resultIntent.getLongExtra(any(), any())).thenAnswer { invocation -> + if (invocation.getArgument(0) == "org.mozilla.tryfox.install.VERSION_CODE") 123L + else invocation.getArgument(1) + } + whenever(resultIntent.getIntExtra(any(), any())).thenAnswer { invocation -> + if (invocation.getArgument(0) == PackageInstaller.EXTRA_STATUS) PackageInstaller.STATUS_SUCCESS + else invocation.getArgument(1) + } + + coordinator.onInstallResult(resultIntent) + + assertEquals( + InstalledTryBuild( + packageName = FENIX_DEBUG_PACKAGE, + project = "mobile", + revision = "abc123", + commitMessage = "Fix the Fenix Debug build", + versionName = "128.0a1", + versionCode = 123L, + ), + repository.savedBuild, + ) + } + + private class RecordingInstalledTryBuildRepository : InstalledTryBuildRepository { + override val installedTryBuild: Flow = flowOf(null) + var savedBuild: InstalledTryBuild? = null + + override suspend fun save(build: InstalledTryBuild) { + savedBuild = build + } + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt index 68f500d..e5cfd3d 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt @@ -8,8 +8,8 @@ import org.mozilla.tryfox.util.FENIX_BETA_PACKAGE import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE_PACKAGE -import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FOCUS_BETA_PACKAGE +import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FOCUS_RELEASE_PACKAGE import org.mozilla.tryfox.util.REFERENCE_BROWSER_PACKAGE import org.mozilla.tryfox.util.TRYFOX_PACKAGE 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 5e1488d..78cbdcd 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 @@ -31,6 +31,7 @@ import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.FakeMozillaArchiveRepository import org.mozilla.tryfox.data.FakeReferenceBrowserReleaseRepository import org.mozilla.tryfox.data.FakeTryFoxReleaseRepository +import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.FakeCacheManager @@ -44,6 +45,7 @@ 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.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest @@ -56,6 +58,8 @@ import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_RELEASE @@ -189,6 +193,7 @@ class HomeViewModelTest { releaseRepositories: List = emptyList(), mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), homeDataCacheRepository: HomeDataCacheRepository = FakeHomeDataCacheRepository(), + installedTryBuildRepository: InstalledTryBuildRepository = FakeInstalledTryBuildRepository(), ) = HomeViewModel( releaseRepositories = releaseRepositories, downloadCoordinator = fakeDownloadCoordinator, @@ -197,6 +202,7 @@ class HomeViewModelTest { intentManager = intentManager, ioDispatcher = mainCoroutineRule.testDispatcher, homeDataCacheRepository = homeDataCacheRepository, + installedTryBuildRepository = installedTryBuildRepository, supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) @@ -213,6 +219,71 @@ class HomeViewModelTest { } } + private class FakeInstalledTryBuildRepository( + build: InstalledTryBuild? = null, + ) : InstalledTryBuildRepository { + private val state = MutableStateFlow(build) + override val installedTryBuild = state.asStateFlow() + + override suspend fun save(build: InstalledTryBuild) { + state.value = build + } + } + + @Test + fun `shows Try build provenance only when Fenix Debug version matches`() = runTest { + val build = InstalledTryBuild( + packageName = FENIX_DEBUG_PACKAGE, + project = "try", + revision = "abc123", + commitMessage = "Bug 123: debug build", + versionName = "145.0a1", + versionCode = 42L, + ) + val packageManager = FakeMozillaPackageManager( + mapOf( + FENIX_DEBUG_PACKAGE to AppState( + "Firefox Debug", + FENIX_DEBUG_PACKAGE, + "145.0a1", + 1L, + versionCode = 42L, + ), + ), + ) + val viewModel = createViewModel( + mozillaPackageManager = packageManager, + installedTryBuildRepository = FakeInstalledTryBuildRepository(build), + ) + + viewModel.initialLoad() + advanceUntilIdle() + + val apps = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps + assertEquals(build, apps.getValue(FENIX_DEBUG).installedTryBuild) + + val mismatchedPackageManager = FakeMozillaPackageManager( + mapOf( + FENIX_DEBUG_PACKAGE to AppState( + "Firefox Debug", + FENIX_DEBUG_PACKAGE, + "145.0a1", + 1L, + versionCode = 43L, + ), + ), + ) + val mismatchedViewModel = createViewModel( + mozillaPackageManager = mismatchedPackageManager, + installedTryBuildRepository = FakeInstalledTryBuildRepository(build), + ) + mismatchedViewModel.initialLoad() + advanceUntilIdle() + + val mismatchedApps = (mismatchedViewModel.homeScreenState.value as HomeScreenState.Loaded).apps + assertNull(mismatchedApps.getValue(FENIX_DEBUG).installedTryBuild) + } + private class CountingReleaseRepository( override val appName: String, private val result: NetworkResult>, diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt new file mode 100644 index 0000000..a70a9ec --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt @@ -0,0 +1,129 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +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.FakeUserDataRepository +import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchViewModelTest { + + @JvmField + @RegisterExtension + val mainCoroutineRule = MainCoroutineRule() + + @TempDir + lateinit var cacheDir: File + + @Test + fun `install uses the project retained by the loaded result`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getPushByRevision("mozilla-central", "abcdef123456")) + .thenReturn(NetworkResult.Success(revisionResponse())) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success( + TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), + ), + ), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success( + ArtifactsResponse( + listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")), + ), + ), + ) + val historyRepository = FakeHistoryRepository() + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = historyRepository, + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "abcdef123456", + project = "mozilla-central", + ) + + viewModel.submitSearch() + advanceUntilIdle() + val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val downloadedFile = File(cacheDir, "fenix-debug.apk") + viewModel.updateSelectedProject("try") + viewModel.downloadArtifact(artifact) + advanceUntilIdle() + + assertEquals("mozilla-central", historyRepository.recordedEntries.single().project) + + artifact.downloadState = DownloadState.Downloaded(downloadedFile) + + viewModel.installArtifact(artifact) + + val provenance = argumentCaptor() + verify(installCoordinator).install(eq(artifact.uniqueKey), eq(downloadedFile), provenance.capture()) + assertEquals("mozilla-central", provenance.firstValue.project) + } + + private fun revisionResponse() = TreeherderRevisionResponse( + meta = RevisionMeta(revision = "abcdef123456", count = 1, repository = "mozilla-central"), + results = listOf( + RevisionResult( + id = 1, + revision = "abcdef123456", + author = "developer@example.com", + revisions = listOf( + RevisionDetail(1, 1, "abcdef123456", "developer@example.com", "Fix Fenix Debug"), + ), + revisionCount = 1, + pushTimestamp = 1, + repositoryId = 1, + ), + ), + ) + + private class FakeDownloadCoordinator : ApkDownloadCoordinator { + override val downloads = MutableStateFlow>(emptyMap()) + + override fun enqueue(request: org.mozilla.tryfox.download.ApkDownloadRequest) = "work-id" + + override fun retry(request: org.mozilla.tryfox.download.ApkDownloadRequest) = "work-id" + + override fun cancel(uniqueKey: String) = Unit + + override fun observe(uniqueKey: String) = emptyFlow() + } +} From 9f09a78beac30e16ca5f67c0a7f64f3a37b16413 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Wed, 5 Aug 2026 15:14:05 +0200 Subject: [PATCH 32/47] Preserve Home flavor selection on refresh --- .../tryfox/ui/screens/HomeViewModel.kt | 6 +++++- .../tryfox/ui/screens/HomeViewModelTest.kt | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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 8c63e0e..3077e0d 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 @@ -95,6 +95,8 @@ class HomeViewModel( private var installedTryBuild: InstalledTryBuild? = null private var initialLoadStarted = false private var tryFoxCardDismissed = false + @Volatile + private var selectedHomeAppNames = HomeAppFamily.entries.associateWith { it.defaultAppName } private val appMutationVersions = mutableMapOf() private val appsLock = Any() private val refreshMutex = Mutex() @@ -183,9 +185,10 @@ class HomeViewModel( fun selectHomeAppFlavor(family: HomeAppFamily, appName: String) { if (appName !in family.appNames) return + selectedHomeAppNames = selectedHomeAppNames + (family to appName) _homeScreenState.update { state -> if (state !is HomeScreenState.Loaded) state - else state.copy(selectedAppNames = state.selectedAppNames + (family to appName)) + else state.copy(selectedAppNames = selectedHomeAppNames) } } @@ -290,6 +293,7 @@ class HomeViewModel( tryfoxApp = tryFoxApp, cacheManagementState = currentCacheState, isDownloadingAnyFile = false, + selectedAppNames = selectedHomeAppNames, ).applyDownloadStates(downloadStates.value) } 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 78cbdcd..b168bdb 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 @@ -477,6 +477,25 @@ class HomeViewModelTest { assertFalse(viewModel.isRefreshing.value) } + @Test + fun `refresh retains the selected home app flavor`() = runTest { + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel(listOf(repository)) + + viewModel.initialLoad() + advanceUntilIdle() + viewModel.selectHomeAppFlavor(HomeAppFamily.Fenix, FENIX_RELEASE) + + viewModel.refreshData() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(FENIX_RELEASE, state.selectedAppNames[HomeAppFamily.Fenix]) + } + @Test fun `date selection is retained when cache refresh finishes later`() = runTest { val cachedApk = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) From 946516f9e9dcf9b2ff76865806673e43dca03ce5 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Wed, 5 Aug 2026 15:53:42 +0200 Subject: [PATCH 33/47] Format displayed commit messages --- .../tryfox/ui/screens/HomeAppCardTest.kt | 4 +- .../tryfox/lan/TryFoxLanReceiveService.kt | 6 ++- .../tryfox/ui/composables/PushCommentCard.kt | 4 +- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 6 ++- .../tryfox/ui/screens/SearchResultCard.kt | 6 ++- .../tryfox/util/CommitMessageFormatter.kt | 17 +++++++ .../tryfox/util/CommitMessageFormatterTest.kt | 51 +++++++++++++++++++ 7 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt index 7261801..ae95115 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt @@ -29,7 +29,7 @@ class HomeAppCardTest { packageName = FENIX_DEBUG_PACKAGE, project = "mozilla-central", revision = "abcdef123456", - commitMessage = "Fix the Fenix Debug build\n\nIgnored commit description", + commitMessage = "Bug 123456: Fix the Fenix Debug build\n\nIgnored commit description", versionName = "145.0a1", versionCode = 42L, ) @@ -72,7 +72,7 @@ class HomeAppCardTest { } } - composeTestRule.onNodeWithText("Fix the Fenix Debug build").assertIsDisplayed() + composeTestRule.onNodeWithText("Bug 123456: Fix the Fenix Debug build").assertIsDisplayed() composeTestRule.onNodeWithTag("home_try_build_revision").performClick() assertEquals("mozilla-central", openedProject) diff --git a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt index a16957d..66bf8d6 100644 --- a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt +++ b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt @@ -34,6 +34,7 @@ import org.mozilla.tryfox.EXTRA_NAVIGATION_ROUTE import org.mozilla.tryfox.EXTRA_RECEIVE_FROM_DESKTOP_START_REQUESTED import org.mozilla.tryfox.MainActivity import org.mozilla.tryfox.R +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import org.mozilla.tryfox.data.repositories.TreeherderRepository import java.io.IOException @@ -462,7 +463,10 @@ class TryFoxLanReceiveService : Service(), KoinComponent { when { !message.title.isNullOrBlank() -> message.title !message.pushComment.isNullOrBlank() -> - getString(R.string.lan_receive_message_notification_title_comment, message.pushComment) + getString( + R.string.lan_receive_message_notification_title_comment, + message.pushComment.withoutTrailingReviewerDirective(), + ) !message.revision.isNullOrBlank() -> getString(R.string.lan_receive_message_notification_title_revision, message.revision) !message.author.isNullOrBlank() -> 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 ee77960..dc764b8 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 @@ -41,6 +41,7 @@ import kotlinx.datetime.format.FormatStringsInDatetimeFormats import kotlinx.datetime.format.byUnicodePattern import kotlinx.datetime.toLocalDateTime import org.mozilla.tryfox.ui.theme.TryFoxTheme +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import java.util.regex.Pattern // Helper data class to store link information @@ -111,7 +112,8 @@ fun PushCommentCard( revision: String, pushTimestamp: Long, ) { - val annotatedString = rememberLinkedPushComment(comment) + val displayComment = remember(comment) { comment.withoutTrailingReviewerDirective() } + val annotatedString = rememberLinkedPushComment(displayComment) Card( modifier = Modifier diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index 8a19f02..e7caf60 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -58,6 +58,8 @@ import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.ui.composables.AppIcon +import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import org.mozilla.tryfox.ui.composables.CurrentInstallState import org.mozilla.tryfox.ui.composables.DownloadButton import org.mozilla.tryfox.ui.models.ApkUiModel @@ -220,8 +222,10 @@ internal fun HomeAppCard( color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium, ) + val commitTitle = build.commitMessage.withoutTrailingReviewerDirective() + .lineSequence().firstOrNull { it.isNotBlank() }.orEmpty() Text( - text = build.commitMessage.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty(), + text = rememberLinkedPushComment(commitTitle), modifier = Modifier.padding(top = 4.dp), style = MaterialTheme.typography.bodyMedium, ) 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 cde37cd..f93ff95 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 @@ -55,6 +55,7 @@ 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.withoutTrailingReviewerDirective import java.util.Locale @Suppress("LongParameterList") @@ -68,7 +69,10 @@ internal fun PushResultCard( activeInstallKey: String?, testTag: String, ) { - val commitTitle = remember(push.pushComment) { push.pushComment.lineSequence().firstOrNull().orEmpty().trim().ifBlank { "Revision ${push.revision?.take(12).orEmpty()}" } } + val commitTitle = remember(push.pushComment) { + push.pushComment.withoutTrailingReviewerDirective().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) diff --git a/app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt b/app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt new file mode 100644 index 0000000..0f5925c --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt @@ -0,0 +1,17 @@ +package org.mozilla.tryfox.util + +private val reviewerToken = "(?:#[A-Za-z0-9_-]+|[A-Za-z0-9_.-]+)" +private val trailingReviewerDirective = Regex( + "\\s+r[=?]\\s*$reviewerToken(?:\\s*,\\s*$reviewerToken)*!?\\s*$", + RegexOption.IGNORE_CASE, +) + +/** Removes trailing Phabricator reviewer metadata from a commit subject for display. */ +internal fun String.withoutTrailingReviewerDirective(): String { + val subjectEnd = indexOfFirst { it == '\n' || it == '\r' }.let { index -> + if (index == -1) length else index + } + val subject = substring(0, subjectEnd) + val description = substring(subjectEnd) + return trailingReviewerDirective.replace(subject, "").trimEnd() + description +} diff --git a/app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt new file mode 100644 index 0000000..a4e052d --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt @@ -0,0 +1,51 @@ +package org.mozilla.tryfox.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CommitMessageFormatterTest { + @Test + fun `removes trailing reviewer group directive`() { + assertEquals( + "Bug 123: Update the feature", + "Bug 123: Update the feature r=#android-reviewers".withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `removes trailing reviewer request directive`() { + assertEquals( + "Bug 123: Update the feature", + "Bug 123: Update the feature r?#android-reviewers".withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `removes comma separated reviewers and approval marker`() { + assertEquals( + "Bug 123: Update the feature", + "Bug 123: Update the feature r=reviewer1, reviewer2!".withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `preserves commit description after cleaning the subject`() { + assertEquals( + "Bug 123: Update the feature\n\nExplain why the change is needed.", + "Bug 123: Update the feature r=reviewer1\n\nExplain why the change is needed." + .withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `leaves messages without a trailing reviewer directive unchanged`() { + assertEquals( + "Bug 123: Explain r=reviewer1 in the description", + "Bug 123: Explain r=reviewer1 in the description".withoutTrailingReviewerDirective(), + ) + assertEquals( + "Bug 123: Update the feature\nReviewer metadata: r=reviewer1", + "Bug 123: Update the feature\nReviewer metadata: r=reviewer1".withoutTrailingReviewerDirective(), + ) + } +} From 3cf76533f4c9219e0a049624ac6049338ddd323f Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Wed, 5 Aug 2026 18:18:55 +0200 Subject: [PATCH 34/47] Add paginated search results --- .../DefaultTreeherderRepository.kt | 20 +- .../data/repositories/TreeherderRepository.kt | 7 +- .../tryfox/network/TreeherderApiService.kt | 1 + .../tryfox/ui/screens/ProfileViewModel.kt | 375 ++++++++++++------ .../mozilla/tryfox/ui/screens/SearchScreen.kt | 155 +++++++- .../tryfox/ui/screens/SearchViewModelTest.kt | 71 ++++ 6 files changed, 497 insertions(+), 132 deletions(-) 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 ea1a188..45c8ab9 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 @@ -1,5 +1,6 @@ package org.mozilla.tryfox.data.repositories +import kotlinx.coroutines.CancellationException import org.mozilla.tryfox.data.ArtifactsResponse import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.TreeherderJobsResponse @@ -17,6 +18,9 @@ class DefaultTreeherderRepository( private suspend fun safeApiCall(apiCall: suspend () -> T): NetworkResult { return try { NetworkResult.Success(apiCall.invoke()) + } catch (e: CancellationException) { + // Cancellation is lifecycle control flow, not a failed Treeherder request. + throw e } catch (e: Exception) { NetworkResult.Error(e.message ?: "Unknown error", e) } @@ -33,8 +37,20 @@ class DefaultTreeherderRepository( 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 getPushesByAuthor( + project: String, + author: String, + count: Int, + offset: Int, + ): NetworkResult { + return safeApiCall { + treeherderApiService.getPushByAuthor( + project = project, + author = author, + count = count, + offset = offset, + ) + } } override suspend fun getRecentPushes( 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 71a6980..484a476 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 @@ -12,7 +12,12 @@ interface TreeherderRepository { 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 = + suspend fun getPushesByAuthor( + project: String, + author: String, + count: Int = 10, + offset: Int = 0, + ): NetworkResult = getPushesByAuthor(author) /** Gets the most recent pushes for a project, as shown by Treeherder without a query. */ 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 d8df458..9396b7f 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -21,6 +21,7 @@ interface TreeherderApiService { @Path("project") project: String, @Query("full") full: Boolean = true, @Query("count") count: Int = 10, + @Query("offset") offset: Int = 0, @Query("author") author: String, ): TreeherderRevisionResponse 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 078fa32..41a6943 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 @@ -3,8 +3,11 @@ package org.mozilla.tryfox.ui.screens import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -18,6 +21,7 @@ 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.RevisionResult import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.repositories.HistoryRepository @@ -147,8 +151,34 @@ class SearchViewModel( companion object { private const val TAG = "SearchViewModel" private const val MAX_PARALLEL_ARTIFACT_REQUESTS = 6 + private const val PUSH_PAGE_SIZE = 10 } + private sealed interface PaginatedSearch { + val project: String + val queryToRecord: String? + + data class Author( + override val project: String, + val email: String, + ) : PaginatedSearch { + override val queryToRecord: String = email + } + + data class Recent(override val project: String) : PaginatedSearch { + override val queryToRecord: String? = null + } + } + + private data class PaginationSession( + val search: PaginatedSearch, + var nextOffset: Int = 0, + var hasMore: Boolean = true, + var fetchedPushCount: Int = 0, + var hasRecordedSearch: Boolean = false, + val loadedPushIds: MutableSet = mutableSetOf(), + ) + private val _authorEmail = MutableStateFlow(authorEmail ?: "") val authorEmail: StateFlow = _authorEmail.asStateFlow() @@ -161,6 +191,15 @@ class SearchViewModel( private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() + + private val _canLoadMore = MutableStateFlow(false) + val canLoadMore: StateFlow = _canLoadMore.asStateFlow() + + private val _loadMoreError = MutableStateFlow(null) + val loadMoreError: StateFlow = _loadMoreError.asStateFlow() + private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() @@ -175,6 +214,9 @@ class SearchViewModel( val searchHistory = userDataRepository.searchHistoryFlow val installStates: StateFlow> = installCoordinator.states + private var paginationSession: PaginationSession? = null + private var activeSearchJob: Job? = null + private val deviceSupportedAbis: List by lazy { runCatching { Build.SUPPORTED_ABIS.toList() }.getOrDefault(emptyList()) } @@ -275,12 +317,19 @@ class SearchViewModel( return } val projectToSearch = _selectedProject.value - viewModelScope.launch { - _isLoading.value = true - _errorMessage.value = null - _warningMessage.value = null - _pushes.value = emptyList() - when (val pushResult = fenixRepository.getPushByRevision(projectToSearch, revision)) { + // A revision search is deliberately non-paginated and invalidates any prior session. + activeSearchJob?.cancel() + paginationSession = null + _isLoadingMore.value = false + _canLoadMore.value = false + _loadMoreError.value = null + activeSearchJob = viewModelScope.launch { + try { + _isLoading.value = true + _errorMessage.value = null + _warningMessage.value = null + _pushes.value = emptyList() + when (val pushResult = fenixRepository.getPushByRevision(projectToSearch, revision)) { is NetworkResult.Success -> { val push = pushResult.data.results.firstOrNull() if (push == null) { @@ -342,8 +391,12 @@ class SearchViewModel( is NetworkResult.Error -> { _errorMessage.value = "Error fetching revision details for $projectToSearch: ${pushResult.message}" } + } + } finally { + if (activeSearchJob === currentCoroutineContext()[Job]) { + _isLoading.value = false + } } - _isLoading.value = false } } @@ -359,139 +412,211 @@ class SearchViewModel( if (SearchQueryClassifier.classify(emailToSearch).getOrNull() !is SearchQuery.Email) { return } - searchPushes( - queryToRecord = emailToSearch, - project = projectToSearch, - ) { fenixRepository.getPushesByAuthor(projectToSearch, emailToSearch) } + startPaginatedSearch(PaginatedSearch.Author(projectToSearch, emailToSearch)) } private fun searchRecentPushes() { - val projectToSearch = _selectedProject.value - searchPushes( - queryToRecord = null, - project = projectToSearch, - ) { fenixRepository.getRecentPushes(projectToSearch) } + startPaginatedSearch(PaginatedSearch.Recent(_selectedProject.value)) } - private fun searchPushes( - queryToRecord: String?, - project: String, - request: suspend () -> NetworkResult, + private fun startPaginatedSearch(search: PaginatedSearch) { + activeSearchJob?.cancel() + activeSearchJob = viewModelScope.launch { + try { + _isLoading.value = true + _isLoadingMore.value = false + _errorMessage.value = null + _loadMoreError.value = null + _warningMessage.value = null + _pushes.value = emptyList() + paginationSession = PaginationSession(search) + _canLoadMore.value = true + loadPagesUntilResultsOrExhausted(paginationSession!!, isInitialLoad = true) + } finally { + if (activeSearchJob === currentCoroutineContext()[Job]) { + _isLoading.value = false + } + } + } + } + + /** Called by the final result card when the user reaches the end of a paginated search. */ + fun loadMorePushes() { + val session = paginationSession ?: return + if (!session.hasMore || _isLoading.value || _isLoadingMore.value) return + activeSearchJob = viewModelScope.launch { + try { + _isLoadingMore.value = true + _loadMoreError.value = null + loadPagesUntilResultsOrExhausted(session, isInitialLoad = false) + } finally { + if (activeSearchJob === currentCoroutineContext()[Job]) { + _isLoadingMore.value = false + } + } + } + } + + fun retryLoadMorePushes() = loadMorePushes() + + private suspend fun loadPagesUntilResultsOrExhausted( + session: PaginationSession, + isInitialLoad: Boolean, ) { - viewModelScope.launch { - _isLoading.value = true - _errorMessage.value = null - _pushes.value = emptyList() - logcat(LogPriority.DEBUG, TAG) { "Starting search..." } - val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) - 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, - ) { "Push lookup succeeded; processing ${result.data.results.size} pushes" } - val pushesWithJobsAndArtifacts = result.data.results.mapIndexed { pushIndex, pushResult -> - async { - val jobsResult = fenixRepository.getJobsForPush(pushResult.id) - if (jobsResult is NetworkResult.Success) { - val (signedCandidates, unsignedCandidates) = selectJobsBySigning( - jobsResult.data.results.filter(::isAndroidApkCandidate), - ) - val selectedJobs = signedCandidates + unsignedCandidates - if (selectedJobs.isNotEmpty()) { - val jobsWithArtifacts = selectedJobs.map { jobDetails -> - async { - val artifactResult = artifactSemaphore.withPermit { - fetchArtifacts(jobDetails.taskId) - } - artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { artifacts -> - jobWithArtifacts(jobDetails, artifacts) - } - } - }.awaitAll().filterNotNull() - - val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) - if (visibleJobs.isNotEmpty()) { - PushUiModel( - project = project, - pushComment = selectPreferredPushComment( - revisions = pushResult.revisions, - precedingPushRevisions = result.data.results - .take(pushIndex) - .asReversed() - .map { it.revisions }, - ), - author = pushResult.author, - jobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)), - unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)), - revision = pushResult.revision, - pushTimestamp = pushResult.pushTimestamp, - ) - } else { - logcat(LogPriority.VERBOSE, TAG) { - "No jobs with artifacts for push ID: ${pushResult.id}" - } - null - } - } else { - logcat(LogPriority.VERBOSE, TAG) { - "No eligible, non-test APK jobs for push ID: ${pushResult.id}" - } - null - } - } else { - logcat(LogPriority.WARN, TAG) { - "getJobsForPush failed for push ID: ${pushResult.id}: " + - (jobsResult as NetworkResult.Error).message - } - null - } - } - }.awaitAll().filterNotNull() + if (!session.hasMore || paginationSession !== session) return + val requestedCount = requestedPushCount(session.search, session.nextOffset) + logcat(LogPriority.DEBUG, TAG) { + "Requesting ${session.search::class.simpleName} push page: " + + "project=${session.search.project}, offset=${session.nextOffset}, count=$requestedCount" + } + when (val response = requestPushPage(session.search, session.nextOffset, requestedCount)) { + is NetworkResult.Error -> { + val message = "Error fetching pushes: ${response.message}" + logcat(LogPriority.ERROR, TAG) { message } + if (isInitialLoad && _pushes.value.isEmpty()) _errorMessage.value = message else _loadMoreError.value = message + } - _pushes.value = pushesWithJobsAndArtifacts + is NetworkResult.Success -> { + if (paginationSession !== session) return + val rawPushes = response.data.results + logcat(LogPriority.DEBUG, TAG) { + "Received ${rawPushes.size} pushes at offset=${session.nextOffset}: " + + rawPushes.joinToString { "${it.id}:${it.revision.take(12)}" } + } + val newPushes = rawPushes.filter { session.loadedPushIds.add(it.id) } + // Author-filtered Treeherder responses ignore offset. For those searches we + // request a growing prefix and advance by only the newly observed pushes. + session.nextOffset += newPushes.size + session.hasMore = rawPushes.size == requestedCount + _canLoadMore.value = session.hasMore + session.fetchedPushCount += newPushes.size + logcat(LogPriority.DEBUG, TAG) { + "Page contains ${newPushes.size} new pushes; hasMore=${session.hasMore}; " + + "loaded IDs=${session.loadedPushIds.size}" + } + if (rawPushes.isNotEmpty() && newPushes.isEmpty()) { + session.hasMore = false + _canLoadMore.value = false + logcat(LogPriority.WARN, TAG) { + "Treeherder returned only previously loaded pushes at offset=${session.nextOffset - rawPushes.size}; " + + "ending pagination." + } + updatePaginationWarning(session) + return + } + val displayablePushes = buildPushUiModels(session.search.project, newPushes, rawPushes) + logcat(LogPriority.DEBUG, TAG) { + "Page produced ${displayablePushes.size} displayable pushes: " + + displayablePushes.joinToString { it.revision.orEmpty().take(12) } + } + if (displayablePushes.isNotEmpty()) { + _pushes.value += displayablePushes syncLoadedStateDownloadStates() - if (pushesWithJobsAndArtifacts.isNotEmpty()) { - queryToRecord?.let { userDataRepository.recordSearch(project, it) } + if (!session.hasRecordedSearch) { + session.search.queryToRecord?.let { userDataRepository.recordSearch(session.search.project, it) } + session.hasRecordedSearch = true } - logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } - 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 + } + updatePaginationWarning(session) + if (_pushes.value.isEmpty() && !session.hasMore) { + _errorMessage.value = "No push was found with a job that produced an APK." + } + } + } + } + + private fun requestedPushCount(search: PaginatedSearch, offset: Int): Int = when (search) { + is PaginatedSearch.Author -> offset + PUSH_PAGE_SIZE + is PaginatedSearch.Recent -> PUSH_PAGE_SIZE + } + + private suspend fun requestPushPage( + search: PaginatedSearch, + offset: Int, + count: Int, + ) = when (search) { + is PaginatedSearch.Author -> fenixRepository.getPushesByAuthor( + project = search.project, + author = search.email, + count = count, + // Treeherder ignores this parameter when author is supplied. + offset = 0, + ) + is PaginatedSearch.Recent -> fenixRepository.getRecentPushes( + project = search.project, + count = count, + offset = offset, + ) + } + + private fun updatePaginationWarning(session: PaginationSession) { + if (_pushes.value.size < session.fetchedPushCount) { + _warningMessage.value = + "Showing ${_pushes.value.size} of ${session.fetchedPushCount} fetched pushes. " + + "The remaining pushes did not contain a job that produced an APK." + } else { + _warningMessage.value = null + } + } + + private suspend fun buildPushUiModels( + project: String, + pushes: List, + pagePushes: List, + ): List = coroutineScope { + val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) + pushes.map { pushResult -> + async { + val jobsResult = fenixRepository.getJobsForPush(pushResult.id) + if (jobsResult !is NetworkResult.Success) { + logcat(LogPriority.WARN, TAG) { + "Push ${pushResult.id} job lookup failed: ${(jobsResult as NetworkResult.Error).message}" + } + return@async null + } + val candidates = jobsResult.data.results.filter(::isAndroidApkCandidate) + logcat(LogPriority.DEBUG, TAG) { + "Push ${pushResult.id}:${pushResult.revision.take(12)} has " + + "${jobsResult.data.results.size} jobs and ${candidates.size} APK candidates: " + + candidates.joinToString { "${it.jobName} (${it.jobSymbol}, ${it.taskId})" } + } + val (signedCandidates, unsignedCandidates) = selectJobsBySigning( + candidates, + ) + val jobsWithArtifacts = (signedCandidates + unsignedCandidates).map { jobDetails -> + async { + val artifactResult = artifactSemaphore.withPermit { fetchArtifacts(jobDetails.taskId) } + artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { jobWithArtifacts(jobDetails, it) } } - 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." + }.awaitAll().filterNotNull() + val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) + if (visibleJobs.isEmpty()) { + logcat(LogPriority.DEBUG, TAG) { + "Push ${pushResult.id} produced no visible jobs after artifact resolution. " + + "Candidates=${candidates.size}, jobsWithArtifacts=${jobsWithArtifacts.size}" } } - - is NetworkResult.Error -> { - logcat(LogPriority.ERROR, TAG) { "Error fetching pushes: ${result.message}" } - _errorMessage.value = "Error fetching pushes: ${result.message}" + visibleJobs.takeIf { it.isNotEmpty() }?.let { + logcat(LogPriority.DEBUG, TAG) { + "Push ${pushResult.id} is displayable with ${visibleJobs.size} jobs: " + + visibleJobs.joinToString { "${it.jobName} (${it.artifacts.size} APKs)" } } + val index = pagePushes.indexOfFirst { pagePush -> pagePush.id == pushResult.id } + PushUiModel( + project = project, + pushComment = selectPreferredPushComment( + revisions = pushResult.revisions, + precedingPushRevisions = pagePushes.take(index.coerceAtLeast(0)).asReversed().map { it.revisions }, + ), + author = pushResult.author, + jobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)), + unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)), + revision = pushResult.revision, + pushTimestamp = pushResult.pushTimestamp, + ) } - break@searchLoop } - _isLoading.value = false - } + }.awaitAll().filterNotNull() } private suspend fun fetchArtifacts(taskId: String): ArtifactLoadResult { 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 2ca94bf..abbdbd4 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 @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Arrangement 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 @@ -18,7 +19,8 @@ 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.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -52,8 +54,10 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -66,6 +70,8 @@ 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 kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import org.mozilla.tryfox.R import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.data.SearchHistoryEntry @@ -84,7 +90,10 @@ private val projectDisplayToActualMap = mapOf( ) internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" +internal const val TREEHERDER_LOADING_MORE_TAG = "treeherder_loading_more" +internal const val TREEHERDER_LOAD_MORE_ERROR_TAG = "treeherder_load_more_error" internal const val TREEHERDER_SEARCH_HISTORY_TAG = "treeherder_search_history" +private const val TREEHERDER_LOAD_MORE_END_KEY = "treeherder_load_more_end" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,7 +108,10 @@ fun SearchScreen( val query by searchViewModel.query.collectAsState() val selectedProject by searchViewModel.selectedProject.collectAsState() val isLoading by searchViewModel.isLoading.collectAsState() + val isLoadingMore by searchViewModel.isLoadingMore.collectAsState() + val canLoadMore by searchViewModel.canLoadMore.collectAsState() val errorMessage by searchViewModel.errorMessage.collectAsState() + val loadMoreError by searchViewModel.loadMoreError.collectAsState() val warningMessage by searchViewModel.warningMessage.collectAsState() val pushes by searchViewModel.pushes.collectAsState() val installStates by searchViewModel.installStates.collectAsState() @@ -120,6 +132,44 @@ fun SearchScreen( query != displayedQuery val showSearchHistory = !hasSubmittedSearch || isEditingDisplayedSearch val showCurrentSearch = !isEditingDisplayedSearch + val resultsListState = rememberLazyListState() + var hasUserScrolledSinceLastLoad by remember { mutableStateOf(false) } + val currentShowCurrentSearch by rememberUpdatedState(showCurrentSearch) + val currentIsLoadingMore by rememberUpdatedState(isLoadingMore) + val loadMoreEndKey by rememberUpdatedState( + TREEHERDER_LOAD_MORE_END_KEY.takeIf { + canLoadMore && !isLoadingMore && loadMoreError == null && showCurrentSearch + }, + ) + + LaunchedEffect(isLoadingMore) { + if (isLoadingMore) hasUserScrolledSinceLastLoad = false + } + + LaunchedEffect(resultsListState) { + snapshotFlow { resultsListState.isScrollInProgress } + .distinctUntilChanged() + .filter { it } + .collect { + if (!currentIsLoadingMore) hasUserScrolledSinceLastLoad = true + } + } + + LaunchedEffect(resultsListState) { + snapshotFlow { + loadMoreEndKey?.let { key -> + resultsListState.layoutInfo.visibleItemsInfo.any { it.key == key } + } ?: false + } + .distinctUntilChanged() + .filter { it } + .collect { + if (currentShowCurrentSearch && hasUserScrolledSinceLastLoad) { + hasUserScrolledSinceLastLoad = false + searchViewModel.loadMorePushes() + } + } + } fun submitSearch(queryToSubmit: String) { hasSubmittedSearch = true @@ -195,6 +245,7 @@ fun SearchScreen( } LazyColumn( + state = resultsListState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -252,23 +303,65 @@ fun SearchScreen( } } } else if (pushes.isNotEmpty()) { - items(pushes.size) { index -> + itemsIndexed( + items = pushes, + key = { _, push -> "search_push_${push.revision ?: push.pushTimestamp}" }, + ) { _, push -> AnimatedVisibility( visible = showCurrentSearch, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { PushResultCard( - push = pushes[index], + push = push, onDownloadClick = searchViewModel::downloadArtifact, onInstallClick = searchViewModel::installArtifact, onOpenClick = searchViewModel::openInstalledApp, installStates = installStates, activeInstallKey = activeInstallKey, - testTag = "search_push_${pushes[index].revision}", + testTag = "search_push_${push.revision}", + ) + } + } + + if (isLoadingMore) { + item { LoadingMoreFooter() } + } + + loadMoreError?.let { message -> + item { + LoadMoreErrorFooter( + errorMessage = message, + onRetry = searchViewModel::retryLoadMorePushes, + ) + } + } + + if (canLoadMore && !isLoadingMore && loadMoreError == null) { + item { + LoadMoreButtonFooter( + message = "More builds are available.", + onLoadMore = searchViewModel::loadMorePushes, ) } } + } else if (canLoadMore) { + item { + if (isLoadingMore) { + LoadingMoreFooter() + } else { + LoadMoreButtonFooter( + message = "No compatible APK builds were found in this page.", + onLoadMore = searchViewModel::loadMorePushes, + ) + } + } + } + + if (canLoadMore) { + item(key = TREEHERDER_LOAD_MORE_END_KEY) { + Spacer(modifier = Modifier.height(1.dp)) + } } } } @@ -488,3 +581,57 @@ fun WarningState(warningMessage: String) { ) } } + +@Composable +private fun LoadingMoreFooter() { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp).testTag(TREEHERDER_LOADING_MORE_TAG)) + } +} + +@Composable +private fun LoadMoreErrorFooter( + errorMessage: String, + onRetry: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth().testTag(TREEHERDER_LOAD_MORE_ERROR_TAG), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onRetry, modifier = Modifier.align(Alignment.End)) { + Text("Retry") + } + } + } +} + +@Composable +private fun LoadMoreButtonFooter( + message: String, + onLoadMore: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(text = message, style = MaterialTheme.typography.bodyMedium) + Button(onClick = onLoadMore, modifier = Modifier.align(Alignment.End)) { + Text("Load more") + } + } + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt index a70a9ec..c8aba7b 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt @@ -98,6 +98,60 @@ class SearchViewModelTest { assertEquals("mozilla-central", provenance.firstValue.project) } + @Test + fun `author pagination grows its request count because Treeherder ignores offset`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 10, 0)) + .thenReturn(NetworkResult.Success(authorResponse((1..10).toList()))) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 20, 0)) + .thenReturn(NetworkResult.Success(authorResponse((1..20).toList()))) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success( + TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), + ), + ), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success( + ArtifactsResponse( + listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")), + ), + ), + ) + whenever(repository.getJobsForPush(11)).thenReturn( + NetworkResult.Success( + TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), + ), + ), + ) + for (id in 2..10) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "developer@example.com", + ) + + viewModel.searchByAuthor() + advanceUntilIdle() + viewModel.loadMorePushes() + advanceUntilIdle() + + assertEquals(listOf("revision-1", "revision-11"), viewModel.pushes.value.map { it.revision }) + verify(repository).getPushesByAuthor("try", "developer@example.com", 10, 0) + verify(repository).getPushesByAuthor("try", "developer@example.com", 20, 0) + } + private fun revisionResponse() = TreeherderRevisionResponse( meta = RevisionMeta(revision = "abcdef123456", count = 1, repository = "mozilla-central"), results = listOf( @@ -115,6 +169,23 @@ class SearchViewModelTest { ), ) + private fun authorResponse(ids: List) = TreeherderRevisionResponse( + meta = RevisionMeta(revision = null, count = ids.size, repository = "try"), + results = ids.map { id -> + RevisionResult( + id = id, + revision = "revision-$id", + author = "developer@example.com", + revisions = listOf( + RevisionDetail(id, 1, "revision-$id", "developer@example.com", "Push $id"), + ), + revisionCount = 1, + pushTimestamp = id.toLong(), + repositoryId = 1, + ) + }, + ) + private class FakeDownloadCoordinator : ApkDownloadCoordinator { override val downloads = MutableStateFlow>(emptyMap()) From f805ec1fe4989badd48fb23847ed5d4217bd8da4 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Wed, 5 Aug 2026 18:31:31 +0200 Subject: [PATCH 35/47] Highlight installed home flavors --- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index e7caf60..d91036d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.datetime.Clock @@ -98,6 +99,7 @@ internal fun HomeAppCard( val appState = app.toAppState() val title = cardTitle(card.family) val tag = card.family.name.lowercase() + val flavorAppNames = card.family.appNames.filter { it in card.appsByName } val isNightly = app.name == FENIX || app.name == FOCUS val isDebug = app.name == FENIX_DEBUG || app.name == FOCUS_DEBUG val isVersionSelectable = app.name in setOf(FENIX_RELEASE, FENIX_BETA, FOCUS_RELEASE, FOCUS_BETA) @@ -141,17 +143,31 @@ internal fun HomeAppCard( modifier = Modifier.testTag("home_install_status_$tag").padding(top = 8.dp), ) - if (card.family.appNames.size > 1) { + if (flavorAppNames.size > 1) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - card.family.appNames.forEach { appName -> + flavorAppNames.forEach { appName -> + val isInstalled = card.appsByName[appName]?.installedVersion != null + val installedStateDescription = stringResource(R.string.installed_chip_label) FilterChip( selected = appName == app.name, onClick = { onFlavorSelected(appName) }, label = { Text(flavorLabel(appName)) }, - modifier = Modifier.testTag("home_flavor_${tag}_$appName"), + border = BorderStroke( + width = if (isInstalled) 2.dp else 1.dp, + color = if (isInstalled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ), + modifier = Modifier + .testTag("home_flavor_${tag}_$appName") + .semantics { + if (isInstalled) stateDescription = installedStateDescription + }, ) } } From 2af6ffca21fb51818d9cc6a780f3b89043b61f10 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Wed, 5 Aug 2026 18:48:57 +0200 Subject: [PATCH 36/47] Remove duplicate search loading indicator --- .../main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt | 4 ---- 1 file changed, 4 deletions(-) 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 abbdbd4..adc1974 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 @@ -522,10 +522,6 @@ fun LoadingState(candidateCount: Int) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - CircularProgressIndicator( - modifier = Modifier.size(36.dp), - strokeWidth = 3.dp, - ) Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), From 4a68385e3b044391788a791b2a5f1b81bde67282 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 10:55:17 +0200 Subject: [PATCH 37/47] Improve search pagination and nightly dates --- .../tryfox/ui/screens/HomeAppCardTest.kt | 62 ++ .../mozilla/tryfox/ui/models/ApkUiModel.kt | 2 + .../mozilla/tryfox/ui/models/AppUiModel.kt | 2 +- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 17 +- .../tryfox/ui/screens/HomeViewModel.kt | 17 +- .../tryfox/ui/screens/NightlyDateFormatter.kt | 42 + .../tryfox/ui/screens/ProfileViewModel.kt | 25 +- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 100 +-- .../tryfox/ui/screens/HomeViewModelTest.kt | 16 +- .../ui/screens/NightlyDateFormatterTest.kt | 60 ++ .../tryfox/ui/screens/SearchViewModelTest.kt | 69 +- assets/tryfox-icon-black.png | Bin 0 -> 554148 bytes doc/unified-search-screen.html | 216 +++++ plans/tryfox_mockup.html | 763 ++++++++++++++++++ unified-search-screen-plan.md | 115 +++ 15 files changed, 1382 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt create mode 100644 assets/tryfox-icon-black.png create mode 100644 doc/unified-search-screen.html create mode 100644 plans/tryfox_mockup.html create mode 100644 unified-search-screen-plan.md diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt index ae95115..245b21d 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt @@ -6,16 +6,21 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.datetime.LocalDate import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.ui.models.AbiUiModel +import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.theme.TryFoxTheme +import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_DEBUG import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import java.io.File @RunWith(AndroidJUnit4::class) class HomeAppCardTest { @@ -78,4 +83,61 @@ class HomeAppCardTest { assertEquals("mozilla-central", openedProject) assertEquals("abcdef123456", openedRevision) } + + @Test + fun nightlyCalendarUsesStoredBuildDateInsteadOfRelativeCardLabel() { + val buildDate = LocalDate(2024, 12, 31) + var selectedDate: LocalDate? = null + + composeTestRule.setContent { + TryFoxTheme { + HomeAppCard( + card = HomeAppCardUiModel( + family = HomeAppFamily.Fenix, + selectedAppName = FENIX, + appsByName = mapOf( + FENIX to AppUiModel( + name = FENIX, + packageName = "org.mozilla.fenix", + installedVersion = null, + installedDate = null, + apks = ApksResult.Success( + listOf( + ApkUiModel( + originalString = "", + date = "Today 09:17", + buildDate = buildDate, + appName = FENIX, + version = "145.0a1", + abi = AbiUiModel("arm64-v8a", true), + url = "https://example.invalid/fenix.apk", + fileName = "fenix.apk", + uniqueKey = "fenix/2024-12-31-09-17-32/fenix.apk", + apkDir = File("/tmp/fenix"), + ), + ), + ), + ), + ), + ), + installStates = emptyMap(), + onFlavorSelected = {}, + onDownloadClick = {}, + onInstallClick = {}, + onOpenInstalledApp = {}, + onOpenTryBuild = { _, _ -> }, + onDateSelected = { _, date -> selectedDate = date }, + dateValidator = { true }, + onReleaseVersionSelected = { _, _ -> }, + onBuildSelected = { _, _ -> }, + onDismissBuildPicker = {}, + ) + } + } + + composeTestRule.onNodeWithTag("home_nightly_date_$FENIX").performClick() + composeTestRule.onNodeWithText("OK").performClick() + + assertEquals(buildDate, selectedDate) + } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt index 39b5f8f..dad8f7f 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.ui.models +import kotlinx.datetime.LocalDate import org.mozilla.tryfox.data.DownloadState import java.io.File @@ -14,4 +15,5 @@ data class ApkUiModel( var downloadState: DownloadState = DownloadState.NotDownloaded, val uniqueKey: String, // e.g., "appName/date(YYYY-MM-DD)/fileName" val apkDir: File, + val buildDate: LocalDate? = null, ) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt index 0e3a82a..5bb2323 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt @@ -16,7 +16,7 @@ sealed class ApksResult { */ data class NightlyBuildOption( val id: String, // the build's full "yyyy-MM-dd-HH-mm-ss" timestamp; groups its ABI variants - val label: String, // full date + time for display, e.g. "2026-07-24 09:17:32" + val label: String, // date + time for display, e.g. "2026-07-24 09:17" ) data class AppUiModel( diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index d91036d..21878c7 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -75,7 +75,6 @@ import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE -import org.mozilla.tryfox.util.parseDateToLocalDate import org.mozilla.tryfox.util.parseDateToMillis @OptIn(ExperimentalMaterial3Api::class) @@ -187,6 +186,7 @@ internal fun HomeAppCard( appName = app.name, version = selectedApk.version, date = selectedApk.date, + buildDate = selectedApk.buildDate, selectedDate = app.userPickedDate, dateValidator = dateValidator, onDateSelected = onDateSelected, @@ -267,6 +267,7 @@ private fun NightlyDetails( appName: String, version: String, date: String, + buildDate: LocalDate?, selectedDate: LocalDate?, dateValidator: (LocalDate) -> Boolean, onDateSelected: (String, LocalDate) -> Unit, @@ -280,21 +281,29 @@ private fun NightlyDetails( modifier = Modifier.testTag("home_nightly_date_$appName"), ) if (showPicker) { - val initialDate = selectedDate ?: parseDateToLocalDate(date) ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val initialDate = selectedDate ?: buildDate + ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date val state = rememberDatePickerState( - initialSelectedDateMillis = initialDate.atStartOfDayIn(TimeZone.currentSystemDefault()).toEpochMilliseconds(), + initialSelectedDateMillis = initialDate.toDatePickerSelectionMillis(), selectableDates = object : SelectableDates { override fun isSelectableDate(utcTimeMillis: Long): Boolean = dateValidator(Instant.fromEpochMilliseconds(utcTimeMillis).toLocalDateTime(TimeZone.UTC).date) }, ) DatePickerDialog( onDismissRequest = { showPicker = false }, - confirmButton = { TextButton(onClick = { state.selectedDateMillis?.let { onDateSelected(appName, Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.currentSystemDefault()).date) }; showPicker = false }) { Text("OK") } }, + confirmButton = { TextButton(onClick = { state.selectedDateMillis?.let { onDateSelected(appName, datePickerSelectionDate(it)) }; showPicker = false }) { Text("OK") } }, dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } }, ) { DatePicker(state = state) } } } +/** Material DatePicker represents a selected calendar day as midnight UTC. */ +internal fun LocalDate.toDatePickerSelectionMillis(): Long = + atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + +internal fun datePickerSelectionDate(selectionMillis: Long): LocalDate = + Instant.fromEpochMilliseconds(selectionMillis).toLocalDateTime(TimeZone.UTC).date + @Composable private fun ReleaseVersionDetails(appName: String, selectedVersion: String, versions: List, onSelected: (String, String) -> Unit) { var expanded by remember { mutableStateOf(false) } 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 3077e0d..c1ffa74 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 @@ -436,7 +436,8 @@ class HomeViewModel( private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> - val date = parsedApk.rawDateString?.formatApkDate() + val date = parsedApk.rawDateString?.formatNightlyBuildDate() + val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() val isCompatible = supportedAbis.any { deviceAbi -> deviceAbi.equals( parsedApk.abiName, @@ -468,6 +469,7 @@ class HomeViewModel( ApkUiModel( originalString = parsedApk.originalString, date = date ?: "", + buildDate = buildDate, appName = parsedApk.appName, version = parsedApk.version, abi = AbiUiModel(parsedApk.abiName, isCompatible), @@ -480,17 +482,6 @@ class HomeViewModel( } } - // Converts a raw build timestamp "yyyy-MM-dd-HH-mm-ss" to the display form - // "yyyy-MM-dd HH:mm:ss". Returns the input unchanged if it isn't in that shape. - private fun String.formatApkDate(): String { - val parts = split("-") - return if (parts.size >= 6) { - "${parts[0]}-${parts[1]}-${parts[2]} ${parts[3]}:${parts[4]}:${parts[5]}" - } else { - this - } - } - fun downloadNightlyApk(apkInfo: ApkUiModel) { if (apkInfo.downloadState is DownloadState.InProgress || apkInfo.downloadState is DownloadState.Downloaded) { return @@ -594,7 +585,7 @@ class HomeViewModel( .distinct() // rawDateString is "yyyy-MM-dd-HH-mm-ss" (UTC), so lexical descending == newest first. return timestamps.sortedDescending().map { rawDate -> - NightlyBuildOption(id = rawDate, label = rawDate.formatApkDate()) + NightlyBuildOption(id = rawDate, label = rawDate.formatNightlyBuildTimestamp()) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt new file mode 100644 index 0000000..6b9bb72 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt @@ -0,0 +1,42 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.todayIn + +/** Formats a Nightly build timestamp for its date chip on Home. */ +internal fun String.formatNightlyBuildDate( + today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), +): String { + val relativeDate = when (rawNightlyBuildDate()) { + today -> "Today" + today.minus(1, DateTimeUnit.DAY) -> "Yesterday" + else -> return formatNightlyBuildTimestamp() + } + val time = formatNightlyBuildTimestamp().substringAfter(" ", missingDelimiterValue = "") + return if (time.isBlank()) relativeDate else "$relativeDate $time" +} + +/** Parses the calendar day encoded by an archive Nightly timestamp. */ +internal fun String.rawNightlyBuildDate(): LocalDate? { + val parts = substringBefore(" ").split("-") + if (parts.size < 3) return null + + return try { + LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt()) + } catch (_: IllegalArgumentException) { + null + } +} + +internal fun String.formatNightlyBuildTimestamp(): String { + val parts = split("-") + return if (parts.size >= 6) { + "${parts[0]}-${parts[1]}-${parts[2]} ${parts[3]}:${parts[4]}" + } else { + this + } +} 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 41a6943..a2cbc1e 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 @@ -151,7 +151,7 @@ class SearchViewModel( companion object { private const val TAG = "SearchViewModel" private const val MAX_PARALLEL_ARTIFACT_REQUESTS = 6 - private const val PUSH_PAGE_SIZE = 10 + private const val PUSH_PAGE_SIZE = 20 } private sealed interface PaginatedSearch { @@ -174,6 +174,7 @@ class SearchViewModel( val search: PaginatedSearch, var nextOffset: Int = 0, var hasMore: Boolean = true, + var hasRetriedRecentPushes: Boolean = false, var fetchedPushCount: Int = 0, var hasRecordedSearch: Boolean = false, val loadedPushIds: MutableSet = mutableSetOf(), @@ -462,9 +463,10 @@ class SearchViewModel( private suspend fun loadPagesUntilResultsOrExhausted( session: PaginationSession, isInitialLoad: Boolean, + requestedCountOverride: Int? = null, ) { if (!session.hasMore || paginationSession !== session) return - val requestedCount = requestedPushCount(session.search, session.nextOffset) + val requestedCount = requestedCountOverride ?: requestedPushCount(session.search, session.nextOffset) logcat(LogPriority.DEBUG, TAG) { "Requesting ${session.search::class.simpleName} push page: " + "project=${session.search.project}, offset=${session.nextOffset}, count=$requestedCount" @@ -518,6 +520,25 @@ class SearchViewModel( } } updatePaginationWarning(session) + // A blank query means "find a recent build". If the newest page contains no + // usable APK, automatically look through the next 50 pushes before asking the + // user to load still more results. + if ( + isInitialLoad && + session.search is PaginatedSearch.Recent && + !session.hasRetriedRecentPushes && + _pushes.value.isEmpty() && + session.hasMore + ) { + session.hasRetriedRecentPushes = true + logcat(TAG) { "No usable APKs in the newest $PUSH_PAGE_SIZE pushes; searching the next 50." } + loadPagesUntilResultsOrExhausted( + session = session, + isInitialLoad = true, + requestedCountOverride = 50, + ) + return + } if (_pushes.value.isEmpty() && !session.hasMore) { _errorMessage.value = "No push was found with a job that produced an APK." } 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 adc1974..05e43da 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 @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -54,10 +53,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -70,8 +67,6 @@ 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 kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import org.mozilla.tryfox.R import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.data.SearchHistoryEntry @@ -93,7 +88,6 @@ internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" internal const val TREEHERDER_LOADING_MORE_TAG = "treeherder_loading_more" internal const val TREEHERDER_LOAD_MORE_ERROR_TAG = "treeherder_load_more_error" internal const val TREEHERDER_SEARCH_HISTORY_TAG = "treeherder_search_history" -private const val TREEHERDER_LOAD_MORE_END_KEY = "treeherder_load_more_end" @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -132,45 +126,6 @@ fun SearchScreen( query != displayedQuery val showSearchHistory = !hasSubmittedSearch || isEditingDisplayedSearch val showCurrentSearch = !isEditingDisplayedSearch - val resultsListState = rememberLazyListState() - var hasUserScrolledSinceLastLoad by remember { mutableStateOf(false) } - val currentShowCurrentSearch by rememberUpdatedState(showCurrentSearch) - val currentIsLoadingMore by rememberUpdatedState(isLoadingMore) - val loadMoreEndKey by rememberUpdatedState( - TREEHERDER_LOAD_MORE_END_KEY.takeIf { - canLoadMore && !isLoadingMore && loadMoreError == null && showCurrentSearch - }, - ) - - LaunchedEffect(isLoadingMore) { - if (isLoadingMore) hasUserScrolledSinceLastLoad = false - } - - LaunchedEffect(resultsListState) { - snapshotFlow { resultsListState.isScrollInProgress } - .distinctUntilChanged() - .filter { it } - .collect { - if (!currentIsLoadingMore) hasUserScrolledSinceLastLoad = true - } - } - - LaunchedEffect(resultsListState) { - snapshotFlow { - loadMoreEndKey?.let { key -> - resultsListState.layoutInfo.visibleItemsInfo.any { it.key == key } - } ?: false - } - .distinctUntilChanged() - .filter { it } - .collect { - if (currentShowCurrentSearch && hasUserScrolledSinceLastLoad) { - hasUserScrolledSinceLastLoad = false - searchViewModel.loadMorePushes() - } - } - } - fun submitSearch(queryToSubmit: String) { hasSubmittedSearch = true displayedQuery = queryToSubmit @@ -245,7 +200,6 @@ fun SearchScreen( } LazyColumn( - state = resultsListState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -337,32 +291,28 @@ fun SearchScreen( } } - if (canLoadMore && !isLoadingMore && loadMoreError == null) { - item { - LoadMoreButtonFooter( - message = "More builds are available.", - onLoadMore = searchViewModel::loadMorePushes, - ) - } - } - } else if (canLoadMore) { + } else if (canLoadMore && isLoadingMore) { item { - if (isLoadingMore) { - LoadingMoreFooter() - } else { - LoadMoreButtonFooter( - message = "No compatible APK builds were found in this page.", - onLoadMore = searchViewModel::loadMorePushes, - ) - } + LoadingMoreFooter() } } - if (canLoadMore) { - item(key = TREEHERDER_LOAD_MORE_END_KEY) { - Spacer(modifier = Modifier.height(1.dp)) + if (canLoadMore && !isLoading && !isLoadingMore && loadMoreError == null && showCurrentSearch) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Button( + onClick = searchViewModel::loadMorePushes, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Text("Load more") + } + } } } + } } } @@ -613,21 +563,3 @@ private fun LoadMoreErrorFooter( } } } - -@Composable -private fun LoadMoreButtonFooter( - message: String, - onLoadMore: () -> Unit, -) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(text = message, style = MaterialTheme.typography.bodyMedium) - Button(onClick = onLoadMore, modifier = Modifier.align(Alignment.End)) { - Text("Load more") - } - } - } -} 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 b168bdb..1c17c4e 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 @@ -9,10 +9,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.format -import kotlinx.datetime.format.FormatStringsInDatetimeFormats -import kotlinx.datetime.format.byUnicodePattern import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -70,7 +66,6 @@ import java.io.File @ExperimentalCoroutinesApi @ExtendWith(MockitoExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) -@OptIn(FormatStringsInDatetimeFormats::class) class HomeViewModelTest { @JvmField @@ -154,6 +149,7 @@ class HomeViewModelTest { return ApkUiModel( originalString = parsed.originalString, date = dateFormatted, + buildDate = parsed.rawDateString?.rawNightlyBuildDate(), appName = parsed.appName, version = parsed.version, abi = AbiUiModel(parsed.abiName, true), @@ -373,15 +369,7 @@ class HomeViewModelTest { ) } - private fun String.formatApkDateForTest(): String { - return try { - val inputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd-HH-mm-ss") } - val outputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd HH:mm:ss") } - LocalDateTime.parse(this, inputFormat).format(outputFormat) - } catch (e: Exception) { - this - } - } + private fun String.formatApkDateForTest(): String = formatNightlyBuildDate() @AfterEach fun tearDown() { diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt new file mode 100644 index 0000000..b1cb218 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt @@ -0,0 +1,60 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NightlyDateFormatterTest { + + private val today = LocalDate(2026, 8, 5) + + @Test + fun `formats a build from today by replacing only its date`() { + assertEquals( + "Today 09:17", + "2026-08-05-09-17-32".formatNightlyBuildDate(today), + ) + } + + @Test + fun `formats a build from yesterday as Yesterday across a month boundary`() { + assertEquals( + "Yesterday 09:17", + "2026-07-31-09-17-32".formatNightlyBuildDate(LocalDate(2026, 8, 1)), + ) + } + + @Test + fun `formats an older build with its normal timestamp`() { + assertEquals( + "2026-08-03 09:17", + "2026-08-03-09-17-32".formatNightlyBuildDate(today), + ) + } + + @Test + fun `preserves an unrecognised date`() { + assertEquals( + "unknown-date", + "unknown-date".formatNightlyBuildDate(today), + ) + } + + @Test + fun `parses a build date independently of its display label`() { + assertEquals(today, "2026-08-05-09-17-32".rawNightlyBuildDate()) + assertEquals(LocalDate(2026, 8, 4), "2026-08-04-09-17-32".rawNightlyBuildDate()) + } + + @Test + fun `uses UTC midnight for the calendar selected date`() { + val selectedDate = LocalDate(2026, 8, 5) + + assertEquals( + Instant.parse("2026-08-05T00:00:00Z").toEpochMilliseconds(), + selectedDate.toDatePickerSelectionMillis(), + ) + assertEquals(selectedDate, datePickerSelectionDate(selectedDate.toDatePickerSelectionMillis())) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt index c8aba7b..59d005f 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt @@ -104,10 +104,10 @@ class SearchViewModelTest { val installCoordinator = mock() whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) - whenever(repository.getPushesByAuthor("try", "developer@example.com", 10, 0)) - .thenReturn(NetworkResult.Success(authorResponse((1..10).toList()))) whenever(repository.getPushesByAuthor("try", "developer@example.com", 20, 0)) .thenReturn(NetworkResult.Success(authorResponse((1..20).toList()))) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 40, 0)) + .thenReturn(NetworkResult.Success(authorResponse((1..40).toList()))) whenever(repository.getJobsForPush(1)).thenReturn( NetworkResult.Success( TreeherderJobsResponse( @@ -122,14 +122,14 @@ class SearchViewModelTest { ), ), ) - whenever(repository.getJobsForPush(11)).thenReturn( + whenever(repository.getJobsForPush(21)).thenReturn( NetworkResult.Success( TreeherderJobsResponse( listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), ), ), ) - for (id in 2..10) { + for (id in 2..20) { whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) } val viewModel = SearchViewModel( @@ -147,9 +147,49 @@ class SearchViewModelTest { viewModel.loadMorePushes() advanceUntilIdle() - assertEquals(listOf("revision-1", "revision-11"), viewModel.pushes.value.map { it.revision }) - verify(repository).getPushesByAuthor("try", "developer@example.com", 10, 0) + assertEquals(listOf("revision-1", "revision-21"), viewModel.pushes.value.map { it.revision }) verify(repository).getPushesByAuthor("try", "developer@example.com", 20, 0) + verify(repository).getPushesByAuthor("try", "developer@example.com", 40, 0) + } + + @Test + fun `blank query checks the next 50 pushes when the newest page has no APK`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getRecentPushes("try", 20, 0)) + .thenReturn(NetworkResult.Success(recentResponse((1..20).toList()))) + whenever(repository.getRecentPushes("try", 50, 20)) + .thenReturn(NetworkResult.Success(recentResponse((21..70).toList()))) + for (id in 1..20) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + whenever(repository.getJobsForPush(21)).thenReturn( + NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")))), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")))), + ) + for (id in 22..70) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "", + ) + + viewModel.submitSearch() + advanceUntilIdle() + + assertEquals(listOf("revision-21"), viewModel.pushes.value.map { it.revision }) + verify(repository).getRecentPushes("try", 20, 0) + verify(repository).getRecentPushes("try", 50, 20) } private fun revisionResponse() = TreeherderRevisionResponse( @@ -186,6 +226,23 @@ class SearchViewModelTest { }, ) + private fun recentResponse(ids: List) = TreeherderRevisionResponse( + meta = RevisionMeta(revision = null, count = ids.size, repository = "try"), + results = ids.map { id -> + RevisionResult( + id = id, + revision = "revision-$id", + author = "developer@example.com", + revisions = listOf( + RevisionDetail(id, 1, "revision-$id", "developer@example.com", "Push $id"), + ), + revisionCount = 1, + pushTimestamp = id.toLong(), + repositoryId = 1, + ) + }, + ) + private class FakeDownloadCoordinator : ApkDownloadCoordinator { override val downloads = MutableStateFlow>(emptyMap()) diff --git a/assets/tryfox-icon-black.png b/assets/tryfox-icon-black.png new file mode 100644 index 0000000000000000000000000000000000000000..71beec9f23a07c65cd85e7d67491504161dc83b0 GIT binary patch literal 554148 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

=XP^{LydeJ)o}Ak|)Y5u)A%zBljR>^BRI# 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

Wq@Ff_w46z4HgoKEz_)K?$q#UK5+Db6#y? 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Ν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 z�>Ekq`t3W#S4@k#( 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%0DA%`sSAUR>A_Xo<$c+x5ZutxMwan}Pd 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

P451scNr4$v#=^}*A zqk$V|Kg|foNe5G3wIJx2M%a+lr&&`F1P|8_v$bV!Cr7#-3m&`UJdfJ`mVCFn3y*Dw z#?$Ykq8_C<`4zw%QJv21lqS&k5bi8^M_G!e(fT+zep%dcCZWAoA2UbrTDPOE9n|jR zqO~$!wNIEnoBNS7Pd6nu!%`H#tgEF`-#N!fqdj^IAtjt%S1fB_gr;|n;LH-cb0dAG z6`fWaW}$MYDw<(Jk%;7$_e=CZ@umshS-M^l6I$wND4!@8dGXG0vpU|qGV<1OkvUel zI9Y{fn`s>T*b}=e{`111PR)x}1C$vA`(DTc*S$$>Wo_*}C!_Onch`AVC|rokm;pU` zoF1#CD~&%sSa-LjO!Luqzvb|BtBG`n5w{6)=Q~!*`wb?_@VJBelRbcN0_jXY z+W~sG9sqkgC*K6YPV(6EBf#S)!&kK(d9k?%xQ4fcoRFaVW(*NR#2-9?MFBwY*8s(l zYjeH4#P_=XO38ZgDkBHKNQE)Ck&CgHm*+7z*k7E++02WF2hCY9rwTk%g9cL~v(l@@ z+t6KBm+=ECkhICRgK#Imt*14AaUPG` zrE6jqch#C7m-K%@Ehd7}ixv9ycP|q3BsO3Y1&OQ{@H|=BJBl0oI7qkH;++P>Ve%a~ zZ$cGM>na+%dt!8uKrR<}>C{q$=r4A&N_(W*4-d5_h5FTPZjeC56xsJi>4u6{^k6(3 zlrcmQqs6PaWn&bpA^PPbA|)d8v14nNQzhCVq%)B-r+0yRo4Yf!3>33{h@_B(QERxS zI@uv4rln@!Za^-DFSrpMA@e7ikNum6M)|Aj23$SU= zk{5WZ1NdKlAScwz0-uxI4%WsR<6o7?2sGgr?~E4?MoN$Y`^byR6~AincUqb*D|L8f z<~K%}r5DJ1-3S8mnm%fCfbBgkzScY)Ps$mTaVZCMqta4l@nrm8lnkHur~Q3$she-# zzFi5-dlQBA^1KO1AeTL{5FQv+(j+>3dtOQC?q^u;uYYZo8OiVOqJL;@rkvm8evr>< z`#Vt+70jaen~blcZ0BB^mQKg_;*3%?(7L`1|hV&a7!xcFx-qCa&W7h0S|)q>eAC%QX>^*XD}b?eiPW?}#rx5I_Fk zF6~lXPPAB%hPndT8r8EltjhVXC6L4UZwkVfg=>aSmF-M5QPGcyqKFi!O91lg9`O*z zTz%hA8+nv5hG&~)1s2w6UBnOY`%;;Wxpvj z4Px^6<>i+eW~8W#e)<+Cfsy)Ja)>)z`KCaliy`f=i3j96urc+Am}M9A>B4xfdWN|V zN)g;MRzeE>Su80+ChrL;>ml^uAS<;frq9t>0~f3qFbNk}-oAHrR|A^SU(D{CMpOTO zlO!#2{R3SX9^pBm9#mX~^<|YgE<1kth~0_eH?zh-??6N0kC=e^I!R3U=V#=W_i2{S z==xy^DE7S3y0%d+ngyrQJ)7`Ia|~nH?;iK5GQGTFbs6~wBR*ryEevzBn~YznKT

F)i;#6-2etX(j`~hRqYZ|vb_Izg$dJdZ}{lu+!{^d7W2YFeG2>VN%o|~gjTG`2v zjuqSjZ6V6fQFMOF$K-0QY0KTm>x^xtld=J)D0|GlW)cn+$?DLmK|nXzFteU` z&2Au7P7mVdY_78ji*U?Sfs2Q+V1yaNtk*!C)OEjMiU^>Kc%_Dp;H3~uR10{k9bY5l z*55ajHAN~4@2sNQot8Jemc$ME6l02eCqhBszHi6F&#}@d423tQD_Nsa~@L8;ha&3Md^=` z;aF9Xsx$)!-#7?I1(+7(+7S&kDvKYa{0vd&z%SUQ>WLL9vNh0KLC({3NkOoSZN7z# z9WY+$ki8k#Y0&cGGTlsbZ{><7wcO_!Ak5I}cf0;Yw76M{jGShSIO}BO-q*`Yt#_Js=PXXqZKLEklWOo;W>G3mHS=gb0W z6;Z3E{g{Eg=b@dN-sH2)$UUuwEGnZn%5X3cv4bl3ay3GmVZ8_%@@94!e*%-YoL^Q7 zV`F7BcmL|0`EqDhe`IM;Ir;@cI&H;nnqCi2;#`52yXs}uykG=L@iT3)p~a@JPF%S; z7UTS@1nWxppJwn&fD`Vy$=JypYYK71_)-(b+NE| z9)kr_FLOu-_C%zh5o~*uc8=P{740(~7XR-oXhg)ZMQqQAF;BQ8)GP+Ac$b7g+x>pZ`i>M&S@gyOf=M+)<#rW#}?WoQK@lCGSl zU4P7zgta*W2HM4S2z^uqyvOJaax*kTA(3U#-=JIipEol%ZQuCq`mTkAyOa$r4qfC~yT%y!jFONKn*ta2W)PbnT`~m}cU2b>O>iJh(|mT57k%HqA_O%~vzqLe zIQVi#-a7hd?`z?WuM;z<{an0`2U9oUnl#Agd(N@+TpljzV6pANsBnk2+~3SnA)}i2 zZUPl&5NDt_{LAt6?0mN@x_suV#qc@sGfM^^R9+ut(Z~75s(NYsN}%#)9^V(oN6k$e zbcgzBP02t#{QPzt>;^a7$^zf*rGo!)+b#UtZTD4K^ZT{c0}fC_>^f<2LZ4M%XST&j zD1H49(F0vootct_S#L;#n>&WrAs8c@XO-U{^0QMKjxWkucUr@RC$Q<8+&&mywjs8R zTNl>B=IaxVc>uuG1*)Zj?i|WvfvmXHd=h_RWQrYe$p=t{5MG72mCw7auP5if9VsDg z!U_e13uY~R`oqTGfPEv-@PFOMi$1V4L>iaTB=X{d4h0NuX=koVx7d^7entvwhnM_h zJEyxGBe}mfR-pnryHF@gWEMRa!^qqz9>B#!!s+c;(*N|hq2#qApk_y{nr#!YU>+61pF>56e?{6N*;F1Zo% z43EHCBULq8*aT(;Bt!K3V+xp`P1_sw4=^+6^C}$mz7#vOYUV`b*tQ*ur>!A4`@)Z7 zy5Z1y@*mCOUV&m<1XOrUx>nUfK)6-v=$H&?+7eXoIy&@?795 zZuei9S`iMrnE8g=Pj1`wT*Kx)pCtC)!5QK0kX{>n&Y_v3D0QyasYQ?bV#9gKx~``8 z9-5a+%QoT$x$M(EJqjeJ9-p<%%ZKB6b=Si?r3rv}fWa4-Y#L@7#rXz?Yw77=g8^(M6p1*#bZN3N+&@oq$(WFc?0i!Njb333=_kRA^Tl9k z{E7cnFblXz)fJKCD2l#QKZl{;>;s|S;h3YPWlNIhOo|;6ndQ5|7m@jjGsaJX ztTbQ3tbXFZB^+6`CR^#C$f+mWm$i#;+0oqPfiPNwGnkIy16z(`zXAA*-M^RsP34Qj zNY5Y!LY%@5ZEA-*_S@huZek+A01Bs`;DKScYl?`MaETnEgER(1QSa$?M3A%l7spuu z$%8-BsyHwa5E8;!_;_)S=OrbxxqE&Nx4mmBM6&pk5sM1ey8jc6x_+6}Pd(}{7>U)I z?Vm0F7$vlB$)x9=vGT>3FsXq^ID1%9+}jQZEH4^(b3aagbBN;aDwPMOI$eyL4VJu? z9}IqodigT1jX8N6m~pz;n|w zmNF>i(wRIE?R*T7yOO$_=9Uw3{?mA$)p66CkM}R0_KZ(7Hb2_~h}D^aS=B!}Iwhq4 z)MDir)1FZU+{kRfG|aC}I2q8YOGAmc*-30|T_0BgcmRgRB|Y1xqIvrr3{`qXLsCO9 zpB4G&0&Q4#H(E0uX}h*O$=Pa++Q9dBZxd#dxhh9|7zxo7lri@@HA}29ET5!##@bY3 z;&rh|b5M6oZA$w#s@36DZ-Y3vNs;p`08nUT>GvILWYehezROMP5)tCiwm2N&))`?~ zxgdH7tH@E1_D|I2xwPM@?%oa04#f$k>;t7syrd*!sgJ1Th1aq&sq(4&V^;^*)XDy3 z&&VMqsb;9<@g4TB6E*=&csE3CfWC7+bwYE@QMOOI6z2(tG~TQPXJhPmSbKp!-?OrZoMEiilV9=onLIlSckTit#uEiWDj0-fDRXW zc%KiKHgo^tg0%`!>{;ro+wx4|m6|bI#2c>D#8CP7o)!m>3e<$3sQBW5& z{FlIsp`8NrntcAKrN?r6Tz8xBev)=Ulj}WdT-CH+x;L>)FAUH$;R`Yo%sr@O|0DZe z==~2~ATdhkOx0iCi|0IOtEVj$(hss0f+BbaRb7A%((Zc&$cdg?Z+EikNWBP2L|U5I zr-Qg-qE{5BB&%KcCFSLjkiA<9Y*JE!g2bme`ED5Iyz)GIB8z_hcLTOYY!_kS+bf+m~ISmaRHt)65hxQvpUK z_^)8nYj>l4oO&*-7{MeR5fwg9vk*;26*A4@lXOK#WADzL%(b--4K3N2AJn)mNU7Ro zXVDRPvQq~y!<-$)H3R#O8`HP1^|?hsNnw`jmW_8V!rcXv=MAh%Go!a0ftGzL@_)&F zIsFyDUB$!IGR3#gd$-Ddb@&9yPdlU+EVK_EJTam?diS`z#N*(@kvNII=v{rW*>}6` z*lcom*M8pnV9|U4cLG4&5a{g(zGhfhJp9zsjhlS7__)3y_i=KD1OlLa9LX`rN0Dv>YrB>HdgUgvgMoB zO)uqWGG2v?*<4yvYgl9tir?#t?sf>H|QoG6LT zv+;R4xjhX>4(*Q660h%UZ@#*$iN5zrKI%#%_$M*$5hYyb8`GmU9{wy>>7TNCP%3GK zkp%JTeXQxYG+u-j9yYWYOuIDjbN9jeXPwv`{fBS0BO$u%JPoB6g$iCcEscWQ$+ZM% z1~GXX&f~7ug`>ZP@tWTWN|S)?%ZCcP*I|lP=T>-iJvl zr3c*P`aV%`qZ6&JMxDzpTd9Mg6%kXyMS-YAT%!BQ9~l%a>2SAI42;Vmh7ccZTGiy* zK+%@>WD#5C&TPz>KPe$mn@| zLjRY}6f*>ozn5%*FmR`pz5d$hdlwgy+vggJdB}Z5Eq>h|fWazj{M&0r^8h<@05!nG zM=-Vi^5R1G%h&Z-0MU9i2~xq+yH>@xL~u*Ehw#;c9&`P%TJ?U$lCJTn3k^8l>1tLS zTqjb_2?Q-ZlBveK#rR391Jqh9vLZT&szQOG_#Ecsa(Hriapfp&vZolNCrtCp@zjH) z#`)NIFQn?RwF0)pyyk9hX1q)B7|y`UN9381fm~v(YjSOEo_!?R;Nm0ZwCfBcdtP;T zJUs1vRs?>4S|kx3^vYMw*nvTSt{OLZ&kF_4KAxH4;CBDgA={Vz+5|6T9)&j~r8S zZQ6vE2P9n>gHNM-HyJjB4OBSjOdqK=IwsFfkRR~*t}{JdFe;?*gk=8o=?)vTPN{Sbc zTkN9l_=YPLS$n%cT&jwsCP|lIxqEIFea7w`$A?YI z8A^DVb(`g~#}2jtu#RlW&7zHk8tle9v|IjbFI+s4IPGA5Vuq={w>bq(EUJT1>_a-@ z*E(IY#Q}?Ni7}i@(8G+~?n0D{b?ufEduA+AZxmp29^ThjljPY(^_{g6KmnfOeH%29hcQZ?{+?|V^aka(gBT8S|Kr$0#{KLZMUBT5DsIVfWQkdQy8t96s zqzxn(NrRL!B8n1$VJDQtOp_R-Tjgt#tQT9&Fqsib3$hNSStVC80pQU0a;rS9O`F}> z8#ja$h2do+h55-60tY;;^$VxbQs?XB^8|z)MVbU+(Eef-MYOxg1Ia>A9!i(YrlXjf z8%QKzBJkDj9aqlYy6nX^91|ur94hcj zfD5GNmd6hXnZvJ(da7PbX(0m{!YU6q)3@~xJg*1R9>^~JvsLPacRSLhiq%ry-Nq>| ztrR+OaF{Ty#~W0Y?(_KRK@Xib?GQq-W%xyks=Hms$JpO*ebMPi?42I>mTiSCHO=5$ONOB%7f z08l`)>8V(nei#R!%K6XU?<+39l!DhGJ33T@1m}O4hW`VV{UtH@<6JY0<(C3@9ZuOH zn*%W-UzRK%nWj@AXK~lmT;~F8#iIMn?~BKLs-$Z{NfBg5lp4uT?(}aNKDE5hJrc-D zdyp)C-$gJ_VWqRg{zXH+^a2Yoj)FF=8+)7=OnHaTBvJfMd#qlvs;5lI$u&P!)+Xt;UASkNj09=$@d88=8mSVMcn!MUGMk&!4CQdRZk_ad| zvAbE8B2xIHew@&K@W578UxZ8rZk2-motLN+Nr`*_Xzrei?;`3dC=TsHwjtFtK0=1J zW|!hpyp?jiTpQJi{(IvatAf8(y8<&L5S50qLxapl(c7XN=(WfUDaiGsz{f#@lsqvF z!~(iAy3)HQS5x8}_=YqD6dkTWXDCUtMLligV#TMyWyg`>5)tWP5N85f5T}jL-u~UW zrv)QMZ0S~>3T|?cr&Qa$*@|I@qCmPl?3+DhN}>$%$(gv(k32W(thRwHpAgGIVYAa z^rye<(}3ETjEEXIHZn5L_v@1Tr+?t<75b+OU%zQza8}J4U6I5VY-HM$G5*}?&3b#n z=F1Ou2GRF$iZ@5D>t z(2SuTCl7~ZBJr>Go|d;m7dsWEc*Ls&@AEK}0I{iZOt5rDNi1O0>gMJmbKg~9J2k`^ zXYW>Bl0jo+a08(~JM~8xe4_{0ZwDliK%UdbaO-DI)ToT}fg|NPr!G}KO;x2;^gho&6K#%93pNaO$7y0`^4cfYXfdS^#uA9CVB3-`LQ>$)pH@eekIS;LP ztaIY>zJn?-7i`Fj6wxA=4>cZ{PiePycdr3^7@jHDKUeIo9cmKWNv+4+)%D7EnAGm} zFnVvkVaPBwJkh|C_jMhE{nN(eo|+$vj(zIXsZs=%=26ACI=^X1uX-V-a~IA^tv~iWn?&H%EdpXbISjawu8t~&+Z!l z!tj!5@*Si+0A~Kzpb`aJ9F)@VoLEDW()su&UwDVBD*M4vvH-nAc)X`yWFsLYD2u7} z0qz)n33zA;gW)o}u>cC5a>RJ_YsK{nq@pzp!mf4UnT%}eN6APbYue18&k41541em= z&DNq4s`R}LogqOu|IEjHys z@q^eVl;3;5=0>D;t(5ClNai_9j@MRDZVKyTH3}L+7b67WLgKL=RrW02@c{It1)^T2 z$P;EYTaL9%Wi!xL_;}voDsLW*MlSOXP%K5}iEUGsezeCs&Drn8855C_myz@%ECpnC zB4Z!PCoMBR5rx_e*(e;UBX+qk*HS^6luI*?b=)bb_hsWhLayr(@P^&B_@?agmEunv zE0olq z1~RQ{&QDq6$F*IPzNp(<3&*{AA;}eV4CERD>O2sX7^;Qd3IZj?y1#}0xy6_M?-t*` zlw8@NR+9r(9}>Z&2<>BVoPudi|AdAQzA*i5?7ZW0vF{|G+dn1usXZ<3Td$`MgS#AZ zi?7mo*8%+Eo40Lae%tHmQ949{0la_G2bl)EkpWI1zXVk`b5FN!Z852&e)mdkTs$uj8x@Ev6P_20Tqk9#S{C_#}n30D%pwUDNVWW{*X19$ zmpWLLBYSMDcPz9Z zG{{$_I)Xz_>}d!$&=R=-#OL-)-WX<;Niw{sA3&R69KO_j<*zMR`gGfT*w9m_kiI^6 zIh)LugSYjvBYP$Xk;p;R7rz{E_TyJDZ58g`EABgk_q47(rMJtvtr*iQ&CTUlL`jy>{_1Rhs)FHH7%vC%pQd?ki7)vidXQ&6^zjO9HNVB9(0QPXi;vI4cAWI( zt0|KF1|LP7zJQa}&m*O4CzPD|?@RJXA;DhrIj?)Q3|XUpXwuisL0W75C&o*Pg!|dA zHEQPcKppq06vV3B#HLSe#NKgoRLg(|K}w3+|5 z-1l3kC$R`ys26Y-H+|qN-TVqvsIdqS0$GEw1v6V|jXY`Gqm%2=b9=E*t|CR^s2+Um zVVc>!3iqQ^u-by_u{A=nJZMAyHX{h%B*+XQzjles^|Rxu;{AB9I4YcTjlY~oHjGP( zR3P7LQkkh07N64+E5c4j9>1X;@DzG!r+qA1p<5n1s`^vpi6L5Us()`ILsxwd6NdK? zsIeqhl`0$8#^MG%gRYvI?!n^$eOCF|JiOT=QJMU*{e{rbv}D9EHTA`I)Kb#GGWbm# z*W6D2E^%1H`%!G@_rvreaU_QAq;(SdJzn9YCajY3;Z%l@hXl1c!L%KfHgq`3<%h`L zD7kK24sWp*eoT(aes}1eKBfsf2^+@jVUm&s^!Vb7<(CMo$Er{=b#UonO1Hb!`7HM& zMOTywG+Df@h|r1}19XPoOfygp=F6VNlRA4cJ2HjGv9a~(!`zzMgh$lcRJqYA5)q&H zC#@L7ck}F@b`13Yy99bIdjW}_V0dzVtJsv^gd5#ajSfxu<9Vd}ho#8YW1yFF?iG1Ka$M5lm}-@^_5f1}scSH9cE%|=AlNL+FF6)7 z0~Ht0wYfMrJ}p&i&U+XpcP_Spvzr2i$51H^kv;v6+)6CfvrTen3GKEX{2Wh2ET)F(wWUg_s`ejO-K{gDZrjKVxGo_;>~e z)5Y?#pknC!cqZpjoa=q!g3%aEQ=L>JI2a}?fmI|-rSw@)h-07+Y;Zs0-)#+5NLIJS zD;YRr)@zVfgr_8E=KSs-)FZTE`6A}mF{$FLZ#1l*k6M$fT}aB}#P@1B40Ke4n77o5 zii}Pw#`W`M%bWl*)KZ&&56YFjbtQVKubl8S!PMc$6XVU=Stqc`P|Pi7Y{9k3TN6va>&Y^FWr29%9bqZBv$R)vHgg ze_wr=$y|OUSLRMQ`fyM}h#n!{h4C~6uW*NkN!g#XJGA-4$EviMmf^fA-4Flvp?hSW zo_%3vwLCIdOq*|9jLsHXS@77K9ku)_x6|;PEmhC^I$WH7oQcAeZU5DKt;xyKdRz8^ z)8&_fumG8ZKSdL#LK@G;wnfF>Zh}mLO!Qf^XnV^T7xuk+#p77-@wl+1!@xj9gmeNo zkx{VE9=W>whly1R{hxR}2mJhNSk-=+2aJXv*kw&ieL=#MKE%o&w1u5e?*BTwo4&w0 zw!ZyD)5i9|2d-%22AWH}P9+)Ua%f31i5)zJDkxjUhBKVfkF~ZE8FsU38{U|I{Ovn$ zfif{@zbA!=Mw@MtmZN$~QA*M&H$f1?ekw+Y9Rml(fTyw=6x7KvMRW@Dkea7#cj#O= z52K<3Eo{$%e~m7=b8aKUT3%XS>n)_jXcRV3k#C^sTf?E1Y? z_UB0-9b8GIqO7!0On4hf%?=&;{m+!KM9ZVh@Wg!N`tyb;YNsYI%bt~%RGR9&0c(;Z zMK)>Wn{lxN0VcqkC4vq}hP|)M5)v&fy08ACGhP1%tch~7Wo>&6V8Y*}``$KWKL;QG zQ=;}7-n56tkHT8?7`^~)EG9n_kMM?rIJImTHg))l9-W?PJ9U5jh34@`k5S6TZCjPG zvXktH5n4Q(5SI*4@R*G6_r(!*QTwo~R=6FZt&XGTHk2#&0)?Fr;zpj!5_UY9pC=h*<@2~(TS7j zk)B+Cx{Wjykv=0EaX?F|gzrBDKHmSW3g&iCvXpVt{5r@kwlK*kSbjL}-)Nf*Bn_rM zb=RVHOa94Y2~%x%jZR3<{n_>YC%=o7#`~8ItnjXRmM>fwsgkiXW}x6$lWfA>fJCB% z6Kp?qYD%+pB@hO$F>zLqNok|=-6#b}i{wYIiWku43fyv96I0nyb4u09G-(f{iZrZ~ z7<2bBv@8}>1iuepysC(zB~#=_1F_IJ!f87(T{gyi1 zzaq2xrfP$n5)qXa`;x}N*_8#c0V)4yxI4?LjlH{cF@3H~$#3h}DzF?Mo?d-LllGcteo!^!9Hk?0}qW zN4bnhU*24nEqgb49Uq#yhOT!J>q4KqM)mj6i05c_rI071i$`qXtFpBuh$+WJ#bS|G zN^Hx&T7{(_BI83Rx?4qOqyjkrr))aH;6!U76FIw=f9<^tbr=6Gh47ajib%gPH^Lsb z^>YFMfh_!aj>X5;LX4h=s$lf5;{qU?kp#UY0zM|Vaz(}0^5k+Gb2ua99`(U{Y!5+R z8dtZ2Xz-S2q@ttIPY78m5Uj=p`KfXPiquM|Wo##ls3kYWU4@JVqo}{#$3`o6B3_d- z?(EGS{ts2}6qs4SECGJOBoo`VZQGjIwmC5;wr$(CZF6GV#%BKg@4dTk=j}Z7>C@fS z)m0@jT*1+nG%;qOJ9Ixu&@=@OmkYCyf@+@vCt@q!T#Nh2!KuBjC4<2e#wkQJI4KTL z-Qr==syJ5BKgn|E>327gEmP(dik$X);qArOvF4i&plC^ap)egjSsHc)`RFvRIWBZG zGR}!bA7VknFYC$g_S=XnSYu~-@hG9Q3Kob0BiJWIDh%$%JPO7Cx@6Dn?Te@4H4r54 z9a_g&A1Y2)kjw0t9~_fNKHcSg>t+SJVyfI{Xytw1kmB>_r~qkTG5T%{eORrhm=^U+ zJS`Y4+S%8BvQ9chTVNe`QMnL_jR75UwESpjd+8F}dfF^I+S}dTt=8oPKeFZydH`3J;&~VL`jP#>W%Avm2w_ZixDgW0m z-ls(Z+J=X12vf)L9e50rseloxj17FijPE7$4E!^ye&?c(vCEpzQiU?#a?`~VRiXE2 zk&DRLr-;VgS(~iSoI#f>1s!06@Ow}j$k}8C$Pn6U5bI!uLlH%^BsAeDT0tuSDv?#% zJT7*6m&EY0seS5I+>?Q0?Qi8yNgG6RH&C?tf#-MN{?AlX+7^y~Wt-9v4>_Qsb2Z82QV8Cp)0Qbl6{U1i_$iWay59ZDoi9A%sw^$4_dQ9pJ@ zT8s%e;NywJb0bdzT`9EX3GwEUKdxp{0)W_!M32Zlq6R?K)ip~QRQnd3^re#Ov^kSD zXre&ezOM}BleO};v^WOAArdqlD=f1PeK8wn2qVUI+!c(J;!_RWu0rA#l1lK~|y| zWT`_P>=%Y#tLs5)nh?Eu(TT=j2+w3Xx5IW1tgoYSSm;>>dK&;_5d{%P}R=RBy`7ZPhJB zZ0IpZ{pG7Iu)c9tu6HPSt+P;d)_NXbU=JI|06MDDwHf^_W9Y}hKcQ>@J8^f~N#H>E zfzXJxS|mJ)h<2vXWd8Lxc6{~qFhMxfMJ%3?FmKvUORwn7!p-0tILTGll$0b`o2X&0 znLoe2KCU5^+r3`n0Y^g{xAiBD0nWKV?9a-v!io+H0=ma+_~y%RePt1>hVSiF_tUVx z#kaOB5_wBj7ThnaVoT*U;Tb;C5HyZCu$dEOBj2dSjztb>vwflrT})DNVm!#k(!$c7 zWq#tF0qO$Mz|=sbF;ket5lWPwa&q4UTJR*b`vV!5O+jriq^(Xn#a0xjGN;ZSkEzOZ zXPfNOlv#)M^66^TIoJ91JHJ)OI5WCSRb-Qe&yH+mHDyn!aGC#$oWu`rdi_xiOj& zib96{)oGqAvP76M@p8%u`l%*7JmEB>76T0AfVB0hlXs)EU|`|G4Ns7k1<9S11n#br z!505=7gX!@1=)Q7OoMvR`}L36VnmA4iWJD0SJTpG=H1tk`O(P_?!pE!v~kh<=s89< zJj#nB!U8Q^-MK+s0>c%;!DO^{)vC*k>`asp4ytU5R@C@9^(mr70}J9ypH(v; zRb6?nc~l_FcEB9W^1e|Wndt_r-l^cnc_8oSVcN#UH`o=vr>Xk~bxASIzZltZ&VAxx zZbacRG3<;j2^s0|gmD@1M7V2H@&Sd9i3x*OCsmNikZe_HgGq4{?GH5v+Faa@(1~mG znpUjz8+GTsXo50uA0)t4WPL)0yXrXAJabcsN z6FzR&a;l|#d@AxM?sW`7-d#dUIRe#Y$mrua!A)nHg2-u+Av^o4BCOPG=4)NPFk^C` zc9TegQ>#%#FS)VT+#Gda7zCUf3k#faBvRa3)#>(rk#(JH1`I(}zs9>7p6HSWL}|Ym za(l z-k`os@v^d1d_?J#KG2r{O^L!JMdJl{WO3Xq@mxeibNXEyVIBw5R=Y$qm6(T*>t9_@ zk@~;ouV*b3yf-)Ovfndg$a1x%MPkfEUoG+f7=mObg`yj>fAfHZi`QH>~ zMlxZ4{Ux@xlH3d4Ntu8m_aHyYlLXbrZu%57wOWtrACh|_{xC8lC=LKCA^Rnk^ZQv( zhToL$k~=sz>Q`&r5q=imLKYeC6Lbtuj(~rzX^(sKRzq|92b#s27O+kEatv#*(_g$= z+k@cOnSXdEPSAgE&!gF&KfOEKsx*whT^qcd=Qt|bn?T^N&-@8PFMGB4dYJwMvD#)* zF$;5|{GK-x(>7`!B)Qu-9q3{vAaxl50Wsp)67K>sLD(8|;IqR1ic2AA*kksnWdx+S z6(a)Ub4q3VW~C7bURyC_JFQ=+Z9UHf@BW9WI8>M1O> zGB-zhXwccafwc-O$CO#pA$t{{d*ne4vnbOhGD_a7PewSm1>OE;9B>(X>xe(yjYZd; ztAi2?8~76Bf_#yDh|rM84fl|f77%1aKlW=HtPG!zPbZmdTzYygtjMce7^`F*f+4^T z!imn~VA1zZG?!B+M44*tm+B!gl1n7mvmgJY13km#hq`8{(Q@|L&id8`#lgeeIBs&QVc2Pd zW9a-RIII8P>zC)B`KpukPUBDdMdzT4(!BYtDfBJJz|oX>*;sL_41C~HDcX{Cv7N$z zM1J*@71!eZEc*NS6_%H%cKiAbeq*&4n=DuX&u0u5qp)y@-!#^ps*3?HAT5K@g~8zB ze(87d@+`AkO6_eOdUm7n+kMVgZpRVaW7Hz3wQO!j7$6lL#Is?4wa{B>jU{3#3%>!y zB$3g6I#wjbv3#8+;;zJ!*o~PVr^YFWiM<<9M))=u_9~!}dUqJ{`HV^}QzsvS7>U~* z2A-}0$RiEAAmqD4D8K%mGb>V-H=*%QX9) z!jl6GT5r+^39na0pCrSG>W>(17$l_JpipKBZGKY-g-MWg`>E^23)yUIAR1@IFr)3i z&(7aGBHvt#CVHXc219hlQhFEpxHuoCESIZb+{ZJs>Aqm6^tmrmF0u@&^mb#N%`#-( z@?UuCVCH)|xk+w18cv?URIyqORtq07f^*0N%+p7Y6?R-D*GUU#*tiG#p2@O}Zf@VS z^h@4+YyVijK(#1qriqCH8p7TB}@X!69VO?=49|di;)rS*I+VeE($^>F<=;2rq_d|y?^(i`x%PFRYqIyy^K&VR7A!X5` z8%Equv6G>|P-R$+xOIVCE)c^ecnp?&NXRnt1N}KXUpa-Wx=@$0D8`2`vpcd6%*rB85y19_I$3JBP|zxv9ePgMU$ZRgns=)%RZD+| z4TiS6z~fgFv#ERgteT`2lQKXMS`2@QvBAPUcAfa+c^+4gz8%|L8NP9R30NV#&(?Yo z+yyuY{cg_x{3*x11fNCy0z`@>!NKO;;>>uBJvq{Cl-+y#L;!7`{KV6o(8?V(k}PDf zFN@1v=up9C%7!|I0R%FIv>exhzv7S0UaGoWeeNQwtGIvC+9587C8@q|v=xBEDs_FW1xL;j}}(5syt0qLiH%Sx!# z?hnh7%Y__w3Nij(_F~450DRNV|BOf!zY<^*c~9esqnKBWM}C}bG97S`hEh1B5NihJ zH}A1J2}s8_IgCqitk_I=%E?0jzSYf=LtYp9LuI(zF+8Mq6Yj||KoUH659n*rv|_JE z#*{}$6`f69$ZzF@a}JFD$SD$^d$)Ip8Ox7kmt^++E%2`MtT6+L${2ShISTRiYK9@inMjp`jdq^ZMqN-kbWB9~bv?t(e z-6(jdg@%vn5@&I_pb|0ZLS=94h%St~s~tGm87n#b*rSv#UF|Pi2l#gRusXk_=|YYI z*zwNtHcyeDSuzC3k;eoYer{n1s&mPS)Rs-t1;Zix&kIT>@;3bD?A0~30(vb`A)e}| z-1`r}YrL?;ne?`;Gdi|g{pkj;Ak1+cJ2F`)r>09TYFTv~j?RneD zf&;Yu{=RE#092p6trrxFDx)N+A4>X?C&RNI=EbTms;kJ)+o@o?g=nvQS#0ntisTU2 zLgH;C`}*ejq>t;$?COH*l&Sz!7EF3uCM*HqL;yuDQ0sk76Qke41 z`RLSm2sjwL9n{o6kNjM<9M~aB_kCE~&x#~Fg4o%;e=diEgUyb&q=($;Rr7CmL1F`B zY?*L=13WE4|CJ)WMi_E$bZ6Bgb&C2~x{3+l7d9<_ICT;)(y5M|E%1M!91RoR-QJiU zkTH)L??J%-q41}G*U!CF@|JR+tkAd?NnoO_P2H`0VGqE}WjH%|Q%Hd}N|iWhJZ@Sd zI)@D_jzL)yd$KG*Xtc@dPoWl1GFdr74Q9%*ANimg2Mzea3zb|R&A#Y9(X#mCR3j~; za1B~md&Q^J0e9bb&40)v3Ad({*^rYAz1$oiKtob$eF;8T#fR*bgYt_&Gf4-&d(x#a z4J%Ozs4_ds5e)eSmRQrhLP`?2pc3GxSN^A8ZJ0K5+-W0X4n07wPqdN2B}hjWeij^1 zAL@k<9w`RjABzB3?I%ugG`QS6t5@A@`~5k2jK1N3PtE!5k+tSoqUtS2PtI={#3|ps z^6-%idtm8f`*_H5pFV>L*IwhvGCFQ|le0EtbBB;D*>)b|nQBjG5nl)^KWNQI4vA^S zP9JvXMX^_mBV=U~Oy3vqUckdCIUOmLFeWQBW=|vem#=*e9b+pMzs>5Ye!eN$Y;LBk zAJfuK=r~oUMJ>ZyOnpw&4BlY8gL$F7CMSGd+Vmf^O8#GHmACIW^(y|VDI95YCmJB-higr(}fj-_0xE3{z!3aKzvGCGtPEi2)lL1=d?CKCP_lJ&z~(+tB9Oz zn*t%{{sS+ zJ-@4jy}Qd7m~Q|Gm=9r=KI|vs2sa6Q5%B~uTWdd4W^MaZ)x%r9vvOsbMq~RlOshMc z2T!)Q?9--lxr&VC^6RB?xee+f_6WM8V841)BmH?4#E*QIc7m}nptJ-$3OseWmptyS zm}!thDsXb66M0W&_|ahzp_|dtiIcDBYptL7xK+<>;y%rYBXQt>k)?zS1wM4bKRUGm zbN=+#ReH^Z;F4LqSCNd~TJG?ImzJ^P+0*236XRWllIox*4`eI*9yBjG1bIjFx=gIpCuONyA_05PXNO@*MSq>ggviWr z4Kscz;-=tjwKcwwj(g%wVhO7rRD4pO%7R{EIE~tBQYZV=lcpEf*H5D568So3IGNv%2s`t+u8=O?%B?C4zcII7M)927Es1s@%e3<@lrc^_W@3=U z-%8>n@+KBM)h$~pG>?+W5pcYC8mEF%B$v3by3Qwx=JgUBiEM6}pN-Q)-G(CH{fe`A z7Bzp}uL&t|g}v(r&o>p7gWb5C1(8jtXKvWZfnMyLd z^XbCXIfyYq&u<@x5qIgJY!@a7qTjFAfCp`ixob%|zdcq$!9VDM*fC|?9!`b?WHwL^ zE)sgaq4qix!I>srls&-(YC*!s$#0lYTorrH)8x1N0uGeRJgc>5t0du7nRLhP;U}-m z!jktyqc)tz%VP?GS4d(MyiXYh`P{k(K?t2N*Q>Ng<=HZP)nnZ@YCfvc1+rtiFXy`HZ)D%x&Oh#macM0F`o0z5n4%ck_>SPH8So)#nps{b zt{7wes#nb;ScGOkxqb;C1_L#dDaZUe$-c-0u1B%S+-Wi;N=n76Jj_PvClaaV%3J%W zro|Ko1RndGB!yIhr(<@3hK;5CkHd^}Cm{HdL(*nuzFo~14d1g{&C}Rzo(yKDKk`lb z!1=#UXZ?u1%qL1Zf-DOf3RRNxSRnHy(9gmaM)u=HOg+RU)|IU5Oo8-_m`SO@v^C zv93zj{z~#NO4kDQS^@f9LsGW1=z`$l33;lqE%1}^Sq6PoiYqhxY*5di+9=^HvhQ|q^X(!hx_*9`w0l=@KRmA1D~w} zTKVJdysm0Z$pdn%nPW?B)qgzOtCfPMsjK5@AwvsjarS&auUdbXpoXBQ2CuBR3Arpe zqozxLXWbM#xs!dg5v0hWZj z_jURqe5O1Id>~K^DT;E^o?=Kr_u0>O=QLaEBu8X#?dpm@_Ojnm$my)9MA4tD&{LR> zZT#=TAJA}dsrit(kOuqB1K7K8>ePHboSyr?L;v!Bp*Pt65pijr0z_SjF46xQPUGw4 z8{%!AU_QSQ%+rCzc7kPjOQM37^38M-$Eg&0ZTAGKv>O>S2mmbbs)h_6JoZ7-s8t${SRJHa6DeH1sIWCui#rlA^EUKQYG|tu6J3tm7M7 z#d9kF*G%Av9lJQoILpaySl@h zyb_j0MCJ_KpXSli81|xRp3-c!DsO_#SY5|z?@XxPor4K5oj%6?(A{W`r?crQ$B)jh zuAVISZ1djoON+j~KMp!D6e(yYM|+(ZQ$4@(;)l3Jfg*>mOMV~`!r;Nb%}z_M)`ZM;gf@3-Ds!wWaD*>yA${@!!a!`ao<_*n_F7iox{fhE8q#I@G{Y5lx z!@M~gTon~`#;|zd)9g|}%+LaD7Ny&p8Hst$M#;~cQ)eLLlycH_TH(>=2JM2}inr9n zp>~}=kol1X%93SEm7wb_EoIl`H$UCyIydRTy6vAXaXu3Ix_mHw3;WT7B(khSowid+ zPn=hqit#MQEu3fxgd^jnqk2?)*+KMxfoHxCYWatIfLGSn-?)n5_>}l~(0XpSujwAP;VeyV=1$-iQopERyufIxzeqPjU*J5o}oIkdP5~KKw>z` zCi15+=e3M+=_0J4P~)}!6fnwUXmT^k&e1~NIA2 zt2(uJXFb*R5SUO>k3iIj<~zKUGgx#(y+Mm=PeX?+@hKvc!W^o(I*BjJzSJD_mar!? zuFhBS21#@UEcrU$B!l>L9~Hqq)|w0eVy=Dy=Q4H-#hb#bT8BymNz?$F0&|6d-6Dwv zGbU(mum2{VSJgq&5I_OBdb>2%3i~lx_a0^i_%j^5eRZZFEIJ-WWq5Tk7iAgXq;)(t zG0MnWB#ul5mo{T0g&h_lWghV&qr@Gvx|Y0$KHbaHqF4@Ir9RT7@h?w z(0!Che(W6iHX+0Cvf(_Qu8i<76Py~7n9OFh^EQcJTxFM==L#t(>SVam5iLCpZF zUEHwyA0^rQ;jd}rPt6hB)Z>G8D;{ztL@} z)SncV0FBI z+vZ6Dd4?&#h=*4{+C$~zNPrpu2H?$g z`OQX$uVj=jd)nuZ?DGz}447-k)n?11gk-@v9|crrINSOys;Mtf&Ud$)1aabjZ?M3EZ$);6q*1X<)G-%ss>4H^-G zcJReA>Lw4Y*UBz8-ly*6*tCoU?@74jR>DL|k)b;pIB>~>iT%>K5Km~AFnT~8sO8Nr zgKW?-K+M&o*7xW4QnqW4{Tzk$Uxp%O{BKy|Uq3m0TM{j>S_Bs|Gjy^d>d&>Q8?9*0 zR^X(5*-q!c#*O@FPYMW-NqQk>@0V7eb9j`o(Y^_i`NY-yqa!g<4EQzByvEtI%^h_9 z!d$MhD%!V?DSDCs7X)Uym9U2s6-h@s-#vIWvHu`_a*YY9=mX2(6wXGF&g!q zn62U8ltU@2c^~LQFz5MpUes%$a|}MbeIpnRE(DTez9>y=#c9UEu>4ngWq8Dpu;TWi zUU@(mgs!nkgs<(6FP!r8t-8`kSi^MR4jjaGQq*8V_E#pK%eX(LxciO5W)kR$>BI}p zjdX%n)>5vkAlS5c4$E^>v)cTTd_yNv7>6Tdm5#!WzI|f8iXGEA?UW~EdX;)ExdaBdX#)!Qkb_!Qq%F5-~HQ5Q0QrevjC;g-dJ1#3Gi? zXUP10tPivtAvLq(AYOn*Y$tX~RVLaaGa2uc=yS|J5t)~(q>3PUq3k9ls^EhB(H2y^ zlTWkkkzp1A5C?_X0v+}eY01TU_II$eW|)B-|5Kl%kr3|Y zG(2DcO*Yv#mYR9;wj6+-8XgsO#VA_hVYC$Nx$`>LBsb=%P&J^HU)&?eV+^E{w3LJ4 zSLu_dJmsFI_3NM&+Q@hAG~ryUKRO{fyR8dLp31F}EADGILioq4f?}42lsxfJ&T7@^ zVgo@tOQ_d<@CG*kJ3wgb+YFM#T}L-%l!=8KDa(@6__g#`cg!3{$ln)dVB~%yna-yd z!eM?@D%v2R?`#+E-AE;X4?Dzi46 zI7vOl9OIj6;F-~m+bHk>1qu{`y4<0N!bYzTCksPw7K9SUj8Nf~76s2`w8>vp5B3Mg zkG+j&cy?Ehm9rOh%hfnrYL#t1!HzA0?obB6Fo0~_V0rH+`ba#@#Fr7bace~d!#%Gz z0Qwz&RieUP+qk2H2S&pCTI@W^rRntDt{>BqiTCAEwO851?!&+t>mu4-y6yZ8eNmwl z=JMN%OP)Pk^L`Af3;;2w`fcfg`0!uX6p&|5b@u(hJi8|bUh^1&3CwOYNX2SJ51hvz zFpG4q<=%yG8%Z*b7@5Y!TNV1csS741>u967;ErTLeW_%_YwtdLv%SWu@|3I3r(8X_ zg>@XO`;i##Z2JQ5dN)NI^W@y0>l_>+`hB99ueAo>s}-kTJ1KP^67nxIPN(B>Rl`4-I)`SCG5P+$n zLTX3GCtK9Qv%8<39|sc;Ob=FLfy{gdkTu9D4gT+(8D&H@k}Y^*g9WI;i(v?D5ZJZ@ zJnl}9Bh|A%oSb}oAFN~XP6=xvZ*$@C>MSu^IGk0IHkUh5d69zR>ILxF=WRk zQF~f8Vl<7#zDX%@Tm&Q{_Pgg(zqtn+T5Tq}Bw!F=XDGl7;viq{cuK8ZQx5F89qr-} zc~VZzDYO2T@?N7QMop6A+8|^n>I{xko7;w{klh5{C6p#jiu{VMgpAf9362?kVmi1C z=Mb~pgSh1w-{(O0F4^Ju*BOzq%HaM_UdpfQfZs8&kz%%{O-|Z5n?S)wW{B`0U8$qbiGF6MZW7l4%&oK_=#%RHMRdSl#pCX3Q?d)MTU0t+Co7kl3!d90+_fbT* z7HD)j#23l7_a&joY7HdI&qa(2nD@;#w2!nyC1XaL-5(m_gV+RJRGPoqw=Q_7qTl|w zZ}~m$fO9(EY7wt}l-}%6(aD>AKaaoV9ao!PVD?&3?dPO?S<*|akc#4{1s&X^{jLzn z<@n^ca%7rse~~m`k=GD-rcsQJ0yG0fQMc8)gw7@3Cas-Nq=kEOJYxloy_Y)i-l3sj zIAJT#mvQ?599Ox=DDUN?{tw=r2T-%%rR{cp+eu#``{ zyJ`A;EsJ9H-voy%rOc*63{jx*wxVmD7proB@1JZ@#Ef-PTjhDAIh+pr^e^{_a^o&_ zlvOnGpIBfhv+sqo)RC`$et`^IbJHXNRCtNfmiAK-k z@C`~w2lzESJo#GPTHjo{#_UfAyuVu19S3zZr7v^K>6`Y7h@p%Myd3X2Mg1$Uze6Bc z8?3kP+EwC!0n+PTu;C1QRTXIATcbF;D?`L@@rx;5z8y*aSn8`Fm@z51puXfcn;}6F z;eMjHA^4D9X|D_rQU19{A2O~0_PYyBdr%l2DWRGxJ;`Aocb>5FkvA@u_qm4Nm`9;3 zKvBprs(n}7W9ZhrePO?}+vFVw;k>1xvd#L!gv3c8$(2n!P4?j6uk}+a8dDIxEc;eFF*~qHZqkp55*Ys7CBRha1-)>Fc3@p^? z+eH=SY=oap=F>@fs8-uuEY9qu?j3XCm)12xi7b#wM>r`bV58mZOy6R?8315GekvCP z*qtAYIW-V0SAkt3pJxdK_YmNCz8?&}xGap_H*BPTh2M>T?wM6b3S4;%lMN6SBEM6> zp(DC;nTJ>v7-v~l(PI13fxqw5WKD-B7?dCLYvVZF?#Vig%olSuZi`-a!zVM&f9xe# zV5G9yBH_YL{&Y;;KPhVD$cx+%u)00_aijlapBXcBnV_l3u^&CpyqxWjxI=q=Z8g&P zdv)`t8^tlv*lFCcS1Ij#8%UKmfzc^_RH&H~m8I!LSF&zcLf{qh8AKb?knR5;v6dYW zmg`S5q{bD{7dTY)M&*Y>@6Xp%Pkn)hxfDWLn%voQ<3P%1ez5FTxYP13r+HQ%%}u2| zarwJX5eZMDJ;lJ1iw+HCgW(k|uNM{ZV!zR!Ki1Y6V{z_cb=C{Iz3>ZTy{$cREQPDD z1%Al8i-rR1+gVP-T{i>=23ppQL7`tQO|tO4&sU7s*(q#D1`_LlCTH>QQT5Fa&$kEx z%-5gy#)ZE!x=+ta?oen{8~k>(TmrS( zr#j4t08&}x-?iF7C%|kiFz={X+$tNV!L~bG*b}*~|L_Vr+w_Avg`-e~c3`+|Nok)T83$$cT6|eeoE9H#0*1#6I?XlxsoAkY3*$WWS>Wn5u}UW**U%e z5_e$GOe3tI>_uUO^y;F?s49_)0FKZX3vBY|#`fXOto?`4Qq#u|c;ZOHx4pWOq*;|(fSCNU0R0lam)Lg+l7H*DfUPDSAeSOxc zMxyJie4jJ@&6{N;T-<8h>l$zT^d%?*a=cW{SfAA?hky-=4hWS&W zN5&;#5_4T4FXo3%8ErMud}yr>RPWd6Q&IpnBRB;+y{#<#J=N5x^;<5^-Z$EY#y~v| zU)!B@bZlRc$)Fa{Em!d=4%fP^F@$=eyQaC-bKM+(0w4tzcACv;Ib2$|f|Gb%lCFeF zs^Wf<4IdQAGdknQ2f2IF!jo~hKg1FfA{bIt8h347(>&FU8#d?luDQEXQt(R3TSo^c z@AaONVfTeXmrQ6^gLMa2l>6?yq!^kW=R-|r-J(@ybvx2D{vo!}H~+?t~NJYo2poD(DTb~0SzDGwzt|>pI%@sN9 zCq5Be_Xjnufng^g$iJc7S79^y`0%} zK57)0fSKR#y-D_bem%I)rO*f*#oC;?!_289>@gs)xmyNb?y?Z?JL$~xD+lZo54oGv z1ak0yi&6XzP|}(&zWSgqUt$^JIoSa&LRK6W039aiorkwKk1LnfwGRCXY=9c|ldhAS z{S6H?H;SWhwd8@G)o&GW?by?1r=-J7Y*SZH%D%vO^vgFxmjzQtMpJN1t(ubC(%3c> z)nB0f?C+Ew>@tN(f}czi>Oc;nI6LDJ)(PC@!P)YrZV+A+HyStCqM$2(iu5I&Fwne0 z&uuqk)3}jB0mkXJD^=8F$uacBMpNNea}x8mJCQ{G{rid2(xZucs=XPqkU&PQUOt%` zz7c*qJ9~vw>CRvPX7{<9|Dc=&5dSkdmoz_O`qog&_)$WJ)9r4*EV4vmL^FVMqwq)6 zCT@fU8JN>7Nc-RGNMJ4?wjz_t!%#sa;!sE)BpQoX9u&ZI+-yF5T;c3l9^UB&0+r-* zHr>G%cRb~-!cR}lONXZL)q+$h#Uh5yv2!;SA;kEvEx!V0=4|M6nG9S=POuozp9A6N z=%>dI-VIuk(l^&B#F%OH`QZE96jvyTK z73#94tg;wK?i+ZP<4*JaFWeZKvO0)>5Y7(QQ2>S=w6$OzNy6y56G#p6rKi-&JZ8Bq zt`DQGww2}*Z6jq~WVyQ&@1@mijxmb$T%$eL`Lf)RXW)BX;S}|x?^{(*M@bU3aCy}Y zO9R9M)MS-zM>4P>5G(pwW(y)KGSNx!P5^l#0EzK58-H2wLeTL;oXNQWbS@3yMKGHT zD{$wHlB24(xwU2QATdjqF|#C*N(Qi0YHDN?JZisp*M4SBBb7b$)s~YlSv{aT4*em*N>1^_n`dipzV8nO~)o~nz+a149qQIVqwg(PD)Q9v>5DB4g;V)K|IGrHPu3-)7F z!P{pRF4wCE{ryqR+n`Hy2w*iJ>nk94FzJLA&@biG}n3H;F?kGu!F+Q%GcM7EAaPA zPbcl)&B7G4eR(0mrq7=Igu1{m-33(=pem;gfo)U@l%2>P>GHJAMjtG@VFD}F3IwxB z)mLMBRcHl63Xv#~Gu)LB8n(49lgib?wqCHN$+CHS7%Cy7s|V8|01@QLQYhB2w7w#< z{aotrSA4v%D45h;WMY`OND*4s%WT#J_^Z)F8rO3Mv0#w!C&TyRWX!i+g@1fp`P^IE zD(h-}OIq-BUcH&VMMfcmTTlp~PdNB@#)WvvfG!}(u9Ys6gJ10?c|H!FZuSs_TA9;& zk5@rDMs3SVSp%`Q)ap3?=HdhVTK}Z3067ei0ULvPV4ez(POW90baCBGXMps6@|}(C9w(-k1Xf-w>Ydd{~wkCp9Zj9xE`Tt z%JYw8W`n)HYM=Whb$5CJQL76WfU;$$Ry-+cMwWJcC(>3J_4wRkVT|Ea{D-R;buPloJX1RV5NTBi@Z?ggJ@m2heYJ$dNZ)+wp~d1tw}mI&S2ggkYrk zHu$_+VF{2$FFDJ;p0Ke=@|3?_eR=Kah9m9 zVTo;3K4SaNqWFK~O=fV&fsgu#l0)=PdfLo*;Q>Bj%rSM<}aI+MK|i`)*xb2n3{kL8AFc`5&*UW@Y?Dw8w2w-U)Keyh@T}w`JK*% z9Q!G$$;?8=>|fQFJnkTcR1V#o*=%h=S|g6q&1o7+@VhBciOG!k7-ced0bAM^T;FLh z76~L~ydkv}mAABYXtb$Z?>Y3s$ev1xW9?7!WQ{iaLxMc)?DcK^J=nYkFWE&}h2r0G z;)BaKjUbPwI*)u+D*pr@@6SW)Wb41s2XJ4C=UX+oNw<$Q!_BO$juKKB^G<8e{&xi4 z^#tz`bDrNE)L|b#k@xnp91i^E!9^U|PKsy-3Bn_T{JU9H2Voq!XrUwU&|P{upo@-=TH| zHx%b3@YrZ7$?v->ji{-i6Z+bniX6EBxAmozPsQlB>4 zB|SObnL<6Ssbof+(UM#Wwu?6=;;!dZ8YKl?({?`lFkZ}Ijb$kK25PJkiU3;_>7KUU z)m70`qT4L4F|7@~KVNXU*eu6sSZ*Of+i{r*H>NCfB+c@;B$CMz{C%^QdZa|ivAHXV zjR?IL{9^F!y2`e9a=ngiytVn;<;BRw{w#0lkwaqFUo-S)w9Pc^8){q!Y`~iZxvX2A z^I?~7&1boPP?SHs1|HV@hz;}$)c36bVUt{a&~4}AX;5Tzp~_Fo4?ER~WS(gYLP|GKbD zI|X&(eTK5z?w6474mfM(hZ;|A z{zub?g$}0Lx*t{!8!C@_4c}A~*{`FfH#RK)$)PSMAM;1c_s!QPE;txAW%aH(IG z1Uw2}nXbn!sMcs2VnFo@JVed#*a6ix%x73^K1y+oSObWjK9EpN&wNyswvPk}7cH*a^KU0`zODJ1 z$O7OXGe&cV^EPW`Mum1c%@@@IbSCL))Ou3&PMLA1*wFu0-wzB(FO~82{SQ@0LD^2cO!!4)bSDqsjO2=+N!VlQ5_T zk{vYj-HXeT@TvRf-p1|;J@h39RUT{!$}L)*=`AckP?~oW4wEerc~Q+~dWo^%7clze z0Vq&SK-Jl%OT|h%T<3)cga_Z4Hk`!{bpYTeCjmP2IO+z#)Ay)KTH={)m$nOBJXGPpzG%^prl~*1)FDKb}r!5T?M}JiYpesQa zWclK5FFGzc;g??M_NCKaC};7>^17tJqq$RMhq|wmh-=5`y@kvs#O!NU#e`vdUjk|R zF`IZFUbC$VW=t4EzheTnPqu@$QKiozP1u@eE!*+kWQJAPh6 z@byZ8EX$|OID>jS?d#CX#vmYqdQBrXhj+xCQvz`OfyA~T_4C}};fK=OQ~7_GdIyI} z`|W*r@2sh&rrOzVXWO=IYiGL&GufCpX_{`m~mf zjw;Ply0d?4sOpUtX)H%Kx$r_K=5T8Jv2pgPN3G>O4N$d<@2_QqRB>=t0v8`0O7TLB zWQ^V5$ncC(sahi+uvH@dF`7}ecDf0Rzdy8>DpIE3OJ*=f=}-?0zt@QS^m&%JucT_S zj8|&nQ9z1V&d{vzE|*!FzPx?Vc%qaQzn2^R7o;-rQ&lfDOPjyRyl5u^!(v9~R-2>lqb*6Cho9?S$EPVBM?p7cH z7p8%MZyQT;aQIM|#`wo>E*d*gwZnTd#G)*kMT*Nw5oRY-_I?p^UwleJ#8jbLtlS^W zlUrcSG`{~i3BuR;i))tjkUWu_D98N^A z^B=(F{4d~wC)>yFUbQi>isuWOaF9TP-zb24*e&n;U?8Fh16evRJO!707UOvU$N#zY)X>wHJcp zi|yVlg`Gtt8AK7rWmSksMp~z>Hp~P0&5NKcDu$t&FelrDCDkqRi>W2rLMNJqyqboi zFMS!;gA74TV_^FW{>KhaYZz*~{qw2T-o4cw(c@cfXsuKfq_am|KEIHp>wqXm*rn@$ zZS%3w8QhtQd>g}#iJDJq<+EsXLtT5U#eOaK*sgYZy^Y>zl)D{`D1hD|*jrSzuFllw z!^8W#JA*rdOAGonCMz`zgbjFiwAQIm5@%x^P(nIwt(Nxsh;CRRAS5v(CuW$&^V)Pb zAKcLD-3}YTzzJwOEiYiAzFpp*7^#(?vL-QuNl8p;~P%* zb4=s0h(f!e|N6bsJ=rnH9XI|DexMY76UNx}Mxvo6v3jl1%){nRa2WVl`iXla3AI}t z^8h+IA-3G>K6I3RpP4UPI5~Thz>S2Y0{Fa9!hbR()GntR{55p zT3H7edU}V6CMM!r8KgHQ^EMB=(|?GbetGW}b$qkEWwm9jY5Hk38T%+VplH<<*>o~>%5T?{S@?j|n!&o_^FITt7Zefsy z#OH>0S2+xYYZxrt(bIZt81SZ+>rpqb^INZpXjO>!v1*Yy+U8?;@DE-c?x}pzDN19o zq4(8*GspaD#Yz*CW!;jNN$)0A-q$%!8av!6B$kEU`BC*r8&^i!KO>W)NVJ!OaxBte zmnC%4I@B4g_z>GC=i4ULCHwR=zlPh2BslrvFagQL(0tbCK`RLX3%K#GWo^!Qc35xQ zUv2?rnMOa0__+Cf0GlNVq}{0n)TD4uqD?V2fo9aC)c+{Z1PcFe1M315yy(BW>5*b~ z!&Mv7HvvAL#nzbT^$R*iuQEj^6I6rh2;qCg=x{fAN_8)3h6ms2B9NxRC5^`(AJ!0itI*Sr)gL{Z>kzB0Czl9l53A zbiZIq0ETW^GFA9$u>^$xHd)s!AFGeG^E||c_6Xz*puo8k&HV0g?G_ss9y`Lqxk|rU zFc6(}Sc7FUv=*P}V5vvtpBdU4D5FaPdv~cD-#jw??ntrHRF_z9$01KEkK-iG7dj*Us zEOT>NF(y1}H)%31ZYr0`R z?tSz6XIg-Zts@eI8IXg3s;_|A-XusqrMg}Irn^A6!r6?7DF}!7DIdGX+abL z#@V?9Uy4}t+p0g1kc*qKJXcD8iyMWh->zxI2sceB$SjlAi+SVxRdiUoI<-b7k;y36 zG4pAo;qw}IDR~^Q7T^xpkM7^vN?Y(6yGtcr&&H%&=S8aFBhc}4Z8T8_P8TX0`grcoixf;hIa=r5LTXfhVB7#p&(?rDW-pO4**wX)CXY_x+Trsdw?~7yhA%N#? ze(h_RnYJe1sb5Xl0fE7(rmg;d)Z6yB#|50z(Oj6%y?D-6MFUiALLgr*Ko1u^<7MRh zzLw5v9SgD^RrFH*V{gLY=Mx7p;;M5 z;3loB-b%900B}D|sr+4oFG6!*^OIdvJBMb{icZ0X*h(3SPXg%#OBbXBp-iJ>jf9Qh zwa$BJh)jnm^_D~xWA`W>k|Biq<Gp-X=i-GQpBwX+ zu&gDbol}b{{yB>p=+LFtlNXz!Jk8>xVb$Kyj<_twTUvMa@u*3?)k`HtuYR;M#ZJt*x!!hlmxfVn$I*&p0OV zN{cAzazrAL6$H7IH5$_e_Ln1c3f-4EaQZ4mbFM^!6;sdOA+Bp@&f190aNxZ`oM9eb zI6dcEgZenu?|Ya+1lB!&d5GFCG5nR^*~z%!1Bc5`7&&X}R|@JN%lrGsf&07~fnRKr z$r^X)l>*m0@<^FdElj$S)<^XLJT?napok@QitkeAYc2jt2MF5xo^mj#ae9B-&4V7h zFRtHF>wm*nGXJ7B;!KmB&So&OX{m`vKkICENZGMUO?wnmc1ED9APMSTpy%-wJdEeVYfL zkC?+nlt=dmr&BJz*pgvDQ062i1VatD-Ys{A3{3_kgbS78^o>s&tsIh|Q6ji;PuuxI z*Ah|2&@~E<;&IxqDv~TbNbu>1_faS?-k~^hy{|;Iur?5tc_Hu8Pn;b?PAq_7+iNbx z4<+?y3FMF!33lQ*f(}#wB_bK=5KA6e6azn!GG-$j3Nk{L&q*V?76Inr0o2Cl7Z-B2 zeyF!-v5%F$q|aYQIAb4BB!ky`DZS%x{pG%;@y5b1>MF-(Zh21C8A53Fw&}VPx^VWJ z0t0BkS=T7g=D-&gxAzsY#ZM6-3cHl+ zk}@ufRw5lE*`ApV!+3;oK$Nnp?T+*|CVp_c(jT!>cB}0Q>%$4-bAKF&9`S= zDy!sOi{g!X(MI@`)PuGoI2F@m6wYv4em2ny&rtxLaYthT+^)Ug+n^g-m!my&FI+*% zGVSG%-A)8Mim)YI?7bmJjb~%yFD%auDR;>X=JJM0O z-9#?^QTXgg^Vb)0bH?dYL!8sXT~*CxqzUh6$pJ7yk@4hw5DB4nS%0>ym!?e12O1X9&!W^FH?d?O>mc^W%Vw#x?x zum#8o9@z5I0FqIvo!4!@#rO~Z-g=g3m&nFSxJ z7)4le3H${63*oeZ2AU>T1DQsu;p@8Y$%T3uKVdo!1CcO*`Va;3T}m*i$3y@R^B z6t9}DhaTKo9#s4&8ud657nfP0iTJqxEXsO==>~%aW-(g~&wc;YUoX?kJ!8hTV#Uj5H-?d8jJ@7VEh$xIm4U|1T)H;?!U z_9L5#4^|rw)LJEGg~W@$m*|kKs_eg}mivE4 z0iI>DW;!TgRv$Y`3VG8Wd4Z1k;D_a&wVdkTHmFvhMLA`L2g6NEkzr?Pj+gV>$DPedc)sPa*~yj`NC)u^k;&@pA#($srR{B=xvNYCF1>n#EV5rozFR(tN!w`&GV3BsH! zUt=p6q#^{7u5~4Yq)XLRir4kD{)pu^1Ama)b3WJNFC0vQMqR3#?z>J!pydRn;;$S{ z!yBNvw}L-!c`d)@y$Qg>M-+seN-hC_0Dz3RhJ+_e0verzshB2k z+Lw=xx+1303XHb&#aqCK%2$XO(Py|`ZfQ!Q-$~^ zEIkFnCZ`A^J(0DUYNtUF^4Vgj+IZBs8)M5Yt2FzVEBtQYvcl_9k?+O8 zJS-5CzdaxI?MdT%H4SdiUEZkXrrB-SH9)SC`Z9G>EMVF)N99iUD-H-H6S^$rr5Ecr z0B~G4Q^i5O-!RWKk%9@sAuYKcBdI#xw_;h?lA0z^RHPK*GPC)Y8Oghd5@v?=BEL|` zRVrq+S&amqld|`=d7>T3RH^Z?N^e_=fWK9-+Jt0Oe#_p z=49s%WXtSTCcqJk71FQ+S^1;`(4W4XT*&Dn8mZ*Q6j_bK_CC`AB0a}v>!y4a7p5UR zg8bmPhljQTk9xvK2j~-Gg4dO{yE6^6Up1+3pHOmS*^r2YK>Ri#UqzSAI>SZih{`kq zl*nI#@HQHIq7V>{+O?`n^fw;bLxcYMz==}SN&m?*sonKS^bOJtI`CSzoqM~H4Fdq~ z6cq#J7DE><&Ff1acxc{HsD4|rifI~4kqas1#Jp{=}%0l zW*Ao9s5vVnJ48BWXXUI->jDAg4NRW!kiM`mze-B6iM5VqPZyq64v+sBPXFK4);Gol z^ni=3erVqUDcTTfdiP#lpMjJ{+X)tF*4ee7EfUKk0g*~Nc+0=IX*DaLK#Kp^uRxQ zwD!qOQ-eDt#hlvB_n#HIn8p0?)wOpQ#L(#b_h0b3(SUf;uGOf0(!{}kADtI#DNxKC z1c{6DYQaj_gM^I&Ds$DJX)5qoUvc@#ltv!t z7J$u*F6__l)QWGH+sh41Rg_)cW^XC)A}v&E_drxCkjnd8IK6JwFm`F&vNT4zbm=a` zRiU^4<8|8<;fS`Xz-O0Fq+M3S!8vewSSB5f#9pUfHR}BY)z&1ne{w?6D5j&sXn*3njip$+SV-w%kLm-`1*(}tMZ$vg0|z);QGJXJ z_^T{y1jnSHz;2~_W@Jbl&!0%Jkql$xD(zKVxs31h83ED#kveZH`yyj(l}n^b?z&FSgUxj+z3kL&eVE?qY^pUUB{A2;)wL| zoKqQ_mSh}yP0Lt~9G-XcdH}hUHs<;Q*{Z~``e)_hyI{ksU*#k1lnU#DJa09Z9`ML+ zwR&d@EBz|i2FN`iA`#fjhtYn6VWwJ2ozF1OlDkTM%uXu|FE-`)13j-jS{pv2-_GYR zb91YN+TevgfPn^t`Y`AMuHt1ej@f;SpmEyVY>w{O?+ofYUH-8P-7U}6z=Z^sE|YGQ zO#*z;ror68s0|l1rR0WD0V1rY;Vm>e+ni}x#Zb&mT`<)3J z?O~hvzVd|9ROA4+X<4NNV(Y zk7CnOrR%x0pV_uu_04?3QGwhG zZ^w@fy(Q7i!S6&R{`p6Frh6JniH8E70-&5~79G|tyoKdwCNpa-&Gs|ZV>()uP}{`q z47Oe)QFa>#)3v>Mjf#CSI%4!J+5{RQ<zc?T7Ud!AuhPBX|E3~lxKX8E<}dtS7q!xPgKG^o)H zXFhbH${vW`I?*}l0y*y(@G{KF@uOu*Mbp&=%}Dwy0wN7oE0m8;$xBw{31&+Ek`-Xz zkQ=AfWQ2!LH5ixD+b!&vp#J$Q(Cej!#)NPRWFB5J01kvi=Rkb&LJ2Mc==l&0y$NZz zK*uCEZL`(u-wcS7N%SOqYYT?bI^&~S8Pb%_6I375wM?Urwp3~z1F(`(lkYNCjun`3 zt~c%HHI)(s`4_ulD}8aswop3|7^XiC!EjY!cl#-JDgRec;rX}f0R9ixH!G;#U(Dqv zwR?5ie?R6^+Y;eg*q(BvaWb|hRM8*rpQoHl?-+lhB^oF0arqSU^J@CMFmRM_PoqoW zYtLEVVB0%neb^vMGOl6?>@B}oSLkO#z&6z;uw#9kh(1w4l`q_-!sdUVmwbisu=rY&6Z)bEJYnktQL(` z5DV38_o*q>7Jt3CNd2Hn38VXSqE<-mCsxOgz0|d@cxG=G7fb@zsa#rU-7W1WZ%Fw3 z_=1fDa_I#iEa-U3^SKvCF-BlWp(=FvZdJP^9*DfB(>6h7{S*52mC=PFUR5&7ohmB& z;FPg>P{2mOX3y(0+aSgAdzTGPLmgL57+-!v@RQeNr4RJw;0Ga#IvxM5cbAIw!4>52 z@q?G6F}~ymAay!}M4;F0UZSkUg9Bc(@>v+~W(X!eEQp4({P@9uIR4W-X=WiyhTihgmreqZWu!&=ZiCh_y-nr zeG$4XWcejzcX;%SzF*c2sW_g41p5$C_B_UGwRa`SS^eP|WYLW!z}Bv>0^@_j4VgG< z0bjPV?8XGEb(fU*J3U@>Ge&!KRT-w923KF7cEHpD z2(M?MJE#X~P;xK5;s{`-+PwyvW_xfye1TVb{1O5CnP7?QXb?l-^K^kkILZB1P+0lI z(s?~cL*iAt7_Ka#D!LT@)up1YZnPKS&G zRY8_Gzf3P}VV+oU?Y;?s4RH5fL>KUN8U?Yz*ViZ_XU)TGkD1_|hS|go^$BV4>gUXf z=-z0EfNMPJ4mXYgH*NnUm-XeH?MeEyI-BdDN}gP$txbrw>?#v818S$aX-=={68HX+ zqaFty0u{C9UwbGCEN?HGvD|*=VuNPX*DdBM6qFVx>N6p6;FYctMCux)7C#xhUeY9* z#Vqzg7w|DSCZ2dL21EChcrnuN&;5g5_+L4FC;yW>lpa2#LNkWqFFK?}6~Lqd{wL zL!~U_<d7!s30D<9Wgh>adEya|~NGchv065;b>y5dD|X=nZMmS&NO=ZDdqv~(u*fdZEt zseeCdGmMpVuCzIoT6$r}sePb1I~3ST;00YryqpX1Yrod8Ea~o71>3BTeA~jEKd?Y$ z??bAW3pg`%loUvWY;?Lq!VAu8{LyB}U~L36R(N=-@Qx0NXId=UiWjbA`&%^5d-6MA z?XVQ(8TVhZ26@WV5H;uUY?`cm67=v@moQDDbt=N_x?HDo%fy7*U%rBAOKzj_$CHx+ zb*$FNCfLUzF*Wa%<*)!@vG&0%CX-DpG=APL*E{><|8}30{7>|RSA(2Jn821ehpqfN zWQ`x|aPT8%j`*zeV98}d@QU|!rRM84EtHcw2$g+^cw-+PmoCKxd9$9GzWeL6F>bz- zo)`F@YrV7lIi~r!$4PI0J}84j+rThj`v#vYSBL)W`sBGWgXtCdoCO6fCUwcXr7M#H zv5UgcP94vcHzm9P#_$I4rXId}Nh@7Q?;!S<8kV1ZxF$YT7&Dl-%5qW{ zZnC$-!k7}?;ToDQ2et`l+-Aje%qig0;6ms2Iyu^#B>3Ug<&FHQGZ0n>x*FE!j+%*s zDRA|^vOyZry*8I?lez1wvm|a4!C?V`ao&gsal`y9h5Rwm7D^EsJhj-_BBD!LwIM{X zLl+?^s%~b(_V(6r2W`oT<)BtP)KQ|ho98`{@{=V8;MA=D>dqO&G4H*NXpjIMwX#Vq zr6uKfofLu9Pt!L#_yVFCTzW}g7lOV_qkb53?YdOSEKD$?`@M6idwsq9C7D--Fg-T} zZ+{V;nECHlCf#xh9;QThdB9${$B%5k{2}6|YVx7B(}ZthR;q?BFoNDCP~0$?#Sg=H z=ij(uZ`*Dj1ps~UZt2gjEhnE4T9QglP$WH-Y#>WqA5-N#h(2WyDiQ~ ziA9c>e)^#lPky`dfV$c8bRa-J(xDQ)qo)^+hRit*NwI*_pjLy<<@d1;+Cq*Dm5X?l zkb_>TX5(szB(6~t7S~#0awtPla8|Kgqr6C?N#_fApQ!_LE+Mf&6V^|@bJ)kBq&9%| zI=#sj>0X*qIp(DnzTZ<6X3r9k3pMWdx8vabU;jIOtZZ)2$b6FuFNhaA$H-+@dRW|O zxRoxZwBOfdWy9}WMj+F!9i?)(ho_H+(A(-xWj{fD>5igp3fI}rbVT8&6p~akYvhO4 zO5fjGJ=?ufUk9TP%ba?}Av_xqE+aZY4Ee!8;w!m0)H^|!=!4UZ$8q98%fJ5Zjm#t8 z$UbBxfpbd8i$WXH)KTm@-cI(W!lU+P%(@71_3^|@mtQ9~Xd=Z`rNlfTNzumT!dP9LKwEM?wXk$5`H#E|uHH>bB3F9~$h|e6XEB-Y zB(~xC3h8+sJb(B8o<()%F@qgTL~;F#wy)EX13UCD0wC2l9XfP$bbOF#&GW4-TE_DB z_LYMeLw=*VmmH;+6M)$ZBW>9(P%rlJGi}wv*N)%<*Lj6={#K*$u`yY>fq2u~eQ9A$ z=n&5RYrEQkS99_Axu@L~)ej!g#iFH?%$!WSOT^}K#4(IDB0~HrXsxCn+#3dkiZHG| zuZnlK#~l-tFE}QveDgCoy3wSKJTrI>4{*QL@pS&iX2ySu9o03*bQB1}Ir8H~t6%s*9iv)F9EGV`SWNDqP<^8V>ODlDdCP<5J1 zM89=;a$R!da?WuGtp8-@=u;b117r5V(tIilofH4byTBAW{ruFQr7D86W&(2*DO-4H zqh>i(W3Kz+H;OFngM68Q?;rhvhNjJ`Qr+3AFcn(&@>=W*Yb8`vsdd^rzunO^G1jn? zf=4(X#_6)sL~j+rT-oy-0=G}o&U^i4`Fx`ry}l_lNsM5CiyRM7@mul3_`8=^=!Ye+ zdg+=t$Q2H6N7Od5qMWocZ)>Yw6N(T}m+=sb*Y!_&d{_*V`5zCJ!Zm8)>*Zx!PiqSP z2T5cvJWC4XO~3fz4U(JUhbe_#jPH7+@*`1MXsYPAjj#OIs04 z!sBREm9=6fq^=??-wVCZF#1F=hVU4`Zd+rA`elM^Qw8 zn41seVf_0Q3TEu`k=6Zydp<+efo(kKr!WSzP{PFE4l+7+SPw&J z=M{2Nb^-;b4{A7z)udO50{U@SKK*yVS2ClxvRVN7uFwmb2Jsq~rsV zB5t7rSpoCW9UXgVIURfBkn%ktZvNI5!>Tu!Y+PgPLCaZAB5rijXPIDRI1+|CB6qE# ziIHU*xxGyB>yL7Agw#YI%t)b7d5n0(YzIBmf5hJE$3z742^Vl3ww_r;z>nS$jn=a7 zZ$kMmf8|gIGETb$q{a&nO$9`n@mML%8$y=^TNaRM2`aVmEKt92xD;R&{r7` z{k9!|a^9z4lIGJp88fH!I2H8p7AZ*1xaEe>fa^a@pUc$$`7=5~ts!rlXxmc(6kuzF zAC^g2#tGcky7FX(*E_T`n)f2Wb6MqhliXm_5{Nv0K^#*%tjMHWLmkoB6PK9+@krHuEMJg)ZvdJ%5O z7=ZLsX5CTNM4|}Nj(P%GW%Y{n)U9gk;vaBjDR?vS`U`!y6#P?%gRryr@Vy`v$akC@ z;O%oxY@g)b*!7nCo25B;Wu3qMeo9xxFsZ?1W$5D>)f>(u+uqN(?`7jP6>7Ig^?Hv@ zI2jdJ3_Qn^2C`>XXjM&C_Cm$&or8nOo=oo3cB*`GlO75r)33KEbv-MY;hk-oXAMup z+8zdXf}h+Lx;gC!aV}A$YVph*g?x@re%o67hwuOJKfa$YvbFMwKMP=uJ0`%u!mxB0 z40YFtx88h2&}kX;+o2p#ty^IzLgM)|&C>!O(7M@Q(_cvP^#CLtDZ;fbA=vVH987`s z=SP^!P_}u{wKGlV*61Q*>zDnWLTho{K;`bmkF|mWs)OC(!pwISj1#XLvbKQkQ@(?^ zG0W4gnr#2b<}j9+s2Tf_h6Xs?*-S}1n9?-uKlusp2udwz8zCmV?GuwRmg478iBb=w zU!8rY^&JV=EDR}ti!)8P;cNt~Ee0Lht5q2re_QY1wzds3Cw=EMOTG- z41An+jbq^a_8REE%8vKmBjTENfsRTugG+7fxhe$AOA&6Os2tdxFwaL+KGABx^v`pf z9x$9}<-lzs=ds)`b?m)bMKcfh)!YqxEcrvZ!}Xn*j@|^JA(Au+3_P&hyTfHubPtk% zDK%t;bG|FcNsHRW*1x!2@+Q{t8y0(Uq{&*3L(T3`XqrMblbvJbtG&pPj^?F^Y!ujn6e*WHj+3MAbdAtC{gZkPM zTedv3n0VJU;;SFeW};w$G{h!hpzdaJto9QH_*hAG57v z8T1_zy8;nNx{07Tns(K(#hxoh9hHt5h2YE|e#DB+1%kp=#>SD62dpdd4s}}EM%F~0 zgrbsX@8k&?o#=^Qw3v;Ktc?y&`0u&PP7S6H)dqKGi|0lmEg~qWSsKQR`${Bvu*<22 zTL0yiTK>l^tpnZMn%{cXOs{Ivp;r;ft+jc=VRx$?dc2945AmknBYh4#T}Q1r1MqY_9fMbmtgn6^)pk6wA8Es$e%8y3AG7N3pEMZ; zbI(g>#1$Ei8*%ro>>YyeneZ$YVOLk9%yQ}=uvQzfD0wD}skt+EWlTi$uItN?2%Ggt zRO?JC!36~70Q#I@XfwSKfkvi(I(Z+=_|Mndo+5d;@3D3D(i;t*YL3u@%}cLnhBam= zm#^d-D{f&{f2Qt23xy%j6Y2*V`b>3>H5z<%iH`Ig);uQFDSAMT)0KTKR8?EQwBgJT z7CY_3{ON~;PT7ICNBiXV#A|)0UNcD3GJw8^8j)Y`@I)0Rk*BfnLYf7|k8k?}>#*rg z4~}=$aiu?yaOXcMN$>yPTzGpw@Nn0Vfbv|aA;TQ$BJ9RLfK$Bg_KM570$tpJH=y&K z_uDhhKL8Poa?-u{Z6APcN{9vL0Y~z;nueH-x%pWoJ$;yt$ABgc+0Q=-(IPNP6$|+C zeS;}J%J>OOSxi7+ni+&`jqn~wf)EoE8mNgQy7@IfGn7AZEp~?fMLu!}%5(uophnWv z#~-V8c#Z(JXhC%y>ds|=PcF)InOf6ciJQ5CA*y-G#S7aLVZQpZYO_JpH4XUXDj}X` zEG66bBf&|F)J1Pr1j8Kj97os3dvi2R)02E;yv~2QIIoE4Evg3q(=g=gs94qAE-i0& zN{UK`d;<;*x^H}r7ozL{E|2swo|E)HlsrP_t6fmPC_WLf2^u{U4cacZyTtB3$W|+; zk`c!br3{G(2mErBsGuz@A_%tnz}Ic8@Ln2*#x1zKQsvy~e{fRHv5!B+!i*op(|aa% zvK|M+oYr%P@@|GSYEam=`|!f%2wa?o<$<60x*Yze*pown+*6o(a^kaz&J-uEI^F)y zDZXBTn+$1;5WEqBff^`6OBYuUxoF)gv!q2xuRWexYZyT&BTCJ=N8ZPp!9hALCfE_G zc)#*Fe!bDY*D{ogyku}4tA2aGm@#Jo@q!x9a=9iGsjwFB7XTxFIH@b^xAT`fmYo0a zP7(j(ojlXAeMj#$2o{xu#T=uQh5X>!`cL;=kac$6%z~_fGA09U@!xJNuVlF4r{bZL zo>LKI7up{l)56OFaI?h(dF#K6z&_BW0-Z_3Y){gFnXbd8#{RSi@v3;BU=`-b^nIe`v@OVsV2t_|Ks%rJELFtKFPkvifQeam>kHK+4f80 zeFf&wEPxS|`uD!PD5_n8Os!=^o6*!9K}zONXvsrDZJ1GSd-JTcVQ?nWquC+$%Ueho z%B8-pPa;NNJnP+vq}B5rIJf zT_>PZ^R(acq6>PCxZ%vm17-tcY7)3Q)ZNLfd8up38X2e<{NRiZ#OWZ`Ih+bsTYN-Q zmaCR)H|{ulOI_EFy-mAf+FN=~Z4mguL78d^oWYDloW*~CCgyCA%dyk?{ifyf3|y~& z60%vqrsAV0^+M@J`(i(8Rn+iDdI%&Dw+ynY&SV|acCL~Ai%w|lDUfrFR%n)Y`DMAb zcxmQx28Y^kgNDCd%NC<*b_2gLQR`ZZ(d-Xb+Q!wk4X=t|SEEhkb#>}r%QQAFa#0sD z7PcwFsj5lK@r8xv_gFXaU&||BW15`4*05+6NQ?XNZ{N0}oPWAf4*zE>>FPCan>*NM z4AzD6onp`_L(9uDl1&710^Ii(ET5j<%-=`m;Q<~9L)7SYge*tbd3>yNo?n!NP0~U# z?m?JemLX*!a7hmfQ2t0YOW9P}4juz=1}#ySr>-5WHh21MunbQ?1RfzPODD$+J;16D z>OqP|PsdvAEl_(9`Ly)EOHJ((vSJd0bU#i`C`6EgrC;5v*hsB_1QMaLSn$8I)JdA{ zk1b-v7>CoH8P5}j5_~Imt2F{*t%3~bSnUf=*<|1aV10VFJ&P{!f^ z+F#ArlG>A>iJtS+mwgx=w^d>66;Q*2%f@`{e2QoJa-N+fV0wGoNq8VIr3#?;m#5aHmNPT)Tn(Tn(h-upd#)H?|D`M<)nf-as0P$ZQQVI*pvp#l! zgjZ|6)4A#&k2<8;lGo{O(DN6H(ZnpjWSyJ+k{UiQuEMEBZlq0A38;~VrGPqciRzUB zf;Y~(!Z zYQlEF1j74^Xhwnq9{{a4f0)uI{HQ$vJpfl1A`uP5ZKDm-&_EG+v0pe;^=whtm4hFU z`T4y_Gi%;|tlbnn@)4V=!%^B|vH~;E2;cc-Xn08o?l(uq{PbAA{eU@0MB zK#M-xizAX#nKq9^(HZr)wytF0y7}?NEQX{bV|sw-KC-OP&zvvSpAkrMUWBni)wdGm zyO?;1M6Qucz~75tymg~>!~Y>Mp3gaDi0~c&bqg?L!JF~hu#qwN!yq)=I?T6;XZPZ zAT@QkY74PLS9RQF6m}6JfQPpi``mfp+2!mHsn11?d__Fx5Z)1G(`E{SH3l1l7jLTv zpy)#@o6dy>&;YlY`y0LNXnMz@$kLOA?HYBNA&EpX)U{VX$?_q#%GtZ=kAD4b%QX2U zx?6KaK5DjxrltHY#g?S}QA!weL8b|ao*zAbA4iHF{EDHiqL=e#pM2j%3)X9J4Yh11l~+LHu}amB6!u2EDMYc={5_1HWZ#Qr=29)>9Rd1ad5!>Z&wAFZm3l@ z>rknKtEE7^|Kg2*acBU%O`V}XsxX(eq_JYhp5G6WIBMn|Z*2#L$nns+{_V1+zeWaz zuQE6={uk+?`c=GkERLEng&C}^-sGk1j)g~-Z7X30wA0_O$H{s2Qi1eQf?GOfN(8TROE#Vc?_3MR%Wb)!-{u2vki>_v6mKCBYzf!;+=uM zTdp8g&u~YZkW}5v!m=DPYvl|fKD??PzMaefAyf@N|6@B#PMqL%qu#0CtHA1wwJzqe zB&=k7+zQx7gdmx$hsPgz&&+qR`abcGRLhOdmO|SH=u%3zMZ3@SjpbO00W!GJ`?v|# z_M)eYtGWoNr-TWbpU@^BY97-^< z%yEscCKt$BPAR;mqK4NmiZLMqypsf}ShOfCN1H21w!43%WC@Xi?mUX!TLM?ZM zDq0+E@JpiRYB(C;XT2iA+M?ss8Gm{* zCt)?NU~m0x+w3A`fBK;KFoCn`w@DjlTt4JW{Vf6w)iPIZGvYqcBz@HcT|xJrlbntt z+nd>Ojt0{Ew*3P*($fY6nd-P0nmK2GcTW1HX#>knQV8R=+l63X1Q*NX#2lR=9`7sb zwFfR1lXZIdB9=3&eRsNZF42koyN;Y?s!mOyLrU!jt{Px-IyF9?I_h$iechoj`c7Sy z!2*RYg?-FpJ>aJUE1jQE#NYUka+g@y7MIv)AbbXp+p1pSs^DqCvHkL_qmA0Ec5L}J zYQgns(0G`(I5sE;DnR2;w((6KwN> z!&c(AII|<9%SDH^aQKLa8U4`ie~}=i6j(pDYwhiIT?y#oZcbUIv0|M>BsqrjU&-7k z|F26m>t8q_HtW820Q3rq?9O)2)5kSMMakpp?QunI_mz{oj#;^%tTK2rX1L#D2Dr~x zus6YcEZ*&6FuqScwdM%Nt1rOCEwFE-kSEP#Wr--NWA{$(=aLOIQ)fhJ+b@UpgyCgr zemd+m^4{>j6S82~7(RM@N*&$X*ffKJY<43MyH=`BXe@)*LcaB=_RkyTQgQfgBMe=Y z*-MPRD^@`MmKto+5qMvDBHqLiZF>cU=sGA#>1m0bxiox+2IK7!?Tr?ZNmwB?6FXrN zdg4}IA8;cZUVnbYYApQ-Oo#Rxw#L7L9u4gk za&vi_U#mH}df1M0Y<+b-ie4r71D--F@uM>{Svg;oS9&tg!p4|iQpimyC6`7Yf5zL~ zxzW5I{TqO8+sukYVBRwFmFMCenkMQF9n7H|>IK~HS==oQ*mj*fz3Zk#TMh1fPn(Y$ zsPb#oR_J=^3Q?WshAw_?Zf~H;70P;Wuel2hu3E$0`r~t>6Zb^Bl(KL%aXOf8qR(|2hYN(NK-* zm+S>m!DOvT9Tzv@X~;>tD9CL!%nDDVX=S5TOPZ4JL*1ecBn?<=ln^MA+z0XWGBdh_ zLe?VFM6&sj3V@)Mv?F(AM}Rh~a;2rro@F>(#_lRU_gzqx#2b4>V@5&`4>h6W1yPiD7CWLz z$}7(L_Qw_8GT$^cXlB2P;!n)T!)nkoy3KEJIWk!s5NpY$C@7wN6|}=oH%hLfEV9xo z^boEObod2p#-Rj0%h))KPH6CC)u1SSH#t@$1dI4ro?s^2DDS5Z1BlR}`L~i)({r(f z9QcG%N)}PL-fqDg(7-^tN zUsnEa#l-!)VuD)d`g6lib46UCE1PR{nore(IkBso*Eq-L!R1%I1$T{Qqi8qX#Pm7Z zj5KX;K$G*DMaGZNt!ebr)xR|v`a_(2rzEh5MtSPH!y7c9 zdJDj;EIuRbF$LY%o%xR*?-orAIGG&la>o4eMb9JwTJ^iEPPW?5Aot4_QR&97saIgZ znL-@LUE-84e8!?lVns|BCJOe!yc8P7!(E4SmjsWQ`AY<98lHrWUpU`i`+KbKdUYYA@hOaBbLEZK)?@hfg z56;dfn0$tKG;-H*_7m`Gljm7dJu>S*OVyPB)3OvaiHJMq3yuUqY}+_LbEPE0oqGtg zSgswReUq8CbViQ$7Z!lk;JG}ji=0Lj`)Dg6R8mn(xNyQZtbIJ3EkrxDISwzXhsmT9 z)vRA6BZDR(d@B37UhCz^31Vg{^_BN%4xl(4wh^De*@7qR;oaAyB5U~cba1=fIMK6U zecc8}H^zrGvB5+`@xKJKBZ-$uhK2L(sCnQoSyr9FO|xraM?N)YE>KK1Rlt9B^G30|4PPWCbQk4jL} z4EAxKAsGA+QF3Ey#aFevcDd)_{9x&~PR#IURA}+;{o$X{R6zFodH#q7k7JG|?=esA z19$Nek;s?qOkdiokjvUf1SO7iItMyW9n``~Sq~-(iSQ^(JZ=QFlGpCqybS1T!m~L2 zrGAyUC{!CYnBSwkG)UNZ;?8ZT%jc=fh);2xQCHIR0{O-C1TB$F>|EXLLm@_Psl=(u z?#{0TEl~6^_sJCJFzJ0BS{~M~uM$yWdt!{m7nj^%ALYV}OUp0fhboJoi#tQhv9i)S ziqkiL`!EgXo^QkuP!f8JwvJvS3TFRl&!PMYcgT}vQ3bAveC(0=yMY4gK8u2cmGv5dHzecttnKej+$ZZ^UTEF1x(ZW zv7tBth=F0^PoxbOnP;SY5l^Sv1^{b-Q)!~XZvC|33m3guFDHleb{{lUHB^%FLDf16 z6WQ9LH;H=YE1=VhCseHTNieTZiiCz2iObf?{m6saM1;&IY4RGc6+*wjI`VfTV65Z( zT>p(tauKGHPw?q^_wunLF9pa5WZmI|Jgb@Mg%Mwnz#?s$0YVmRI4I--0kwa3K&A%H zKO*U}@Gf|DkwIy#vag~ca|!Vrg{_QJXj3r+q)Ch?Cq&j^d%Wa97xwh|*}s7#h(&B@ zql2+i&8iY)Ms~5=lqnVHKi?d;wVAZxjcMTb@h)mdrraWpLw@yU%VBB;Z`50$8!ch@ zMRxWX`ZnF=LlEgsOXK)Kjg=VSE`mEqb_O8GZ^RQ*o zy0$x=n%~WQ)2r#W5O#4@SO;7GB;;cD6Q7G?1{htAyhl*w5J4l$RN4F+ z5!(Cg9_ms4jHEt&B4VPLNE<+{RsF#s+{jc-JvbQ*m_vVAMP}2`O>At8Wi!z+7YC$M^bUNs@pGt>gmt>z(Tzj;JcEe}fju|BV+XS)(2= zJR`R{Re;VphDtZ%?~9!bV~L&6OzWD>^Ig&~;fv`@KTELIu1DF)1=hhsbl0b1V3qeY zL3Vp3W1#qx!=YV3)I7x&gpS1$jyZ;y{cB}ok_nF;>e&ihM zv#lr*)`KWn@&xJS720j8BHp&Uhr?M}R`1co9rGw>oOq^5K4`LEbcUgu=_ZBi`Qux5 z9{BGff%MxUo8n=;vT1hQLWfYO14JkuWKlfco+no^z8#S7rMBn6jGvIBiqAHAbeg02 zU5AZ7V$;3*HP3Ji?LNe7bzo`|9k01#oVGtou17dMTs8a{@`4Gjm7M; zv8e5|Hu7=zZF+859%^jW_Ga{h7@~DnviFdC#;ok3lgFTHOZ*k}2`LxLW5I_D z#!WSEOfGd(#^T<(MBO=QP)L7Fg&;@g5@8EEYIv*L8*^*CF%C#!Es(0!w z(AhDRB#O@#q97L$wh|q#n(>knw>Bh@$!^TO0yH~`A)Sl)fFCj5s`_Fnf-e=lK69ue zsGVcXzjj4=_=yV*=fjYk!iAt`O3s6nPhXs|oLFN_;rx|p zq|=_D!PR|Ev?0?i zS>&AAJ)@ND@g7TSs@rfB+~OSE6A=NX>NhjavW0(IW6a+F*i#nm1a z!oZd4+fNZC;K17VcD}Lk+vF+KoLnIsqf`7J zOZRg`4;I;!dm+Myo@YzIjXrJugc<;==cDhkh+cIFiBpJHygE)T<#poP(ed$4YGnDk zo7qnj5eROQo~P6Ho@7eFi|}n#s;1dFXdEkF#^_uaP7SW-W2?RcT6QRRU!wzbPAUjU zAd?+_`g*PSQIa@KMYy)CucQ}1xRL;s+%L0efs4hwfJe=PiN0mzzX2UkQPj7=h${C(25~Gagp;v zu*Ow6a^TPP123S|fSGp|m@Vd58`9<~72IS5iSH}81M{X;3=F=GF8}Jg_Vz^LAGkC_4 zIh9IaEG!*8c=m8x9|eVr%qmEK-kzLj zz-ozPBV)4k_k5!Z)hB3Ub!W}_(SGfstmXOl^h3GPgrOd5j*TOe7iDM0yB zKDbe;As}qUIaXJ>N$ye$Yvj9zr0r;Gg0K(@rOur2>9vElvZxdlvK_iU>ICM*&9eYB z*zp3%?aqZnsbO>uo@jMAojl&4>m@&wKoLW%<_g`Rfjc<+pN3*N{~Xb;brE)xJ|~KN zcMwhB+MsI0v_;b@oI1ZgU7NW2hN%n?+GIQ?W_Zt20JmiIepVC0f6re6dT0M+5HE+&W9a^IcEG7T{>yo2Rs!O>6W}9)zEYl{pWC^YA;Q=w zo;4n~5nkUB?(mCX&6;&R=mNp-!QqXKaa=*qC`C^Ul?Z$$V~Eg{u;8-v>DhevsoPq) z>gr2hb1y?7hYVRV!M1i{4hq8wb4d!8kWwTC7ZEKXSF;+Dmhy)X={?8o&;~#HA&$Nc zhLLW@?}6;_fAy2fQ>S+=RJg=-jx{`)8Ti|DG zw9V1+dpwQhkNaOo%~54CF&gID0@jry{wy^WZM)7S6xpwoG4ohAI_8x_rNn#j2=8M8 zQ!SM58i~p)fUq7;^%~D_s&lYqYN=8Ew0`gaqhWLOpsB`CkhxaP&2Ha6JHVa)!?&T- z>bm+ipK*<|A;Wlt9iE~Ggwa7FvhgSCN6~GzeVrQlG|D)AQ5c9cjabq!{-k&lmAAZ6 zm?ur+c8B8wtqEzUhd&!s)4&>dq9T&UiK+)p*|5sqLkgF2@rT!)~u7jSCcN`{v)&Pj&aV1>LfB83Q{)r zU)4^&V4{fue#rIOj7iA_9J4hzQWqZsY7~v+@lzAB%kG#~WAx?cZ1!jy*3CZ7y_f=w zq?NG$Z5X8_XysXT1u%3b*=lXA89qcj1^1J-G;_Z(X)OQ%rZ!i-M`2gKNE0nNH=U-O zry_zo4l?6HWw%w|EMKU$VfNz3L$5VG@+EHEG*9FDVz-amXLvl&BO;Pw!3vM9H?h09 zpC`!!gHugF*j=Z~u4~qef?zSCV#X zmHHH;kq>W^NF58CuEUGRjN%7=^Z~aa4?VF#K05RmcY*9;R_dYt%R5p@lsQs=%xM zD3I{pdhU!eXQf6fI^A~9f|+lT@Xpq9N?l(JyW7|Wl^<*XeB1cqL+?B(5lI9{>dwi* z<6<6qHQlR5KWzG}26Fk~*VpI#eA+2&U=8+B*p-)mY5m50EqDFlGphYM4v{is<5XW4 z)#mVOwlhcWGN87sw7g=7Advsj;Zy-OyWD5STUofqP)3Ep2j^|dcIN);gQLrx_9de2^A0{v(Y7#w`fMnAVHJY{ToZA87Kb zWP4%A4tKc4H;_?K#b?S+y%%w(Fs8@f=lZWp`9B_kPE5$dw#{%oc6@};ET->NPFTPIKG&}u zRZkzfl|BT1)sm(|K`^2L%6!ailw#Uw*Zy_bHc6mzN%x?YXX-~U#P64}T0bWWBgDkV z%m3gCmFGyyaj5oBPqfzCIvVbV`q=e)C65*At>3Xro>yN}qC;6%#PYiGE~5iU85z#^ z1$h^9Yd=-clzLb`4^IG!Ryyer(qCn-es5XhJXkH=iX}*_s>zTtfmI7vrZVCrN~{xF zzfp)ear}`vMuu9*0HZp*3m*cx8tanBb=!QMt|ybI?_RqYccH)iwVTV!aZpdqAGgvoF{%P84@J1sRPJd6PcI3?A zWY7C<-U?U>B_PW)U#;(Cq}#qPj61p+92zoj;K5$z1$Pr&6BV+`lknagn(@X*uh>?H ztGENYzhxltJhWo{9qIc{?(je~p!IlCK?NeKg6Y|n_V$NmHhbKQfUfFg`xc<8y`m5T zGrryUg~^YPd^eG<%UihR%0K@NJ4pU7?BFDR$O*l0JQifJ>ysotM)PsbzAOTaN5P|a zcO7nYZ?-Q7ZCy`OD}G-(@5R#%JC!xg3;J5y7lT~bl_}>pZK}C{jrIBrmMs$9i-r$w z`8u9TM>Ez9GtfO&z`YY&yQRG^GWa=2%^uyp=`!=~99|2=$S?x*4|hN9?R`jM*bD1m zPn?}zd{~qQKaE3IJ_5lgK5M#~8zKO}k7MRv&DPZwCHfW~>Ksy2Wq}8h9>(tHou2+P zou5A3Dcv6JU|s|expZLqo30gYaK?}KG2%ZP@fuP8zJQPt)M zk@ra`mh@t8nQ)oiw>E={1K+-BKbl9M>sl+yl=l*#d+%V^qqOvoYgWwr%FuEswP?R` zd-x6Hk;L-g10-*0S3O=rq1O*!c-LyuC}fZ)mQj^wj=v8xtVZ^(V1~a8pr#oM*ZHZ@ zBrDY`NQM~4Xg=RNCCFw#U=#n#oKhWV^SF0@0`kL&z8Wug%jf#X)$9CLO7yGN%%66@sn*!KvKW?%? zeecW-;t64*ij5n{6PV1Yb$WXL8b@AEv;H^cHp7gfM+vlQp@o%4uHrE9rVW$X@L=eJ zI-17(LRnJBeebWwPV|VjeZ5W|Cq3F8tqlVX2$C~^R_}9}3zzs&;Yu_L@kzmCmD-~+ zC5&DPb^(+GADYlp?*dH~f4vmbeT4%WF;zFfZz5|(K$v~`Wo zmMLl3sm^_LmRG4jwKl_>)RM9y*iIT`YdyLW9K;>m3akX-xxJ7qG4b&6dF(#)_I8k# z{myFZJtoaauV^oCxYpGmP#?>pQxOkc!2Ra$XjeSLiSk}Ze2nQNmEQiBCuLKus2ID_ zkGKvKCe=y+hclwwef3K+jc~qKp(~p7PAt_`(nkO`z6C|7Flr~{$BoAo7L{<8_ZAr5 z0I>UBm!y=;Z`{we?KK+uOqyWCSx1n#PARX1~1!#{*sn%+LkW4exRZ?JbK%CG4ykPiE%>P)+kTa}1x(t1E6p>;&x zPc@`0FmNIq3YMKuzofMeiglidbvzS8MDF1=0*GshSn8j_nBSLd^CvzQBup3`M#QGB z|KS-kDLJf^&{amI{|u?6PpXcu!nmPkA-uaE?e%)yeHz{^ zVN_q$Ywap+$Dhjpdo`YFdDx9J!Rm^Q?{fYV1 z6?-^U`CnSZ!YWweDYxH?1Lzy8_u)p9qwZ;~?5jBF#hWC%uoSqwogbGaAW}SmknihP zygD5&=VNtwVd8JO`W_hRsM{4ZbEGhnuuwRUzSc!p!2{*Pk7H_@?LVoB0(9I{w6L0sC^J{xw+S125gi`NNt z_gbnRajHjU+I6F`Dl*F`DZ+e`$=~f)-pv8AL}okFrn=nLm=#hBW~r}C?poed%@~V7 zKH$#<_24NOrPo*eBe?vd^NL}g$X!{@XCkr+D#8_{#IRiAqXlo{LtWhVY}5ttEDWf* zz;Q@e`dSbtNKguOfu4{v!hkgh6`zxgQ%p`wZmj3#%sxfiIA=5Nl~YxBSPT`KLI^`EuXug#P8yhbOq_Sf>6FR4#@pmYx~E$>vL;d^yd`*>dn1)92}@ zdibAFL*sXY6YT}c1^7pve};cYAyTX4qvV`iwx?%a)aO&K;-NFVd=seB(Mj!$N=i)D zG-RVF;5YnDef5SK=6(f+T8_VhX(Dbb*6;aXe9-Jn^iQuzIC_{pyyL9Idi$dgLmrr% z_0u{G(?UGoU;E7j;yD$PPAe-G?Bbc6_CE+&EapDa&O81-6`9Z~R+xLpi5A|O-@$2h z)X^ZViQkb#?h8*IV7~1fZoAD4k3h6s0>gHWwnj7$CC&S>z#5(ISfcPdy(~>mH=wak zw~{cdG;%E8q|Go{_#QS(L?+#%l-?M*2=WESdoH@2U-sCm`T9ggQPa9EnGw7>jO<@t=&l0 z8y~tB0=4cKf_UPN%mi=Egd?>igHQUciJ$)Uz-N4l`u~k_#GxsI@DbjMBk!WM-fI+u zCQSnN@n)t2PYf^WpynfVG{Ia10RS>g4SZZg*Hnqv1h#R&hmN0V{^%dnj}FLNxoNgl ze3I0Uz$Ye%y)CE7Edyg)%jQNl*=bcwdP!Urv@F_WX2_Lwm4wKS3t49m`P5zzt-Hjh zxBbskrCcxT8jNP7Rmlx+iGJ&~R>)2$(^%#b_OkODj*&`h%GGs_iPh9EQiE4X-p4B9 zlyadvI=Ne4-T$pc~$Z1TO#Ed$84N{QM_6N$fW0>{;)ZU zk3CDK_kjcW^`gZ2t#FKKPO#02Rb30%j z1SRyWT@nkOd#FFk8n&34a@NDlv*M>sm^6JS0kwi8G=9Q;UP|JJ5$j2eeMIN@gA*IK zZb0V(fFy3~8#OaLyFQ5OyAWZpfwk}H^QP$lFkikc1o&zF^7zbW_^z*;KB%+*S!mt{ z$`&UDY;0^uPJrcq8m?iQWK=Hqi+Uw`mVVx29A1mi2+gC;Di z7z%U}gKr?_L`j--Cfg}P7Ywq~nW|}CEzLo=OC$A_$O zly3h3VD(rTLs|AlsS8&dbic3sGpkDfH>=iC+#KBmXTiD!nI9uvQk(U`Hoj{$sI6HX z&&XUM)Nx}#LyO{sKr-Q7l1a7jQ5Ku2azh5R3G~vubI(O#2-04LBRZoaRbVw?#a&iE z$!z-m5+3Q6)YFGlq;MEs_!@4p_1>GL!GsJb#{t$_`Gwun5#M?K{Jd;})I|>%-WW!! zg&XB0PRFGBo@AvJRR;=D0587JZY@n820v4d7*m)lMd}_~c7LqQX_)5S#re5m z0xsi*D2FHW88c~FqlBS_In8hFyVFY9DgMhIND8H>0o}LZ&b}#tb|_~46xOPhk3;@y zRiC?qy5mFrAKk;Oh$p%}%m<6#Bw}O_O0f#r*_%TUc`8~_A`tnN+v@KlmZ2eB#ji0F zQE20W9V&Y_yMxpmW>`HJxjg3mkfPMQT@~SSD!iW4?W?|eXGdlM4~_j3e^P08P_bBx zRavOm=NBJFlZ{niE!0AlAIIMm*OQd0h2Y@ZhW|@@u--+bqH2c=K z0r2k)qzJbp$mh{S&X=WP%ES!X1u+=P@VDXTn6QpqSZ7vO>6Y(R#lm0dgrmwS-0jYp@ss{gqS{{g z7W)XM5ipN23?MWnvv>blxAjn(?j+(|NLz|paY|y+J{@hp-Ez3%a!q)?KECV+K40uk zULb&*PZeE`ZV)!AJ-LE=G2FO`0z%_dHg;%}tAncHD^#>xvBH<+jz-O0T*L*! zwZtdQL|kF@_3OoS#9CTsNTpag%b9I+7kB2390@m_C#7@6LK*vRAF?Wg%}$Q%!k}=5 z(G{j)j}sZXhgCC9i!QtUf}P%+fXmK!+ztS?7~gyAE&9I?=WM z%r7y_PBN;B%)MnHo^RN8yJKkSga*qD@rI-ngNCbd{=Y|IZBU}B6M}i|heJ1OE0seg<4H*06SF6D)!ix^$AV8@}m`fYYDnv!U_1-y7$+G~T$SXzg(R61eOydhmWku6WrpYIP$gQc-VX}LS)tb^N_hy^Z85@pOArcAvFDT9 zQJmOPjtL11=$Z+w>YTDNAs~xr3cx`4)M55(fu0pdIUE6dyXmJJljt#J5_J?%C61~r zNe=w$o}aZ`p2SS@a%_w0XEG;?4^~&(pF8_M%I?#6BGoIk;p~z4K)G%=A>@L@#_t52 zHr)U>u>V<`yU{HI$*>!=zQU&qy0V|@OU^`OX$@I84zwVZbNq4dm^~gIrkK=s5X0hg zT|ayDI}(w`=IgBmkHpl1-frQiVC%D;mM;QB%?B4rVoe!QQCbptBQde-j5dwXa-z5a z%Im1NwPv3}@5)6{EXfL+{RrsTAV^F~?DN+M9}KYba=O;N`u>yvAc5c5>ObKK=h4>Y z3B+R>?f>zSCv#duS*lO5964wq`n{2MzGF*xzWCycSE&Taz))HLY3xPGAHCqEWUK-E;(Zl7O%Xj-F52BdKpZoTlcSZyq0+iP>$v zID1er$Aww}7bAGKdqJu7CNM9zP>amE=Q4NR!&V`%(n6c4y9hFsY&NJdl;P~2X~nWT z#S%a6h;%hUmTu(`Ze{aJt}l-i;|c25zvM~kua0Y-y7)#|VxeiHFnetzAW(mB)#}qr zbTu!2+*D2MGQDmzUaq}|xBiu{CfJHupS>qJ@D9f_S4#uD)@V-(><9sc^RQ5jLoF%y zZkccybtk^-oNNDBQ`Y&}-ADeNZf&x6FY0n<2M3=pImkXOw2R*KUfL}s)&aPLrr8C5 z%5x3**01|N70-b5zi4XEIE$Khnv^;S3k}+!Xi{_{5ny3%RQ#UC?^U z@QQpw+~F%AfFXcT_58?2{i&|R*>}k}D-o@z9)^=txRVzp{xp-UK2+|W-us%@l`AeS zsjbSGj`;fPQiYO|Aw9Yqj9=RODU7dN;+V}tUHc5b-)=b@@8U-XN2e7NRfn^Hm5)VL z^4o|t=iB^0iNo#Q-GhYT4*Qq$gCmZ&pK)<LLUT})&0@a5MB<$UwYv;o+ClZ)M;)>M z>3Fpf$$~Sd%XeHQix`4~WXQUE15C{*ok@P=S9=d$54*TY)Vaqe9xsLVz;jGsi4Fk0 zV)ZF?!!TV2SKXOe+^g9#<8T1IxZPNwnFC8Ete3reW~z_{ z@+k}_VxTgfzlY4E_kdRkH0qwKzlljW*`Sj2GeGJ*45+sM=DNV!w;G zw7QT>M7+x4s=QZpQVQ^i^mD z30n;Wz`FDGCk-pS-@W@i9Dy5#xU^W!F0H<+nolWrr|CY*;0r82p)eo{@lY;#rD?Jr zqQ+*2WHg-`^GEHkp6)d5a7ZF^rK8B-8Yu>=vhNh*>(PD<#H$lbro87!IW&N`q&o0( zMFi|p^~Sf~TpZg%a6>r!*0RQb<@ z?TY@NgAV5qI^);_HvpYf!LG-LvETIHT|FE_&enMG`8Vy}HqI9zW`(|Z48U}JwLsY1 z)*ryEkmzN7q#=$^l77$#Sj~^RyWzhWD!AjnvOmeE(Qn$N$F^{gm12#_4(#7`1qm!#HH+5|^2?)xhP^HMyGYt*u z^Mc63zQeo>HZzBpGwe6rouSd{{F`am6j~47Z&Q1%JI^khx}VKrXyd=ZwC7U#bEWuW z61||Hl4Ys$S!I7B;$PxCAvCL5B!rsAZ+5PqQ=7v`7EbPm0wU4S(Vp6$t{@PIuba_1 z>p5WHJAk;0R46W#P@lt{YAoGpO=^QR_Q&~<`|yU}=$Y3!in#YDf8^rOJkU8t$Z@2q zN`VKXAbpN%g!8*-klobLZx}U$ie*Pqr2r%sB%az^TG^tg@oIS1YP+IiqB~b&U3ek* zJp~2DQ_M)8fTydZID6RS;Hva`YWzK zYa1|&(wW9vJoF%;OJn>#gU6lNacqS?ys#w92NkxqkHcGerK)@PJ@Wky^hrgfEhBo7 z^_2Nind6u#vZ;X&jU&?%CUFAtD{arU^DG$5rWM3$KMYXCg%F}yz?vkaucO1WGP0U3y5&3z+q+67tB>q3~`hX=2X4t*aEl(!KBVD(D#_KbI|6DbgUmDL|k01@;4iwbR zeS>G@uLYa$=d3R{=v(iTCvQhxbK5gu0j26CiokP}5r9+MyM+orhYLxM%wbuo6aB$h zH0vEINUz!=X1xcAxhevLy<1Oq8w{}v8_#6C$EM)N`&(W5*O*aO{_wLwYpqc-W&;AzA8Obd0s z=BzEbZsLr0H<`zP&>rvu9)8H6Y%|v;t_Fk)pJ7_a>}`k$csg+x^zlGAJ`yC{g$!R> zslxazPoJ=VpnXbq<9EwheD@bVL(-2YUynJ+$BNU+w+rv;#SD^Eaq!pxR-CCLm<^C0-J%10Y`Pg)v=}?mrXaD8sd7?MxI7>ZHp`T}%qUunKqgHhW6C zLcvpDN7@d-+f+iECS<--Z1Q>Ot2te1dRgZ1aWq<#M*h5y_mXzx zON4ybILZ>0pT(6I9eS6t%VTAd@33bwtE+FJzRf>hg_+i3DH;ymRJ71)rcrd4-%c1p z5BGE6{nbzG$75u$x9p19|JQ-c~qf*i2>)<>BhmNLq5HMfsUG&)AscgX`*E0&^fRqvsR( zgAudocRwOpJF(uJWMv9gN@~Aa8zw*PX9ow=Xz<81@2k8!F&{McJk@aBqRj4*gRXYX z75z$lQ0zF>M>b?K0_`CKFTv0MWyC{a!R#36bqNa+UO^&YL!hzawx5ryF_lU>Nk4^d8@t6?qWzQVo}9i3}G zmorcSY)C@H9NlilA**J#WYq{vj=6zi*xz>v!UQ!}8ot`OkF@^DoE^LrkVK*8dU9d3 zfI}uRiQR795)INWWyq`x4#7s45SS=Ex9Ur7sIz$*?we8^DIe8yd>R`ByS^;@{$8Jq zNFA@&xieEdTYGqU8EJ>%*T+FcL0Qn$SP;HLS+44WtxTCyymbssK!p7QOg$_k4!0)T zZ!dBNq3$>QiJ*zsCo{X!g3j%Q7Mmq`|9w98Btg_(uAGtJ~-` zOJ>bJX(s~$JcX+b@T_!sd4Ipv4G(+Z`HdF5#h3RZTS$y)F%qszuVbxf=m5%W+TU)| zm(`<~nE%;L6#w5>uSlyx5Q`8H3e;!Ro?2Jk9l3(n33S|`4`lCs3)f5k z+D~>#-;EZLSUH@I!uDt;?B)xT)ZXzb6`_qux5F^gG=g)dj*0Jbrsah#j34Ksm~~Rv zfDjju2e)4k{tmYjxSh6UCo=$EpjfSN1|ueLQ1hsj@eZ-lsivcc`o`rY_}zUb)Zq~< zKksc-Cb7J~Hn_+Hb8iT6+j!kJyY+r!ISM?b@&-deH!qIOqE9gG#Jd!Loz;cZHW<(1!am>E*xUH)-f-hEjrvBsSw zQ1=j+9ks0&@2cRtEZ7`pj~7~LeSdJ%2WzjTZSR`3o!g55=6s?4XFX_u(#k7@7Qc#6 z8N|C2L}SrFX{wB&p=yD*5MJ8;v5Jq30%4i*d#1#J-S|wJgG}4Dvf=bAg2+>1G;;Fuw5sHjxA8bn*OCj}vle$K z-;dW-Fp$8{46x9Lt6a@5n*0uC&X4@Dt}S;LS5imdpB2Z>e?h3vwZ2#FPvhWqAN+Sh zXKF1DsgeP4sQ%Z3SoiPD&0p7D6MH?wkPPRtEy24*G(He$?jc_rZqK(FErFsE$A)K3pH*WpJ?>+mX7=+B zuXnI_>u!6Nv%WnZ9lpgUPiA*>0@E(5-h2(?efT0R1b7WMbyS@wtftPJ8LL2etdL;_ zZg(5xcU5J}GIJz}Cic55%4nUluULjeykHBoDSO4PxO%^=TR?M9pxEA5);+apcChVW ztGzql+nywN)tlI_-@#Tifu!+mk8SSghWZ~bT%(z~FCYmn5M+v`(S16jZ#uC-4&f8x z+v*DKCmZCHDlaf~A>w~uVO%&bgTjQcC=xCN_&II$*FU~PDkha0X8*-fDiRitwO@QC1()=v#B}#tit7+;Vj;+5uwi z7)x)<)I_S9(-_#1M6^Ni=sx>aY?@^%@Rl*k^V%H0kSRgD4Ocz2_kc$$iNwdKY2z3p zI(f%4PE?xHrIoyiSxm>5{CWJI?^zUWER^@4ja!NBba!QNcc80D=Xrx;ygBDFJWJGw z_|l^=yO`+N6{;3WbNjK*oMmg1S7o%000(F+17C^ z?jqkxTRIc{ZMhE|*E^$uuoX*Ax8G97UT4-|*5}wy=F`(%!Asdgx!30u4eKZyCBf+B zP%!TTc#i3~c@$we#hZ~$u;uJT9wQRpE4>SS1MBZQl6FEQmLq_RmZ5jLVPKDYPe?Gq z{S*6|8V5#z7>mr;<>Evo7%-0`Se#EO*C!y6Cp8wjN^{$JxB|kz%9w(;R{m{~9)+h(doHz|X6wSAS7E z-{UQnMa}aef{3?z#(-8eo&*LH6#`Rw*+v&@>I(8{3f2mLqEhs@(Yj$@&u}8t1Nr2o zWfu(A#y_;pUv&xV~E`tYS%PapG( zr23wbzC5vxZ2q;IWbS+RGU$(5%sP8A3%`V!zhe3%^YLoPs4GfL#oy*%a@UkR#Up^%j|&na)Fev==ZjwGkD<>f|e z&osZj6shl(D`^DpyzsPtn;kxN5*kyQlQ1^s=5M4Nfgn^gT|uh?^t@IZeVm*e93E-m z8r~8-!=8U!ku~H0H~@USs$Vp`7@s?8Sl@R4$6ib1MKn}JD-~Ur8~4QP+~LUyS{9tYZLx3=}BGEe=JUYaGJhc2=H;b2LRB}qas80 zQ>>rv7Ok6;7Uu+(H9g}}z^qr~foKbtK+Tn&FJ&blSt&ptn1bLqZK0$09*NVY72TNp zXo{!#1a3TfynlvJ$jxwuSGK9w6Q#Ll8SsVtZ!!57^63?hz7@-XXAe)I3W~o>Y}0|z zPSS=6*rU;9SN(20=tuejuLFL&9~@jnaB+MRZqx^WY6az1BK_NgiP36+({|Bx=qJtj zz@R*b^%T%gvX!rHyHZ*T_?#NXW#@`rg?x4G`9AY;x()TxvDKgy@$WLpr=O!8bh-;3 ztNDH=$Df&uG4$30$`IS?#_?qph3 zx}}Yzdq4@dn(jghne)}(eRi5R&!-CD5yTQk%G==y3G1tdzNYZgcayL&E9(=!pga=- z=G+O#f8Q34DV)Ac%>GD&I$nJm{==uzI(a#Hxm9VlG2^|?F_pGeXx8(`Y$#aqgGNB> zO%ZRsy}QBP(V~>*E8I-}xjHlm=ahIKA0B=i2i;V9%kf?Vq-DT6iCU@@$y~Nyux5`q zkcLeDeogG2=O);UKc7&nFG(R>+mKRe(aQ5IvW7G_t{ zj>S@eo!fL?CiruJP*wtP&$fIq67v@Ou{kKv1e6uh2C}EjZZK|b8TmIb%5u) z2*P`GD{`KSRumIo$eQ5SM3k(BFLLp zU-YlT!aTG!1xYI32&_mH59l9)8nv0pPQgT^CxUioJI|XrzW{Q}ui72o;WzWmG#v2Y zfeVl)s5GL_&973pcpQP^Q#j!vQV8)@pA+mjq)X^-d*Au?tQ*fO$So2HeCUztZ(ub0 zqP!I+DU&%!z?iRCK4tWJ7(f0Mz$jqj1H&-*kTkl(EU3J8;MhvDn7^5Ug(Shz(r9m^kQ6^YV)G9OGwCom}@tXb(&I z$5*}a-_?lZ3}tPl-aNveGQ3vGB{JFf%FHhsp>0@>j~wj<*SvZ7*ni*kKDl42Ifc?8 z4Mzw&9j+(7PC@_vR@~Wg#hOjPRl3{IxeWhH3_|d=810$%t&d*5gBzGnQT8GH=lff56G}7^QO+Y!t~oDVPd!< zNybfd2kiys;>_#74QBcNvEkyq?cVo~hX8A#+D|zj{IKyH z4gJ_O(x#~RMUZ`vG22G*_@u-dpt12&?!+jj^Tg?{u1yxLCstD*lld||kF%Y%D1|+1nCDyLXLi5o;aM}6mp$=XGod!;joMqh z^J(Mlp3J)IX$WrAiPO!$$|=JCl+zdy>v>X8{ha}Ka#wG)@VoOdJe&qt_Rj9Vr4~zi zjgEK5>_YK%FOo2Bzb#N2kJ5pirRD8)@a`y9>7b^hV1^y{T$|M3I|bAWr4CCWz zl$Td;b<*-YGS8Dbi5j2);0v+IU`9G&zBDKw$dx`kfvDkKV{h@J+^}|!j z#aNZ!@=g@>Wns+iG?3I1tIg6{puLf-Q-;%vQ#$;$6mz0FJ+l#c*e=M1hfid|n;GsA z6i4*~U$`>fMHRvJ?_rU3E5~hf8!j*c5^1c5ieWf~L(hX($@Rueg&gds-*{3rz^_qB zHoHb7N2L>8O-dvb<)Ny8mdk8E{2W&5*wFgRi@Js+#IEtTV0{s1b8pqva_WvUhh`HU zk;4Tml(2;|uTV~c*nNz2<#Lq#lA+kszyqX~Nq^#N_TH$Z)yp^Lz2Sb+7koC;w{1Nu z@t!#C&Wrbw4foq=1xitUT*4s{U6HK@RBfwf%b?|!w-lqce;7Xee?!lbd6m76BG~d- z_)*?9KLdrYFc`s0AszwD0zSrg7oQy;TVjy4yIw9~5m08Ilo+B{6__dwanY98PmnC0 zx|^kI_r(W>T&eUhuCaDA1yo3-lc@We@1}>l$`fB|l&;FvFk{WeyA8yBXr4~Qoh`FR z0l>6*c|FK9z-aB&s=iu9`?_Y!uK676lKK+4Klxe!ptMR^A zYjuk~kJCdUl8S>h$1$}5$Oun9e$+i2Ts4xbxf4HEgyG-2g?QQ~g#b~|fN?0;EW_9c z=j=X2C#9B8fxTL^~=qe#SvJCumcg7^t-+;5qBYm4G;LCriy$c4%&DB ztvv2r;&_4cFmd-D78Zpmy1#y)nnYgsSXkbwV7eQDX|(_B&<~#p2CA?YGU^j0(J>}K zq%D550=F<8{U~;zA*1GEiZ+HOB!M+dr)^P*^Dt;Rz2^;>yWIEj$uhm;_Ct4JrvDR%v|Hi7@i4g+euk2HkPQA2wE{o#eJuZA9TdS2NcHH+O?9C9{6_A@fsGIA1Wk^AXakg1I-y5v% zM%FsloB}*GQ@e70Aocmz**HkRT&@^{@Q-qM`CG4Uc+T)YYZ{$!n?f)Z!X-MafA2>$ zK5PcL7P#gw@GB?YPf|| zE42gyaeO~bFJk$4cwUyALrWl<^p#QU08^P>u9{$L8fMR;FqUDs-=d5zxX}cp$4F*w z242z8RD4XYy_XVH9K3eJ!naV9V}`o%(ep`Y)S1QG?Jtsdk*7r51%Pd&`&MMz%@l>b zO$wqD^K5zue-L1v4h{;`JIeq5h*VVbJ-~Jc#`l^|kWe$ z^IpdCAtr+N`Lbd|Tt)J|kgz6dcaJiGvSik+o!nVrI$Q-hzNM}*!1R(xw*vsC9F3pWqH~BCr{jf zzSH(SjEpd~!`zemH7*|-{wTZUEZoXh{1GDV;j>I8jV9|a~=vR2|#hCSMSg73J$2KotHVu?}C)&q7 z#)%*{Ie0Vxc6;ldgy5Gp*_R1Y%UQDqzhbRKSOutmpVS=4oG+)7L_e#rryBq8aVZbc zs=8F{kg{O0X8had)#Rl+gVK=gYb!mi zz{J|9jadKtDL5lk;sUqz;qY)@2ihOqgQqOuVLHzyiqyOK- z82B6n%jxn0O9HQI?Na=49h`)%IMNoa>bLMLhi6iynu7@51YWIMKo$_urWvBk=m~Mt z#`KQ2I{>YDzK7Eue>`jR!ir#Jx#Z!l<;`?~(`)UbnFtggFZcH}L}XhmHy_cR#Oa#L zIX_v_6C!pj`{%4+p>- zdVXuoI*>1^M?iDdv!QtLmz*tS{)3Q{d(v8PO_#o}t%JY&9rvf9<0j9Ev+JP8n!whr zlk>|m4&fq;4OhhuZj?Wq@E6M;uAlB{CWJCLH(A(ZP(vd1js!UbG5@mnlFOP>kiOW|IT3EHD7YXWd*+ zRwee2HE?dLa#XSK5Vw|SoS5N#eeDodM{;Y`I{E;1Z!5kB*~wAa8+n$~v}TJ_gy@0W zhbd6~8^1vwm@}E4(`fkN!R2El>3l_RFs~vyk2~BZMGbu#qaLQ-Z1#dj zYmK`q1PTg@$e+4TTjcWuaU8{hSB#I+uDYtZ#0QnTAFW4X`my<`Hc2SV+1ddAsgO8+ z8ixCA)InOJ9|1h~E<$$egEZIT4sBd3mzIBDl?m~nG=AI6`4P1;jcI{4Zhp*BAx3?R z&&2VUkeSdQ@|aorxF3B(doyB!aGaR&-KK|X$lyW%>CB}t{B0$;n=z)b+NhH$^(j|<2?h<4G&I$aIq$Cihn8r=O7ouXGhN((!jdPlp{R4R0N-F1c zq_=7poHS^>I~R`Eq^DQI>>hpvkDhSE*bovDZ1$pfw$@Rw$$^~$Zn;T8B7F*HEq4Te zem*SS;r=B8o!V&0sVbZp^$f>L0yyBOjPJvOwTeOhlb^<#BWEj}Qhfn8DIjsuqXu0_ zvZJczBg%ulm69)E0-nih*ztr~S zPrEv^A9GQ=_3qnl_BBx?4)n=ncT#)%W*Z>ww9&sGrPF_xlZTu!IPa#%?F<@vsK6g5 zo3-dFHUNk$_Kvip9-Gdl3K`w0&h5NEow5F4YTJVs^;LmnnS7DaVgcJj-us!hb$vI$ z9c>iQhVLeBnM>nQk6B2SSF?)!hOb*`;uX&eKMLOO!giiErc=Iw8@3?^KJk#Q0&D$i zNhTXjAi+9OyEpSo7&2rWR=gmO$mc-oI-%o=pe9$W;FW8jZq%4!p>zQDX zvuVlZzUhr6*Mv=*m!jL}`|%eh*@yo;kSi{={5n-a@K(`#95_ZIl%%(D+?~B4utgqFopB?hC5T6;fTqY%=#79S$`B35P{lH zUIQI{d{w#Hpw_un@Kb0{5?YRwQ{t)6I!KC*iQy-m;ADXV&TlgU}rkj3Ns3 zea;xbO#R2Iw=7BN1?}VKo>f*lMNww3)nQyh1YOrnOz+#}-!JBW$x7kwjai`Vg80Dr z#k!nHwHGuAsK0ZC%^?=u{wz9N6LZ31(}S`@Ka5Fxf;^l6l-m+k+H5O{ugRq?Pdwx`e6s8V5_AhF;`ODE(pcJNn3Nr2<`Q4m@$33!wJq>vdLzK zv5jkd6+H>Sy$G$98+h@juZzX5SXBXM>}Q#$|FPh)YAo3FW`qfJ;DjH?rBcQZ;!T^* z5@)A=c+Od!_h)tM5yE{>Yi@bqdUl*cZCt(*4UG%w+O6CZ|NSJq!YPFUaXiPDTKVHG zI38EIs1ZTTE=Bf*Sm@d{%HT&~4B*zc@yANzs1YhtZP)2n05&pDfQD2%=VZ&X@imc6 zCjKiZwNXMMbi=8h_vJ@A&1T;*sLD8+Qkjm3&QJJ3p?KEU$%7N$vFQ_)2MJ?69EGJH0jx^L*Day%(N|vED&1 z8_z5~X=1*oErs~ZRBZ)`G9&0NE&%JWJF`(TArD@Bce*s@eZ#32MlZ1P6^AEa8dlR! zyVxQUDx@cO|EUiQj0%CeN3p$0msZBGD*jalBWA0)UIcBiujItpE(8Y59Cl8?SrbK>&u?QJ;}GZ-_EU^hY+KDu_Fy*3_1NT)J!g>3n7$T*sT zny=OHrw8;&SF&&5OA45nu5BO2Wrn!?isv?J4q4QgZxM0^a|oFOBW0@tES>s#{1HDZ zMD0g-TYvzKxQ6IT3L_f9twYR6JjrN(mciTOm2-Bq1t%Hd)6+7Di#Pu_k#TwcZLb1? z{BAQkf82ms99qaSJ^7#!?{oSTm}{RhLCgFx@A0z@eifmpCcOZ~<9O^4P>BHS*UdzE z`csE|-Ll5)yj(Fz3iF^k4|ym#YA<*ap_x6Szdirr`x(}~d(CA=(W!A4rIAS~wwAy< zS(Aj;g5$mR1!fJUj}3VIT}R}L1G{`0knr@Vk0HDETt{wojtW5Y7BcMh{xB&I9PSu& zV0r7qMGlB+!M z1(DMPO zKLP|#;pCusp?_%?#&~YiTDld30)}o4{7N6v14xbBIGBU$t??k`{@$50s*9FGR3E=8 z8(1`rO!QBaXz`GK^SYD33%!L~8vx5z+={{jwiU1staIVA;2%cc)4utNsVMXY_G zr#{0+5-2&mlZUFc3ub5x=`ZIVDjNvh9xQnZ>EZ(2Z{A;!=B#|wV7xxJbKLzrx>l!i z+8nf*shfE|GvmJ5VWj_lyV0a(l3*s>Ei|pMo86FM$vv?k5m?_&6^AggVOKh_aDPr!V_|s9Ehu3Q^tK$niykL}K zSo0^T1qUO2a{{UR|Kc1S11O*^x#}FB5=BJaOyVn+91~<1S1q1UDfxB^EUy~VXw(MR zZsp(sOZ>A6unzp@R;)Dp25u`1KmSTu+*bydzNjEG#t5x(me=gjYF0I^=+E2PDv0T% z2P7me($BEEfwg?hbHliNH?g)O*&IfFG7RR)N{z_YWeKqC)Ly0Sn(nk|rpFkQulZ)9 z|7GO$TmNAEpTtMo#Rq(ru0~;1_peC<_#@$n>+e)B^9lQSs8l#KsQIZ+=t@M*SyE1U*s%^vBSj>}%&Kh4}a(~pJl(RnvIFPAbFtAv69PC8f}I`FKrxTRNo#SJ2;Z9r27 zW%(gsoJ8Jxr*O~3ok>g;-is)9A89Ca)wpSq9w~Z9xu(k1%fF1lrMpPLMN-1T9R zBi8skkW3#d=_Or<92YLKi;mpw>7Nq}%2D#4wbaEz#Zm0j=D$n=cD9n0I&E3VT2|I9 z7u6hS zQ=_gsqi)6#X+fgH1u{PaYZ1w`B#dwVUVDNk43JUmukaX#W!8i?ti+e;N;z`xo0q%KkrEN=ii(PH z-~We17Yc140HjdKL11GtdRT2ptf@HCWuwxcuxv!9LJ)fV_=ubG1Gw0!BfcvYBkAyA zv)@)r*)*xxaqB6H5{4)pyDkcuoroY6y456KFe;6HZ)UDxrOTpl8f+0@qK;F*T9U%$ z68|IjOJa8P9f#z#;zvUG8&Z0jo0IzpIaM+}&%tn=_FS`<7UTl(sXzaV>3KYwP7F^w zCvGRu5*We|sghwO1b;%ICDGNyYM*}dS+khz7UnGJ1i9go^A|>BQif@g=O$CjVWI2W zB$mASp->!OAbKXX0t@KcINyMBkHMxi*J5*aHwaO8W9~;~80aiBaVcdE&Ld&zKfeZU zQFdPWp#S1j{Q7kTA;}Durlvy*YFY2w^u#RBP71{cja0W&S#fFidh`!4;WcMBTEC6a zvZCL|Yj56t20pT;*|#io&WqDVEW+0~YjcQY@tJvzf2zhZ1=Dp4NBKK@K>04FJbHDo z5G=_%rd3WiUPv!3AB84P(gy+c$om6b>Zkoqgaw;~joIblQGzZK_yeR5>%f>ixij2O z9%isY1JWd5OQ#0?`_^43AwMo7j-6ZWyDD_HG=s3&)(BilsHdL6>fI4SS4U6b%F}us zOvui{KUD%D?>|WRweHC3;qY{gf!kE^K-X`6J_BZv2s>ddE8B-v9x!K> z+=)~JkHhC3b{W@yN<8P45Z++HVII>L zKAv(GN2dO*pz&c7Ze`LnlbP$_=avzAFt0R&z&LBU8g8|~2363YZsWN_NIF9bg=w$g zwySC)bQT%p%sUyPiU#F99_cPSDb->La&%)HGrsLl30Vhr5bKysPBVirNX|2cjkJFk zSdmMzw5Sk=e5cWOmF=Y|gY-oJV(t6SVzCJ9KQ4m^uuQR0`5K7V*oC_$!1`Y=zoG}X zrkXIUg>r#R6cT2=r4d(WtEgQ06yR%b99+6UR6gGQZT{^vj?+AmU!%NM>5)6y4m%R> zG)6noFPw18$@tq)j80ViO&PzwJDKIE@r%@v96U32DUO{Cg1Pm^wT-G(>VnxpIj$qi zNatsEXv;5lJ0R$d6ps&MnR%^%nj~z>2V4hSahwseTSx8T4*+_BK>F)U z_tl#ao95C#tr_e;#i=Wxd3C39mF23Ab)is(Y6%)zSWJy`SQlLY2cS-;&%nQ52s+H( z-E$Ise*C7-j{O}X+n^4WKamu%3xCkz<~mfGroUZmBeR+P zTvIzh-P1Gq8xmC^zF{CYsCyw}&w}~nUH9IsETy{}r%cDNFsMJG9@*d%nMy+{AT_f} z2PX7Y*NACK;)XfE{O%KXDl8@5GnPhsuqD+}&W>O`%IDmRx9oP_h;&dk!EtyKKxj|Y z>D!k=HM9Q_VEvUyB(EK#ge5S94z65)`yBW`$vTxGm}gGrdL)zv!LF+MHunJQuA)9Emm z4dFY3IruMdRm0U(x8om&TmpHR5V;TYnjdRhP__R^-Hu{L!ateOtI&_h-yKGCuR`>*Q_g01&Fn9|5h2FKS!`Z)U0Xco!Z#&AoN9 z{4;~>uoLKj@h*7%0c+6}lxdDR$a7LOiE0gRf(w13%|XQBYpy>#o0dai0~y%}QVhmJ ztElH}bhDGQqRb4be=hP`8#8|a3{MD%vI&m||IW0}y2Y$c%otIC$`>X5poAex4L}O= z0Mffg7uFZPjB6eN0Z%yL$sWkA2!Etpju{kENIR2VbO;bi@$B%Z4XEvEy#oHnzf37| zl}h)?l&Xn!grHl^TsOl2=jH7L?XOZP0Z68zQrn(`y9<$fR?c2tV9ZQ4{-W?vVC-Lu zh7H23aI|a^y%q_F^k39jS>kv0kcNjCDn|Q5N7Ey_+cV`-MyN6*%w-S2f2=0jiGMqs zP4tQ~Sn-QLLsw$a!?q(LeThUsc z9Kfocpo+ymXeo>gz1~pP)Lr$s`Q!;fQW^DI;xxK2*a<(D3shnmmna$HC%yk10(KuoEIcURV{vbIr>-dD9LG-r-VGUJgBD!o8DIOZ8^dKNPh=-x`WZ=&7 zA!?5!hhxL;=I~a={99K<7iY(}2|Oj6wlPcbg5N@d{JhT+cwheAKXbq~i`wU}zDU>L zb}odPP6&c@xq+hv7|Pa~kwa^x*5$7TA`l)ir_XmzDNPG{;{aDGU@w{Agn1E+g+A!nEg-0)+Q7n|M; zL0M0L1dPHYo};D5<(P&L>w|`wCJSGBj*#v`@jJ3d$?X|mW8ZR0??x68n1S<4eD$!W z{UJYFWJ{!SLiucbAPt*pek1G>iu?HPEXj(pw`z3kPW}!8Kr6)X7a8>HP)W{|a+&{Qvd*58%egv?tXSN-`nhMpZ z+Q7NMT*3)=?H&E_9ZY@wmo2Ro`TtLIrRUA3GDL)RMTvX!>Au(EMb98;0+~ghxCu45 zjizc-w!m%%AZH69TtFCpWU$nR23etlc-#111F|RS7A8k>FE3t!%JHF_PCe=z(=uG{ z+adY2kkAm{p>2skpA*yYa(-Uzg?LolQZ+WU{O_7AveY55#)GR`hVmsW&GGTp?=|kx zC}0A4Xx4zuXB~6D)=opuT2=rzOzsGG+&Frs_hr!i%%|0gJy zOE<3bkmYk68i-CB8}F_9=z|@zQQg6s6}~CZjab}_#%Er1eo=eZ^UW2!!Y{_$RyMhx zmW>>o!$e3np&7oWz(H94F_ZKqqDv~jDS2CVP^$UX=BaH|mg3FZK<`SyMasB{!l5oV z5oerX`2n6zZx^#T_Mo*M1Yyg9SB1pkNTed)?NzM_kv^}G>pI=2t7U3@c&cO8%6%(GV}{wM$bZ=aSYo~udulo`@yMj0g!kUL9XPaV%Z+vTWI z?oEzL^&uej6sLn3;7~K_rq$6PY8UG^mi)dq_q2HtjJoVR8h*IuR4H;z6;>UC3|s48 zS;zO+Q(9N+43E5K*%S-}Y{Nm=aJYYS5167JD8o z9{;{G?|rK&{>{p~GNx{mdUGle}LTPq6$N*+PRX8l6SBq(Us z6~zS^gBgqfTL*@^p8{)X-MOAVzn9PnOb>1+4IL2c+LE)rWI zb;WCfHi4Nd1IZQW2H0cf;l`kS&Xim%?);UWDa4a^WPPjW3&J=B>xUKGS<=?B_<39U z*~;}^5LhNFMw!~Ai_1q<;2$;UJ@Bsa462Xq-P_Z;DIm!nAQw+k_R?~kSD5ClB;n}Q zGFCxe;^v=txdd%OWBISF0>yR#eh+1z$OHGz3vK88qf$A=4uX>S>T9x%dA_HpV7oJ4 z9S=v3gsvYno*pIDnkB9Louha5e3 zg8~;n%zlcB>MRCZX%|N+D7)fj91`Nn_*I?0S10;~5bk1zdD3p!GGgv>BZ#EHgWg@N zl!9MWn%`=HxcE9`Kn>rUHf3@DI<|j7_Qw)6y{l2_>eP_j^O10i0hFUJ(nG9Z|I{A7 zyTrNjZK7n#-Jh&ILzYCdqC3Pih4R3xcrfS|B+uTJhY07@MkP?|gk!Jx)vDa*66xhf zFKiE&eZjV2d}Ckk4gT~_d^(bxvU3gI_i}iN-U}C_gL+jvX<6ZBFDBemJQ5YBv+pHsD*&8(n#VT{z2f z%vNu7%>is2TIpMZ&)DZ4B|D=Crvbb9^L;sC#)604%vA!m3d(q<$Sa8ssT*f5Cr0{` zOmJu;D{(CfN1;JCjf~{eFVv>>fX~gUzbayP98KlF8h~kIxlZn~pj1psP^Td%N6=RG zUV_p8BAuFztu!Zbzt>2LEIKSY;G~UTH+MLWpTs?vAs>kGb{SD3Jpj5r?=CU$&^qU* z&hbg&2|?^;9eLr#IF`sHCRB`a&q?Ol*D`7&qtQ=&4n7uF>$X~t zH7$AZFelm|h}}-)`@_}5c-u>E?(sV&NH>lH4I5>1C6~`7gHI@_5A9p@G%xsN0D#Yw9@kPu5#<2Z5k%d8aRuT8`!3Hwt50pjidoBamQ_HmSl6 zT#N0W7q0m@vM+4cIU$u>0AV!W_&$`8J$yM)q=W5?Wj9_=I?Q-+cfI{TP6% zahT->td+@i$Qz}f8A6YVc}gD(!$-r5Hb2AueWnab|4I7*fZCW*f1WCn4J7 z{?^fSsX3{UcIjhuQUI1h43eXN;NMx2r0t7^^998XJ5=B+ZPw5pOMyRX#^t(j>@due ziXwu)u!yBG4w|iSh(t^ayqwYjRiBbv35D=LLN}0hH~R0C;-r`!IoP_R z^{EuSEeB+VH+&W+?is3cGPXDVJKpJvnH0MQmA9qX8lT1Y7^g2musw0Jo&m+Eh3dXQ zZ2@H_H3!OCwRE!brd+~ZIg#3?#&0{!r!_tx;K?k;M#jClXxtE?Ap1;@Wk;w?*7aP_ zQ9*mM2*J?x9WdX1OD20k544Ym_%udDQ$raZ!{%@}X#(A+5^WJ0T7-0dyvXJ0*GH{rR7iRbf1Ar)u)7rD z;NYN8wdT!%eqI+H9~J_lI9IG1Kpq>?W6`SFtGoFBkkCZrr^n`JJs1EG{&`W!K4FzQ zx30TJ+E8~0qN@<%F<-p{miZ>gL3qeOle935Gcp&C1Jmbrkxj%rg`uIXBykk&a`>n) zkX_bgv2kA%e$6Dn<`Q(Q4`LFb!&{^koa>_i1NsTnB59=k-k_ z89oSqi#x|IJDEs}Tf-oWpEVzUFmA%{O6PQyEiFodHsc8v5cZ?5)GV?0J*85gHbbi2YI1?RLC&@QnCMZ7LQADE|m}^yQnisn7aJx6+ zHgTj6+wtvuZc*VA7zanmq@l>{N-3&UZtKP51XM;m{&Ryz_0aXoA7`I6?5Ace@)ehB z2C9=Crjl^*3D`qk)DCjmLW*|`06uy(1iksg>c0@YTI}@?<4#2WkA-e_bQ<2yT0eV! zv9!he+*M7!PCA~9y^(aWz$|$c|H{>{>BTE`G3B;GUKJlr&bfXPw~smK49`C(ZXTuH zp-p@E-cdy1ZInS2rT%W7MX%Z8V&kF1C0FZ1wo?z^s`hrGvgayjDB z6bhLH#Kh6hjn3FP-S<)_ECiuX%j`umH$1nrHhfgdx~iojK9rL3OTUWZC-TBnEAPFR zNlnekGZ4_I0(H-aE=L%0E-nZk#ku%_hP$4wlvD__CGDlB;je~xN06u{W6E6?XdxpMDb&hj#W?fcF z5EBPj`DqO}Z@;+$)rP0t*>UGaqQWPN?5_C#35=QO=`Sy^&F3?8WNhpA911@2()=T;H%WtNXmhY>Dx@X_ z{ry+G))Em084yzdLae2uGf0P$dXe(7nvWLX^yxfkK!<|zvSQsUFwdG~tD~{QBhvd8 z3)U%yJJcBLZo)knDJ&nU--EM2A%OK0cu9|(^I^crKBthM>5xQt`z3~SLHvTy-P+sW z1u+@q%X8ZO#Azhl`Yu9@xETaCg=ZSsk4326?%-Z+4ltd)*ZABN@c}LtD}kuL$KkT| zB~^?e3=8WV8}mqyM^Nzj#mN1iY|4+15&M6|Yyc`j^_`AZN6Kn2+Pew+OGQSg?zj;` z8^|{N$3fIjM@E50cKJsT&TTChqb=MmPCTX4&|spd69bhNS^z|dvua3tB=ccj+-N6P z(*S4lko9jn8OamFs8hU{QgA-5h!){3x|}euc|X$Z$5?M-5P#;QgzZVB?ej z+U`DMFRa34V&O*%o&x}^ab-<@EQ|-9xW<+S^|!U#IXGO40&NV z=Am+4UIOTK3X4LBsC3_9yG?|fWr^!!HAEW)den9eaVspF3lCnYx#h@vB)WS$LVlNi zbbo7)8vBacttaZa9|E0{Yw#>vYRhMev}att2C}K}uC!`DOlG^Jc4hgZLb~kGF*tA}UU=JGof*>{WlbQ`w>xsX zCTtHZSWJL0K0wGZIqjD;i1B`VS~CkpI1O5tIo5q{wsXjlC4z+$-acP?ncR!#tgrek zCL2QPHZ+4r;D^L%iC^4+YR8_eg1r5s`zqMFst^{lg$RrY2LS0BFuj$(rT(AC8q`Za;>TgNe4`O^O|MvP1AZN znYxP26l1g_w>B`NjGuy1WS-X8xHkT@#jx|5DG3)Yn*lv1jCUeK$S6yBOs{m~ldZ;7 z7|Wc&CD9*CAupyrWpgMcC4Y$YTpwlU0P;L26BvR!&*d1xbO4!8GtbVw zmAjJ%n~{?LyU*PJYKQ>9xv+7RUz9f%C1T~D%AKpMoh7eT=b?Cf@`}lBS(W)(ruhKa z%b=tunx^E-qO4wDuAd_3$Hm-w^5^O}DzmP9Znb1aQ_$LKVy(u_<*1_^jtqaJxY-x1 zK)8!-=RyJOaOadmK}v*~ba}R$XMYV*AfDf} zi>Ls`&{@1t6y3Ma-;I=$A;L;kpPFyIs-gf8`2$1tLOXeYTN0|Nw3$W@ z0%WDh{5xO0okl0Kw&=+&@F+JO4g#*K3^<{tKWC%N#ZxPaeRuY^#y_GE&GFuhs7g*g zka58mmar$abj$F@l}(vNcAiGx@ZGIzV=;A(Z>gT-$nY~R z>tka-@if&+q{I^V2322b{0{A}OY7vXp3P)IUk`ryJ@fBwSbP{(!IY3&lm|a#+#IO1 z(2KPg3iW;{y3X z`AQ1oQR_A1a^}XBE7}jM2EDNIt)ddrSx6KY<%8N6n?o)W4m!m2Q>h18m`e?cfyfht zy%p(=Kk+-+!4UPn#KxmZwFQHt>$a*bD3rB?>!^20+h8#guK|8->oK3yAJ>av+)7Q33uzd|2Z?kt<<|87|+iE*b-A$qBrhP>75YR^ia zp~*Yitnx%AFaw8WrF&QqFf1J)$FA|BiCu~k-e_~r9B{^)P*^uI-s>nEG@WXUPGNKT zPC%)N*o1;W{5NsP`s=2{h*F(!3p)$a%z3h4(c8B}!e)b@m7vJbS5{3|+B@CldTCA@ z)J0vR*2S0`CWyX}A?gDoHgg2%F;xHIjB$g^#>BR}9o7;RV-GC_wq}I+yA77#GRNlE z_yw-*iE_r;lqr&-O;jO4=p^N>PP>Q=hO`3VgV1_Z!*0aH2ON}AZ5=Tbz3U1VY4+() z+rVt8c5e{ca*TKMtne8LZwAqhJ3|kr>qEvw@A3&o;n-fBb(VQ{ve>UF@vv3B!^GX5 zcmE=c1m!>43xFwS9WrN=Az5xyUYI>tf1jIB{oG-w90MY=JmFUimJi64JLN)zMMiG{ z%IlD92_HtBY?U`tq{?sG(HbF}gIX&&;BS9%kh_^j5~VoKk{`U)MJV%cmdt1*v%=EunvQLnHIQ=WpGvf;?*@O*8|LiNE#qWBpP?QfrBp zEynd+yEpY>hL5&+Tbvf($n0mmpXl609q`;&NjZ)QM3|$j@#b%*w;0%=&q*O6xJgK% z-$#^ke6p9>BN69)A1$lNS25xhaPc@}AvuSf9i^p1OqOOCYPaJ%{Ymo8 zQ%y(iAiy7==jlR4J)Eq*3B|$6E{~c(MxbuFNg@GGFZ9$62VU~xUdKMWJ_SJogy%1W zMttBK$M_c}p`DJ%wh-bzND2qlm&;8xPVtiNuDm4(W8&>4_+#gPFpVmyHe8&X9>?)& z2fa8yerG&i5*3ie8Q$i>hmyjzwqXdH0sPN3&re?~@YEQwR6i%IHX%a($8_=%SqC7W zLuawi;j zs$cGg-K7eryeKcf^$?Pt8e>pYorX-kdr= z)T|q~vwnlO(6&jZOr&bN=(}E4*PWn?++n{r)Ff!m?lvR_%6V+ghPKG}xUM7NL-9)}L~7`KAJ1Ejo0wWfbB&47BUKviJzTzk92EE_Hov)_;!< z>#pVtYB3%KX0-R<*EuJYaTS}dM-JH1lTDExHD%(w7_Nuy*K<>5ZT>A(TvA1xXi=4% zA4y=d*aRpPMoqC7;Bne5Qm|-*k~14wsZiK0o^4{-@P!v*1J$Ef=|3{za>#ZE+D`1o z7N+UX(JOVEKB~$EfFqI@U9elwl(ijf3Q4;fSTSFgUB}#!M$Rt`A(!t>pt|ne#p#U) z6iVFWuWsZXPa|^rt+J&Kn(H}8#p^R$N-xEF^{5lBmn6>DIE-nYU|5lSKLfM%K z?B(k&yCMerJkd`0A$gh9sCOKG`dIn4(Nz6Ys3g#W|0}pFie;zP#W5{FRL!!P^4rTY zRxCbjQ?7@@6NyGgBF*2;^rm>;*j{IY&;>;kL%1BGYrcLW9x$d4gmbyweMDKR0_06b z2ewY-z(Er4pIFcFh!KSGhDShNjdd@H86#&@A@f(;qap5 za`KW33^<3!u3XpsLi5bkHj8evzn<3;J6y?~#kCW&FyJx(9i82 zjura^aws;`3a-uwLy(e!$~9^T!PByu&hC7j+B1Rat}_$`@-WstJxaB?j_2Fh;`J2u zHwVnPo8Pv*iaBmhQS3RL%(z&UCoM-yce!g}NCVFo5l=vqm^jwa=|KKjlUmOrhsTWk z%D;Ip@MTIe8U{NLR0?{JSDd+pO-w!#u(xr0Xp;;vC&2`$65&yDh1xMqWGS)udj4Ox zZJ@9s7U>xT45t{GdD7k+3U&%Uf)G>BPJOnv?kqPTdI)}?Rug7|#*X0Mu7C29IdK$N zn{IgS40Oj@jr{qC9^=I|Ml}B`oFm>uzF^rsY%LH#02S<^20gBs9lt4WrxM1L%E_(gLMAQ*&XR6)^g6BVucItxaa4eRyXOCl!b8Ku z4fb6eA!WmNR3pV3jFUfHW{%@a*5dGYf=R>u+jQM;JaWHFY2%nk@Yi4+ADj$Yv7-Vv zo*ejk2AmGL+hq^9OFD9l7yJighR69iRdc;2vBi%?IFkhzU7Gj))F6ZqHAp5r06k|C z41|0nj~!AO@lX1%Oet84EEjLOiU>Q;v{_+XjiCV5VZ*psOux8`?5t&fd^YfK<78Tn zA%=w>KBCRZ_4FdKJ-QVLN|ld8Ke^kptR=OgZG0CND!!32BxNqo_?*v3VQYOTlMN06Tu=NIArik}S)SyvzIX<_rAJ{T4mFy@YVLekr_ zDEaYSLDm?7MkasvnfZIm!OJeQa=KwD6zknk^9q3^AJ9}%y5lV5OtUkA@$NbJwO5$W zCELsU< zGP;4oCZG`7D6xwKtdnrO$yiF6^LL_yv{RJx-O%#uDzfB$!>;3xUaPHDDv zoOS#T(xoU_z+-}VPWxf`;Ef$IDP%276CgO2SxiE#|1Jk03rEt;Rw9jJD2AdJM5%@{ zWIvIAAA+>_jW!ds|Mju*fB!FoZe8(=Bk8!2n;97+sT7D6y_V0yP^u^1r<+lS{3)@#$GoRheh?oArG1W}RR$SZtnUpr z-!I*CR(eI0OTTt;yH_a9%vfuOUA2RDLqC?pzSk$G&RdE-x&zS;dC!xvv+Lu6Jye+>{i%bMB@f4r%S88FDQ}-p?axc_-*5@9z8nBmoz7OI05x}!g_>XVH}RTz&M28MgTRNjj{6m zyFjMj)yF}We`P35?kgpa{*eC{Z7U=kx2x+qV{JppuXolJOsEg zm(L)WA0&j0bViNgeU&TS&ubn~y!Wt$G zJ@ydx*rq8@1e*f;^S)<<HmTwbO@z*86 zB#)TBEpClT$1VuSaddn<1f*sq*}dm|_Pk$|i|9|tb5!u!oj@5c!rgXHFf9|6!Q9iL zB~!>5VSKPT8TP&q#<#+sAbHgx{j$UIMv#Z|`got^wBcnai&IKY|U=WZ6mNn z?~?wJn*G6>NCVNJO`I3}pwG&m33q{f;KYt$>6f`xX_L`Hr{9~1bUv;@ldA=r@y9?P z??1Q_v7Y^oZVxxr2A$V$*5a@%XRO&i_V)Y5+EzjalU!|_=tKy242Id0FO#UKsLyl7JYPP>g+u1Lf0F>@@ImS^+c0z3Q;{ZLRmR0aD)wn{%1Ex8`FC2^YJn!oxns znAXw9NJ_uq#~4us#8Y~iNASz;97gq~SEz(XAvj_`_xoHSkiO3wJo(0bMG zaP+8cs3Dqxk*Wo(3g6ump82&Pa#6(`xk;R`+cY25O3#Si`*B32@y9=8=i6I*jF{FNg3JVOl z<8Ov)(rV$aL#+2(#`dreXpP>UHcn&&MBNgv-jAfZu7B39M#w9Jo%0Z%XOm0|@lFI) zTXZpKt{SKno%X+c|2~^tt3=YLgt`;kK5-*47xUMspUCIeUwJ?X(i~O^R6?H@Jg2&4 zu%=rmj#(@9&&}_!2rU&SPX_sIZtGHXR1dv0DPQX5V|=zK&-)!jm9su2fF6$H9kwVM z=RI9NKPFOCmj{#Srfx*V2m!xni&*UBPE4=N8Aq6fY_>Sp;luhb1eI7>zFyC_Jx7#l zvlva_&_lG?FCA8kRh`a*x8Kqjoh`(MGj-cmpys5wm1`7qZ2Y0I`_Dng4X+SB16}oU zg3-T>hXDP5LZHFMVA75>8!`BXG_6Z|%GR5PRiZmbZ&o4!$44KcSMg;SRYJOHY% zi>7=ho{JA2S>=7H7NT~@AZ!fS?z`ULK^r(Zk5k0wi7BCfIr(@)pR{3Lh~&`kw}Ord zx2fsSdO9!3J`e0=0d6SAD#SVsSmjIpjU4$bODer*9 zgiV3^Lt4~S7~-gQT_ zjqyXg-HjB_(KJ|eBSk}k=~;gY1q3WIuc*vr;vN@8N6so;E0Sh0L5WVPiw0xJZ`iNT zJKatt@T*P9--p>f=ekCEHDUnC6%q=kp5BKF?5kKZ&Sb6pf>5Cj->IFg0O~^pR=!7% zd$({ukkrP_Z&>+T94>{)J)yZ_Gjw=B`viBTS%|+3T>XXs_KV*He0sO1x0#wo>;HYv zFaiJDc0+_{74-vibOM+0#lzOE$i&8tp#@B^wf~IgHM7ssMT~ZO-u)48)af!sdxORo0XxcA4Q=`vt968Ru{{4ZCQb*DdQpN z$&&W!!GkL@@@7q#J)U10bk)=7U@#=zx4>mwWn}(*GPTBzN)|rmLrtN&+mAo?HHK~f z>LZ(zi446>nB`86l2=)a%1aq?aKGghg;rl$@M#?<>Q)lUTRq&T!x9a_v{tC3d+#YiPoqh0pgF(RxvQ_x&Xhz=VF$R0`ME|X(+DXs;GVm z)(`?)-J0S@lS#=)x;)c`AnT^cA}aseLJp8zTt`wn63_|n;5>i{E@JBMsNjI&gHf4P z=LxMkeG+Es8Rj3QK#n65!`A_Axv=iG4o0_%Z3e_nA%v1X&MD_^#@}+v8D3*!&G2DF zCTMK_MMVKtnIWP}59Lq1@GM^cGz;}k6;ib)&134&K8Q`|F*;uD+2{7Q+qebdl@-xU zVA0^LF*TANlE&v&7pWHW}zh zyPhfenbVpByjsMpQut(eM6d*&FIGyI>*2B}oY$w{=yY}x_hUx9Yy^g=XqDhkSa=~> z_y?83_R#mhOV2f#od#R|LM~&%VUVp5rn5Iciv)-GAGiT?Tq20M4awm_V2fz5DbdbK zj-t`e9O2!kym4G!r{an2PRc-CRYePrIJ%O3k-s_Ttv@>G-e3I82u#6p=XuKosN`oC zKF6{o>q0`EUb4gIXJ-trXv<6SuCoLt^d;q{kuFVoKFlD%%(skH8(!q05EZt~Y9E_| z8|S5!s~eE$?>>Bg;_2S7SL-lECH69s$3z}sFJ9l)s!mzn3fSf4Hz00>{wdD5C2Tt+l3)1AiP{Ih9#(;=eY@61(mzv>hve<`_qG0=MY zEFX#v7Jj2!ho$>zYs-^j|6eHV{lC>?6JHvZA5&&LP+@|0#_&U&!9f(9o3(QH1yz2} zAx9USU*afs+1T=yKdub3o?oHciU=8)o5dU%m{^gRD zDojaz{=tJ8h!*)=OJJ_poM1kVZLmtElAN!$A9ln;z7(@RH8X|tHS7ulVuH=9?o04P zw^ck-jH~ddUDDY_ak2jHDN6je*LivmZlD(a+Bz;kU<A$OcxH9vU>VFnYh*$=>5xOpFLW9oY4ArWQ zSd!mY8&4NEL{dVM*h8V*8v^6Nwx;REB&IisIWaaSm zO6SR6EkS^Swu|2)y@$<-Zj|hcF?KMj@K83VCflID8Y6=9IPhi`YfyeQ)2+{V;RuDa)m&57Oz~E`%4c+a6zE?7IFW zr`S@tYnvQEaR2BkLba75Zq={FNUu%?TcL@dl{e@eLLD8zPqCVG2JbDf(o}d;PY_3p zEwRgqN!c!P~`3kea7x$WI1s8$x5h7g(Oo=c;#}Wq;=G*uPI15_*}` zP-C)?1cvi+Ws_C$?>a=;sg<_ z=;nvp=v`Ouy(+Y_?Re&^b?6MkOI}K)N!EZj@S}JcV#KTZz4_Lz`jW*tqQ8Q+?+{B7 z3^M?_J5%dSFh21eUjwn`7mH}H`e@v)UcoJ@;`;mwAn<`K%y0Dr?ejLy9><{KNrw>) z+@Fr`98)r7ZaS;3I?v;}PzI)vr^r2;o}#gBRUzl)Z71CG^6y!CGpl7~Z8z76R*Mn( zV{Msc;SUr6L=T&80Eq9D*WXg3q{RnUw?hc0OU7+Qm_*Xgwh$=di{|UNSb3py;|YnJ zUO+a~x8#jjN(I`OJMsqdU+HWd-*yF>W{WKrtKxnV%7`Wz1YH?q$P?&Gn~JG)AI#QI%ls7i@L{X*4JpP>mKDZ%3s8$ib+y^ zO&z(N$9H#St_o>rShVakB{V8UiEUdZ( zF~+ZNqk7VE69{PzYV?Ft8QGK0{Et^f1t)AcIrMimLvreO*Rru#8luEsFIGI_92J}Lg_7)oVNGEqQ~Vag=e0LamCb$WVqOnLH= zRW<*NGbmmsi-I6H3z6D<3&(hBy?$^9E|?b`nE|rSbx9HVze-V3MSgu9s#KM@zmuF1 zJ}N+SR@f^3O!{<@4CejfoVfboy8RI|XhM;7Y?uvY;#UWIT32c??Xk!0NUT*lQq8s? zGq{JnmnVuMP*g(7e5A5B&ZD|{dcW>X+Y(>ZC`qTe1U9w1nfWC|WR#|vT64(BNw?B? zgbyn_#zw`?5=wvAhw!$lOx>?U>()}QeDOr;xhjwdL1vv$r*g4p^i#=;1wb1@i$vP8 zxkr?32U|ZeAKcNg&C+FFEf+hJh=!&0l6AB7!!!iyRg!vrA=jPXz>dBs<{*j}O@N>0TcyDvK5;e857&qO#a-`r=(!;KLwA z`PdzB=7{O`Hdt)f8tfz(w93gS-xd^^D|?YTNo(YJb9t1=AIk2U6b|GU>bSx#=gyK# z(MnoUPzBT zw*s@kL9tDoaN(UDiIsPt9+rT`cZ@z?@KB5o`Q{2|5LdfZ>*S`%XhBQn>%JHQ;Ppe2 zM)Da`9WNF~5Gfo3blWF&_*^HyZnh3ng`YlYIirsA|3*6x?W4&D)To~L*FPg)(J!Kgt@QQc3Ji+M2dE?$WK>LB&B}-q zGq*au)HmmEu#}#-Ol>m`%|OpzLiI*=Kg_%p7s~7DKH=T` zS4iMW)RmX{a+i&+w&(8_s}}aA_UK-PJ6c-9uh=pJA8C97e>r?aGH!)2V00QEHD1C` zw4nFW+E^SL{YY0t-)Gb+y8ApsJiOI`2T>yV;fr~-!2PnccB2_L`b}qPisGFze&gxE zU~GjrOzJ?=A){~6SPi8J5E*=hZ39ZY$Lf`DR+-;Hy89d6?*dWLS~a-nwiO#F^~?~P zapPK@^QyMUiJ5HX?Fu+xJwC1O^?1 zar-!3(L_MQQJj&*4{fWCBy%ln;ZuaO8B=2;_nvX^qjC&9#T*RWSD0LzxMME}?>v`f z;wXrv6@b2s>Q!@I9|m|s=>;xkj!?e&_mNL+Z%2bT(($?H7vr+-7pb#g3cLN zZX5U(!UY&`@76o^YdV<6(C>D2DSufh@|ae2dYs^chwPQqVXt6PAmFp|+sI0uqcRkS zycu`t_+CNkL$78Q>;FD%g8%!l#pFBTdNOrfp1EpS(_pkv+3$>I1rJtJ+gaf+!zh+9 zgxtZou5K62%F0pM(gk02Fufe+K2mNo(Co$*jt(bN(v-@81YmeMXqP`gj}S1Z73d3{ z&Y<`OWKI1c!s`?5IYhG?xmD1GdIUVyl6^k$i{UNEPd~Ud3Y&DreRYAD5tvCYCQ@k1qm-@ay zx*Biuo^TZbv-LYj53)N$k%3lh3dYZ7HAZJ=VYa*1#T!Y_%UrY9&x9*Mt{<3l%JzcX{CawELMLTUqw%w|tZ>*R zL0~pXS>B;o`$-nFQ`3o(gr`JE&#*5r@O0JO;Gf=&;{O^yl6)c^UXjJyPe^x49hGI5 z!4p#rR*2A=1BH{EQesiUwddK?xNZA62leNT*@isSAT?+0W>PIjAI~QS|57rAJu{cd_rEJto5OWK;a7E^K-OyX1=VpciofXwNAa0hslh>p(h;b2=;s~vw# z>Lwzc7ii?!q$iA^?r%*zJ1t^~#801sfx{&6r_tq3fv&jx-K;#~zngvFe+B|9#pQ#njz}ZgrcCesc&nDqCaAdtSo%?Fs0XuGJ9Wh|7o>ESI z*!LWSMaZ))JTo3U);UC7m;{6H?x?r(iN`(W>2VDo>>3y3K-o{z1Ruc}lF0p&AVS$C z2Tpub#m=VWY4_srLBNhOY2fy76ZM@31DwH<>ve2_yV=`PFAMb`72dQ4GxRJU-#7~AIi z_i1z+q45JVX`8n@?AGQA%(GYhq==CI>qLG1d~sDOnZ&1yS%Lro=n-quUc#bX+32=l zq~&&!AuTl&0n2=SV2XRInp)c}q@>BP46VLT@OE}(YNO5FqB-tL1b9ulMsi5X6bX#Z zom;p3jV}3w{C12Gu^l`E7W%>rKL`8W%JX$J1ly+BP_#5TTh>%XCZ`b^DDYk5Lbo#b zx6A1rr>ciRVHTH0VZsNqR!5qfzdg1)UoBZDCs%Q7W!abNVXqQ}W+%M-=^VY_b%jJF z*5TVX#O}YiX?g^D%>(?SrrE`;?w&e3GrJx3Pv#e`nJfiLyPk$3XYI_CqI+?A2>Sna zF!D`ypc4+M+nPO{6qNeiKkl8i9)42(moS2nk^ciX!~|58Z3Kh@K#JwXDzY`}hkHNp-Vr2<7sM=d_~i!p!Zkd#MjlzvB?NEa zEPh7}tVSRXTu1sm()fi~JEht?F2MJFl=d=`z89ercA-f!p-E?zG4Z!23l zF8r#^+eTC^A|Nt2eTCraaq|1sJUcae!DYQ(o5}adb0XSw$i`sU%OLpmbaC>&Wr!xj zCP_*djm}!D7Z$&Mp{aREIA&ST|Mcw_xLFV8Qx2k=Chj?HpJw5uznU{pb9+Mb_o5bV zC&Y}Vohe#OGlNLGiu4WLe-p|de7;efz1DK|u(1BbdYK_gNC$9%w1gU0hiVSbZA2p< zQ}L<&mTC{%)A7qw(FtasvRa7pUjAlKk2L4*2j(j0DPapRswW-&(aLA%c z>jf2enrCWCLJiz5uqX)$d@?Bs*?T0DcD1?L zp^EmcU3A<0W}aREBV2|HpgxgA6_IbTo4FC_4i{>8%Zzcdr_zgOe8 zoJQY9!$v#9Y!YGhdE|)3(DDPrCz^_Ng_Re>$~FtTxNenojLfqpqv-fu<9GoWizT!U zF~Pjcm(8|F{ZzbRi&urNNgCkFAcs@hz1nkOK34qP;m5SI(t_S?ewPLd}DN zDB;oOhJKJZ$!;*b8P3S8%=UC`@;%$%tsmafiIMz#s~Li;d`IQ@

rEVbj>m|6M?tdz=y_P8@k8YK={4kw5#H@4R}J>MKWwYiwNJ72K7nytWx+`?KAO6 zyv4T8UPx>B#9#Y`aJ&VxVlfI^#lL?Keqa3@-WS}ANIy6aycdlN8J)5rNBC*8I$buR z^0VrlEf6OvocWVSo=L{M=~ii$~`Xz22$NEWCe`h;R-7 zN9(~bVra<9s?!3q{8Qr!qki#2+r?z;9w|~&m8pDuUw=Ve>1uRxG@00Wfi_H<8jhuc zw>KZFKx*ha{kZ-zh7o1g;mTwNIAcx*wtL(RMQ2t%vYO=ekeA%8xIejynCCDVAUt9) z+!M&4ZozI5u>D=}^K^H9tXw@X`WGmFM5{>H?9DYQFzJ( zo+VaH1nzV74HGohX1z|sWV^3D$BDR#LCfP!`54A)wPo2s0|J%)S9vAxw{hz;nyK7O(BF_{!)yBq9(!tv90nGJjXSqodlcbp4&VTmnCGMpv(4(Hvcmg6)lu0 zB~McW?QVNd*Lx**9EYnu$3GoU1@-lJhnbm`SzUTp@mf~C(1b!*#ad)i(+rC(<{qfDLv~1E%oDDI3-bc zf(;7~nz)^fWpk$qWpAsAg;g}zegA=@z zA4aG3n1@V;(!#Wp*ij)nEq^IXYiQ(JLf;{Aq5&qz16ViiU1HqFmjRVnv{-?Z7xEf8}09^xJL*7t4-z_4-|Lg@rSr1W&;E|GMzCdaPbyHW>-zM}yk#5KUgPdaO z1KHG{*93w14@$JN@6Sh%M`nl9x&Pu!0(a2#pF@Q)fctWVa|@-{+N~CQsLgA%`AS9X zjXR4VT2S!xjqr@4o?<-Z9ehXl&eWswM@_ObfCHZ&mBsBnIglo5guoO>A4xs*VlRQN zqJx3`7L5*1g3e+=wY!z5uwD0C#M)H)nRh!M?Y%%>ir3da_NnEc8reL9lp1u3Xi^;K z6Y)W*-Mj&C46-|bhXG7>#SW66p&l(o&!LxX1SfW#0kiK8o=tK+9ON^iZeGEO@B*E&L+gIsM@mj;;MyWpfG>G#SW@@JpPt&*xYJE(V|A;6F7gj zUbN%)jP>t%&pQ(UtKrd;Q}o>vQCpN~sk(Qp@^}|!lAYq{q^(mT#;1JXISTwY*{lfg z>%kE}|NDNMm@hUGpqG#A54$FNdxUGj4UG%V;m42=#3p)Rw>tAr4@O#aCm`}+CbL}E zi~fNdF4;^5COSqh)=v;%LCj#*N{cF;+d1=&(oIlW#_?;YP5I69@X*86^t1)wa`F$F zllbGnczl^E3bn-tHN~<_1#Ep9(e6-wy5?P*wSGya-^kQ!hC)f#yAIYBP)$e>OptKZ zG*ktk3aV49yHlGr>tU)FLd6%MKBzw)+}+J56VT6mu_K56!I2Ad7Bxfi^EFhAjVWDf z0BQGbHPs_A)VWB?^iMoAGYspmEEM=eyS&SM$ov$-^aE-m@Q>@iL<)x_dLF%tsC41Xow{Nj6z z^4CARG{kLxGxE)ws}u}Lfm)4)9T@b9E3(JEcYL(oz_ z*#j+lh$}UoB2XCZ~0>%3`9xeRJpfB4mgrHgKgSy<) z-APpbhmsahz9Xy;*+2t~;rN|)l&g?hH?02;<)TR@cGvaC026w9$YhO&Q9;`e8d|;Z zr4v?5MS8K|Z=Y`l==s=*)7!OZvb9S|(kuPV+z-KrD0pf4_{+D>~b`f>~SVhn&c}f^9ulIahe%v#eRU90hEM54Z;fXWj7ji%Ds8ldXr++Zd7`L zHy#N&2AVj-0#Z^GB8PLSC@Sc=kPEKjVl+mIkUy;MNxobco$|o$BUKFnkWL@$5q14oq}rM)fEB!KW&kSPPTkia*)~Q?DqVAEY#b)d+A=1sN^{Fm>?CzIe?i=B?Nj7U zzU=u)_ktA;i19e9wj^ZqM8RcT7Fw#>WfbZg3^ilX@ix4|o>jbah4(?v6js7Jdz_W# z+S_+DgIl(!psn1nI`=keJp6zdbGX1zCz(ArIXxTUAUg6JP_jnKRxKk{v5gL6~yl8E1xDV(5 zt^EEk3i<(>g5ZyUh*TQNp%8vEBXhcgi>6Y)AMJ`Ko5jc#ABX5^59?I5@d@=TSj^ERX>OgU^zcy84>aa^mxpBeVlT$lSfy!>TSOSE*knt>0 zT_lYlg61Va{$Xex8}5HYNfq#QcXgONZ%ho|!xivYmB~Uyy7f!wfO=1KdfVOD_N|NU zO}ob85d^8e=OZV`6*zZ)Kg4KW#S#e;MG=EP#Cyu6$J7ObZGN*0+aY9e#$##ffJJKZnWT7oS6# z1CGgQRF98Brr49R?-fK|RUVVmmv1R4>f{R$g_4a3vWij#BL`_;lM%H0P{uy_q4FqlZ18M%vRnr8&i?Q81|zM2 zclcGFD9Vhx?oTQ5*SuS0V2TKIfzS}3duC=NDeKtajt4p0-<17|g3r56eqc~X3YBN= z1Q90xF117wk^~F&MiY4(NAR4P4)BNfFX_jaVnq-N?FC#w&l2IiNG`ci^=V2#GTq-M z_Mf|+F=Jk#JAuC_6o<-3JyYoO+$QnHEX~OM*KoE^}I0!E!*2B7h>vQFtM}|eYust)lPhz9q86ozdYBOSF zQ0}jIoP4z3p~5?%p!;Ec!YMacO)Nr%PixnM>Ru7$5SG1(!^yNo(K$q4!rXRw?<*dN zY`abz96+OO;W5KeF?-d@{*JmN1mO$HesH1eynan<^n(dY!5Dogc^B1elE^ZFtQ^ zGT0yrT3Qn^zHYGV-g#g70Cc#A1e^2zc(s)^c+cbu&>lcfI=g*a;nXK3^qN77x2^-W ziKN82Ukz7z6Af)Rwq9{RcHrzzdVFk)#w_EwX{9(wxKdlCs&c*~!n=RC;JYBMt6xjC zH1Nsx3QC!R|1)kk|IsO*vM6YLIBemQfhMbHHR(Uu#(#$_>N{OGoPX&&!B27KRE;FQ zAEBBy5mn0#uf3JmQ}cFMJ@b-ZAHg$Y2q$rXr7lw=}ZG`u5^eZ5Yn)u@fp*35d ztItkfTU9nIaNiInDh)wj@)|ePu&|_8@b|t6Gp*$|P#iQkDv13SXBJX_qH2caYDR7N z2%B^FF+t($D8VkHh=yOxtM;k{gZTJzKCn9SFhqAjPEmYg$NtH-m715In%`nf2{&R< z-mGJxL92qvWF51dp*Ah{O2=6NDtSwO_wazzbEE$+$_I!*-OEMu9Utmp3a7$yu#IDn zx%J7-Oms1W8nHCCm6~EA->hVN`1ju-7vf2q6j-^#KUZLG(Q31susg!r3MI4AuKM+k z>;+RkvV%hkQ9RYp>;b)_R0V^+TcHTMO-LANM&DMxzQ3W-O+gtu5yXL*t*@vs` z!;cBsri^C7fsHhWmTXYTOqG6Kwe1f`%^Y^)MH3bbg_{6+>3wu|i-V5BOoSp@i9Zba z7^%^i7>!ELCFhj?wL)@X?|QZV-Tzw>B)HS>fZj8e1LY4-f0svWO{F2e@Kc23l{q@Q z5Y9h=;23yl-{PjpgPkdd3Yy{9mjt91UcC@Muf&iD`FmrNc7x(q8EalfDI#U@EHj75 zpPc75E3b(gOtURa);Gy3?M4x6q~WwQ5azzwNTSHR{!jWfrODC5BRe;`y9d>ldI~R( z);2{BxYTzk$DI#sEzM2(waa%fYyxcV%HK<#qEUT!uCAs<@&CADyC%)DT3gER?j1+{c`HX$nR|eu{8HVNmN;Q_cR1ri{d8|q4*k42cgVV6#Rz{YOZMqDp>2%A z1}113JV#SCn4Se!reZ8{nd$MK?=l)gG7 zPw-7psPxW*gEK)5W2436R^8Oo^|T&Xpf@9zDrp74Rhhpl58h*DHC(Z{9M0vLPwTbn@D)ja8H}Hew-b$K`G40A9NEzKk7E{rb6&XPju>RD;ZHPnFz+_pcYFvvRL?h_@dVjonZ+730H7wS1drjhnTp9kvmT(d02GYvW4|RxCSv zWIa;{71X3W)CHD z@CpjYyT@x@F`|Z$a<0OTv}N<+@Xdj;1F6IAe?tc_YZdz=S$*(iqE_2x?EPEGNaJzT401l&8 z95hW17^<@j5%)CYySr}h9HNh=U7Y){0ERS{_mgYJrZrs+K`cgzcS$G?Q(g}TPwBw; zp$|&WnBO`>_h*jb$xSCW^;h?wrHqPah_pdKLf6r<%w(b($_`HvD}+;TY+9Q;CD;xa zg_^W^ea%nWbM}Rd@zEhl1V{7-1ICAENIFb+RLy1o0i3eUG{~&s{_W|D%sF`OZZ*nl z`~A9m%GZ!O(mD`wIX7j!`w>cjEEbHe&#)!Ct>N;119Cj1S;VL^A{#sGt9n~NARVx{ zk$b?G`i0(ahz<*@O<-;4^V~i1_CbXBOqj`C=N53?g&^ckn&iAiWuX9qaY0Q#0;Ra%b_3$Fl0*-9!U$dIv*3axDE0U2Gw8QCCd~P z{^KN8Ez(U2iir4b?2j#q;BvDV!_yZkb(NzZX)VO}Y8fJ2EMv|m&o-ZWCG#sdI9mON z1CfEKPq&VLQe*6LGuRY-IvGPYMYdRfVIoAz7-L)Vw6nRV6)2>cYD&BOm z-`#q!59bpSXeH{wJBxYC>$iRF)9laAjf%vk(r#*ZW-|oB*xzW{Dh@d58adx% zbpO6xij?~UgYcKD$O?30!#7{l%KDbsZsy%2TU}rQ5-!A$-K`CVfw5GAU`Icaq&52P zpM2*|d3khy^=6xDgds#`Wo#3_5I!S)x}(r7Hh7Q%h!ymJ0ozQyOojo$ zgEG(F3ER8$L#>j3$*~`hzNzvrB$3(DmAmdQANRcGC3C-22cWBPFs5>n5FCX}|KMRA zvg-fY2w6R$D=sEVCR3)H;3Xr;?g}A<291Y1TMxjjDsH?b3iGfL^ABm+H65E$&8Fkv zte=MT=w~^kO${I=qac||;Ria7eyZnkg5}?)d3ta24s;ykFQnDA^hc@-&2x;a)CCpL zQV;0UbLao0s^$7Tbc>q2S_JNe%-H>YI_*Fz*w!h?X_maC8(Xz#6am>_k16}0R7P&K#7_#{Q5Gm#aYIfg zmCZUTY~$p^HuYEXli{><_@^wJQz2S0-1NI{MbvXGfPhLcFR#)aHe@R7cW-}cI>Y1l zytj9I+R~?v6Z_*PDgZZ5`GO&8tz@$Qli!``X!L804oWC1@=!OCGCRc%L)R_L?}S*2 zIcvc?8|YD(pySRpdqXEDb#Ly%=2;KMjOP9EOkj4pUl3@2g*xLNl|YJ|9^wg19@(BW zHkjS*c(<~ix(Wqwk?M9W0>A$b^T$RHeV5IZKNU5sT+9}rMv;HVa(`tgnJ8whht2RL zV7Uzdi|x{CYkybh%A z__@UY;}o5{<}9e|_2}p_nKO^qq)SKS{OBO^;?(@5dlxoGeznVLMWQm{hY|ER9kK3b z1x-dCGW`@U4kJ$Auj|{@8)3@-e~J`e(p7#?<*il)%&whjlrVPBnv%(Gu#{gxt>a>I zG3D{H;?*mx?n`$ok=HLv2AQ10d5^N*p(NW{7lVs9Grgo92 zn`7FDp`4N-UEbHf;;4M7%e8(TQ~=eDO?~URz1lYc^zE0~@)rqyG_IT*JE#Y@CR3b4 zrYLsl_cD!rFe){Is|-b2^;O*3Z6=kB3yF1Ok`q<=%Z1XbCl!&Xg%cdW^`!~qHBhB` z&3f(y1>k~iODVC)C`Gx{1?Z0cS7j zs_126nH)L4Rp!t;p;Y5Ue?6u_#70=Jp`sneWCp#e{VfTlogJ{u(pp$=+pJfq!;P!{ zh263}!X8ht2ZP7ubt(AEecO!a=N^c>G;yIx=u$y{Fe8!3;+U2_l_uMSnNas?jW#*l z{Cf10c7v(6P>*lNoXEoHSTYgno|{x*uLsoxlMvpVQNtB#-fb%wA$1d$%SR(&p+q#nx*IwyFH8{&*ZyOmBN)HuPx}T=A5)pXYJyF?MG`&@TA*+)>r% z-de9)hv}t=a_x6(DQ8)arj-7VK^M(hOWblK2MBkG#%gatU)9WH0u9Sa!e&3haT$R* zH{eg)@1E*rv~@>s2Efwt^6MDnrKO6Z#TMj-nIF^F8o0JJIZh@V`}s|Oh1aKpmz?^b z&wwZYige8JCYJnme>jYyj*XY~wjE>m1|EsS#y?>!mNVo)6!xJ2mJ05n*ev9Ec&f41 zOYG7CZ#$&n#3=GEvU7d;CZ|V-akkmOS~7%xYF`*;(&Dc+>S$Tn6B(3o!+^cuXQuan zQQ-%ATZ!q7b5qsLho`34$xo`2c?P@dKWJ)`?(wpdFBY$E-)Mc0mYj{|KZmEJBT&iyngV`F3m{3l_`u88nZ>K=CzRVtU?jZthOM83ZP50S}VMQ@!gw8j_KOBD7YFzYupqIOPM-*2@dLPeEAI`bAC?Kf2 zBjz$Vz}0vCRK)R9C04Acj;0;7+vr9tx%}14>hw*9Pc0;y)OeEUWD4_->Sd7Y2jw%t zpsYvRnO&`chdwL85DI=h4u?y+@^FF+Si5ZH_jLN!5EZhpVA9ZYq&X{vmfQ>~me{*D z{C8tR!73N;Yv2Df%0hg075@6`(myb_jHWI0+fah>)%P{wurV8vk;%sx*V}^17x*{$ zis9;$b%;x2w%Tkg=?16F$WM{uNMRc-+DmE;rP@Q(fUlO7%=;;uWsCq4i^~0zOa04b zLrCTSY3TlULmKzI(G1m_q>Nn2)Cg`-kE8q+98HOY2!uRzxA5lXg$=pF@T5cI^;dH< ze2$k!Q%DDaOw{IZ2;)!@R$qt&95t|N|T@{Gil zd5|y;Yshbcq&_DmFFq+x{s{?_=F2<kFNc744p_i=P3-O zdCfN{pZV5(@^^s>15Hnl2g5;f9YO!7x?G+oeBWp^rPqyHi5&&8(R#~N$D7&dP7tC5 zpK|AUxiEa&g^x&k26Dd4W_h|K)~lw=oaIWhLAx^Anrh0kGx*KZ4}&yS!T$>b&=*!R zgYdmymp58CXuG^8pcGi5fX+EqVW=-Xh&By7y1e|QE-+EYW@1u4iITdPqwJX1ax#hw zs)Sv8U{J+VqYkJX+vlVFjn-rk;L*OIakiK6V{rdaDKtF@8+}__D`vyJ)9!|Gr0ZM# zqY_p$@X`!#gf=DAHg+|&IZ?qGT-|~XV@hk!d#T-yqFTc0lHW5`%ww@AkBJZzV|XiC z%c3Ca*7mZ}YadSX@tGz_o0({Rd}n3VRXWN39#Z?H--q8}bP9%V!Z~F+cL!LBiM##h zWO+`sEA<=jX6ku)&O?Q?4MHH5h#hO7CrhN;e3bH1Y)jMlpKtu!8MAetFr#{Woills zxOA!CRMV9b7E|04{v)}Q=`%{%W14K)k5H;IV*Ndx(re4q z9MJi1=?c367xS2V_kE+z$1>BdYePgH5eSnchM{2g1IY{aZm&XWxGCve81qK1KJ`t3R)n(A33jb42`rI?k*OKyu z&hkojz}4b7av+|$H4^WDpTLE^_?!{pGZ1inh=pk2l*>&E8WN^Db}uzS@d(I{DWbSUDJ1Tt1EX4M{ZuAm z-70E5a~D;{@Q9?}d|Y<-mM;=c6qoO7vxbEiP-Qc(Txf-omIff!s{f(F8Y?;>Kv{Q~ za)oFc>5$GVL47T4u--G(&~$iCA~I)B{Ewv~e-Q`nB38ZUkKXS+Ja6vb<$dWK|1SA4 z{)an5f$J=w$IB{8y?ah-o`HgK} zQ;=zSUiqsKhcxaV(Y03T0-Bb?5?|&{9_iX`m(ePyY8-%%s04Q3UU)%e(TU$a`L? zE6kuD(!N_ZWu!|gRJKW$MMZcta-)w|RsY<9w|UxwYcmt(yEhfaP_kx+1r7l;U*&fU z-F`2im7T2qT{u{~cej4AV1~K*hAIetAPX0* z+*dPTodb?4%`-_9Fgyqa?k}cgYrO-c+Kj!nRKs0gfmr!h5#s;96pTz3yMwM$RLx9S z{uu}>-B#cxoFPUCmQ9<0xyi#<&|lpS=EI2nWa|t8i0O4H%!7t}b3w+Wo@c5It_zOv z9k$=Kw3LxQ3zRnb&?ASMk*Vf&*_lH)n05sz2t_8-%>Q!6(aK{XGslax@RgUVm*KKt z;vo>6$H8WQWmHv;^<_%y-;kc!kh>7dvN3`kPd$%1eEfAD#YZ(8H7-fbLw>CrLa0@k znYA>AH4t(7-;n0B-*t{EvpVL!S4VNtRUauD{pSIzk0yokS`&P%jJxi+>b>uS*nRW} zgd{bXUZ!|oZSvqs6B5Ji1OMUCc$_y^_r#o~wdSV_G(2o5&2aT_rWOr|^=30SCT2S9 zd2DPbyc?hv%WL4Gc7!3ogF0F_|9l0L4PSC=S@RUBkP;{!iP8c>Xo=-aCEnb26bO){ z#g6>B-&n=dCDm)X7#tt3HUe|`K`(?%W(Iag0>%12lyD=>7@`); zJWSULed6yaLv)Qd2KV@`(MP`pKY8X$5Ou4Yh5ZX~ewU#0$pm;t93?L4&fCN=jqr5T z;^g8R_ZW&w3!F?pLPGMyQIwU|Hevhyg_d%pgFJ?BVr3#dVL3a@R_j_E+IWe8`*PhK zXf@MWI+c~Wf6kqGdCKEW{7 zn|Eg^%kq$gC;kwT;elo^HILxuk-}Vuzo=C&Ik(HoQ31_v!m{hQ%WDCTbnomGhT^v( z@dv=3CxBZ^5Y|=hK|xUPGTEiRXl4B~jhtt(0dYA?W=82v%fAr2(!YY!N?qn!qUt{0 zA%xt0)Jy}Hqe$&85{|k2@0EW%P}p&+mi1A29uGAnCL9_x2GBNf z9~$P`jRwYjVrOFv+_H7Q3rw3N1_zYRS?$`A9G$<|I|#I1p7izycHVU=9W{~cOE=6` z*b3+-rWUfJZgeQ2Z=+$KpI&9vt_-uR84jqQ(!P zZ%A%?)u(h+*}2_TXw=a}+^wVY^5l1c81oeq=!0m3tJuK&%7m&dTvv)UF6muUx9(aq ziR|RSIbls%)38!TFILJZA##ei%;t~0K$k1v8Rw#B0dM0uAo?fC8%(tbMcFaWR5ScD*6xJ~iV_O33;FcT!^H@TC>WY7G$r3{^6?xDVEwgI z8ye_Lo>%2ySn z!YmG~ZF-q5b6zb_5HHxxDpwqb)Z2(pRTz*=YO|1XbQKnud`|oj7oG(ouuo14-bK}! z`ya7fIDt&PXqzNbsMnv9iSj55(ZbOm{5|b~P^9MtWOb&Qkl@_F(lS#;0XGO>RU%KeWNP zXd$5K)g&<^@s$W}NTu~|xmjq?hUxN``FGhDqMg#jWV zd(GRn9_N6h68U=~83v}^f~NglKS}#rGZFXwXqFjkEDLGv(nvi4Gr7ZwMW+SLJaqx5F{psm3 z=dUYJN!@ll-p(7U43$|IYjJMUI|AEZ|D1{)nV+JGuk#O|_wEE%CGMnM3t zvP{mrBBz9j%7`E8;1LlaP@2%$#TUI!*1yHAFNs;oEL8Z93Wd5LG4gmr{#s{Ei1}86 zUz_N-Wy4=Kgo7OI9rU&^3GG->160h^pT+N3nCWnFi=8h+bm{D%H40f;>|-1}UEUsWsqbh0ZAQrIoN)PG~EMfiq7lVe%MCSng@Gwk*P<}C0YQ0ZHit{N9;{3q`NR1XKX3n}c zsx6Dgx;ou)-@BpHPc2%LlBrm7D35=&*=a7e^8{|9zpVbO6OZZ`8)@vapw{lXU)ZNxO zBXvgr$#`m&iQmRF&=3~d0tP<{YhOQNtt*vs|7v|?1&SLfXu+=Y72r>Sf{O}o`hN{y zVSWj_9`nWgX$;7`B<^X6W0tpwS+PE;8#C)gk*2@Px#Y!gb&m^>+Kwo5`9enAN{En; zLHcGrw2tytiqz6$7uNu?K9qhVz6`D2&~0%mA3#Bj9$UWw3wU;c$ODP(FV!A+*Kra$ zhfW)82Rfu8odT`c5S2B6Wcz}gP*%w zsV@3>hZBmGzz)%*8#HovwTjAyuw{5yw`abjAyIHxXh5lS7f)223*|sM>Gdb`9I0L> z2Ohplbpdp)-Es1uno+&~UvMuMpAL{#WZum+L~m$+*&(p2`qjcT9zQMiC9Ak>lR`%f zLMjC8ht!#8u~i-I?(7W**t)!ABAtE@Kc98Sh`nsh`n6^kE|m_w-%Rt70&)7}zCxJW z{n1w?)sm@5U0ZP*lX2cZ!$=WM5t#0G7wJ>x$HcoyNlEr&q+$K^U3mYt)%IVStbopq1JvlW-2_4}injmy<`N(k4p&h1EsMlil!n9J(47^5fT0%s& z(=O`<9nD{>YqmvmZbs903NOiH!f_P6%%@ErAvnyMS5Y2ES`o?NUvpK<30?pSOOf!7 zGJhBRx}Q#4RFHP%8JkW%Q2W7SRot>K`_}&=SG_|h*XZI%aNbScW@5y@y~Ul7hqvI} z`eoD}2VNa*5G#Okd4k-~g`aPMX?F{yyk)qp>QEQhYU|?zb42DI=lD_7CssX7;!$aH z91i^)1Wl??Ebk&A)jQEv!RgQF;VhTiCuNWRxN{`0PtejT*-eUYcqA`}1x~VQy;vot ze2|+}gZkdFB;C6f^B)yi2rv|hyb(1+x<&B?(?1jtq+wa|b4Zw_*BS35Xc z(%e=gZ}rpVDzA6ib+NVqoQF^x_0jD^l(64WXO;P8HfNlC4#0N}q5Rv$tfT&aAS|+^ z7#xDSpvRuNTUOSrnUKww>yeP*r~@JK;45pcbf~h$6N+V-$=y{Hz4!Sq^dbS+p_s!b z*@p^Lwfrh<*Hi&*P}uWFG>DqXXAf6!iM~*lqqP{y_0aU>@7|7X5M$=tmJHOolQgqX zt>F?IZ1RChaQ@90^G7T8U%wZ${i*~n z+fbKxnmx1Xs3enWBqaL+m-^Q&1mH{@2^8*}{p$DYiPWXPnHtqurUv=k*dmN$Qun`e zxCwx2OjI(Z2v{^FgJ^=7fN)|J;F>om#VQJ~rL?z3*Qvh1nA1=-ttoMm^v^u{IiXXt z#Zdus{B{P-Sw59$}p^)lhaX@01<*m6l0a7SgB&UQK|=Sc$-0D z^c;PDNv6avX8J;b?`AJ1e})Bn&{!nA)?FG{2kBI#H_*NJNm_(zN zb1kNCS0jQ;7ar&$+OhHo$WU4QKMMv|zzO{aW}GYYS^jNh?LFZuizU!PHbd|9(jmSG{{#$2`= z?cl4a+5+LLvdKbd2$d(2U)*^f*~h;9*f34wLYyW-5}G?k$0s3_Wa~CQe^s?S${n0F z+|a!wEG9aCjT+PH{LvH2DhGH#C{fj#n4WR%T|9|^uuP%GF%JCM4gnr=Dpzlxl^u%< zRM25{=6D{#dXNS;jZ!La!E&0HO&kwZa{1>jE7ESmqU7Csf^s(;3Hqs7$R6sQRujJl z%LYWVhp^M4#YAc>`fXS$pn`Q=XT@DdUB4*{tJzV)Q37aaybHd`vlvY9+G7NKS>*1LX}*HpNq1q@HIZOEmF#RJ$)%I=-K9 zF!O-a@(dy4pECmj9`jJb&pSOqenW+Yf{hLkJ0^5z2mc+WOma@6r?3C*#V{x}WnMR7 zA+g|OqJn9#>{zz3lFV1F-OryWo!}o#eMld$8IthJ24#%C{S-_aQr&odYeRbU&8Fjz z-pPjnP6}8HBae4ft%iz6KmV64Dhu`=q}2k_pzB`y5F|a$Lt8sJE>zdWef15nt63mx z^Ib4g%J<6}IlQe6;J~I#+atNe-p1koW_1lDBs-=V7*e-?7h+PQ=refQ-M;)Bf!sYR zU(d%h1F{Uu{KC_iGtOjHo8@}ZmbG*t>DkOLi~Y(WkU$;g+q$Rk-~-Qlg>)@}6PUJW z7F53%xZ?VUwThQY08QR0!|n>CiP&C%0r2SeFV*Kr#S?Q*{3gIleg4WE*Xc}b(Ms&T z8@77(mT{N<&j^uatY6!dtBwvFjzsfS@G~jXKzU>%&%_RKOLQep@-1pAfGb#*vT>b( zGGFhL^d3-KT)gfi&tsXmFp(~~)9+jaM%bb!2A{~{Q8?zOm--!izwt^pZ-g)I2 zo?^6RVx#=!xEdNJ(X|}a{qgp)#SL~k?u=_T$Y_5=w`XCCd`sxc96B1L(nVQexPV9M z#I7Je0c=5*S9x_UeN}8TQOyPq5GCh25#tr#3@ka?{f;bJEvhL%zgwo{QOGvOx7pwR zQ7EjrZAQXzTF`WUc06P@#zeh)C%4@T4v6qVpKH*!Q04P5`^-d^Z-{@C*4JuX3mW;mk5#bcU!K$GA#347?|$8>zr zwn_A}G(>j)Enl3{?b&h(ssGcw;?kTsX9SrOm8NWbkDG*TF7{4&q|~4`>jPqnC8`Pi zO`DmVZwPdq6p@NLS8@(CEJ>6|}QDsk%Gt@OY{W z$F*4iBgCFd{>;+R3>UvVOgvN1H|wk*A(@kh9Y9Y6WXn_jCHe()hGdoQAWA^pg&UNi zr*1JQLNC!2_K8z%;msFXlB*FyF`$fO!1<>DLp?J1RZ&q$>8lVW<>Z82GjRcB(ci#?vIm{z3hOjxyN-yxeHQP!K=Cz zH{+443*XK&Jd_Po$GR|)+qH;-!P8gp%f;=M$AH*D3F2#!yaojQ3n!MlBVlKE(+8b1 zZw;HzY(!1Ru8qZS57(oLG)fN2D24R zf}Gib=CF=U_Am(Mabu*F@&Lo&03rQ4FuC#J0I_?!JZF(rRT32)pahf~0~)05PVRY| z#l@W?;1v?$l*)TE<(fh50D>2U$x`h%{uAIApG-X;1aLdW4h7sM6jg|{Dgwi zQJlx%AamMmEFdnOZaT6=^3@`{<<5OtOYkPYxBDr~*Xc9bl4pLGpH^m3@=b!$3<2Sp z^WH(BgA6fat|4k0??={;6Ry9;QriF=9_k4B@}3WV@zES>0-fAoeOifv4q5!?CrfRX)WPHl*c% zdpt_|-d;bYk|i!h@TyI<3v{?^vF+qWy_0l>bgCT5BK*RO9KEcIvAQ{qj+$LEE8|Gj z_+>LAIQD+q8cJ&o;fDr3FxY$opceNmCacy(#=4 z@SM7^T-V$|EWU)1WVAw%MyB93o91xc7X|lDZ(1sTtv?hVX9bmlKlPAm0-z^(%1T}2 z9D3>%gQ<12!h%k#KL?&Dm)_H>A6utawf1tbxs0y?XP&nGjv7}M;ru#~uDBLPF$bCH z(wM)(S$~!1Ig^z0tS)d6Xz#U~@@Tor__1Ppj~-enJ4J+wF4cUA!p z$%Pu8o4n+>5b3b!Rftq~9`PBzT(cn&XF0l_Du;;e7G^76-12AYl4W0>g1Y$In|GlB zNbOYJN3X8h5C{aJ5&xrV9Av8{#Fed5WctyVUDC3%?Y2Y=#^~*{qz+QOz6DT*$Oh~S z#h@(u5U9e5fy7pS=-X-|19O$s-S4T(hkcnFBVSe52OphITwKzg)cvpM>9v`lX&|GG zSr!{0zRqgaH}s|Z-D_p3)WeQ1!67`R$C8>K)tDbjW6s+2LU|YfqgguT`b4YX457(Y zrKoAx?4!rrWtx3iJhH_WHVd)nn&QudvjIhDAnY4Fr&!{Nmp?B)nsJw((S&Ky+LUSu z`3vZZMugL@fpLemGB`k`DQD*x-71c|NQsBFPI`TuSh8}A)9X}Y%H|D|S&PsUa(fU@ zUf@G-B{)3XP+AA_LfD<->ej|&qbTSAW=~un7TIpMrIwy&-TJ0Q;#k=D@T%taPV|{q z=eq4nguO*OF!iQyxoGDA6x~9H5@J@Tycyb5w46z*W z-p5MJxVG9}KZT^t7ze}>OV4Xi;@9A0KoEH-X58R+i?L{XtU11MhqC6XE5Iu4wfo*9 z#QiU1H3gz(-Hu{ICxWqca6Jf|34UCC{>Cgx{GeP?1efVP9@vXR!TsSbd%&BICtDhu zDwhfzrL1^W-Q{}Fc#x!qn3C&vnD>j(4&wIQ0rwuKqD6)cSC(SZ3j;kra4RwpCsGcrUs;gafb!KP6L+Vh?u5G!R ztZh)@(|792XyzO?Lt4?~v1W|soC%XF1&AB8J`X{eV`-VX%r5BJYi-p=4RCT~>(hVj zo>6@409hgCKoL1aH^g@)D4)dGoY|=&d(@{ffVePIqU!MpLhDW zVn-;xTbl@FEQIwg|Fk$Q7jd;MJkLY$Ua906@hy94Jws z{PU&k`~$oK@ZA3ASB_Ak?xFbR7zUR%TxWmT_8FwU;Pr50X0vc$1ruSHnyz)gd8jP^ zs4^!{qVB{0nL(CzyclT32hytcEL4>U^h16nD$1vYN?BiEgds?w)4-R%47A^}4}S?D zsSi-Z@1=a#s-~^d8zc7_vrXp8QiZxW9v&9gYgR*)#7k^rbk^p)FW;$hcA8z~hB*np z+7UecXT##xRo@`^zHQoS^GG20nB=VWUL~X-vqAHJ)G655oX1W7jlEf z`Z&mOxi~yb9LNTpyMGZc12F`BFn4L{s5j!z6G8NeoT=c%m9vwq`99M$JP3J7Mg~E6 zYFTqpq@xZfiISu5r`>zy1P}Qd+~pIR<)xf{<`~E!klU=(o-h-+5qEK1uHi zbSm|Q1j_CE%gWX|x|%L2Ko{AZX{MO};BX9FXP?>2;H#*?!UcggdPE05$}yukY4**e z0Ev8ZaEI|JE~kA6KccaQ=)%23ht8umSKBYquI_dsxCeP;wpqV zP+^k=!RI*%$9j3WITd$KJE_2Vg89uE=s?nM8*e4j-z(w%ky7DSc*mM)78*@YPw6#_ ziye;tjt_q#bYG#F0$ENPoi1GZ9_dFLQ4f+^C>lDN3OC(*y3m&F?dxDOL+cqLymrS~| zlzkhf$_igzoDDfVRl6&RfNv^>{ILv~TxsK*-y5@iC}v6xlsmfvkhUv@ni8+C)Da{z zeSl&t@+T0H8$D5n^O0x!?XgD*RZnEY>cj(H1P4(HfdI#wTWP+jAz3_+K65r3yGTP{ zb)x02jLD)V&mukgd;)p*HXJEg&*BX2XU4(7QLd)YL@;S+>bvLh0GgkyHb<+aNJfFG z>ERz|U6tXhI>sRrMU`W={a}}6y+CF0TJ;`Y@fH+~+t~L%pSL!HvxR5Pu|f)}h4p7& z9z}3Ml#gMlqO|RSf!9t)YQ?(MG+lV%Ye0vR%1HhfzHas$Mddwk%OS#_Ff`n^lP>A6 znQoY3;miJ63C+o>&et;m=Kjrcx3{56)O@RilOB!3yTw>$2Szw(Wd{r*b&uYhMuEc+ zdD{DLU}~YMEa6LK!L^*&df0>O^}l-7 z=jDbYq36rSAb|CKy!`2%%jj<@>|c?{I9Jc(IXYT+QT*3cRxc)%m29e^OUMmKjod2U z-d$&BhpBw%1J1<}@I_|h&cH3n+3;9bB!FPaL#v}UNvZaS0Oj<_84B)Odl?v-HSSL057{hCNio#dLZnkU(t&wfcIPS z@PGqH=`CoI%p#F~l{jkK05Z)dRc^*ydvGyrGBVAbZOD!Vn)<)Vj#4gC2|L zYRKLw#`_DKEJsNOmxW-(<%X_J`sqh6qxJ#M{6}!I0JyjlDheNQ9=!-6|N6R2Ppwx-6XDlKSsN|pca5zJV$HLywiQBm1xdCj&fDPb^A=41AOCzu5aS#;k z(WVKXuKcMZ2bWso&xokl+Gl!OA`8ZDZ=tAZ4H(W8YWKb4nK76nI~n0T5cf-;rMGq? zQ@!yLJO%Cciu?jedqFohy%d!Na$@XLJGty%Ru8E5E_158d~d~zCy-SBPsG12W`57~ zc?il72z#rl9P;-&7;URDKKg6>k7>^)6=a&?i%7*Ci4cj)$y`-9Io7r=l;SbwO?PZO znbYwpBZ?ODZji$V-5x8}U2WR*wv9GXb6dgGNj#eB*XvEzi20Nh3(Xc`0RGuN0#VuK zHg>=}IuaqMJUPP2v*Vvxd06tV0RMzrJJUty#!<+b~@x;HrnPFwmbaaWaKZoK$jOt!U!q%Zj zWFcbwd)LMdcj(}BHFF>hnn~U@DEbr-)~+0STfQC)+a6?2eLe??bnP{?X$29zW?H?r z`99HBdlT-#iLMefU)hwiY>CO#9YV6MyY-dGVY9u|V>f#sQ}^;+o?uuDI1wh&T0lj) zG=_cHpsRLGIrsu~4>|%WXZcHxH@@9mu-A;S-%(+9`=O*d^7@L6-wV~*`NFOVPlv=l zcQj8uRykH3YM0&|66eLY;>o_ahIF3&D~GYcxNcGl3jcGIpM~Oo(j2Z@W5hp^k?n)S zWscJRyNrnoD&BmS!n`S}{9n91IcF5_tx=xv`E7<236ob6 zexZXseWfB^JS0vS$?yeV2d3C0`c!7?S|?~@K~GDB7F#=5!}qdKh*V6QY$ts==CYwJ z{=B7Wfh3>p=S7wcUe6(=n-eM|1GGk`23zO|G;njM%F^bl#;UJ0 zR=07!J$|fv@Op<%=j`4t(!*_u4`8jhu=pLN-}f<5U!T=?%jso(_FU-p_QquWH4M#c zGvb@OO=ot-*sK+8QFs$NSM@^X#HEbBlBcGC^`&4S9``DZKGyhteC{0BNL(W*< z9YW*!Z3bXhEdpU(yUie8)%P?mQXxe~1ea6g{XlL+Ac_R=}4JoU4c<)T<=CL2{ zDI9FxIH+zZQR0Rh0JwE!syJS~iZQ0ChVpgpQ-^ae995h$n zvj$Rk+!*{6|4~J|XpMW$5SwcE=&U>sdd@nZT6gyaZw|gY*)p+*@*0HDy59_rUu(WU zDeed@VyOLLDXcs?JaH!c0w%r@Pw4l`Cr79Jk-_T)O z#O2^P_ z-E5&BZ$e)W1)yJyvR<$jUofl=CyJ!Ll+nX}81rN*Q!lrERdg--&3#?iqLD==fj^z= zbNd!!=kugFb+yaK(uvn&t*$jfU0!+qc^_Y9kT&mKr;j>WPxi7Xja1Vuxq!V`p`?&) zWt!>>BJo8u*b7Q1BHKr7eb>+{Bq^`2`%6k@#0~54gN=#0NJsdcpYMjNTY9VZ-QaTu zE;Ygtn30+P+E@Z>G_B_pT07dFace=>SNNfC1O-55(MjkrBiv{tlG*uGzm;=BD@L9V z!>}M<6-HLqG95hcd=Jw+Fvk{<Qtg4JUAVoeDr@}`Ea1AYqEyM z1_jx#G(0R1@&P#CvV%H{dr#`aT7)HXQ%a#HD!Aa&2k0ZniYtNOP5aA$qK<@*L^;As zAtnlO?JF|8-tj=cU`mOKkbM+hRTCxIOKaOV{-;`pfxgrRk9nqjw6!1Nx14KObiFs5 z>yR=f&pzt%5$E2MWsfrWJ%Q~-HZ3fnI`>wS%tY+8;zyE;VFT5A>p$TP?~O4(7ZV)B z+Vav^=$8Ygp@q4ObA<@2a*=*e-mk>K6s_Bsg%4hCns|~oEd|}#`5=MGLbpa%y)P*t z58gDWQzyR;bFtQaGaQBInz3pyJv74E_U#_0lisxL2W*l&8xxNHf=@yiCVW*Ee534VAhn(E<Qc_K}O<$>%mkW1iVK+B1L3A|yM!ny!AZx-R5)bL1wX3@i;UNdnrk%LoQNDV* zme=$5k4wHsA43p5G^oiA7ee@n_<3h_B;ph)a?P&pbx-=B1&?+uvx9fa=(r_cdMhw0 z5-j@)qi{p@^7_ohe$(HYw$|T%rwQDTfKfmPpG~eC5Vil6kTD~sCdxJT$z4`$S*^E5 zgN;~iBh9z^qc^xks_mG1#+a<8oi_+43a!SsfAS`tpDqTjLy(8WKOnPLKtn!*;K(JQ zkva<0u|nSJR{y!aP^?fYUk$la%tN1(Gjvp9P%1?!_6l zatCaA_AR~_C~ZsFo=@6nV3F{7XmG0(qi2cCNFUxzm(BtXc+Eu;J5A(Sxl96vK0{&? z58pWRBdq))VM)h1C%RU@f?o7umDFAqO+qf`rxPj!^*kym0@E~+oWJ=1QXd)jwmHh4 zz387+6N5u7`-q5wK3JF~MA?0f3DgyDm>m3sgg(nIn4%fceOQI;kyTZh^y2eIA^EuF z*?W-f@NT{hch1B`%Ijz^zRbk0uBQP6pZD96C!~t&&8eU@BTrGT%n+;YS+W$j1dr39 z@4jZmHmnE+3qEFm+phtcYB7mjZoU^JNVzD9rwfj8xtMM%mT2A1NOEVUUUhn%$5^bq zQ_0hTWZurpdYx5O#{~X53z}i6-G`Kj<$6D*ojz-uH*~YCdQOUyo4UTB5|~h|$zL-d zZ1&+T<{b+&v$|R0)=@%+bE~hL>OEXH*s6lpr>fVy12t15ZI}o1zOg#vC_CZC#ix~G zaY7a!(O5!1oKu(fQ<0533WWBX1<8rs&4jzdU+QZ|&&&Vac@52(1mK2=@Vm|YFf)gx z|KQF-IXf@DN>Hqe)$`ZI4*Wa42eb@pxy$u^C=7{LHvG8?JA#?wH133n+ta)3Gg<24 z5%6P$R-d2d|7-Gx!L&G5^AAnQKb<$;O5Uw=WU_yaigK33*o}(9$Q~&NScG7xAqgnp z?Q33k6FI{qsGuK{joY^J;Qm%!2U;oH(e(zv};haNrJ?Mn`O8rM>NVh#?usg+kyQ%*MZyjKVJ z4>^_LSdtopKqJ(e6k&2%{-}LzG!n2s#%uun^)jxC@iKzfHv zR)r{^-V>l_taWPtP^-$zL#k>+NN!u4-d9$3t(f&z)byz}@(i#Cf2(r8GU3@diDCun@1-&-eIckKb=#M%YZ zO1Y2M`BYdA&`+{(n;Qh3$+HciwYZHw36@9ccKD`E~e?RS$skRmN{; z`0w+@LhECIIm)`os5Sgxlu^RJp)5q*7-}4PjIz5Ug>Qx4EOUP=KX@J6bmH?7>*aJ{ zc(We`6Njf9GZydL1fMHPD8oBncxQjzGn=ijY(4=M*j}K^ZEL>)u->Bf*2%htQS;bI zstz4xPwcQC1%Yne0_&dgnH#r3etVrRi07qI`)B0G5_z!0WWb+4^Wq*!nYg&Yl9p!9 z95PD}x=DdED_+c_WutTBd^=gEPlBzxH@tYGX|^x{h_DuZHKYxCRXKC^CRl~Vsk|DdeyyJ*v2$3dDla0P(_v(opf4N zTx*;ax>WO5Onr{J04Jqk(RtDI!PsZ+1TV`(=7XWA3#($|J=6c!-dDdh8F&AWMjBCS zgcx*phjgnb-95&JIC3B$NQs0Zohl(Ux)~uQt;C2iKw=1^Q~Eof&vRYhf8u%nzFqfy z?(;tJI-sy-M_{+W9uR4cudoCN$I)CDhmBS zVhfTvEqPWIp3UYnwl*sZEqx|{R?3UNhDTgMXF1=J^{I5!>Y>wV)*EB{&^JU6;JD(A&f}%h+R=3Pm*#bJU>drRGnXIin%4+ z0G^>Fnadde%T%n0t@wm?+V>~yp2&SVz{9Kctw6M z$WVLHc&Pw(H8}=J56gMF*)E*CpRC~xxkH!y*bte{B$X)HTJBjwBs2K|BQ9O$A2K2v z#7@0hTk@{C&Zjm$A;o3F_giR=hnwF>Wn#oOR3MFTyM93brUkxPKh?Xde!kGw%I~r= zjQme_a`e#j)0E0vW9s&Mq|?M}=kd|5`}eU@B@?^Boel?H~_K^e|cC589=S_v;^dc@geBe0uNq=&( z1QZkZx9Q%+tzy!Yomz6y=P_N)d2R;o7RJ9@Lj0sW zq#~rWXbc935vD&*aXA(vJY(lI2rl)%iUQai?#^~SEI4;$YLLiFAf3r04{BXocVZ&t z;f_pi@kxF_QsVq@$+w}?8diJ1j7&zbn56yZj#&#%yAsO2aL;2<;#+c|oy?Fuup6ff zt1UvxrPu*$AECBK73+|s^}9;jP2^`^09``dTHr^zR~5c zMf&8K3{Ao7feww!jMZ)uDU64sG3%Rey#!yfs$JQZ10*6oHB0C z0naojM(&SXn8;<)2WYiM7jZxLy3FtLn;R+4Scm7t_DL�X^bMVT+LL;sE(2sBJ`J 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

EWs2){gUpp} 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 literal 0 HcmV?d00001 diff --git a/doc/unified-search-screen.html b/doc/unified-search-screen.html new file mode 100644 index 0000000..50e876b --- /dev/null +++ b/doc/unified-search-screen.html @@ -0,0 +1,216 @@ + + + + + + TryFox unified search mockup + + + +

+ + + + diff --git a/plans/tryfox_mockup.html b/plans/tryfox_mockup.html new file mode 100644 index 0000000..2e4d6d3 --- /dev/null +++ b/plans/tryfox_mockup.html @@ -0,0 +1,763 @@ + + + + + + TryFox — Android mockup + + + +
+
+
+
+

WELCOME TO

+

TryFox

+
+ +
+

Download and try Firefox-based browsers

+
+ +
+
+ + +
+

NIGHTLY BUILD

+

Choose a build date

+ +
+ + +
+
+
+ + + + + + diff --git a/unified-search-screen-plan.md b/unified-search-screen-plan.md new file mode 100644 index 0000000..583355b --- /dev/null +++ b/unified-search-screen-plan.md @@ -0,0 +1,115 @@ +# Unified search screen implementation plan + +## Goal and scope + +Replace the separate Profile and Treeherder APK screens with one `Search builds` screen. It must let a user select a Treeherder project (default: `try`), enter either an author email address or a revision, choose the correct request from that input, and show downloadable APKs grouped under each matching push. + +The screen will show, for every result push: + +- the commit message; +- a formatted push time and author; +- one row for every compatible APK-producing job, prefixed by the existing app icon; and +- only the APK variant best suited to this device, with the existing download/install state and actions. + +The Profile destination and its Home-screen icon will be removed. The existing revision and author deep links will continue to open the new screen and immediately search. + +## Current implementation to consolidate + +- `TryFoxMainScreen` / `TryFoxViewModel` already own the project + revision search, revision push metadata, artifact fetching, cache state, downloads, history writes, and installs. +- `ProfileScreen` / `ProfileViewModel` separately implement author search, then repeat much of the artifact/download/history logic. +- `TreeherderApiService.getPushByAuthor` is hard-coded to `project/try/push/`; it needs to use the selected project, just as revision lookup does. +- `AppDeepLinkParser` already recognizes both revision links (`revision`) and author links (`author`). `AppDeepLinkRouteMapper` currently maps author links to the Profile route. + +## Implementation steps + +1. Introduce a unified search model and query classification. + + - Add a small, unit-testable query type in the screen package (for example `SearchQuery.Email` and `SearchQuery.Revision`) plus a classifier that trims the input. + - Classify a syntactically valid email address as `Email`; treat a non-empty value without an `@` as a revision. Treat malformed `@` input as invalid and expose a clear validation error instead of issuing either request. + - Keep `try` as the initial selected project. Retain project selection for both search types, because author lookup will become project-scoped. + - Represent UI state as a single screen state: selected project, query text/type, loading/error state, and a list of push results. Each push result contains its comment, author, timestamp, revision, and compatible job/APK rows. + +2. Consolidate the two ViewModels into one search ViewModel. + + - Create `UnifiedSearchViewModel` by extracting the reusable cache, artifact-fetching, download, install, and history code from the two existing ViewModels. Prefer moving shared behavior into private helpers or a dedicated search/result loader instead of copying one ViewModel into the other. + - On search, dispatch `TreeherderRepository.getPushByRevision(selectedProject, revision)` for revisions and `getPushesByAuthor(selectedProject, email)` for emails. Preserve the Profile screen's bounded author-result behavior (currently 10 pushes) unless product requirements change. + - Convert every returned push to the same result shape: derive its first Bug comment when present (otherwise the first revision comment), retain its author and push timestamp, then load signed Android APK jobs and artifacts for that push. + - Reuse the existing preferred-job/fallback-job selection and pagination behavior from `TryFoxViewModel`; apply it per returned push for author searches. Preserve partial-result error reporting if a later jobs page fails. + - During artifact mapping, select exactly one APK per job. Determine compatibility from the device ABI list and choose the compatible artifact matching the first ABI in `Build.SUPPORTED_ABIS` order; do not surface other compatible or incompatible variants. Jobs with no compatible APK are omitted from the displayed list. + - Preserve cache refresh, WorkManager download state observation, automatic install behavior, and history upsert/update behavior for the selected artifact. Keep cache clearing disabled while any selected artifact is downloading. + - Delete `ProfileViewModel` only after the unified ViewModel has equivalent author-search and download/install coverage. + +3. Replace the Compose UI with the approved layout. + + - Rename or replace `TryFoxMainScreen` / `TreeherderApksScreen.kt` with a unified `SearchScreen` and move generic composables out only where it reduces duplication. + - Keep the existing top app bar, back action, cache-clear action, and project dropdown. Default the dropdown to `try`. + - Replace the revision-only field with a single `Email or revision` field. It should visibly indicate the detected type (email or revision), search on the keyboard action and the search button, and be enabled only for non-blank valid input while not loading. + - Remove the `Find a push` and explanatory text from the search card. + - Render each push as one card with the commit message, a formatted timestamp (reuse the existing push-time formatting/chip utility where appropriate), and author. Beneath it, render one compact row per selected compatible job: `AppIcon`, the job name, and the existing `DownloadButton`. Do not show ABI chips, task IDs, unsupported variants, or a second variant chooser. + - Use stable keys based on the push identifier/revision and job task ID. Keep distinct empty, no-results, loading, and recoverable-error states for both query types. + - Update/add string resources for neutral search copy and result states; remove Profile-only strings only after no source/test uses remain. Update accessibility labels and Compose test tags to be query-neutral. + - Delete `ProfileScreen.kt` after migrating any useful private UI pieces (notably the compatible-APK row) to the unified screen. + +4. Make author lookup project-aware at the repository boundary. + + - Change `TreeherderApiService.getPushByAuthor` to use `@GET("project/{project}/push/")` and a `@Path project` argument with its existing `full`, `count`, and `author` query parameters. + - Change `TreeherderRepository.getPushesByAuthor` and `DefaultTreeherderRepository` to accept and forward `project`. + - Update every production fake and test implementation of `TreeherderRepository`. Add an assertion in the unified ViewModel tests that an email search forwards the selected project. + +5. Simplify navigation and preserve deep links. + + - Replace the Home screen's two callbacks (`onNavigateToTreeherder` and `onNavigateToProfile`) with one `onNavigateToSearch`; remove `AccountCircle`, the Profile icon button, and `home_profile_button_description`. Keep the Search icon, now described as the unified build search. + - Keep `treeherder_search` as the canonical destination and keep the existing `treeherder_search/{project}/{revision}` path shape. Rename its argument concept from `revision` to `query` internally, so the exact same route can preload either an email or a revision without changing existing revision route strings. + - Change `AppDeepLinkRouteMapper` so an author destination maps to `AppRoutes.createTreeherderSearchRoute(project = "try", query = email)`. Revision destinations keep their project and current route output. URL encoding remains required for emails and unusual revisions. + - Keep `AppDeepLinkParser` support for all existing accepted link forms: + - `https://treeherder.mozilla.org/jobs?repo=…&revision=…` + - `tryfox://jobs?repo=…&revision=…` + - the corresponding `author=…` forms, defaulting the project to `try` when no `repo` is present. + - Retain a temporary `profile_by_email?email=…` NavHost compatibility alias that opens `SearchScreen` with project `try` and the email prefilled/searched. It is not exposed in Home and can be removed in a later release after any persisted/internal route consumers have migrated. Remove the plain `profile` destination rather than retaining an empty screen. + - Update `HistoryScreen` and `ReceiveFromDesktopScreen` navigation callbacks to use the unified route helper; their revision deep links should continue to auto-search. + +6. Remove dead code and update DI. + + - Register `UnifiedSearchViewModel` in `AppModule` with the dependencies currently split between `TryFoxViewModel` and `ProfileViewModel`, including `UserDataRepository` only if the product still wants to remember the last email. If retained, load it only as a prefill; do not auto-search on a normal Home navigation. + - Remove the `ProfileViewModel` Koin registration, `ProfileScreen` import/route, and any no-longer-used Profile-only composables/models/strings. + - Rename Treeherder-only identifiers where that makes the unified responsibility clearer, while leaving storage/cache names and persisted history schema unchanged for compatibility. + - Do not change the Android intent filters: they already admit both Treeherder HTTPS and `tryfox://jobs` links. + +## Test plan + +### Unit tests + +- Add classifier tests for valid email, revision, whitespace trimming, blank input, and malformed input containing `@`. +- Replace/expand `ProfileViewModelTest` with `UnifiedSearchViewModelTest`: + - email search calls the author endpoint with the selected project and builds multiple push cards; + - revision search calls the revision endpoint with the selected project and builds one push card; + - each card retains the expected comment, author, and timestamp; + - jobs without a compatible APK are excluded; + - when multiple APK ABIs are available, the selected artifact follows the supplied device ABI preference order and only that artifact is exposed; + - signed-job preference, fallback, pagination, de-duplication, partial errors, cache/download state, history write, and install behavior continue to pass (migrate the relevant existing `TryFoxViewModelTest` and `ProfileViewModelTest` cases rather than losing them); + - an email/revision deep-link initialization triggers the correct request exactly once. +- Update repository/service tests and fakes for the new `getPushesByAuthor(project, author)` signature. +- Update `AppDeepLinkParserTest` to assert author links still parse, including percent-encoded addresses and default/missing repo behavior. +- Update `AppDeepLinkRouteMapperTest` to assert revision route outputs are unchanged and author links now encode to the unified Treeherder-search route. Add a route test for an email containing `+`. + +### Compose and instrumentation tests + +- Replace `ProfileScreenTest` and `TreeherderApksScreenTest` with `UnifiedSearchScreenTest` coverage for: + - `try` shown as the default project; + - typing an email/revision changes the detected search type and invokes the correct ViewModel action; + - the revision deep-link/loading state and result header still behave correctly; + - an email search renders multiple push cards with message, formatted time, author, app icon, job name, and one compatible APK action per job; + - unsupported and duplicate ABI variants are absent from the UI; + - download transitions from Download to Downloading to Install and triggers installation as before; + - no-results and invalid-query error states are accessible. +- Update `MainActivityDeeplinkTest` to cover opening both a revision URI and an author URI into the unified screen, including back-navigation behavior. Keep the existing QR-scanned deep-link path covered. +- Update Home screen tests (or add them if absent) to assert there is one unified Search action and no Profile action. + +### Verification commands + +1. `./gradlew :app:detekt` +2. `./gradlew :app:ktlintCheck` +3. `./gradlew :app:testDebugUnitTest` +4. `./gradlew :app:connectedDebugAndroidTest` on an emulator/device when available + +Review the mockup in `doc/unified-search-screen.html` alongside the final Compose screen; update the mockup only if the implementation reveals a needed design decision. From 3c84ab13771676c55a2deb706b529ed91c18a63a Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 11:48:27 +0200 Subject: [PATCH 38/47] Refine search pagination fallback --- .../DefaultTreeherderRepository.kt | 2 + .../data/repositories/TreeherderRepository.kt | 1 + .../tryfox/network/TreeherderApiService.kt | 1 + .../tryfox/ui/screens/ProfileViewModel.kt | 115 ++++++++++++---- .../mozilla/tryfox/ui/screens/SearchScreen.kt | 9 +- .../tryfox/ui/screens/SearchViewModelTest.kt | 124 ++++++++++++++++-- 6 files changed, 213 insertions(+), 39 deletions(-) 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 45c8ab9..faea7c9 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 @@ -42,6 +42,7 @@ class DefaultTreeherderRepository( author: String, count: Int, offset: Int, + pushTimestampLte: Long?, ): NetworkResult { return safeApiCall { treeherderApiService.getPushByAuthor( @@ -49,6 +50,7 @@ class DefaultTreeherderRepository( author = author, count = count, offset = offset, + pushTimestampLte = pushTimestampLte, ) } } 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 484a476..87d8eee 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 @@ -17,6 +17,7 @@ interface TreeherderRepository { author: String, count: Int = 10, offset: Int = 0, + pushTimestampLte: Long? = null, ): NetworkResult = getPushesByAuthor(author) 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 9396b7f..4ed723b 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -23,6 +23,7 @@ interface TreeherderApiService { @Query("count") count: Int = 10, @Query("offset") offset: Int = 0, @Query("author") author: String, + @Query("push_timestamp__lte") pushTimestampLte: Long? = null, ): TreeherderRevisionResponse @GET("project/{project}/push/") 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 a2cbc1e..85347d7 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 @@ -148,10 +148,21 @@ class SearchViewModel( val failed: Boolean, ) + private data class PushPageResult( + val pushes: List, + val hasExpiredJobs: Boolean, + ) + + private data class PushBuildResult( + val push: PushUiModel?, + val hasExpiredJobs: Boolean, + ) + companion object { private const val TAG = "SearchViewModel" private const val MAX_PARALLEL_ARTIFACT_REQUESTS = 6 private const val PUSH_PAGE_SIZE = 20 + private const val EMPTY_PAGE_FALLBACK_SIZE = 50 } private sealed interface PaginatedSearch { @@ -174,7 +185,8 @@ class SearchViewModel( val search: PaginatedSearch, var nextOffset: Int = 0, var hasMore: Boolean = true, - var hasRetriedRecentPushes: Boolean = false, + var hasReachedExpiredJobs: Boolean = false, + var authorPushTimestampCursor: Long? = null, var fetchedPushCount: Int = 0, var hasRecordedSearch: Boolean = false, val loadedPushIds: MutableSet = mutableSetOf(), @@ -207,6 +219,9 @@ class SearchViewModel( private val _warningMessage = MutableStateFlow(null) val warningMessage: StateFlow = _warningMessage.asStateFlow() + private val _hasReachedExpiredJobs = MutableStateFlow(false) + val hasReachedExpiredJobs: StateFlow = _hasReachedExpiredJobs.asStateFlow() + private val _pushes = MutableStateFlow>(emptyList()) val pushes: StateFlow> = _pushes.asStateFlow() private val downloadStates = MutableStateFlow>(emptyMap()) @@ -277,6 +292,7 @@ class SearchViewModel( _authorEmail.value = email _errorMessage.value = null _warningMessage.value = null + _hasReachedExpiredJobs.value = false } fun updateQuery(query: String) = updateAuthorEmail(query) @@ -284,6 +300,7 @@ class SearchViewModel( fun updateSelectedProject(project: String) { _selectedProject.value = project _warningMessage.value = null + _hasReachedExpiredJobs.value = false } fun setEmailFromDeepLinkAndSearch(project: String?, email: String) { @@ -324,11 +341,13 @@ class SearchViewModel( _isLoadingMore.value = false _canLoadMore.value = false _loadMoreError.value = null + _hasReachedExpiredJobs.value = false activeSearchJob = viewModelScope.launch { try { _isLoading.value = true _errorMessage.value = null _warningMessage.value = null + _hasReachedExpiredJobs.value = false _pushes.value = emptyList() when (val pushResult = fenixRepository.getPushByRevision(projectToSearch, revision)) { is NetworkResult.Success -> { @@ -429,6 +448,7 @@ class SearchViewModel( _errorMessage.value = null _loadMoreError.value = null _warningMessage.value = null + _hasReachedExpiredJobs.value = false _pushes.value = emptyList() paginationSession = PaginationSession(search) _canLoadMore.value = true @@ -464,6 +484,7 @@ class SearchViewModel( session: PaginationSession, isInitialLoad: Boolean, requestedCountOverride: Int? = null, + isEmptyPageFallback: Boolean = false, ) { if (!session.hasMore || paginationSession !== session) return val requestedCount = requestedCountOverride ?: requestedPushCount(session.search, session.nextOffset) @@ -475,6 +496,7 @@ class SearchViewModel( is NetworkResult.Error -> { val message = "Error fetching pushes: ${response.message}" logcat(LogPriority.ERROR, TAG) { message } + if (isEmptyPageFallback) return if (isInitialLoad && _pushes.value.isEmpty()) _errorMessage.value = message else _loadMoreError.value = message } @@ -486,9 +508,12 @@ class SearchViewModel( rawPushes.joinToString { "${it.id}:${it.revision.take(12)}" } } val newPushes = rawPushes.filter { session.loadedPushIds.add(it.id) } - // Author-filtered Treeherder responses ignore offset. For those searches we - // request a growing prefix and advance by only the newly observed pushes. + // Treeherder's author filter ignores offset. Its timestamp cursor is inclusive, + // so duplicate the boundary push and remove it through loadedPushIds. session.nextOffset += newPushes.size + if (session.search is PaginatedSearch.Author) { + session.authorPushTimestampCursor = rawPushes.lastOrNull()?.pushTimestamp + } session.hasMore = rawPushes.size == requestedCount _canLoadMore.value = session.hasMore session.fetchedPushCount += newPushes.size @@ -506,7 +531,8 @@ class SearchViewModel( updatePaginationWarning(session) return } - val displayablePushes = buildPushUiModels(session.search.project, newPushes, rawPushes) + val pageResult = buildPushUiModels(session.search.project, newPushes, rawPushes) + val displayablePushes = pageResult.pushes logcat(LogPriority.DEBUG, TAG) { "Page produced ${displayablePushes.size} displayable pushes: " + displayablePushes.joinToString { it.revision.orEmpty().take(12) } @@ -519,27 +545,37 @@ class SearchViewModel( session.hasRecordedSearch = true } } + if (pageResult.hasExpiredJobs) { + // Results are newest first. An all-empty jobs page marks Treeherder's job + // retention boundary, so older pushes cannot yield downloadable builds. + session.hasReachedExpiredJobs = true + _hasReachedExpiredJobs.value = true + session.hasMore = false + _canLoadMore.value = false + } updatePaginationWarning(session) - // A blank query means "find a recent build". If the newest page contains no - // usable APK, automatically look through the next 50 pushes before asking the - // user to load still more results. + // If a regular page has no usable APKs, make one larger request before asking + // the user to load more again. The fallback must never trigger another fallback. if ( - isInitialLoad && - session.search is PaginatedSearch.Recent && - !session.hasRetriedRecentPushes && - _pushes.value.isEmpty() && + !isEmptyPageFallback && + displayablePushes.isEmpty() && session.hasMore ) { - session.hasRetriedRecentPushes = true - logcat(TAG) { "No usable APKs in the newest $PUSH_PAGE_SIZE pushes; searching the next 50." } + val fallbackCount = if (session.search is PaginatedSearch.Author) { + EMPTY_PAGE_FALLBACK_SIZE + 1 + } else { + EMPTY_PAGE_FALLBACK_SIZE + } + logcat(TAG) { "No usable APKs in this page; searching the next $EMPTY_PAGE_FALLBACK_SIZE." } loadPagesUntilResultsOrExhausted( session = session, - isInitialLoad = true, - requestedCountOverride = 50, + isInitialLoad = isInitialLoad, + requestedCountOverride = fallbackCount, + isEmptyPageFallback = true, ) return } - if (_pushes.value.isEmpty() && !session.hasMore) { + if (_pushes.value.isEmpty() && !session.hasMore && !session.hasReachedExpiredJobs) { _errorMessage.value = "No push was found with a job that produced an APK." } } @@ -547,7 +583,8 @@ class SearchViewModel( } private fun requestedPushCount(search: PaginatedSearch, offset: Int): Int = when (search) { - is PaginatedSearch.Author -> offset + PUSH_PAGE_SIZE + // The next author page includes its timestamp cursor, so request one additional push. + is PaginatedSearch.Author -> if (offset == 0) PUSH_PAGE_SIZE else PUSH_PAGE_SIZE + 1 is PaginatedSearch.Recent -> PUSH_PAGE_SIZE } @@ -560,8 +597,8 @@ class SearchViewModel( project = search.project, author = search.email, count = count, - // Treeherder ignores this parameter when author is supplied. offset = 0, + pushTimestampLte = paginationSession?.takeIf { it.search == search }?.authorPushTimestampCursor, ) is PaginatedSearch.Recent -> fenixRepository.getRecentPushes( project = search.project, @@ -571,12 +608,15 @@ class SearchViewModel( } private fun updatePaginationWarning(session: PaginationSession) { - if (_pushes.value.size < session.fetchedPushCount) { + if (session.hasReachedExpiredJobs) { + _warningMessage.value = "Older pushes' jobs have expired." + } else if (_pushes.value.size < session.fetchedPushCount) { _warningMessage.value = "Showing ${_pushes.value.size} of ${session.fetchedPushCount} fetched pushes. " + "The remaining pushes did not contain a job that produced an APK." } else { _warningMessage.value = null + _hasReachedExpiredJobs.value = false } } @@ -584,16 +624,16 @@ class SearchViewModel( project: String, pushes: List, pagePushes: List, - ): List = coroutineScope { + ): PushPageResult = coroutineScope { val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) - pushes.map { pushResult -> + val pushResults = pushes.map { pushResult -> async { val jobsResult = fenixRepository.getJobsForPush(pushResult.id) if (jobsResult !is NetworkResult.Success) { logcat(LogPriority.WARN, TAG) { "Push ${pushResult.id} job lookup failed: ${(jobsResult as NetworkResult.Error).message}" } - return@async null + return@async PushBuildResult(push = null, hasExpiredJobs = false) } val candidates = jobsResult.data.results.filter(::isAndroidApkCandidate) logcat(LogPriority.DEBUG, TAG) { @@ -604,12 +644,15 @@ class SearchViewModel( val (signedCandidates, unsignedCandidates) = selectJobsBySigning( candidates, ) - val jobsWithArtifacts = (signedCandidates + unsignedCandidates).map { jobDetails -> + val artifactResults = (signedCandidates + unsignedCandidates).map { jobDetails -> async { - val artifactResult = artifactSemaphore.withPermit { fetchArtifacts(jobDetails.taskId) } - artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { jobWithArtifacts(jobDetails, it) } + artifactSemaphore.withPermit { fetchArtifacts(jobDetails.taskId) } } - }.awaitAll().filterNotNull() + }.awaitAll() + val jobsWithArtifacts = artifactResults.mapIndexedNotNull { index, artifactResult -> + artifactResult.artifacts.takeIf { it.isNotEmpty() } + ?.let { jobWithArtifacts((signedCandidates + unsignedCandidates)[index], it) } + } val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) if (visibleJobs.isEmpty()) { logcat(LogPriority.DEBUG, TAG) { @@ -617,7 +660,7 @@ class SearchViewModel( "Candidates=${candidates.size}, jobsWithArtifacts=${jobsWithArtifacts.size}" } } - visibleJobs.takeIf { it.isNotEmpty() }?.let { + val push = visibleJobs.takeIf { it.isNotEmpty() }?.let { logcat(LogPriority.DEBUG, TAG) { "Push ${pushResult.id} is displayable with ${visibleJobs.size} jobs: " + visibleJobs.joinToString { "${it.jobName} (${it.artifacts.size} APKs)" } @@ -636,8 +679,19 @@ class SearchViewModel( pushTimestamp = pushResult.pushTimestamp, ) } + PushBuildResult( + push = push, + // Treeherder retains the push after its jobs have expired, but returns an + // otherwise successful response with no jobs. Since results are newest + // first, this is the boundary beyond which builds cannot be retrieved. + hasExpiredJobs = jobsResult.data.results.isEmpty(), + ) } - }.awaitAll().filterNotNull() + }.awaitAll() + PushPageResult( + pushes = pushResults.mapNotNull(PushBuildResult::push), + hasExpiredJobs = pushResults.isNotEmpty() && pushResults.all(PushBuildResult::hasExpiredJobs), + ) } private suspend fun fetchArtifacts(taskId: String): ArtifactLoadResult { @@ -681,7 +735,10 @@ class SearchViewModel( uniqueKey = "$taskId/${artifact.name.substringAfterLast('/')}", ) } - ArtifactLoadResult(artifacts = artifacts, failed = false) + ArtifactLoadResult( + artifacts = artifacts, + failed = false, + ) } is NetworkResult.Error -> { 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 05e43da..2607b36 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 @@ -107,6 +107,7 @@ fun SearchScreen( val errorMessage by searchViewModel.errorMessage.collectAsState() val loadMoreError by searchViewModel.loadMoreError.collectAsState() val warningMessage by searchViewModel.warningMessage.collectAsState() + val hasReachedExpiredJobs by searchViewModel.hasReachedExpiredJobs.collectAsState() val pushes by searchViewModel.pushes.collectAsState() val installStates by searchViewModel.installStates.collectAsState() val activeInstallKey = installStates.entries.firstOrNull { (_, state) -> @@ -234,7 +235,7 @@ fun SearchScreen( } } - warningMessage?.let { + warningMessage?.takeUnless { hasReachedExpiredJobs }?.let { item { AnimatedVisibility( visible = showCurrentSearch, @@ -313,6 +314,12 @@ fun SearchScreen( } } + if (hasReachedExpiredJobs && showCurrentSearch) { + warningMessage?.let { message -> + item { WarningState(warningMessage = message) } + } + } + } } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt index 59d005f..78b2d48 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt @@ -7,12 +7,15 @@ import kotlinx.coroutines.flow.emptyFlow 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.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mozilla.tryfox.data.Artifact @@ -99,15 +102,15 @@ class SearchViewModelTest { } @Test - fun `author pagination grows its request count because Treeherder ignores offset`() = runTest { + fun `author pagination uses an inclusive timestamp cursor`() = runTest { val repository = mock() val installCoordinator = mock() whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) - whenever(repository.getPushesByAuthor("try", "developer@example.com", 20, 0)) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 20, 0, null)) .thenReturn(NetworkResult.Success(authorResponse((1..20).toList()))) - whenever(repository.getPushesByAuthor("try", "developer@example.com", 40, 0)) - .thenReturn(NetworkResult.Success(authorResponse((1..40).toList()))) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 21, 0, 20)) + .thenReturn(NetworkResult.Success(authorResponse((20..40).toList()))) whenever(repository.getJobsForPush(1)).thenReturn( NetworkResult.Success( TreeherderJobsResponse( @@ -132,6 +135,9 @@ class SearchViewModelTest { for (id in 2..20) { whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) } + for (id in 22..40) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } val viewModel = SearchViewModel( fenixRepository = repository, userDataRepository = FakeUserDataRepository(), @@ -148,12 +154,12 @@ class SearchViewModelTest { advanceUntilIdle() assertEquals(listOf("revision-1", "revision-21"), viewModel.pushes.value.map { it.revision }) - verify(repository).getPushesByAuthor("try", "developer@example.com", 20, 0) - verify(repository).getPushesByAuthor("try", "developer@example.com", 40, 0) + verify(repository).getPushesByAuthor("try", "developer@example.com", 20, 0, null) + verify(repository).getPushesByAuthor("try", "developer@example.com", 21, 0, 20) } @Test - fun `blank query checks the next 50 pushes when the newest page has no APK`() = runTest { + fun `blank query checks the next page when the newest page has no APK`() = runTest { val repository = mock() val installCoordinator = mock() whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) @@ -163,7 +169,7 @@ class SearchViewModelTest { whenever(repository.getRecentPushes("try", 50, 20)) .thenReturn(NetworkResult.Success(recentResponse((21..70).toList()))) for (id in 1..20) { - whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(noApkJobs())) } whenever(repository.getJobsForPush(21)).thenReturn( NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")))), @@ -172,7 +178,7 @@ class SearchViewModelTest { NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")))), ) for (id in 22..70) { - whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(noApkJobs())) } val viewModel = SearchViewModel( fenixRepository = repository, @@ -192,6 +198,102 @@ class SearchViewModelTest { verify(repository).getRecentPushes("try", 50, 20) } + @Test + fun `failed empty-page fallback leaves load more available for a new attempt`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getRecentPushes("try", 20, 0)) + .thenReturn(NetworkResult.Success(recentResponse((1..20).toList()))) + whenever(repository.getRecentPushes("try", 20, 20)) + .thenReturn(NetworkResult.Success(recentResponse((21..40).toList()))) + whenever(repository.getRecentPushes("try", 50, 40)) + .thenReturn(NetworkResult.Error("fallback failed")) + whenever(repository.getRecentPushes("try", 20, 40)) + .thenReturn(NetworkResult.Success(recentResponse((41..60).toList()))) + whenever(repository.getRecentPushes("try", 50, 60)) + .thenReturn(NetworkResult.Error("fallback failed again")) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")))), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")))), + ) + for (id in 2..60) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(noApkJobs())) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "", + ) + + viewModel.submitSearch() + advanceUntilIdle() + viewModel.loadMorePushes() + advanceUntilIdle() + + assertTrue(viewModel.canLoadMore.value) + assertEquals(null, viewModel.loadMoreError.value) + verify(repository, never()).getRecentPushes("try", 20, 40) + + viewModel.loadMorePushes() + advanceUntilIdle() + + verify(repository).getRecentPushes("try", 20, 40) + verify(repository).getRecentPushes("try", 50, 60) + } + + @Test + fun `empty job results end pagination and show an expiry warning`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getRecentPushes("try", 20, 0)) + .thenReturn(NetworkResult.Success(recentResponse((1..20).toList()))) + whenever(repository.getRecentPushes("try", 20, 20)) + .thenReturn(NetworkResult.Success(recentResponse((21..40).toList()))) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "available-task")))), + ) + whenever(repository.getArtifactsForTask("available-task")).thenReturn( + NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "2027-01-01T00:00:00Z", "application/vnd.android.package-archive")))), + ) + whenever(repository.getJobsForPush(21)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + for (id in 2..20) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + for (id in 22..40) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "", + ) + + viewModel.submitSearch() + advanceUntilIdle() + assertTrue(viewModel.canLoadMore.value) + + viewModel.loadMorePushes() + advanceUntilIdle() + + assertEquals(listOf("revision-1"), viewModel.pushes.value.map { it.revision }) + assertFalse(viewModel.canLoadMore.value) + assertEquals("Older pushes' jobs have expired.", viewModel.warningMessage.value) + } + private fun revisionResponse() = TreeherderRevisionResponse( meta = RevisionMeta(revision = "abcdef123456", count = 1, repository = "mozilla-central"), results = listOf( @@ -243,6 +345,10 @@ class SearchViewModelTest { }, ) + private fun noApkJobs() = TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix", "B", "no-apk-task")), + ) + private class FakeDownloadCoordinator : ApkDownloadCoordinator { override val downloads = MutableStateFlow>(emptyMap()) From c6badc239022ea84f4b75a49ba4aef4bd1ae6e17 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 12:13:45 +0200 Subject: [PATCH 39/47] Remove fetched push summary warning --- .../java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt | 6 ------ 1 file changed, 6 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 85347d7..f0aa4a3 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 @@ -187,7 +187,6 @@ class SearchViewModel( var hasMore: Boolean = true, var hasReachedExpiredJobs: Boolean = false, var authorPushTimestampCursor: Long? = null, - var fetchedPushCount: Int = 0, var hasRecordedSearch: Boolean = false, val loadedPushIds: MutableSet = mutableSetOf(), ) @@ -516,7 +515,6 @@ class SearchViewModel( } session.hasMore = rawPushes.size == requestedCount _canLoadMore.value = session.hasMore - session.fetchedPushCount += newPushes.size logcat(LogPriority.DEBUG, TAG) { "Page contains ${newPushes.size} new pushes; hasMore=${session.hasMore}; " + "loaded IDs=${session.loadedPushIds.size}" @@ -610,10 +608,6 @@ class SearchViewModel( private fun updatePaginationWarning(session: PaginationSession) { if (session.hasReachedExpiredJobs) { _warningMessage.value = "Older pushes' jobs have expired." - } else if (_pushes.value.size < session.fetchedPushCount) { - _warningMessage.value = - "Showing ${_pushes.value.size} of ${session.fetchedPushCount} fetched pushes. " + - "The remaining pushes did not contain a job that produced an APK." } else { _warningMessage.value = null _hasReachedExpiredJobs.value = false From b1bd328ee3cb1a960886b6df7b896e5f40af8dd0 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 14:53:28 +0200 Subject: [PATCH 40/47] Fix lint violations --- app/src/main/AndroidManifest.xml | 3 +++ .../java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt | 2 +- .../main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt | 4 ++-- .../main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt | 1 + .../main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt | 3 --- .../org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2944161..c392706 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + diff --git a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt index 66bf8d6..dfadcf7 100644 --- a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt +++ b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt @@ -34,8 +34,8 @@ import org.mozilla.tryfox.EXTRA_NAVIGATION_ROUTE import org.mozilla.tryfox.EXTRA_RECEIVE_FROM_DESKTOP_START_REQUESTED import org.mozilla.tryfox.MainActivity import org.mozilla.tryfox.R -import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import java.io.IOException class TryFoxLanReceiveService : Service(), KoinComponent { diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index 21878c7..0e42f2e 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -59,10 +59,9 @@ import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.ui.composables.AppIcon -import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment -import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import org.mozilla.tryfox.ui.composables.CurrentInstallState import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel @@ -76,6 +75,7 @@ import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.parseDateToMillis +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective @OptIn(ExperimentalMaterial3Api::class) @Composable 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 c1ffa74..59ed80e 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 @@ -95,6 +95,7 @@ class HomeViewModel( private var installedTryBuild: InstalledTryBuild? = null private var initialLoadStarted = false private var tryFoxCardDismissed = false + @Volatile private var selectedHomeAppNames = HomeAppFamily.entries.associateWith { it.defaultAppName } private val appMutationVersions = mutableMapOf() 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 2607b36..8ceefdd 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 @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Arrangement 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 @@ -291,7 +290,6 @@ fun SearchScreen( ) } } - } else if (canLoadMore && isLoadingMore) { item { LoadingMoreFooter() @@ -319,7 +317,6 @@ fun SearchScreen( item { WarningState(warningMessage = message) } } } - } } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt index b1cb218..c246384 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt @@ -1,7 +1,7 @@ package org.mozilla.tryfox.ui.screens -import kotlinx.datetime.LocalDate import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test From f0bcbf45f580af98fcae270e82e2e0ef7132e62a Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 17:43:25 +0200 Subject: [PATCH 41/47] Route all APK installs through PackageInstaller --- README.md | 2 +- .../mozilla/tryfox/data/FakeIntentManager.kt | 24 -------- .../tryfox/ui/screens/HistoryScreenTest.kt | 12 +++- .../tryfox/ui/screens/SearchScreenTest.kt | 52 ++++++++-------- app/src/main/AndroidManifest.xml | 10 --- .../org/mozilla/tryfox/TryFoxViewModel.kt | 31 ++++------ .../tryfox/data/managers/IntentManager.kt | 45 +------------- .../java/org/mozilla/tryfox/di/AppModule.kt | 3 +- .../tryfox/install/ApkInstallCoordinator.kt | 61 +++++++++++-------- .../tryfox/ui/screens/HistoryViewModel.kt | 57 ++++++----------- .../tryfox/ui/screens/HomeViewModel.kt | 9 ++- app/src/main/res/xml/provider_paths.xml | 5 -- .../org/mozilla/tryfox/TryFoxViewModelTest.kt | 20 ++++-- .../tryfox/data/managers/FakeIntentManager.kt | 19 ------ .../install/ApkInstallCoordinatorTest.kt | 36 +++++++++++ .../tryfox/ui/screens/HistoryViewModelTest.kt | 32 +++++++--- .../tryfox/ui/screens/HomeViewModelTest.kt | 31 +++++++++- 17 files changed, 215 insertions(+), 234 deletions(-) delete mode 100644 app/src/main/res/xml/provider_paths.xml diff --git a/README.md b/README.md index 0e4c6dc..e24352e 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Run Android instrumentation tests with a connected device or running emulator: - **Retrofit** for type-safe HTTP requests to Treeherder and Taskcluster APIs. - **Kotlinx Serialization** for efficient JSON parsing. - **OkHttp** as the underlying HTTP client for Retrofit (with a `HttpLoggingInterceptor` for debugging network traffic). -- **FileProvider** for securely sharing downloaded APKs with the system package installer. +- **PackageInstaller sessions** for installing downloaded APKs directly from TryFox. ## Requirements diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt index d7887ff..e07ed6d 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt @@ -1,38 +1,14 @@ package org.mozilla.tryfox.data import org.mozilla.tryfox.data.managers.IntentManager -import java.io.File - /** * A fake implementation of [IntentManager] for use in instrumented tests. - * This class allows for verifying that the `installApk` method is called with the correct file. */ class FakeIntentManager() : IntentManager { var wasUninstallApkCalled: Boolean = false private set - /** - * A boolean flag to indicate whether the `installApk` method was called. - */ - val wasInstallApkCalled: Boolean - get() = installedFile != null - - /** - * The file that was passed to the `installApk` method. - */ - var installedFile: File? = null - private set - - /** - * Overrides the `installApk` method to capture the file and set the `wasInstallApkCalled` flag. - * - * @param file The file to be "installed". - */ - override fun installApk(file: File) { - installedFile = file - } - override fun uninstallApk(packageName: String) { wasUninstallApkCalled = true } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt index 4f3c3b7..74c4d0b 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 @@ -16,8 +16,9 @@ import org.mozilla.tryfox.R 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.TreeherderInstallHistoryEntry +import org.mozilla.tryfox.data.repositories.EmptyInstalledTryBuildRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.ui.theme.TryFoxTheme @RunWith(AndroidJUnit4::class) @@ -36,7 +37,7 @@ class HistoryScreenTest { historyRepository = historyRepository, downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), - intentManager = FakeIntentManager(), + installCoordinator = installCoordinator(), ) var selectedProject: String? = null var selectedRevision: String? = null @@ -70,7 +71,7 @@ class HistoryScreenTest { historyRepository = historyRepository, downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), - intentManager = FakeIntentManager(), + installCoordinator = installCoordinator(), ) composeTestRule.setContent { @@ -119,4 +120,9 @@ class HistoryScreenTest { historyRecordedTimestamp = 123L, lastInstallerLaunchTimestamp = 123L, ) + + private fun installCoordinator() = ApkInstallCoordinator( + InstrumentationRegistry.getInstrumentation().targetContext, + EmptyInstalledTryBuildRepository, + ) } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt index 38d0b16..3d03221 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt @@ -18,13 +18,12 @@ import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mozilla.tryfox.TryFoxViewModel import org.mozilla.tryfox.data.Artifact import org.mozilla.tryfox.data.ArtifactsResponse +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.FakeUserDataRepository import org.mozilla.tryfox.data.JobDetails import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.RevisionDetail @@ -34,7 +33,9 @@ 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.EmptyInstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.ui.theme.TryFoxTheme @RunWith(AndroidJUnit4::class) @@ -47,24 +48,16 @@ class SearchScreenTest { fun treeherderScreen_showsLoaderUntilSearchCompletes_thenDisplaysResults() { val targetJobName = "signing-apk-focus-nightly" val targetDisplayName = "Focus nightly" - val viewModel = TryFoxViewModel( + val viewModel = searchViewModel( fenixRepository = DelayedTreeherderRepository(targetJobName = targetJobName), - downloadFileRepository = FakeDownloadFileRepository(), - cacheManager = FakeCacheManager(), - intentManager = FakeIntentManager(), - historyRepository = FakeHistoryRepository(), - project = "mozilla-central", - revision = "ed209aa2136b241686ff20489c5cb622348e2ecf", - supportedAbis = listOf("arm64-v8a"), - infoLogger = { _, _ -> 0 }, ) composeTestRule.setContent { TryFoxTheme { SearchScreen( - tryFoxViewModel = viewModel, - deepLinkProject = null, - deepLinkRevision = null, + searchViewModel = viewModel, + deepLinkProject = "mozilla-central", + deepLinkQuery = "ed209aa2136b241686ff20489c5cb622348e2ecf", onNavigateUp = {}, ) } @@ -138,24 +131,16 @@ class SearchScreenTest { @Test fun selectingHistoryEntry_hidesHistoryBeforeShowingSearchResults() { - val viewModel = TryFoxViewModel( + val viewModel = searchViewModel( 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 { SearchScreen( - tryFoxViewModel = viewModel, + searchViewModel = viewModel, deepLinkProject = null, - deepLinkRevision = null, + deepLinkQuery = null, onNavigateUp = {}, searchHistory = listOf( SearchHistoryEntry("try", "abc123", SearchHistoryQueryType.REVISION, 1L), @@ -254,4 +239,19 @@ class SearchScreenTest { ) } } + + private fun installCoordinator() = ApkInstallCoordinator( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext, + EmptyInstalledTryBuildRepository, + ) + + private fun searchViewModel(fenixRepository: TreeherderRepository) = SearchViewModel( + fenixRepository = fenixRepository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), + installCoordinator = installCoordinator(), + authorEmail = null, + ) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c392706..6a19a87 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,16 +68,6 @@ - - - - diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index f83223c..2bb0228 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -29,10 +29,11 @@ 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 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.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -55,7 +56,7 @@ class TryFoxViewModel( private val fenixRepository: TreeherderRepository, private val downloadFileRepository: DownloadFileRepository, private val cacheManager: CacheManager, - private val intentManager: IntentManager, + private val installCoordinator: ApkInstallCoordinator, private val historyRepository: HistoryRepository, project: String?, revision: String?, @@ -131,8 +132,6 @@ class TryFoxViewModel( val isLoadingJobArtifacts = mutableStateMapOf() - var onInstallApk: ((File) -> Unit)? = null - private val deviceSupportedAbis: List by lazy { supportedAbis } init { @@ -570,14 +569,6 @@ class TryFoxViewModel( is NetworkResult.Success -> { updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.Downloaded(result.data)) cacheManager.checkCacheStatus() // Update cache status via CacheManager - onInstallApk?.let { installCallback -> - try { - updateInstallTimestamp(job = findJob(taskId), artifact = artifactUiModel) - } catch (_: Exception) { - // History is best-effort; never block installation. - } - installCallback(result.data) - } } is NetworkResult.Error -> { updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.DownloadFailed(result.message)) @@ -634,11 +625,7 @@ class TryFoxViewModel( } fun installApk(file: File) { - val downloadedArtifact = findDownloadedArtifact(file) - if (downloadedArtifact == null) { - intentManager.installApk(file) - return - } + val downloadedArtifact = findDownloadedArtifact(file) ?: return viewModelScope.launch { try { @@ -649,7 +636,15 @@ class TryFoxViewModel( } catch (_: Exception) { // History is best-effort; never block installation. } - intentManager.installApk(file) + installCoordinator.install( + downloadedArtifact.artifact.uniqueKey, + file, + TryBuildProvenance( + project = selectedProject, + revision = revision, + commitMessage = relevantPushComment ?: "No comment", + ), + ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt index 85028d5..279802a 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt @@ -3,58 +3,17 @@ package org.mozilla.tryfox.data.managers import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri import android.util.Log import android.widget.Toast -import androidx.core.content.FileProvider import androidx.core.net.toUri -import org.mozilla.tryfox.BuildConfig -import java.io.File -/** - * Interface for managing intents related to APK installation. - */ +/** Interface for managing intents related to installed applications. */ interface IntentManager { - /** - * Initiates the installation of an APK file. - * - * @param file The APK file to install. - */ - fun installApk(file: File) fun uninstallApk(packageName: String) } -/** - * Default implementation of [IntentManager] that handles APK installation using a [FileProvider]. - * - * @param context The application context. - */ +/** Default implementation of [IntentManager]. */ class DefaultIntentManager(private val context: Context) : IntentManager { - /** - * Creates an intent to install an APK file and starts the corresponding activity. - * If no application is found to handle the intent, a toast message is displayed. - * - * @param file The APK file to install. - */ - override fun installApk(file: File) { - val fileUri: Uri = FileProvider.getUriForFile( - context, - "${BuildConfig.APPLICATION_ID}.provider", - file, - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(fileUri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - try { - context.startActivity(intent) - } catch (e: ActivityNotFoundException) { - Toast.makeText(context, "No application found to install APK", Toast.LENGTH_LONG).show() - Log.e("IntentManager", "Error installing APK", e) - } - } - override fun uninstallApk(packageName: String) { val intent = Intent(Intent.ACTION_DELETE).apply { data = "package:$packageName".toUri() 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 1f73323..0dc0a88 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -208,7 +208,7 @@ val viewModelModule = module { params.getOrNull(), ) } - viewModel { HistoryViewModel(get(), get(), get(), get(), get(), get(named("IODispatcher"))) } + viewModel { HistoryViewModel(get(), get(), get(), get(), get(named("IODispatcher"))) } viewModel { ReceiveFromDesktopViewModel(get()) } viewModel { ReceiveMessageHistoryViewModel(get(), get(named("IODispatcher"))) } viewModel { SearchHistoryViewModel(get()) } @@ -229,7 +229,6 @@ val viewModelModule = module { get(), get(), get(), - get(), get(named("IODispatcher")), get(), get(), diff --git a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt index 0491125..1db60b3 100644 --- a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt +++ b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt @@ -3,6 +3,7 @@ package org.mozilla.tryfox.install import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.IntentSender import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.net.Uri @@ -26,7 +27,7 @@ import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import java.io.File import java.util.concurrent.atomic.AtomicInteger -/** Owns PackageInstaller sessions started from unified search. */ +/** Owns every APK installation session started by TryFox. */ @Suppress("NestedBlockDepth", "TooManyFunctions") class ApkInstallCoordinator( private val context: Context, @@ -42,7 +43,7 @@ class ApkInstallCoordinator( ) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private val packageInstaller = context.packageManager.packageInstaller + private val sessionFactory = PackageInstallerSessionFactory(context.packageManager.packageInstaller) private val requestCodes = AtomicInteger(10_000) private val operations = mutableMapOf() private var activeOperationId: String? = null @@ -170,30 +171,9 @@ class ApkInstallCoordinator( } 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)) - } + sessionFactory.commit(operation.file, operation.packageName, statusReceiver(operation)) } 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.") } @@ -318,3 +298,36 @@ class ApkInstallCoordinator( "Uninstall the conflicting Firefox app and its local data, then try again." } } + +internal class PackageInstallerSessionFactory( + private val packageInstaller: PackageInstaller, +) { + fun commit(file: File, packageName: String, statusReceiver: IntentSender) { + var sessionId: Int? = null + try { + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(packageName) + setSize(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 -> + file.inputStream().use { input -> + session.openWrite("base.apk", 0, file.length()).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + session.commit(statusReceiver) + } + } catch (exception: Exception) { + sessionId?.let(packageInstaller::abandonSession) + throw exception + } + } +} 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 42aa8f0..2af6833 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 @@ -17,7 +17,6 @@ import logcat.logcat import org.mozilla.tryfox.data.DownloadState 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.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest @@ -34,8 +33,7 @@ class HistoryViewModel( private val historyRepository: HistoryRepository, private val downloadCoordinator: ApkDownloadCoordinator, private val cacheManager: CacheManager, - private val intentManager: IntentManager, - private val installCoordinator: ApkInstallCoordinator? = null, + private val installCoordinator: ApkInstallCoordinator, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, ) : ViewModel() { @@ -49,8 +47,7 @@ class HistoryViewModel( private val _historyItems = MutableStateFlow>(emptyList()) val historyItems: StateFlow> = _historyItems.asStateFlow() - val installStates: StateFlow> = - installCoordinator?.states ?: MutableStateFlow(emptyMap()) + val installStates: StateFlow> = installCoordinator.states init { logcat(LogPriority.DEBUG, TAG) { "init" } @@ -65,20 +62,6 @@ 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 } @@ -144,29 +127,27 @@ class HistoryViewModel( } fun install(historyItem: HistoryItemUiModel, file: File) { - installCoordinator?.install( - historyItem.entry.uniqueKey, - file, - TryBuildProvenance( - project = historyItem.entry.project, - revision = historyItem.entry.revision, - commitMessage = historyItem.entry.commitMessage, - ), - ) ?: run { - viewModelScope.launch { - try { - historyRepository.upsertHistoryEntry( - historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), - ) - } catch (_: Exception) { - // History is best-effort; never block installation. - } - intentManager.installApk(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, + TryBuildProvenance( + project = historyItem.entry.project, + revision = historyItem.entry.revision, + commitMessage = historyItem.entry.commitMessage, + ), + ) } } - fun openInstalledApp(packageName: String) = installCoordinator?.openInstalledApp(packageName) + fun openInstalledApp(packageName: String) = installCoordinator.openInstalledApp(packageName) fun delete(historyItem: HistoryItemUiModel) { val uniqueKey = historyItem.entry.uniqueKey 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 59ed80e..f48478d 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 @@ -75,7 +75,7 @@ class HomeViewModel( private val mozillaPackageManager: MozillaPackageManager, private val cacheManager: CacheManager, private val intentManager: IntentManager, - private val installCoordinator: ApkInstallCoordinator? = null, + private val installCoordinator: ApkInstallCoordinator, private val ioDispatcher: CoroutineDispatcher, private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, private val installedTryBuildRepository: InstalledTryBuildRepository = EmptyInstalledTryBuildRepository, @@ -87,8 +87,7 @@ class HomeViewModel( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() - val installStates: StateFlow> = - installCoordinator?.states ?: MutableStateFlow(emptyMap()) + val installStates: StateFlow> = installCoordinator.states private val downloadStates = MutableStateFlow>(emptyMap()) private var currentAppsByName: Map = emptyMap() private var cachedAppsByName: Map = emptyMap() @@ -503,12 +502,12 @@ class HomeViewModel( } fun installApk(file: File) { - installCoordinator?.install(file.absolutePath, file) ?: intentManager.installApk(file) + installCoordinator.install(file.absolutePath, file) } fun installHomeApk(apkInfo: ApkUiModel) { val file = File(apkInfo.apkDir, apkInfo.fileName) - installCoordinator?.install(apkInfo.uniqueKey, file) ?: intentManager.installApk(file) + installCoordinator.install(apkInfo.uniqueKey, file) } fun uninstallApp(packageName: String) { diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml deleted file mode 100644 index 0d76511..0000000 --- a/app/src/main/res/xml/provider_paths.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt index b5a0dd3..f71728a 100644 --- a/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt @@ -14,6 +14,10 @@ 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.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mozilla.tryfox.data.Artifact import org.mozilla.tryfox.data.ArtifactsResponse import org.mozilla.tryfox.data.DownloadState @@ -27,8 +31,9 @@ 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.repositories.TreeherderRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance import org.mozilla.tryfox.ui.screens.MainCoroutineRule import java.io.File @@ -254,11 +259,11 @@ class TryFoxViewModelTest { artifactsByTaskId = mapOf("history-task" to listOf(apkArtifact("public/build/target.arm64-v8a.apk"))), ) val historyRepository = FakeHistoryRepository() - val intentManager = FakeIntentManager() + val installCoordinator = mock() val viewModel = createViewModel( repository = repository, historyRepository = historyRepository, - intentManager = intentManager, + installCoordinator = installCoordinator, currentTimeMillisProvider = { 123L }, ) @@ -279,7 +284,10 @@ class TryFoxViewModelTest { assertEquals("arm64-v8a", historyEntry.abiName) assertEquals(123L, historyEntry.historyRecordedTimestamp) assertEquals(123L, historyEntry.lastInstallerLaunchTimestamp) - assertTrue(intentManager.wasInstallApkCalled) + val provenance = argumentCaptor() + verify(installCoordinator).install(eq(downloadedArtifact.uniqueKey), eq(downloadedFile), provenance.capture()) + assertEquals("mozilla-central", provenance.firstValue.project) + assertEquals("ed209aa2136b241686ff20489c5cb622348e2ecf", provenance.firstValue.revision) } @Test @@ -369,7 +377,7 @@ class TryFoxViewModelTest { private fun createViewModel( repository: TreeherderRepository, historyRepository: FakeHistoryRepository = FakeHistoryRepository(), - intentManager: FakeIntentManager = FakeIntentManager(), + installCoordinator: ApkInstallCoordinator = mock(), downloadFileRepository: FakeDownloadFileRepository = FakeDownloadFileRepository( downloadProgressDelayMillis = 0, ), @@ -378,7 +386,7 @@ class TryFoxViewModelTest { fenixRepository = repository, downloadFileRepository = downloadFileRepository, cacheManager = cacheManager, - intentManager = intentManager, + installCoordinator = installCoordinator, historyRepository = historyRepository, project = "mozilla-central", revision = null, diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt index cc287f0..d408289 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt @@ -1,31 +1,12 @@ package org.mozilla.tryfox.data.managers -import java.io.File - /** * A fake implementation of [IntentManager] for use in unit tests. - * This class allows for verifying that the `installApk` method is called. */ class FakeIntentManager() : IntentManager { - - /** - * A boolean flag to indicate whether the `installApk` method was called. - */ - var wasInstallApkCalled: Boolean = false - private set - var wasUninstallApkCalled: Boolean = false private set - /** - * Overrides the `installApk` method to set the `wasInstallApkCalled` flag to true. - * - * @param file The file to be "installed". - */ - override fun installApk(file: File) { - wasInstallApkCalled = true - } - override fun uninstallApk(packageName: String) { wasUninstallApkCalled = true } diff --git a/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt b/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt index bab2058..08b64af 100644 --- a/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt @@ -2,6 +2,7 @@ package org.mozilla.tryfox.install import android.content.Context import android.content.Intent +import android.content.IntentSender import android.content.pm.PackageInstaller import android.content.pm.PackageManager import kotlinx.coroutines.flow.Flow @@ -9,15 +10,50 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.mockito.Mockito.mockConstruction import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import java.io.ByteArrayOutputStream +import java.io.File class ApkInstallCoordinatorTest { + @Test + fun `session factory creates and commits a PackageInstaller session owned by TryFox`() { + val packageInstaller = mock() + val session = mock() + val statusReceiver = mock() + val apk = File.createTempFile("tryfox-install", ".apk").apply { writeText("apk") } + val apkSize = apk.length() + whenever(packageInstaller.createSession(any())).thenReturn(42) + whenever(packageInstaller.openSession(42)).thenReturn(session) + whenever(session.openWrite(any(), any(), any())).thenReturn(ByteArrayOutputStream()) + + val paramsConstruction = mockConstruction(PackageInstaller.SessionParams::class.java) { _, context -> + assertEquals(PackageInstaller.SessionParams.MODE_FULL_INSTALL, context.arguments().single()) + } + try { + PackageInstallerSessionFactory(packageInstaller).commit(apk, "org.mozilla.fenix", statusReceiver) + + val params = paramsConstruction.constructed().single() + verify(params).setAppPackageName("org.mozilla.fenix") + verify(params).setSize(apkSize) + } finally { + paramsConstruction.close() + apk.delete() + } + + verify(packageInstaller).createSession(any()) + verify(session).openWrite("base.apk", 0L, apkSize) + verify(session).fsync(any()) + verify(session).commit(statusReceiver) + } + @Test fun `successful result after process recreation persists Try build provenance`() = runTest { val packageInstaller = mock() 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 cce9688..e595627 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,6 +1,7 @@ package org.mozilla.tryfox.ui.screens import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map @@ -13,15 +14,21 @@ 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.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.mozilla.tryfox.data.DownloadState 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 org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance import java.io.File @OptIn(ExperimentalCoroutinesApi::class) @@ -311,7 +318,7 @@ class HistoryViewModelTest { fun `install records a fresh installer launch timestamp before launching installer`() = runTest { val entry = historyEntry(lastInstallerLaunchTimestamp = 1L) val historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) } - val intentManager = FakeIntentManager() + val installCoordinator = installCoordinator() val cacheManager = FakeCacheManager(tempCacheDir) val cachedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") cachedFile.parentFile?.mkdirs() @@ -319,7 +326,7 @@ class HistoryViewModelTest { val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - intentManager = intentManager, + installCoordinator = installCoordinator, currentTimeMillisProvider = { 123L }, ) advanceUntilIdle() @@ -328,7 +335,9 @@ class HistoryViewModelTest { advanceUntilIdle() assertEquals(123L, historyRepository.recordedEntries.single().lastInstallerLaunchTimestamp) - assertTrue(intentManager.wasInstallApkCalled) + val provenance = argumentCaptor() + verify(installCoordinator).install(eq(entry.uniqueKey), eq(cachedFile), provenance.capture()) + assertEquals(entry.project, provenance.firstValue.project) } @Test @@ -338,7 +347,7 @@ class HistoryViewModelTest { setEntries(listOf(entry)) failUpsertHistoryEntry = true } - val intentManager = FakeIntentManager() + val installCoordinator = installCoordinator() val cacheManager = FakeCacheManager(tempCacheDir) val cachedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") cachedFile.parentFile?.mkdirs() @@ -346,14 +355,14 @@ class HistoryViewModelTest { val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - intentManager = intentManager, + installCoordinator = installCoordinator, ) advanceUntilIdle() viewModel.install(viewModel.historyItems.value.single(), cachedFile) advanceUntilIdle() - assertTrue(intentManager.wasInstallApkCalled) + verify(installCoordinator).install(eq(entry.uniqueKey), eq(cachedFile), org.mockito.kotlin.any()) } @Test @@ -523,18 +532,23 @@ class HistoryViewModelTest { cacheManager: FakeCacheManager, historyRepository: FakeHistoryRepository, downloadCoordinator: ApkDownloadCoordinator = FakeApkDownloadCoordinator(), - intentManager: FakeIntentManager = FakeIntentManager(), + installCoordinator: ApkInstallCoordinator = installCoordinator(), currentTimeMillisProvider: () -> Long = { 0L }, ): HistoryViewModel = HistoryViewModel( historyRepository = historyRepository, downloadCoordinator = downloadCoordinator, cacheManager = cacheManager, - intentManager = intentManager, + installCoordinator = installCoordinator, ioDispatcher = mainCoroutineRule.testDispatcher, currentTimeMillisProvider = currentTimeMillisProvider, ) + private fun installCoordinator(): ApkInstallCoordinator = mock().also { coordinator -> + whenever(coordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(coordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + } + private fun historyEntry( downloadUrl: String = "https://example.com/task/artifact", historyRecordedTimestamp: Long = 10L, 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 1c17c4e..c0a0e4f 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,6 +22,11 @@ import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.FakeMozillaArchiveRepository @@ -47,6 +52,7 @@ 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.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.MozillaArchiveApk @@ -76,6 +82,7 @@ class HomeViewModelTest { private lateinit var fakeCacheManager: FakeCacheManager private lateinit var fakeDownloadCoordinator: FakeApkDownloadCoordinator private val intentManager = FakeIntentManager() + private lateinit var installCoordinator: ApkInstallCoordinator @TempDir lateinit var tempCacheDir: File @@ -182,6 +189,8 @@ class HomeViewModelTest { fun setUp() { fakeCacheManager = FakeCacheManager(tempCacheDir) fakeDownloadCoordinator = FakeApkDownloadCoordinator() + installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) viewModel = createViewModel() } @@ -196,6 +205,7 @@ class HomeViewModelTest { mozillaPackageManager = mozillaPackageManager, cacheManager = fakeCacheManager, intentManager = intentManager, + installCoordinator = installCoordinator, ioDispatcher = mainCoroutineRule.testDispatcher, homeDataCacheRepository = homeDataCacheRepository, installedTryBuildRepository = installedTryBuildRepository, @@ -937,7 +947,6 @@ class HomeViewModelTest { (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path, ) assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertFalse(intentManager.wasInstallApkCalled) assertFalse( loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after success", @@ -1106,4 +1115,24 @@ class HomeViewModelTest { viewModel.uninstallApp(packageName) assertTrue(intentManager.wasUninstallApkCalled) } + + @Test + fun `installHomeApk delegates to the PackageInstaller coordinator`() { + val apk = createTestApkUiModel( + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi), + ) + + viewModel.installHomeApk(apk) + + verify(installCoordinator).install(eq(apk.uniqueKey), eq(File(apk.apkDir, apk.fileName)), isNull()) + } + + @Test + fun `installApk delegates downloaded files to the PackageInstaller coordinator`() { + val apk = File(tempCacheDir, "downloaded.apk") + + viewModel.installApk(apk) + + verify(installCoordinator).install(eq(apk.absolutePath), eq(apk), isNull()) + } } From 8a0057817275ee671d6a95289e406264c55c23a4 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 19:32:35 +0200 Subject: [PATCH 42/47] Add settings screen and cache controls --- .../mozilla/tryfox/data/FakeCacheManager.kt | 5 +- .../tryfox/data/FakeUserDataRepository.kt | 7 + .../tryfox/ui/screens/HomeScreenTest.kt | 11 + .../main/java/org/mozilla/tryfox/AppRoutes.kt | 1 + .../java/org/mozilla/tryfox/MainActivity.kt | 10 + .../org/mozilla/tryfox/TryFoxViewModel.kt | 6 +- .../tryfox/data/managers/CacheManager.kt | 3 +- .../data/managers/DefaultCacheManager.kt | 54 ++-- .../repositories/DefaultUserDataRepository.kt | 17 ++ .../data/repositories/UserDataRepository.kt | 3 + .../java/org/mozilla/tryfox/di/AppModule.kt | 3 + .../mozilla/tryfox/model/HomeScreenLayout.kt | 7 + .../tryfox/ui/screens/HistoryViewModel.kt | 8 +- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 9 + .../tryfox/ui/screens/SettingsScreen.kt | 231 ++++++++++++++++++ .../tryfox/ui/screens/SettingsViewModel.kt | 57 +++++ app/src/main/res/values/strings.xml | 16 ++ .../data/managers/DefaultCacheManagerTest.kt | 45 ++++ .../tryfox/data/managers/FakeCacheManager.kt | 11 +- .../data/managers/FakeUserDataRepository.kt | 7 + .../HomeScreenLayoutPreferenceTest.kt | 21 ++ .../ui/screens/SettingsViewModelTest.kt | 90 +++++++ 22 files changed, 589 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt create mode 100644 app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt create mode 100644 app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt index bc35dbe..9e7bf43 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt @@ -9,12 +9,15 @@ import java.io.File class FakeCacheManager : CacheManager { private val _cacheState = MutableStateFlow(CacheManagementState.IdleEmpty) override val cacheState: StateFlow = _cacheState + private val _cacheSizeBytes = MutableStateFlow(0L) + override val cacheSizeBytes: StateFlow = _cacheSizeBytes override suspend fun clearCache() { _cacheState.value = CacheManagementState.IdleEmpty + _cacheSizeBytes.value = 0L } - override fun checkCacheStatus() { + override suspend fun checkCacheStatus() { // No-op for now } 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 1d4f426..35f29a9 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt @@ -7,6 +7,7 @@ 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 +import org.mozilla.tryfox.model.HomeScreenLayout /** * A fake implementation of [org.mozilla.tryfox.data.repositories.UserDataRepository] for testing purposes. @@ -19,6 +20,8 @@ class FakeUserDataRepository : UserDataRepository { override val searchHistoryFlow: Flow> = _searchHistoryFlow private val _lanReceiveIdentityFlow = MutableStateFlow(null) override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow + private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) + override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow override suspend fun saveLastSearchedEmail(email: String) { recordSearch("try", email) @@ -37,6 +40,10 @@ class FakeUserDataRepository : UserDataRepository { _lanReceiveIdentityFlow.value = identity } + override suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) { + _homeScreenLayoutFlow.value = layout + } + // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt index bf80574..06f7b1f 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt @@ -31,4 +31,15 @@ class HomeScreenTest { composeTestRule.onNodeWithText("Scan QR code").assertIsDisplayed() } + + @Test + fun homeScreen_settingsButtonNavigatesToSettingsScreen() { + composeTestRule.onNodeWithContentDescription("Settings").performClick() + + composeTestRule.onNodeWithText("Settings").assertIsDisplayed() + composeTestRule.onNodeWithText("Cache").assertIsDisplayed() + composeTestRule.onNodeWithText("Home screen layout").assertIsDisplayed() + composeTestRule.onNodeWithText("One card per app").assertIsDisplayed() + composeTestRule.onNodeWithText("One card per flavor of each app").assertIsDisplayed() + } } diff --git a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt index 58b9de3..51fe2d5 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt @@ -5,6 +5,7 @@ import java.net.URLEncoder object AppRoutes { const val HOME = "home" const val HISTORY = "history" + const val SETTINGS = "settings" const val RECEIVE_FROM_DESKTOP = "receive_from_desktop" const val RECEIVE_MESSAGE_HISTORY = "receive_message_history" const val QR_SCANNER = "qr_scanner" diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 46e50e7..d8969dc 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -38,6 +38,7 @@ 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.SearchScreen +import org.mozilla.tryfox.ui.screens.SettingsScreen import org.mozilla.tryfox.ui.screens.SearchViewModel import org.mozilla.tryfox.ui.theme.TryFoxTheme @@ -56,6 +57,8 @@ sealed class NavScreen(val route: String) { */ data object History : NavScreen(AppRoutes.HISTORY) + data object Settings : NavScreen(AppRoutes.SETTINGS) + data object ReceiveFromDesktop : NavScreen(AppRoutes.RECEIVE_FROM_DESKTOP) data object ReceiveMessageHistory : NavScreen(AppRoutes.RECEIVE_MESSAGE_HISTORY) @@ -177,6 +180,7 @@ class MainActivity : ComponentActivity() { onNavigateToQrScanner = { localNavController.navigate(NavScreen.QrScanner.route) }, onNavigateToReceiveFromDesktop = { localNavController.navigate(NavScreen.ReceiveFromDesktop.route) }, onNavigateToHistory = { localNavController.navigate(NavScreen.History.route) }, + onNavigateToSettings = { localNavController.navigate(NavScreen.Settings.route) }, onNavigateToTryBuild = { project, revision -> localNavController.navigate( NavScreen.TreeherderSearchWithArgs.createRoute(project, revision), @@ -196,6 +200,12 @@ class MainActivity : ComponentActivity() { historyViewModel = koinViewModel(), ) } + composable(NavScreen.Settings.route) { + SettingsScreen( + onNavigateUp = { localNavController.popBackStack() }, + settingsViewModel = koinViewModel(), + ) + } composable(NavScreen.QrScanner.route) { QrCodeScannerScreen( onNavigateUp = { localNavController.popBackStack() }, diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 2bb0228..8eae768 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -186,8 +186,10 @@ class TryFoxViewModel( } fun checkCacheStatus() { - cacheManager.checkCacheStatus() - refreshArtifactDownloadStatesFromCache() + viewModelScope.launch(ioDispatcher) { + cacheManager.checkCacheStatus() + refreshArtifactDownloadStatesFromCache() + } } fun clearAppCache() { diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt index 13e0076..03cfeba 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt @@ -6,7 +6,8 @@ import java.io.File interface CacheManager { val cacheState: StateFlow + val cacheSizeBytes: StateFlow suspend fun clearCache() - fun checkCacheStatus() + suspend fun checkCacheStatus() fun getCacheDir(appName: String): File } diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt index 8480df6..8d56e68 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt @@ -26,6 +26,8 @@ class DefaultCacheManager( private val _cacheState = MutableStateFlow(CacheManagementState.IdleEmpty) override val cacheState: StateFlow = _cacheState.asStateFlow() + private val _cacheSizeBytes = MutableStateFlow(0L) + override val cacheSizeBytes: StateFlow = _cacheSizeBytes.asStateFlow() init { migrateLegacyCache(legacyCacheDir) @@ -35,42 +37,39 @@ class DefaultCacheManager( return File(cacheDir, appName) } - private fun isAppCachePopulated(appName: String): Boolean { - val appSpecificCacheDir = getCacheDir(appName) - if (!appSpecificCacheDir.exists() || !appSpecificCacheDir.isDirectory) return false - appSpecificCacheDir.listFiles()?.forEach { item -> - if (item.isFile) return true - if (item.isDirectory) { - if (item.listFiles()?.any { it.isFile } == true) { - return true - } - } + override suspend fun checkCacheStatus() { + val cacheSnapshot = withContext(ioDispatcher) { determineCacheSnapshot() } + val newCacheState = if (cacheSnapshot.hasFiles) { + CacheManagementState.IdleNonEmpty + } else { + CacheManagementState.IdleEmpty } - return false - } - - private fun determineCacheState(): CacheManagementState { - val cacheIsNotEmpty = MANAGED_CACHE_NAMES.any { isAppCachePopulated(it) } - return if (cacheIsNotEmpty) CacheManagementState.IdleNonEmpty else CacheManagementState.IdleEmpty - } - - override fun checkCacheStatus() { - val newCacheState = determineCacheState() + _cacheSizeBytes.value = cacheSnapshot.sizeBytes _cacheState.value = newCacheState logcat(TAG) { "Cache status checked. Current state: $newCacheState" } } + private fun determineCacheSnapshot(): CacheSnapshot = + cacheDir.takeIf { it.isDirectory } + ?.walkTopDown() + ?.fold(CacheSnapshot()) { snapshot, file -> + if (file.isFile) { + snapshot.copy(sizeBytes = snapshot.sizeBytes + file.length(), hasFiles = true) + } else { + snapshot + } + } + ?: CacheSnapshot() + override suspend fun clearCache() { _cacheState.value = CacheManagementState.Clearing try { withContext(ioDispatcher) { cacheDir.listFiles()?.forEach { - if (it.isDirectory) { - logcat(LogPriority.DEBUG, TAG) { - "Deleting cache directory path=${it.absolutePath}" - } - it.deleteRecursively() + logcat(LogPriority.DEBUG, TAG) { + "Deleting cache entry path=${it.absolutePath}" } + it.deleteRecursively() } } logcat(LogPriority.DEBUG, TAG) { "Cache cleared successfully." } @@ -129,4 +128,9 @@ class DefaultCacheManager( private const val TAG = "DefaultCacheManager" private val MANAGED_CACHE_NAMES = listOf(FENIX, FOCUS, FOCUS_RELEASE, REFERENCE_BROWSER, TREEHERDER, TRYFOX) } + + private data class CacheSnapshot( + val sizeBytes: Long = 0L, + val hasFiles: Boolean = false, + ) } 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 0907398..a8329fd 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 @@ -15,6 +15,7 @@ import org.mozilla.tryfox.data.SearchHistory import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.lan.LanReceiveIdentity +import org.mozilla.tryfox.model.HomeScreenLayout /** * A repository that stores the last searched email in a DataStore. @@ -29,6 +30,7 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos val LAN_DEVICE_ID = stringPreferencesKey("lan_device_id") val LAN_DEVICE_NAME = stringPreferencesKey("lan_device_name") val LAN_SHARED_SECRET = stringPreferencesKey("lan_shared_secret") + val HOME_SCREEN_LAYOUT = stringPreferencesKey("home_screen_layout") } override val searchHistoryFlow: Flow> = appContext.dataStore.data.map { preferences -> @@ -60,6 +62,10 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos } } + override val homeScreenLayoutFlow: Flow = appContext.dataStore.data.map { preferences -> + homeScreenLayoutFromStoredValue(preferences[PreferenceKeys.HOME_SCREEN_LAYOUT]) + } + override suspend fun saveLastSearchedEmail(email: String) { recordSearch(project = "try", query = email) } @@ -90,6 +96,12 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos } } + override suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) { + appContext.dataStore.edit { preferences -> + preferences[PreferenceKeys.HOME_SCREEN_LAYOUT] = layout.name + } + } + private fun decodeSearchHistory(serializedHistory: String): List = runCatching { json.decodeFromString>(serializedHistory) }.getOrDefault(emptyList()) @@ -97,3 +109,8 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos val json = Json { ignoreUnknownKeys = true } } } + +internal fun homeScreenLayoutFromStoredValue(storedValue: String?): HomeScreenLayout = + storedValue + ?.let { value -> HomeScreenLayout.entries.firstOrNull { it.name == value } } + ?: HomeScreenLayout.OneCardPerApp 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 cb32aae..4ad6628 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 @@ -3,6 +3,7 @@ package org.mozilla.tryfox.data.repositories import kotlinx.coroutines.flow.Flow import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.lan.LanReceiveIdentity +import org.mozilla.tryfox.model.HomeScreenLayout /** * A repository that stores the last searched email. @@ -15,6 +16,7 @@ interface UserDataRepository { val lastSearchedEmailFlow: Flow val searchHistoryFlow: Flow> val lanReceiveIdentityFlow: Flow + val homeScreenLayoutFlow: Flow /** * Saves the last searched email. @@ -23,4 +25,5 @@ interface UserDataRepository { suspend fun saveLastSearchedEmail(email: String) suspend fun recordSearch(project: String, query: String, searchedAt: Long = System.currentTimeMillis()) suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) + suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) } 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 0dc0a88..4bb67ba 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -63,6 +63,7 @@ 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.ui.screens.SettingsViewModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE @@ -212,6 +213,7 @@ val viewModelModule = module { viewModel { ReceiveFromDesktopViewModel(get()) } viewModel { ReceiveMessageHistoryViewModel(get(), get(named("IODispatcher"))) } viewModel { SearchHistoryViewModel(get()) } + viewModel { SettingsViewModel(get(), get(), get()) } viewModel { val releaseRepositories = listOf( get(named(FENIX)), @@ -229,6 +231,7 @@ val viewModelModule = module { get(), get(), get(), + get(), get(named("IODispatcher")), get(), get(), diff --git a/app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt b/app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt new file mode 100644 index 0000000..396ee89 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt @@ -0,0 +1,7 @@ +package org.mozilla.tryfox.model + +/** The persisted arrangement preference for Home app cards. */ +enum class HomeScreenLayout { + OneCardPerApp, + OneCardPerFlavor, +} 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 2af6833..664fb0b 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 @@ -51,7 +51,7 @@ class HistoryViewModel( init { logcat(LogPriority.DEBUG, TAG) { "init" } - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { historyRepository.refresh() cacheManager.checkCacheStatus() } @@ -72,8 +72,10 @@ class HistoryViewModel( fun refreshCachedDownloadStates() { logcat(LogPriority.DEBUG, TAG) { "refreshCachedDownloadStates called" } - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } + viewModelScope.launch(ioDispatcher) { + cacheManager.checkCacheStatus() + cacheRefreshEvents.update { it + 1 } + } } fun download(historyItem: HistoryItemUiModel) { 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 d2d67a1..9883357 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 @@ -14,6 +14,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -55,6 +56,7 @@ import org.mozilla.tryfox.ui.composables.BinButton * @param modifier The modifier to be applied to the component. * @param onNavigateToSearch Callback to navigate to the unified build search screen. * @param onNavigateToHistory Callback to navigate to the History screen. + * @param onNavigateToSettings Callback to navigate to the Settings screen. * @param homeViewModel The ViewModel for the Home screen. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @@ -65,6 +67,7 @@ fun HomeScreen( onNavigateToQrScanner: () -> Unit, onNavigateToReceiveFromDesktop: () -> Unit, onNavigateToHistory: () -> Unit, + onNavigateToSettings: () -> Unit, onNavigateToTryBuild: (String, String) -> Unit = { _, _ -> }, homeViewModel: HomeViewModel = viewModel(), ) { @@ -137,6 +140,12 @@ fun HomeScreen( enabled = binButtonEnabled, ) } + IconButton(onClick = onNavigateToSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(id = R.string.home_settings_button_description), + ) + } }, ) }, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..2cf7a70 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt @@ -0,0 +1,231 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +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.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.mozilla.tryfox.R +import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateUp: () -> Unit, + settingsViewModel: SettingsViewModel = viewModel(), + modifier: Modifier = Modifier, +) { + val uiState by settingsViewModel.uiState.collectAsState() + var showClearConfirmation by remember { mutableStateOf(false) } + + if (showClearConfirmation) { + AlertDialog( + onDismissRequest = { showClearConfirmation = false }, + title = { Text(stringResource(R.string.settings_clear_cache_dialog_title)) }, + text = { Text(stringResource(R.string.settings_clear_cache_dialog_message)) }, + confirmButton = { + Button(onClick = { + settingsViewModel.clearCache() + showClearConfirmation = false + }) { Text(stringResource(R.string.settings_clear_cache_confirm)) } + }, + dismissButton = { + OutlinedButton(onClick = { showClearConfirmation = false }) { + Text(stringResource(R.string.settings_clear_cache_cancel)) + } + }, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_screen_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back_button_description)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp, vertical = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + CacheSettingsCard( + uiState = uiState, + onClearCache = { showClearConfirmation = true }, + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + HomeLayoutSettingsCard( + selectedLayout = uiState.homeScreenLayout, + onLayoutSelected = settingsViewModel::selectHomeScreenLayout, + ) + } + } +} + +@Composable +private fun CacheSettingsCard(uiState: SettingsUiState, onClearCache: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsSectionTitle(R.string.settings_cache_section_title) + PreferenceGroup { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_cache_size_label)) }, + supportingContent = { Text(stringResource(R.string.settings_cache_description)) }, + trailingContent = { + Text( + text = formatCacheSize(uiState.cacheSizeBytes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + if (uiState.cacheState == CacheManagementState.Clearing) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.settings_cache_clearing)) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onClearCache, enabled = uiState.canClearCache) { + Text(stringResource(R.string.settings_clear_cache_button)) + } + } + } + } +} + +@Composable +private fun HomeLayoutSettingsCard( + selectedLayout: HomeScreenLayout, + onLayoutSelected: (HomeScreenLayout) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsSectionTitle(R.string.settings_home_layout_section_title) + Text( + text = stringResource(R.string.settings_home_layout_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + PreferenceGroup { + LayoutOption( + layout = HomeScreenLayout.OneCardPerApp, + selectedLayout = selectedLayout, + label = stringResource(R.string.settings_layout_one_card_per_app), + onSelected = onLayoutSelected, + ) + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + LayoutOption( + layout = HomeScreenLayout.OneCardPerFlavor, + selectedLayout = selectedLayout, + label = stringResource(R.string.settings_layout_one_card_per_flavor), + onSelected = onLayoutSelected, + ) + } + } +} + +@Composable +private fun PreferenceGroup(content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column( + content = content, + ) + } +} + +@Composable +private fun SettingsSectionTitle(stringId: Int) { + Text( + stringResource(stringId), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp), + ) +} + +@Composable +private fun LayoutOption( + layout: HomeScreenLayout, + selectedLayout: HomeScreenLayout, + label: String, + onSelected: (HomeScreenLayout) -> Unit, +) { + val selected = layout == selectedLayout + ListItem( + headlineContent = { Text(label) }, + trailingContent = { RadioButton(selected = selected, onClick = null) }, + modifier = Modifier + .semantics { this.selected = selected } + .clickable(role = Role.RadioButton) { onSelected(layout) }, + ) +} + +internal fun formatCacheSize(bytes: Long): String = when { + bytes < 1024L -> "$bytes B" + bytes < 1024L * 1024L -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024L * 1024L * 1024L -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.1f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt new file mode 100644 index 0000000..bc42da2 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt @@ -0,0 +1,57 @@ +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.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.mozilla.tryfox.data.managers.CacheManager +import org.mozilla.tryfox.data.repositories.UserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout + +data class SettingsUiState( + val cacheState: CacheManagementState = CacheManagementState.IdleEmpty, + val cacheSizeBytes: Long = 0L, + val hasActiveDownloads: Boolean = false, + val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, +) { + val canClearCache: Boolean + get() = cacheState == CacheManagementState.IdleNonEmpty && !hasActiveDownloads +} + +class SettingsViewModel( + private val cacheManager: CacheManager, + downloadCoordinator: ApkDownloadCoordinator, + private val userDataRepository: UserDataRepository, +) : ViewModel() { + val uiState: StateFlow = combine( + cacheManager.cacheState, + cacheManager.cacheSizeBytes, + downloadCoordinator.downloads, + userDataRepository.homeScreenLayoutFlow, + ) { cacheState, cacheSizeBytes, downloads, homeScreenLayout -> + SettingsUiState( + cacheState = cacheState, + cacheSizeBytes = cacheSizeBytes, + hasActiveDownloads = downloads.values.any { !it.isTerminal }, + homeScreenLayout = homeScreenLayout, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsUiState()) + + init { + viewModelScope.launch { cacheManager.checkCacheStatus() } + } + + fun clearCache() { + if (!uiState.value.canClearCache) return + viewModelScope.launch { cacheManager.clearCache() } + } + + fun selectHomeScreenLayout(layout: HomeScreenLayout) { + viewModelScope.launch { userDataRepository.saveHomeScreenLayout(layout) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29f11c0..caff014 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 + Settings Recent searches Scan QR code Receive from desktop @@ -18,6 +19,21 @@ Select build An unknown error occurred. Back + Settings + Cache + Downloaded APKs stored on this device. + Current space used + Clear cache + Clearing cache… + Cache can’t be cleared while a download is in progress. + Clear cache? + Downloaded APKs will be removed from this device. + Clear cache + Cancel + Home screen layout + Choose how apps are grouped on Home. + One card per app + One card per flavor of each app Search builds Project Email or revision diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt index 52d5bdb..499d301 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt @@ -3,6 +3,8 @@ package org.mozilla.tryfox.data.managers import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File @@ -35,4 +37,47 @@ class DefaultCacheManagerTest { assertTrue(unrelatedCacheFile.exists()) assertFalse(File(managedRoot, "image_cache/cached-image").exists()) } + + @Test + fun `cache status reports recursive size and clearing resets it`() = runTest { + val managedRoot = File(tempDir, "download-cache") + File(managedRoot, "fenix/nested/first.apk").apply { + parentFile?.mkdirs() + writeBytes(ByteArray(15)) + } + File(managedRoot, "treeherder/second.apk").apply { + parentFile?.mkdirs() + writeBytes(ByteArray(9)) + } + val manager = DefaultCacheManager(managedRoot, StandardTestDispatcher(testScheduler)) + + manager.checkCacheStatus() + + assertEquals(24L, manager.cacheSizeBytes.value) + assertEquals(org.mozilla.tryfox.model.CacheManagementState.IdleNonEmpty, manager.cacheState.value) + + manager.clearCache() + + assertEquals(0L, manager.cacheSizeBytes.value) + assertEquals(org.mozilla.tryfox.model.CacheManagementState.IdleEmpty, manager.cacheState.value) + } + + @Test + fun `zero byte cache files are still clearable`() = runTest { + val managedRoot = File(tempDir, "download-cache") + File(managedRoot, "fenix/empty.apk").apply { + parentFile?.mkdirs() + createNewFile() + } + val manager = DefaultCacheManager(managedRoot, StandardTestDispatcher(testScheduler)) + + manager.checkCacheStatus() + + assertEquals(0L, manager.cacheSizeBytes.value) + assertEquals(org.mozilla.tryfox.model.CacheManagementState.IdleNonEmpty, manager.cacheState.value) + + manager.clearCache() + + assertFalse(File(managedRoot, "fenix/empty.apk").exists()) + } } 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 7bfa58c..bede0fb 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 @@ -10,6 +10,8 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { private val _cacheState = MutableStateFlow(CacheManagementState.IdleEmpty) override val cacheState: StateFlow = _cacheState.asStateFlow() + private val _cacheSizeBytes = MutableStateFlow(0L) + override val cacheSizeBytes: StateFlow = _cacheSizeBytes.asStateFlow() var clearCacheCalled = false private set @@ -29,10 +31,11 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { } // Simulate the behavior of DefaultCacheManager: set to Clearing then to IdleEmpty _cacheState.value = CacheManagementState.Clearing + _cacheSizeBytes.value = 0L _cacheState.value = CacheManagementState.IdleEmpty } - override fun checkCacheStatus() { + override suspend fun checkCacheStatus() { checkCacheStatusCalled = true // Allow tests to manually set the state or simulate a specific outcome } @@ -47,11 +50,17 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { _cacheState.value = state } + fun setCacheSizeBytes(sizeBytes: Long) { + _cacheSizeBytes.value = sizeBytes + _cacheState.value = if (sizeBytes > 0) CacheManagementState.IdleNonEmpty else CacheManagementState.IdleEmpty + } + fun reset() { clearCacheCalled = false checkCacheStatusCalled = false getCacheDirCalledWith = null appCachePopulatedResult = false _cacheState.value = CacheManagementState.IdleEmpty + _cacheSizeBytes.value = 0L } } 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 f8826cc..cf8e8f3 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 @@ -7,6 +7,7 @@ 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 +import org.mozilla.tryfox.model.HomeScreenLayout /** * A fake implementation of [UserDataRepository] for testing purposes. @@ -19,6 +20,8 @@ class FakeUserDataRepository : UserDataRepository { override val searchHistoryFlow: Flow> = _searchHistoryFlow private val _lanReceiveIdentityFlow = MutableStateFlow(null) override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow + private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) + override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow override suspend fun saveLastSearchedEmail(email: String) { recordSearch("try", email) @@ -37,6 +40,10 @@ class FakeUserDataRepository : UserDataRepository { _lanReceiveIdentityFlow.value = identity } + override suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) { + _homeScreenLayoutFlow.value = layout + } + // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" diff --git a/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt new file mode 100644 index 0000000..98e925d --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt @@ -0,0 +1,21 @@ +package org.mozilla.tryfox.data.repositories + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.model.HomeScreenLayout + +class HomeScreenLayoutPreferenceTest { + @Test + fun `missing or invalid layout defaults to one card per app`() { + assertEquals(HomeScreenLayout.OneCardPerApp, homeScreenLayoutFromStoredValue(null)) + assertEquals(HomeScreenLayout.OneCardPerApp, homeScreenLayoutFromStoredValue("unknown")) + } + + @Test + fun `stored layout is restored`() { + assertEquals( + HomeScreenLayout.OneCardPerFlavor, + homeScreenLayoutFromStoredValue(HomeScreenLayout.OneCardPerFlavor.name), + ) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt new file mode 100644 index 0000000..f9294b9 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt @@ -0,0 +1,90 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +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.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.managers.FakeCacheManager +import org.mozilla.tryfox.data.managers.FakeUserDataRepository +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.HomeScreenLayout +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + @JvmField + @RegisterExtension + val mainCoroutineRule = MainCoroutineRule() + + @TempDir + lateinit var tempDir: File + + @Test + fun `layout selection is persisted and cache clearing follows availability`() = runTest { + val cacheManager = FakeCacheManager(tempDir) + val downloads = FakeDownloadCoordinator() + val userData = FakeUserDataRepository() + val viewModel = SettingsViewModel(cacheManager, downloads, userData) + advanceUntilIdle() + + assertEquals(HomeScreenLayout.OneCardPerApp, viewModel.uiState.value.homeScreenLayout) + assertFalse(viewModel.uiState.value.canClearCache) + + cacheManager.setCacheSizeBytes(42) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.canClearCache) + + viewModel.selectHomeScreenLayout(HomeScreenLayout.OneCardPerFlavor) + advanceUntilIdle() + assertEquals(HomeScreenLayout.OneCardPerFlavor, viewModel.uiState.value.homeScreenLayout) + + downloads.setActiveDownload() + advanceUntilIdle() + assertFalse(viewModel.uiState.value.canClearCache) + + downloads.clear() + advanceUntilIdle() + viewModel.clearCache() + advanceUntilIdle() + assertTrue(cacheManager.clearCacheCalled) + assertEquals(0L, viewModel.uiState.value.cacheSizeBytes) + } + + private class FakeDownloadCoordinator : ApkDownloadCoordinator { + private val states = MutableStateFlow>(emptyMap()) + override val downloads = states + + 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 = downloads.map { it[uniqueKey] } + + fun setActiveDownload() { + states.value = mapOf( + "download" to PersistedDownloadState( + uniqueKey = "download", + downloadUrl = "https://example.invalid/download.apk", + outputPath = "download.apk", + appName = "fenix", + fileName = "download.apk", + status = DownloadStatus.RUNNING, + ), + ) + } + + fun clear() { + states.value = emptyMap() + } + } +} From 492ed8d1f4eaea34cf025a1560361db67af08de4 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 19:44:32 +0200 Subject: [PATCH 43/47] Add directional screen transitions --- .../java/org/mozilla/tryfox/MainActivity.kt | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index d8969dc..c4acfcd 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -7,6 +7,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.tween import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Text @@ -172,7 +175,34 @@ class MainActivity : ComponentActivity() { routeDeepLink(intent) } - NavHost(navController = localNavController, startDestination = NavScreen.Home.route) { + NavHost( + navController = localNavController, + startDestination = NavScreen.Home.route, + enterTransition = { + slideInHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + initialOffsetX = { fullWidth -> fullWidth }, + ) + }, + exitTransition = { + slideOutHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + targetOffsetX = { fullWidth -> -fullWidth }, + ) + }, + popEnterTransition = { + slideInHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + initialOffsetX = { fullWidth -> -fullWidth }, + ) + }, + popExitTransition = { + slideOutHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + targetOffsetX = { fullWidth -> fullWidth }, + ) + }, + ) { composable(NavScreen.Home.route) { // Inject HomeViewModel using Koin in Composable HomeScreen( @@ -317,4 +347,9 @@ class MainActivity : ComponentActivity() { } } } + + private companion object { + private const val SCREEN_TRANSITION_DURATION_MS = 200 + } + } From f68ca4178017d38a5ce166469f102bb6cfc01903 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 20:26:29 +0200 Subject: [PATCH 44/47] Support per-flavor home cards --- .../java/org/mozilla/tryfox/di/AppModule.kt | 1 + .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 101 ++++++++++-------- .../tryfox/ui/screens/HomeAppCardModel.kt | 28 +++++ .../mozilla/tryfox/ui/screens/HomeScreen.kt | 3 +- .../tryfox/ui/screens/HomeScreenState.kt | 2 + .../tryfox/ui/screens/HomeViewModel.kt | 18 ++++ .../tryfox/ui/screens/HomeAppCardModelTest.kt | 25 +++++ .../tryfox/ui/screens/HomeViewModelTest.kt | 18 ++++ 8 files changed, 150 insertions(+), 46 deletions(-) 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 4bb67ba..78bdbb9 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -235,6 +235,7 @@ val viewModelModule = module { get(named("IODispatcher")), get(), get(), + get(), ) } viewModel { params -> diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index 0e42f2e..2a4d1e4 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -97,7 +97,8 @@ internal fun HomeAppCard( val app = card.selectedApp val appState = app.toAppState() val title = cardTitle(card.family) - val tag = card.family.name.lowercase() + val subtitle = cardSubtitle(card.family, app.name, card.showFlavorSelector) + val tag = card.stableKey.lowercase() val flavorAppNames = card.family.appNames.filter { it in card.appsByName } val isNightly = app.name == FENIX || app.name == FOCUS val isDebug = app.name == FENIX_DEBUG || app.name == FOCUS_DEBUG @@ -132,7 +133,7 @@ internal fun HomeAppCard( ) Column { Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - Text(cardSubtitle(card.family), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -142,7 +143,7 @@ internal fun HomeAppCard( modifier = Modifier.testTag("home_install_status_$tag").padding(top = 8.dp), ) - if (flavorAppNames.size > 1) { + if (card.showFlavorSelector && flavorAppNames.size > 1) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -172,50 +173,51 @@ internal fun HomeAppCard( } } - Row( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Column(modifier = Modifier.weight(1f)) { - when { - isDebug -> Unit - app.apks is ApksResult.Loading -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) - app.apks is ApksResult.Error -> Text(app.apks.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) - selectedApk != null && isNightly -> NightlyDetails( - appName = app.name, - version = selectedApk.version, - date = selectedApk.date, - buildDate = selectedApk.buildDate, - selectedDate = app.userPickedDate, - dateValidator = dateValidator, - onDateSelected = onDateSelected, - ) - selectedApk != null && isVersionSelectable -> ReleaseVersionDetails( - appName = app.name, - selectedVersion = app.selectedReleaseVersion ?: selectedApk.version, - versions = app.availableReleaseVersions, - onSelected = onReleaseVersionSelected, + if (!isDebug || app.installedTryBuild != null) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + when { + app.apks is ApksResult.Loading -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) + app.apks is ApksResult.Error -> Text(app.apks.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + selectedApk != null && isNightly -> NightlyDetails( + appName = app.name, + version = selectedApk.version, + date = selectedApk.date, + buildDate = selectedApk.buildDate, + selectedDate = app.userPickedDate, + dateValidator = dateValidator, + onDateSelected = onDateSelected, + ) + selectedApk != null && isVersionSelectable -> ReleaseVersionDetails( + appName = app.name, + selectedVersion = app.selectedReleaseVersion ?: selectedApk.version, + versions = app.availableReleaseVersions, + onSelected = onReleaseVersionSelected, + ) + selectedApk != null -> Text(selectedApk.version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + else -> Text(stringResource(R.string.home_no_apks_available), style = MaterialTheme.typography.bodyMedium) + } + } + if (selectedApk != null || app.apks is ApksResult.Loading) { + Spacer(Modifier.width(12.dp)) + DownloadButton( + downloadState = selectedApk?.downloadState ?: DownloadState.NotDownloaded, + onDownloadClick = { selectedApk?.let(onDownloadClick) }, + onInstallClick = { selectedApk?.let(onInstallClick) }, + installState = selectedApk?.let { installStates[it.uniqueKey] } ?: InstallState.Idle, + installDisabled = selectedApk == null, + onOpenClick = onOpenInstalledApp, + debugLabel = "home-card:${selectedApk?.uniqueKey ?: app.name}", + modifier = Modifier + .testTag("home_primary_action_$tag") + .semantics { contentDescription = "Download $title" }, ) - selectedApk != null -> Text(selectedApk.version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - else -> Text(stringResource(R.string.home_no_apks_available), style = MaterialTheme.typography.bodyMedium) } } - if (!isDebug && (selectedApk != null || app.apks is ApksResult.Loading)) { - Spacer(Modifier.width(12.dp)) - DownloadButton( - downloadState = selectedApk?.downloadState ?: DownloadState.NotDownloaded, - onDownloadClick = { selectedApk?.let(onDownloadClick) }, - onInstallClick = { selectedApk?.let(onInstallClick) }, - installState = selectedApk?.let { installStates[it.uniqueKey] } ?: InstallState.Idle, - installDisabled = selectedApk == null, - onOpenClick = onOpenInstalledApp, - debugLabel = "home-card:${selectedApk?.uniqueKey ?: app.name}", - modifier = Modifier - .testTag("home_primary_action_$tag") - .semantics { contentDescription = "Download $title" }, - ) - } } app.installedTryBuild?.let { build -> @@ -364,7 +366,16 @@ private fun NightlyBuildPickerDialog( HomeAppFamily.ReferenceBrowser -> stringResource(R.string.home_app_title_reference_browser) } -@Composable private fun cardSubtitle(family: HomeAppFamily): String = when (family) { +@Composable private fun cardSubtitle( + family: HomeAppFamily, + appName: String, + showFlavorSelector: Boolean, +): String = when { + !showFlavorSelector && family != HomeAppFamily.ReferenceBrowser -> flavorLabel(appName) + else -> cardFamilySubtitle(family) +} + +@Composable private fun cardFamilySubtitle(family: HomeAppFamily): String = when (family) { HomeAppFamily.Fenix -> stringResource(R.string.home_app_subtitle_fenix) HomeAppFamily.Focus -> stringResource(R.string.home_app_subtitle_focus) HomeAppFamily.ReferenceBrowser -> stringResource(R.string.home_app_subtitle_reference_browser) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt index d206677..afb71da 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.screens import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -22,13 +23,24 @@ data class HomeAppCardUiModel( val family: HomeAppFamily, val selectedAppName: String, val appsByName: Map, + val showFlavorSelector: Boolean = true, ) { val selectedApp: AppUiModel get() = appsByName.getValue(selectedAppName) + val stableKey: String get() = if (showFlavorSelector) family.name else selectedAppName } internal fun homeAppCards( apps: Map, selectedAppNames: Map, + layout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, +): List = when (layout) { + HomeScreenLayout.OneCardPerApp -> groupedHomeAppCards(apps, selectedAppNames) + HomeScreenLayout.OneCardPerFlavor -> flavorHomeAppCards(apps) +} + +private fun groupedHomeAppCards( + apps: Map, + selectedAppNames: Map, ): List = HomeAppFamily.entries.mapNotNull { family -> val familyApps = family.appNames.mapNotNull { name -> apps[name]?.takeUnless { name.isDebugFlavor && it.installedVersion == null }?.let { name to it } @@ -38,5 +50,21 @@ internal fun homeAppCards( HomeAppCardUiModel(family, selected, familyApps) } +private fun flavorHomeAppCards(apps: Map): List = + HomeAppFamily.entries.flatMap { family -> + family.appNames.mapNotNull { appName -> + apps[appName] + ?.takeUnless { appName.isDebugFlavor && it.installedVersion == null } + ?.let { app -> + HomeAppCardUiModel( + family = family, + selectedAppName = appName, + appsByName = mapOf(appName to app), + showFlavorSelector = false, + ) + } + } + } + private val String.isDebugFlavor: Boolean get() = this == FENIX_DEBUG || this == FOCUS_DEBUG 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 9883357..0ebac22 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 @@ -178,6 +178,7 @@ fun HomeScreen( val cards = homeAppCards( apps = currentScreenState.apps, selectedAppNames = currentScreenState.selectedAppNames, + layout = currentScreenState.homeScreenLayout, ) LazyColumn( @@ -189,7 +190,7 @@ fun HomeScreen( ) { item { Spacer(modifier = Modifier.height(if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp)) } - items(cards, key = { it.family }) { card -> + items(cards, key = { it.stableKey }) { card -> HomeAppCard( card = card, onFlavorSelected = { appName -> diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt index d30ac05..58f5668 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.screens import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.ui.models.AppUiModel /** @@ -21,5 +22,6 @@ sealed class HomeScreenState { val cacheManagementState: CacheManagementState, val isDownloadingAnyFile: Boolean, val selectedAppNames: Map = HomeAppFamily.entries.associateWith { it.defaultAppName }, + val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, ) : HomeScreenState() } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index f48478d..792ed9f 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 @@ -32,6 +32,7 @@ import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository import org.mozilla.tryfox.data.repositories.HomeDataSnapshot import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository +import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest @@ -41,6 +42,7 @@ 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.model.HomeScreenLayout import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult @@ -77,6 +79,7 @@ class HomeViewModel( private val intentManager: IntentManager, private val installCoordinator: ApkInstallCoordinator, private val ioDispatcher: CoroutineDispatcher, + private val userDataRepository: UserDataRepository? = null, private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, private val installedTryBuildRepository: InstalledTryBuildRepository = EmptyInstalledTryBuildRepository, private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), @@ -94,6 +97,7 @@ class HomeViewModel( private var installedTryBuild: InstalledTryBuild? = null private var initialLoadStarted = false private var tryFoxCardDismissed = false + private var homeScreenLayout = HomeScreenLayout.OneCardPerApp @Volatile private var selectedHomeAppNames = HomeAppFamily.entries.associateWith { it.defaultAppName } @@ -121,6 +125,19 @@ class HomeViewModel( } .launchIn(viewModelScope) + userDataRepository?.homeScreenLayoutFlow + ?.onEach { layout -> + homeScreenLayout = layout + _homeScreenState.update { currentState -> + if (currentState is HomeScreenState.Loaded) { + currentState.copy(homeScreenLayout = layout) + } else { + currentState + } + } + } + ?.launchIn(viewModelScope) + mozillaPackageManager.appStates .onEach { appState -> _homeScreenState.update { currentState -> @@ -294,6 +311,7 @@ class HomeViewModel( cacheManagementState = currentCacheState, isDownloadingAnyFile = false, selectedAppNames = selectedHomeAppNames, + homeScreenLayout = homeScreenLayout, ).applyDownloadStates(downloadStates.value) } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt index 5680aeb..892486c 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -54,6 +55,30 @@ class HomeAppCardModelTest { assertEquals(true, FOCUS_DEBUG in cardsWithDebug.first { it.family == HomeAppFamily.Focus }.appsByName) } + @Test + fun `creates one standalone card for each available flavor`() { + val apps = listOf( + FENIX, FENIX_BETA, FENIX_RELEASE, FENIX_DEBUG, + FOCUS, FOCUS_BETA, FOCUS_RELEASE, FOCUS_DEBUG, + REFERENCE_BROWSER, + ).associateWith(::app) + mapOf( + FENIX_DEBUG to app(FENIX_DEBUG, installedVersion = "1.0"), + FOCUS_DEBUG to app(FOCUS_DEBUG, installedVersion = "1.0"), + ) + + val cards = homeAppCards(apps, emptyMap(), HomeScreenLayout.OneCardPerFlavor) + + assertEquals( + listOf( + FENIX_RELEASE, FENIX_BETA, FENIX, FENIX_DEBUG, + FOCUS_RELEASE, FOCUS_BETA, FOCUS, FOCUS_DEBUG, + REFERENCE_BROWSER, + ), + cards.map { it.selectedAppName }, + ) + assertEquals(true, cards.all { !it.showFlavorSelector }) + } + private fun app(name: String, installedVersion: String? = null) = AppUiModel( name = name, packageName = name, 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 c0a0e4f..809691a 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 @@ -37,6 +37,7 @@ 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.managers.FakeUserDataRepository import org.mozilla.tryfox.data.repositories.CachedHomeApk import org.mozilla.tryfox.data.repositories.CachedHomeApp import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository @@ -55,6 +56,7 @@ import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel @@ -197,6 +199,7 @@ class HomeViewModelTest { private fun createViewModel( releaseRepositories: List = emptyList(), mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), + userDataRepository: FakeUserDataRepository? = null, homeDataCacheRepository: HomeDataCacheRepository = FakeHomeDataCacheRepository(), installedTryBuildRepository: InstalledTryBuildRepository = FakeInstalledTryBuildRepository(), ) = HomeViewModel( @@ -207,11 +210,26 @@ class HomeViewModelTest { intentManager = intentManager, installCoordinator = installCoordinator, ioDispatcher = mainCoroutineRule.testDispatcher, + userDataRepository = userDataRepository, homeDataCacheRepository = homeDataCacheRepository, installedTryBuildRepository = installedTryBuildRepository, supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) + @Test + fun `layout preference updates the loaded home state`() = runTest { + val userDataRepository = FakeUserDataRepository() + val viewModel = createViewModel(userDataRepository = userDataRepository) + + viewModel.initialLoad() + advanceUntilIdle() + userDataRepository.saveHomeScreenLayout(HomeScreenLayout.OneCardPerFlavor) + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(HomeScreenLayout.OneCardPerFlavor, state.homeScreenLayout) + } + private class FakeHomeDataCacheRepository( var snapshot: HomeDataSnapshot? = null, ) : HomeDataCacheRepository { From cfadf9bab2b96236110d356b7eb5c935ec38f8d1 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 20:28:21 +0200 Subject: [PATCH 45/47] Move cache clearing to settings --- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 29 ------------------- 1 file changed, 29 deletions(-) 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 0ebac22..18ad44f 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 @@ -23,14 +23,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -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 @@ -47,8 +43,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import org.mozilla.tryfox.R -import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.ui.composables.BinButton /** * Composable function for the Home screen, which displays a list of available apps and allows users to interact with them. @@ -83,13 +77,6 @@ fun HomeScreen( Scaffold( modifier = modifier.fillMaxSize(), topBar = { - val loadedState = screenState as? HomeScreenState.Loaded - val currentCacheState = - loadedState?.cacheManagementState ?: CacheManagementState.IdleEmpty - val isDownloading = loadedState?.isDownloadingAnyFile ?: false - val binButtonEnabled = - !isDownloading && currentCacheState == CacheManagementState.IdleNonEmpty - TopAppBar( title = { Text(stringResource(id = R.string.app_name)) }, colors = TopAppBarDefaults.topAppBarColors( @@ -124,22 +111,6 @@ fun HomeScreen( ), ) } - val tooltipState = rememberTooltipState() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(id = R.string.bin_button_tooltip_clear_downloaded_apks)) - } - }, - state = tooltipState, - ) { - BinButton( - cacheState = currentCacheState, - onConfirm = { homeViewModel.clearAppCache() }, - enabled = binButtonEnabled, - ) - } IconButton(onClick = onNavigateToSettings) { Icon( imageVector = Icons.Filled.Settings, From d6727419d5e532267d17744f0fe222040269d35a Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Thu, 6 Aug 2026 20:30:36 +0200 Subject: [PATCH 46/47] Avoid loading debug build metadata --- .../main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index 2a4d1e4..c0315eb 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -10,6 +10,7 @@ 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -173,7 +174,9 @@ internal fun HomeAppCard( } } - if (!isDebug || app.installedTryBuild != null) { + if (isDebug && app.installedTryBuild != null) { + Spacer(modifier = Modifier.height(12.dp)) + } else if (!isDebug) { Row( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), verticalAlignment = Alignment.Top, From f807a467ad38f5c6fa9657653b49eec4aa220599 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Fri, 7 Aug 2026 10:42:22 +0200 Subject: [PATCH 47/47] Fix lint and ktlint formatting issues --- app/src/main/java/org/mozilla/tryfox/MainActivity.kt | 5 ++--- .../java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt | 2 +- .../main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt | 2 +- .../java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt | 2 +- .../mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt | 4 ++-- .../org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt | 2 +- 6 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index c4acfcd..91ebbc4 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -7,9 +7,9 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.tween import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.core.tween import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Text @@ -41,8 +41,8 @@ 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.SearchScreen -import org.mozilla.tryfox.ui.screens.SettingsScreen import org.mozilla.tryfox.ui.screens.SearchViewModel +import org.mozilla.tryfox.ui.screens.SettingsScreen import org.mozilla.tryfox.ui.theme.TryFoxTheme /** @@ -351,5 +351,4 @@ class MainActivity : ComponentActivity() { private companion object { private const val SCREEN_TRANSITION_DURATION_MS = 200 } - } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt index afb71da..145b7f0 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt @@ -1,7 +1,7 @@ package org.mozilla.tryfox.ui.screens -import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG 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 792ed9f..b343523 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 @@ -41,8 +41,8 @@ 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.model.HomeScreenLayout +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/SettingsScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt index 2cf7a70..dbf64e6 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt @@ -39,8 +39,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import org.mozilla.tryfox.R diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt index 499d301..9980566 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt @@ -1,10 +1,10 @@ package org.mozilla.tryfox.data.managers +import kotlinx.coroutines.test.StandardTestDispatcher +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.assertTrue -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.io.File diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt index 892486c..fe6f7e0 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt @@ -2,9 +2,9 @@ package org.mozilla.tryfox.ui.screens import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel -import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG