diff --git a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt index 4f6a32c..9d16d3c 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt @@ -10,6 +10,7 @@ import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest +import androidx.test.rule.GrantPermissionRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -18,7 +19,11 @@ import org.junit.runner.RunWith @LargeTest class MainActivityDeeplinkTest { - @get:Rule + @get:Rule(order = 0) + val notificationPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + + @get:Rule(order = 1) val composeTestRule = createComposeRule() @Test diff --git a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityNotificationPermissionTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityNotificationPermissionTest.kt new file mode 100644 index 0000000..065c87c --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityNotificationPermissionTest.kt @@ -0,0 +1,65 @@ +package org.mozilla.tryfox + +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.GlobalContext +import org.mozilla.tryfox.data.FakeNotificationManager +import org.mozilla.tryfox.data.managers.DefaultNotificationManager +import org.mozilla.tryfox.data.managers.NotificationManager + +@RunWith(AndroidJUnit4::class) +class MainActivityNotificationPermissionTest { + @After + fun restoreNotificationManager() { + GlobalContext.get().declare( + DefaultNotificationManager(ApplicationProvider.getApplicationContext()), + ) + } + + @Test + fun appLaunch_requestsNotificationPermission_whenItIsMissing() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = false) + GlobalContext.get().declare(notificationManager) + + ActivityScenario.launch(MainActivity::class.java).use { } + + assertEquals(1, notificationManager.permissionRequestCount) + } + + @Test + fun appLaunch_doesNotRequestNotificationPermission_whenItIsGranted() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = true) + GlobalContext.get().declare(notificationManager) + + ActivityScenario.launch(MainActivity::class.java).use { } + + assertEquals(0, notificationManager.permissionRequestCount) + } + + @Test + fun appLaunch_doesNotRequestNotificationPermission_whenNotificationsAreDisabled() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = false) + notificationManager.setNotificationsEnabled(false) + GlobalContext.get().declare(notificationManager) + + ActivityScenario.launch(MainActivity::class.java).use { } + + assertEquals(0, notificationManager.permissionRequestCount) + } + + @Test + fun appLaunch_requestsNotificationPermissionOnlyOnce() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = false) + GlobalContext.get().declare(notificationManager) + + ActivityScenario.launch(MainActivity::class.java).use { } + ActivityScenario.launch(MainActivity::class.java).use { } + + assertEquals(1, notificationManager.permissionRequestCount) + } +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeNotificationManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeNotificationManager.kt new file mode 100644 index 0000000..e14a3ed --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeNotificationManager.kt @@ -0,0 +1,43 @@ +package org.mozilla.tryfox.data + +import android.app.Activity +import org.mozilla.tryfox.data.managers.NotificationManager +import org.mozilla.tryfox.data.managers.NotificationPermissionState + +class FakeNotificationManager( + var hasNotificationPermission: Boolean = true, + var notificationsEnabledValue: Boolean = true, + var notificationPermissionState: NotificationPermissionState = NotificationPermissionState.GRANTED, +) : NotificationManager { + var permissionRequestCount: Int = 0 + private set + var openNotificationSettingsCount: Int = 0 + private set + private var hasHandledStartupPermissionRequest: Boolean = false + + override fun hasPermission(): Boolean = hasNotificationPermission + + override fun areNotificationsEnabled(): Boolean = notificationsEnabledValue && hasPermission() + + override fun isNotificationPreferenceEnabled(): Boolean = notificationsEnabledValue + + override fun setNotificationsEnabled(enabled: Boolean) { + notificationsEnabledValue = enabled + } + + override fun requestPermissionOnFirstAppLaunch(activity: Activity) { + if (hasHandledStartupPermissionRequest) return + hasHandledStartupPermissionRequest = true + requestPermissionIfNeeded(activity) + } + + override fun requestPermission(activity: Activity) { + permissionRequestCount++ + } + + override fun permissionState(activity: Activity?): NotificationPermissionState = notificationPermissionState + + override fun openNotificationSettings() { + openNotificationSettingsCount++ + } +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/FirefoxReleaseAndBetaInstallationTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/FirefoxReleaseAndBetaInstallationTest.kt index 0b7ce44..bcb6d0b 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/FirefoxReleaseAndBetaInstallationTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/FirefoxReleaseAndBetaInstallationTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -22,7 +23,11 @@ import org.mozilla.tryfox.MainActivity @RunWith(AndroidJUnit4::class) class FirefoxReleaseAndBetaInstallationTest { - @get:Rule + @get:Rule(order = 0) + val notificationPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + + @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() @Test 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 06f7b1f..51a375f 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 @@ -7,6 +7,7 @@ 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 androidx.test.rule.GrantPermissionRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -15,7 +16,11 @@ import org.mozilla.tryfox.MainActivity @RunWith(AndroidJUnit4::class) class HomeScreenTest { - @get:Rule + @get:Rule(order = 0) + val notificationPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant(android.Manifest.permission.POST_NOTIFICATIONS) + + @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule() @Test @@ -42,4 +47,14 @@ class HomeScreenTest { composeTestRule.onNodeWithText("One card per app").assertIsDisplayed() composeTestRule.onNodeWithText("One card per flavor of each app").assertIsDisplayed() } + + @Test + fun settingsScreen_showsNotificationPreference() { + composeTestRule.onNodeWithContentDescription("Settings").performClick() + + composeTestRule.onNodeWithText("Notifications").assertIsDisplayed() + val description = "Allow notifications to keep you updated on APK download progress, " + + "including downloads that continue in the background." + composeTestRule.onNodeWithText(description).assertIsDisplayed() + } } diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 91ebbc4..4e2ff77 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -32,6 +32,8 @@ import org.koin.android.ext.android.inject 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.data.managers.NotificationManager +import org.mozilla.tryfox.data.managers.NotificationPermissionState import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.screens.HistoryScreen @@ -99,7 +101,9 @@ sealed class NavScreen(val route: String) { */ class MainActivity : ComponentActivity() { private val installCoordinator: ApkInstallCoordinator by inject() + private val notificationManager: NotificationManager by inject() private lateinit var navController: NavHostController + private var notificationPermissionGranted by mutableStateOf(false) private var receiveFromDesktopStartRequested by mutableStateOf(false) private var pendingUninstallOperationId: String? = null private val uninstallLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> @@ -111,6 +115,8 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + notificationManager.requestPermissionOnFirstAppLaunch(this) + notificationPermissionGranted = notificationManager.hasPermission() lifecycleScope.launch { installCoordinator.uninstallRequests.collect { request -> pendingUninstallOperationId = request.operationId @@ -131,6 +137,21 @@ class MainActivity : ComponentActivity() { } } + override fun onResume() { + super.onResume() + notificationPermissionGranted = notificationManager.hasPermission() + } + + @Deprecated("Deprecated in Android API") + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + notificationPermissionGranted = notificationManager.hasPermission() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) @@ -216,6 +237,17 @@ class MainActivity : ComponentActivity() { NavScreen.TreeherderSearchWithArgs.createRoute(project, revision), ) }, + notificationPermissionGranted = notificationPermissionGranted, + onEnableNotifications = { + notificationManager.setNotificationsEnabled(true) + when (notificationManager.permissionState(this@MainActivity)) { + NotificationPermissionState.REQUESTABLE -> { + notificationManager.requestPermission(this@MainActivity) + } + NotificationPermissionState.BLOCKED -> notificationManager.openNotificationSettings() + NotificationPermissionState.GRANTED -> Unit + } + }, homeViewModel = koinViewModel(), ) } @@ -234,6 +266,7 @@ class MainActivity : ComponentActivity() { SettingsScreen( onNavigateUp = { localNavController.popBackStack() }, settingsViewModel = koinViewModel(), + notificationManager = notificationManager, ) } composable(NavScreen.QrScanner.route) { @@ -254,6 +287,7 @@ class MainActivity : ComponentActivity() { ) }, receiveFromDesktopViewModel = koinViewModel(), + notificationManager = notificationManager, startReceiverOnEnter = receiveFromDesktopStartRequested, onStartReceiverOnEnterConsumed = { receiveFromDesktopStartRequested = false diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/NotificationManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/NotificationManager.kt new file mode 100644 index 0000000..2a20b5f --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/NotificationManager.kt @@ -0,0 +1,123 @@ +package org.mozilla.tryfox.data.managers + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.provider.Settings +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +enum class NotificationPermissionState { + GRANTED, + REQUESTABLE, + BLOCKED, +} + +/** Manages the runtime permission required to post notifications. */ +interface NotificationManager { + /** Returns whether the application can post notifications. */ + fun hasPermission(): Boolean + + /** Returns whether notifications are enabled in TryFox and permitted by Android. */ + fun areNotificationsEnabled(): Boolean + + /** Returns whether notifications are enabled in TryFox's own preference. */ + fun isNotificationPreferenceEnabled(): Boolean + + /** Updates TryFox's notification preference. */ + fun setNotificationsEnabled(enabled: Boolean) + + /** Handles the one-time notification permission request made when the app first launches. */ + fun requestPermissionOnFirstAppLaunch(activity: Activity) + + /** Requests notification permission when the platform requires it. */ + fun requestPermission(activity: Activity) + + /** Returns whether Android can still show the notification permission prompt. */ + fun permissionState(activity: Activity?): NotificationPermissionState + + /** Opens the application's notification settings. */ + fun openNotificationSettings() + + /** Requests notification permission only when it has not already been granted. */ + fun requestPermissionIfNeeded(activity: Activity) { + if (isNotificationPreferenceEnabled() && !hasPermission()) { + requestPermission(activity) + } + } +} + +/** Android implementation of [NotificationManager]. */ +class DefaultNotificationManager(private val context: Context) : NotificationManager { + override fun hasPermission(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + + override fun areNotificationsEnabled(): Boolean = + isNotificationPreferenceEnabled() && hasPermission() + + override fun isNotificationPreferenceEnabled(): Boolean = + preferences.getBoolean(NOTIFICATIONS_ENABLED, true) + + override fun setNotificationsEnabled(enabled: Boolean) { + preferences.edit().putBoolean(NOTIFICATIONS_ENABLED, enabled).apply() + } + + override fun requestPermissionOnFirstAppLaunch(activity: Activity) { + if (preferences.getBoolean(HAS_HANDLED_STARTUP_PERMISSION_REQUEST, false)) return + preferences.edit().putBoolean(HAS_HANDLED_STARTUP_PERMISSION_REQUEST, true).apply() + requestPermissionIfNeeded(activity) + } + + override fun requestPermission(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + preferences.edit().putBoolean(HAS_REQUESTED_NOTIFICATION_PERMISSION, true).apply() + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + NOTIFICATION_PERMISSION_REQUEST_CODE, + ) + } + } + + override fun permissionState(activity: Activity?): NotificationPermissionState { + if (hasPermission()) return NotificationPermissionState.GRANTED + if (!preferences.getBoolean(HAS_REQUESTED_NOTIFICATION_PERMISSION, false)) { + return NotificationPermissionState.REQUESTABLE + } + return if ( + activity != null && + ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.POST_NOTIFICATIONS) + ) { + NotificationPermissionState.REQUESTABLE + } else { + NotificationPermissionState.BLOCKED + } + } + + override fun openNotificationSettings() { + context.startActivity( + android.content.Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + }, + ) + } + + private companion object { + private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1001 + private const val NOTIFICATION_PREFERENCES = "notification_preferences" + private const val HAS_REQUESTED_NOTIFICATION_PERMISSION = "has_requested_notification_permission" + private const val HAS_HANDLED_STARTUP_PERMISSION_REQUEST = "has_handled_startup_permission_request" + private const val NOTIFICATIONS_ENABLED = "notifications_enabled" + } + + private val preferences by lazy { + context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + } +} 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 78bdbb9..187df5f 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -19,7 +19,9 @@ import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.DefaultCacheManager import org.mozilla.tryfox.data.managers.DefaultIntentManager +import org.mozilla.tryfox.data.managers.DefaultNotificationManager import org.mozilla.tryfox.data.managers.IntentManager +import org.mozilla.tryfox.data.managers.NotificationManager import org.mozilla.tryfox.data.repositories.DefaultDownloadFileRepository import org.mozilla.tryfox.data.repositories.DefaultHistoryRepository import org.mozilla.tryfox.data.repositories.DefaultHomeDataCacheRepository @@ -181,11 +183,12 @@ val repositoryModule = module { ) } single { DefaultIntentManager(androidContext()) } + single { DefaultNotificationManager(androidContext()) } single { ApkInstallCoordinator(androidContext(), get()) } single { DefaultApkDownloadStore(androidContext(), get(named("IODispatcher"))) } single { DownloadNotificationFactory(androidContext()) } single { WorkManager.getInstance(androidContext()) } - single { DefaultApkDownloadCoordinator(androidContext(), get(), get()) } + single { DefaultApkDownloadCoordinator(androidContext(), get(), get(), get()) } single(named(FENIX)) { FenixReleaseRepository(get()) } single(named(FENIX_RELEASE)) { FenixReleaseReleaseRepository(get()) } diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt index 1cc3290..8883407 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt @@ -8,6 +8,7 @@ data class ApkDownloadRequest( val outputFile: File, val appName: String, val fileName: String, + val notificationTitle: String = appName, val cacheRelativePath: String? = null, ) { val outputPath: String = outputFile.absolutePath 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 4ebe117..efec6f1 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt @@ -8,6 +8,7 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow +import org.mozilla.tryfox.data.managers.NotificationManager import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.download.worker.ApkDownloadWorker @@ -16,6 +17,7 @@ class DefaultApkDownloadCoordinator( context: Context, private val store: ApkDownloadStore = DefaultApkDownloadStore(context.applicationContext), private val workManager: WorkManager = WorkManager.getInstance(context.applicationContext), + private val notificationManager: NotificationManager, ) : ApkDownloadCoordinator { private companion object { const val TAG = "ApkDownloadCoordinator" @@ -28,7 +30,11 @@ class DefaultApkDownloadCoordinator( OneTimeWorkRequestBuilder() .setInputData(ApkDownloadWorker.createInputData(request)) .addTag(request.uniqueKey) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { + if (notificationManager.areNotificationsEnabled()) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } .build() store.upsert( diff --git a/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt index 916daa1..7c74434 100644 --- a/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt +++ b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt @@ -14,6 +14,7 @@ class DownloadNotificationFactory( private val context: Context, ) { fun createForegroundInfo( + notificationId: Int, appName: String, progress: Int? = null, isIndeterminate: Boolean = true, @@ -36,9 +37,9 @@ class DownloadNotificationFactory( .build() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + ForegroundInfo(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) } else { - ForegroundInfo(NOTIFICATION_ID, notification) + ForegroundInfo(notificationId, notification) } } @@ -60,6 +61,5 @@ class DownloadNotificationFactory( 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/DownloadNotificationId.kt b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationId.kt new file mode 100644 index 0000000..bf3ca30 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationId.kt @@ -0,0 +1,11 @@ +package org.mozilla.tryfox.download + +import java.util.UUID + +/** Creates stable notification IDs for individual download workers. */ +object DownloadNotificationId { + private const val ID_MASK = Int.MAX_VALUE + + fun forWorker(workerId: UUID): Int = + (workerId.hashCode() and ID_MASK).coerceAtLeast(1) +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationTitle.kt b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationTitle.kt new file mode 100644 index 0000000..e2cc33e --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationTitle.kt @@ -0,0 +1,27 @@ +package org.mozilla.tryfox.download + +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 + +fun homeDownloadNotificationTitle(appName: String, version: String): String = + "${appNameForNotification(appName)} $version" + +private fun appNameForNotification(appName: String): String = when (appName) { + FENIX -> "Fenix Nightly" + FENIX_RELEASE -> "Fenix Release" + FENIX_BETA -> "Fenix Beta" + FENIX_DEBUG -> "Fenix debug" + FOCUS -> "Focus Nightly" + FOCUS_RELEASE -> "Focus Release" + FOCUS_BETA -> "Focus Beta" + FOCUS_DEBUG -> "Focus debug" + REFERENCE_BROWSER -> "Reference Browser" + else -> appName +} 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 98a43ba..2f66503 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 @@ -14,10 +14,12 @@ 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.managers.NotificationManager 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.DownloadNotificationId import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState import java.io.File @@ -30,11 +32,15 @@ class ApkDownloadWorker( private val cacheManager: CacheManager by inject() private val downloadStore: ApkDownloadStore by inject() private val notificationFactory: DownloadNotificationFactory by inject() + private val notificationManager: NotificationManager 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()) + return notificationFactory.createForegroundInfo( + notificationId = DownloadNotificationId.forWorker(id), + appName = request?.notificationTitle.orEmpty(), + ) } override suspend fun doWork(): Result { @@ -45,7 +51,15 @@ class ApkDownloadWorker( var lastTotalBytes = -1L var lastProgressUpdateAt = 0L var lastProgressPercent = -1 - setForeground(notificationFactory.createForegroundInfo(request.appName)) + val notificationId = DownloadNotificationId.forWorker(id) + if (notificationManager.areNotificationsEnabled()) { + setForeground( + notificationFactory.createForegroundInfo( + notificationId = notificationId, + appName = request.notificationTitle, + ), + ) + } updateState( request = request, @@ -100,13 +114,16 @@ class ApkDownloadWorker( KEY_TOTAL_BYTES to totalBytes, ), ) - setForeground( - notificationFactory.createForegroundInfo( - appName = request.appName, - progress = if (totalBytes > 0) progressPercent else null, - isIndeterminate = totalBytes <= 0, - ), - ) + if (notificationManager.areNotificationsEnabled()) { + setForeground( + notificationFactory.createForegroundInfo( + notificationId = notificationId, + appName = request.notificationTitle, + progress = if (totalBytes > 0) progressPercent else null, + isIndeterminate = totalBytes <= 0, + ), + ) + } } } } @@ -292,6 +309,7 @@ class ApkDownloadWorker( 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 notificationTitle = getString(KEY_NOTIFICATION_TITLE) ?: appName val cacheRelativePath = getString(KEY_CACHE_RELATIVE_PATH) return ApkDownloadRequest( @@ -300,6 +318,7 @@ class ApkDownloadWorker( outputFile = File(outputPath), appName = appName, fileName = fileName, + notificationTitle = notificationTitle, cacheRelativePath = cacheRelativePath, ) } @@ -313,6 +332,7 @@ class ApkDownloadWorker( 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_NOTIFICATION_TITLE = "download_notification_title" 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" @@ -325,6 +345,7 @@ class ApkDownloadWorker( .putString(KEY_OUTPUT_PATH, request.outputPath) .putString(KEY_APP_NAME, request.appName) .putString(KEY_FILE_NAME, request.fileName) + .putString(KEY_NOTIFICATION_TITLE, request.notificationTitle) .apply { request.cacheRelativePath?.let { putString(KEY_CACHE_RELATIVE_PATH, it) } } 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 dfadcf7..2ef8431 100644 --- a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt +++ b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt @@ -37,12 +37,14 @@ import org.mozilla.tryfox.R import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import java.io.IOException +import org.mozilla.tryfox.data.managers.NotificationManager as TryFoxNotificationManager class TryFoxLanReceiveService : Service(), KoinComponent { private val identityManager: LanReceiveIdentityManager by inject() private val messageHistoryRepository: LanMessageHistoryRepository by inject() private val stateRepository: LanReceiveStateRepository by inject() private val treeherderRepository: TreeherderRepository by inject() + private val tryFoxNotificationManager: TryFoxNotificationManager by inject() private val pushResolver by lazy { LanReceivedPushResolver(treeherderRepository) } private val serviceScope = kotlinx.coroutines.CoroutineScope( @@ -365,6 +367,7 @@ class TryFoxLanReceiveService : Service(), KoinComponent { } private fun postStoppedNotification() { + if (!tryFoxNotificationManager.areNotificationsEnabled()) return createNotificationChannelIfNeeded() val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.notify(NOTIFICATION_ID, buildStoppedNotification()) @@ -400,6 +403,7 @@ class TryFoxLanReceiveService : Service(), KoinComponent { } private fun postReceivedMessageNotification(message: LanReceivedMessage) { + if (!tryFoxNotificationManager.areNotificationsEnabled()) return createNotificationChannelIfNeeded() val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationId = MESSAGE_NOTIFICATION_BASE_ID + message.id.toInt().coerceAtLeast(1) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/FloatingActionCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/FloatingActionCard.kt new file mode 100644 index 0000000..fcdbc74 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/FloatingActionCard.kt @@ -0,0 +1,45 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.ui.theme.customColors + +/** A floating Home-screen card with text that wraps before its trailing action. */ +@Composable +fun FloatingActionCard( + modifier: Modifier = Modifier, + text: @Composable (Modifier) -> Unit, + action: @Composable () -> Unit, + footer: @Composable (() -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.customColors.tryFoxCardBackground, + ), + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + text(Modifier.weight(1f)) + action() + } + footer?.invoke() + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/NotificationPermissionCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/NotificationPermissionCard.kt new file mode 100644 index 0000000..b806e1b --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/NotificationPermissionCard.kt @@ -0,0 +1,31 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import org.mozilla.tryfox.R + +@Composable +fun NotificationPermissionCard( + modifier: Modifier = Modifier, + onEnableNotifications: () -> Unit, +) { + FloatingActionCard( + modifier = modifier, + text = { textModifier -> + Text( + text = stringResource(R.string.notification_permission_card_message), + style = MaterialTheme.typography.titleMedium, + modifier = textModifier, + ) + }, + action = { + Button(onClick = onEnableNotifications) { + Text(stringResource(R.string.notification_permission_card_enable)) + } + }, + ) +} 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 4b47caa..4da7a3d 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 @@ -1,18 +1,9 @@ package org.mozilla.tryfox.ui.composables -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -21,7 +12,6 @@ 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 @Composable fun TryFoxCard( @@ -34,29 +24,18 @@ fun TryFoxCard( ) { val latestApk = (app.apks as? ApksResult.Success)?.apks?.firstOrNull() ?: return - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.customColors.tryFoxCardBackground, - ), - ) { - 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), - style = MaterialTheme.typography.titleMedium, - ) - } - Spacer(modifier = Modifier.width(4.dp)) - DownloadButton( + val installState = installStates[latestApk.uniqueKey] ?: InstallState.Idle + FloatingActionCard( + modifier = modifier, + text = { textModifier -> + Text( + text = stringResource(id = R.string.tryfox_card_title, latestApk.version), + style = MaterialTheme.typography.titleMedium, + modifier = textModifier, + ) + }, + action = { + DownloadButton( downloadState = latestApk.downloadState, onDownloadClick = { onDownloadClick(latestApk) }, onInstallClick = { onInstallClick(latestApk) }, @@ -64,8 +43,9 @@ fun TryFoxCard( installState = installState, onOpenClick = onOpenInstalledApp, debugLabel = "home:${latestApk.uniqueKey}", - ) - } + ) + }, + footer = { (installState as? InstallState.Failed)?.let { failure -> Text( text = failure.message, @@ -74,6 +54,6 @@ fun TryFoxCard( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) } - } - } + }, + ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/ArtifactUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/ArtifactUiModel.kt index 3ae3eea..6e68bed 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/ArtifactUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/ArtifactUiModel.kt @@ -10,4 +10,5 @@ data class ArtifactUiModel( val expires: String, var downloadState: DownloadState, val uniqueKey: String, + val notificationTitle: String = "", ) 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 664fb0b..b9c7459 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 @@ -123,6 +123,7 @@ class HistoryViewModel( outputFile = outputFile, appName = entry.appName, fileName = entry.artifactFileName, + notificationTitle = formatJobNameForDisplay(entry.jobName), cacheRelativePath = entry.cacheRelativePath, ), ) 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 18ad44f..4ea552e 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 @@ -3,6 +3,7 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height @@ -33,9 +34,12 @@ 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.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription @@ -63,6 +67,8 @@ fun HomeScreen( onNavigateToHistory: () -> Unit, onNavigateToSettings: () -> Unit, onNavigateToTryBuild: (String, String) -> Unit = { _, _ -> }, + notificationPermissionGranted: Boolean = true, + onEnableNotifications: () -> Unit = {}, homeViewModel: HomeViewModel = viewModel(), ) { val screenState by homeViewModel.homeScreenState.collectAsState() @@ -127,7 +133,9 @@ fun HomeScreen( .padding(innerPadding) .pullRefresh(pullRefreshState), ) { - var tryFoxCardHeight by remember { mutableStateOf(0.dp) } + var floatingCardsHeight by remember { mutableStateOf(0.dp) } + var notificationCardDismissed by rememberSaveable { mutableStateOf(false) } + val density = LocalDensity.current when (val currentScreenState = screenState) { is HomeScreenState.InitialLoading -> { @@ -159,7 +167,17 @@ fun HomeScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - item { Spacer(modifier = Modifier.height(if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp)) } + item { + Spacer( + modifier = Modifier.height( + if (tryFoxApp != null || (!notificationPermissionGranted && !notificationCardDismissed)) { + floatingCardsHeight + 4.dp + } else { + 0.dp + }, + ), + ) + } items(cards, key = { it.stableKey }) { card -> HomeAppCard( @@ -192,17 +210,30 @@ fun HomeScreen( } } - if (tryFoxApp != null) { - TryFoxCardComponent( - modifier = Modifier.align(Alignment.TopCenter), - tryFoxApp = tryFoxApp, - onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = homeViewModel::installHomeApk, - installStates = installStates, - onOpenInstalledApp = homeViewModel::openInstalledApp, - onDismiss = { homeViewModel.dismissTryFoxCard() }, - onTryFoxCardHeightChange = { tryFoxCardHeight = it }, - ) + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .onGloballyPositioned { + floatingCardsHeight = with(density) { it.size.height.toDp() } + }, + ) { + if (!notificationPermissionGranted && !notificationCardDismissed) { + SwipeableNotificationPermissionCard( + onEnableNotifications = onEnableNotifications, + onDismiss = { notificationCardDismissed = true }, + ) + } + if (tryFoxApp != null) { + TryFoxCardComponent( + tryFoxApp = tryFoxApp, + onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, + onInstallClick = homeViewModel::installHomeApk, + installStates = installStates, + onOpenInstalledApp = homeViewModel::openInstalledApp, + onDismiss = { homeViewModel.dismissTryFoxCard() }, + onTryFoxCardHeightChange = {}, + ) + } } } } 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 b343523..cf36ef7 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 @@ -36,6 +36,7 @@ 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 +import org.mozilla.tryfox.download.homeDownloadNotificationTitle import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.install.ApkInstallCoordinator @@ -514,6 +515,7 @@ class HomeViewModel( outputFile = outputFile, appName = apkInfo.appName, fileName = apkInfo.fileName, + notificationTitle = homeDownloadNotificationTitle(apkInfo.appName, apkInfo.version), cacheRelativePath = cacheRelativePathFor(apkInfo), ), ) 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 f0aa4a3..b580860 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 @@ -770,7 +770,9 @@ class SearchViewModel( taskId = job.taskId, isSignedBuild = job.isSignedBuild, isTest = job.isTest, - artifacts = artifacts, + artifacts = artifacts.map { artifact -> + artifact.copy(notificationTitle = formatJobNameForDisplay(job.jobName)) + }, ) fun getDownloadedFile(artifactName: String, taskId: String): File? { @@ -854,6 +856,7 @@ class SearchViewModel( outputFile = outputFile, appName = TREEHERDER, fileName = artifactFileName, + notificationTitle = artifactUiModel.notificationTitle.ifBlank { artifactFileName }, cacheRelativePath = "$TREEHERDER/$taskId/$artifactFileName", ) 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 08f97a5..6064b39 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 @@ -1,13 +1,6 @@ package org.mozilla.tryfox.ui.screens -import android.Manifest import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -45,11 +38,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.managers.NotificationManager +import org.mozilla.tryfox.data.managers.NotificationPermissionState import org.mozilla.tryfox.lan.LanReceiveStatus import org.mozilla.tryfox.lan.TryFoxLanReceiveService @@ -60,6 +54,7 @@ fun ReceiveFromDesktopScreen( onNavigateToMessageHistory: () -> Unit, onNavigateToTreeherderRevision: (project: String, revision: String) -> Unit, receiveFromDesktopViewModel: ReceiveFromDesktopViewModel, + notificationManager: NotificationManager, startReceiverOnEnter: Boolean = false, onStartReceiverOnEnterConsumed: () -> Unit = {}, modifier: Modifier = Modifier, @@ -67,27 +62,15 @@ fun ReceiveFromDesktopScreen( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val state by receiveFromDesktopViewModel.state.collectAsState() - var hasNotificationPermission by remember { mutableStateOf(notificationsPermissionGranted(context)) } - var hasRequestedNotificationPermission by remember { mutableStateOf(false) } + var hasNotificationPermission by remember { mutableStateOf(notificationManager.hasPermission()) } var startAfterPermission by remember { mutableStateOf(false) } - val notificationPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { granted -> - hasNotificationPermission = notificationsPermissionGranted(context) - hasRequestedNotificationPermission = true - if (granted && startAfterPermission) { - ContextCompat.startForegroundService(context, TryFoxLanReceiveService.startIntent(context)) - } - startAfterPermission = false + val permissionState = if (hasNotificationPermission) { + NotificationPermissionState.GRANTED + } else { + notificationManager.permissionState(context.findActivity()) } - val permissionState = notificationPermissionState( - context = context, - hasRequestedNotificationPermission = hasRequestedNotificationPermission, - hasNotificationPermission = hasNotificationPermission, - ) - val requestOrOpenSettings = { when (permissionState) { NotificationPermissionState.GRANTED -> { @@ -95,15 +78,15 @@ fun ReceiveFromDesktopScreen( } NotificationPermissionState.REQUESTABLE -> { startAfterPermission = true - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + context.findActivity()?.let(notificationManager::requestPermission) } NotificationPermissionState.BLOCKED -> { - openAppNotificationSettings(context) + notificationManager.openNotificationSettings() } } } - val requestStartReceiver = { + val requestStartReceiver: () -> Unit = { if (permissionState == NotificationPermissionState.GRANTED) { ContextCompat.startForegroundService(context, TryFoxLanReceiveService.startIntent(context)) } else { @@ -124,9 +107,12 @@ fun ReceiveFromDesktopScreen( androidx.compose.runtime.DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - hasNotificationPermission = notificationsPermissionGranted(context) + hasNotificationPermission = notificationManager.hasPermission() if (hasNotificationPermission) { - hasRequestedNotificationPermission = false + if (startAfterPermission) { + ContextCompat.startForegroundService(context, TryFoxLanReceiveService.startIntent(context)) + startAfterPermission = false + } } } } @@ -348,47 +334,6 @@ fun ReceiveFromDesktopScreen( } } -private fun notificationsPermissionGranted(context: Context): Boolean = - Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS, - ) == PackageManager.PERMISSION_GRANTED - -private enum class NotificationPermissionState { - GRANTED, - REQUESTABLE, - BLOCKED, -} - -private fun notificationPermissionState( - context: Context, - hasRequestedNotificationPermission: Boolean, - hasNotificationPermission: Boolean, -): NotificationPermissionState { - if (hasNotificationPermission) return NotificationPermissionState.GRANTED - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return NotificationPermissionState.GRANTED - - val activity = context.findActivity() - val shouldShowRationale = activity?.let { - ActivityCompat.shouldShowRequestPermissionRationale(it, Manifest.permission.POST_NOTIFICATIONS) - } ?: false - - return when { - !hasRequestedNotificationPermission -> NotificationPermissionState.REQUESTABLE - shouldShowRationale -> NotificationPermissionState.REQUESTABLE - else -> NotificationPermissionState.BLOCKED - } -} - -private fun openAppNotificationSettings(context: Context) { - val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) -} - private tailrec fun Context.findActivity(): android.app.Activity? = when (this) { is android.app.Activity -> this is android.content.ContextWrapper -> baseContext.findActivity() 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 dbf64e6..cb44e48 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 @@ -1,13 +1,16 @@ package org.mozilla.tryfox.ui.screens +import android.content.Context 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -26,10 +29,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +42,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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.selected @@ -44,6 +50,8 @@ 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.data.managers.NotificationManager +import org.mozilla.tryfox.data.managers.NotificationPermissionState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.HomeScreenLayout @@ -52,10 +60,29 @@ import org.mozilla.tryfox.model.HomeScreenLayout fun SettingsScreen( onNavigateUp: () -> Unit, settingsViewModel: SettingsViewModel = viewModel(), + notificationManager: NotificationManager, modifier: Modifier = Modifier, ) { val uiState by settingsViewModel.uiState.collectAsState() var showClearConfirmation by remember { mutableStateOf(false) } + val context = LocalContext.current + val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current + val activity = context.findActivity() + var notificationsEnabled by remember { mutableStateOf(notificationManager.areNotificationsEnabled()) } + var notificationPermissionState by remember { + mutableStateOf(notificationManager.permissionState(activity)) + } + + DisposableEffect(lifecycleOwner, activity) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { + notificationPermissionState = notificationManager.permissionState(activity) + notificationsEnabled = notificationManager.areNotificationsEnabled() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } if (showClearConfirmation) { AlertDialog( @@ -101,6 +128,23 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(20.dp), ) { + NotificationSettingsCard( + notificationsEnabled = notificationsEnabled, + onNotificationToggle = { enabled -> + notificationManager.setNotificationsEnabled(enabled) + notificationsEnabled = notificationManager.areNotificationsEnabled() + when (notificationPermissionState) { + NotificationPermissionState.GRANTED -> Unit + NotificationPermissionState.REQUESTABLE -> { + if (enabled) activity?.let(notificationManager::requestPermission) + } + NotificationPermissionState.BLOCKED -> { + if (enabled) notificationManager.openNotificationSettings() + } + } + }, + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) CacheSettingsCard( uiState = uiState, onClearCache = { showClearConfirmation = true }, @@ -114,6 +158,38 @@ fun SettingsScreen( } } +@Composable +private fun NotificationSettingsCard( + notificationsEnabled: Boolean, + onNotificationToggle: (Boolean) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsSectionTitle(R.string.settings_notifications_section_title) + PreferenceGroup { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.settings_notifications_label)) + Text( + text = stringResource(R.string.settings_notifications_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = notificationsEnabled, + onCheckedChange = onNotificationToggle, + ) + } + } + } +} + @Composable private fun CacheSettingsCard(uiState: SettingsUiState, onClearCache: () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -229,3 +305,9 @@ internal fun formatCacheSize(bytes: Long): String = when { bytes < 1024L * 1024L * 1024L -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) else -> "%.1f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) } + +private tailrec fun Context.findActivity(): android.app.Activity? = when (this) { + is android.app.Activity -> this + is android.content.ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableNotificationPermissionCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableNotificationPermissionCard.kt new file mode 100644 index 0000000..7982972 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableNotificationPermissionCard.kt @@ -0,0 +1,72 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue +import androidx.compose.material3.rememberSwipeToDismissBoxState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.ui.composables.NotificationPermissionCard +import kotlin.math.abs + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SwipeableNotificationPermissionCard( + modifier: Modifier = Modifier, + onEnableNotifications: () -> Unit, + onDismiss: () -> Unit, +) { + val dismissState = rememberSwipeToDismissBoxState( + confirmValueChange = { + if (it == SwipeToDismissBoxValue.Settled) { + false + } else { + onDismiss() + true + } + }, + ) + + AnimatedVisibility( + visible = dismissState.currentValue == SwipeToDismissBoxValue.Settled, + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(animationSpec = tween(durationMillis = 300)), + modifier = modifier, + ) { + SwipeToDismissBox( + state = dismissState, + backgroundContent = { + Box( + Modifier + .fillMaxSize() + .background(Color.Transparent), + ) {} + }, + content = { + NotificationPermissionCard( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 8.dp) + .graphicsLayer { + val targetValue = dismissState.targetValue + alpha = if (targetValue != SwipeToDismissBoxValue.Settled) { + 1f - abs(dismissState.progress) + } else { + 1f + } + }, + onEnableNotifications = onEnableNotifications, + ) + }, + ) + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index caff014..f95b235 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,6 +20,11 @@ An unknown error occurred. Back Settings + Notifications + Notifications + Allow notifications to keep you updated on APK download progress, including downloads that continue in the background. + Grant notification permission to see download progress in notifications. + Enable Cache Downloaded APKs stored on this device. Current space used diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeNotificationManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeNotificationManager.kt new file mode 100644 index 0000000..082d5a7 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeNotificationManager.kt @@ -0,0 +1,41 @@ +package org.mozilla.tryfox.data.managers + +import android.app.Activity + +class FakeNotificationManager( + var hasNotificationPermission: Boolean = true, + var notificationsEnabledValue: Boolean = true, + var notificationPermissionState: NotificationPermissionState = NotificationPermissionState.GRANTED, +) : NotificationManager { + var permissionRequestCount: Int = 0 + private set + var openNotificationSettingsCount: Int = 0 + private set + private var hasHandledStartupPermissionRequest: Boolean = false + + override fun hasPermission(): Boolean = hasNotificationPermission + + override fun areNotificationsEnabled(): Boolean = notificationsEnabledValue && hasPermission() + + override fun isNotificationPreferenceEnabled(): Boolean = notificationsEnabledValue + + override fun setNotificationsEnabled(enabled: Boolean) { + notificationsEnabledValue = enabled + } + + override fun requestPermissionOnFirstAppLaunch(activity: Activity) { + if (hasHandledStartupPermissionRequest) return + hasHandledStartupPermissionRequest = true + requestPermissionIfNeeded(activity) + } + + override fun requestPermission(activity: Activity) { + permissionRequestCount++ + } + + override fun permissionState(activity: Activity?): NotificationPermissionState = notificationPermissionState + + override fun openNotificationSettings() { + openNotificationSettingsCount++ + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/NotificationManagerTest.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/NotificationManagerTest.kt new file mode 100644 index 0000000..753c5dd --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/NotificationManagerTest.kt @@ -0,0 +1,49 @@ +package org.mozilla.tryfox.data.managers + +import android.app.Activity +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock + +class NotificationManagerTest { + @Test + fun `requestPermissionIfNeeded requests permission when permission is missing`() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = false) + + notificationManager.requestPermissionIfNeeded(mock()) + + assertEquals(1, notificationManager.permissionRequestCount) + } + + @Test + fun `requestPermissionIfNeeded does not request permission when it is granted`() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = true) + + notificationManager.requestPermissionIfNeeded(mock()) + + assertEquals(0, notificationManager.permissionRequestCount) + } + + @Test + fun `disabled notification preference prevents a permission request`() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = false) + notificationManager.setNotificationsEnabled(false) + + notificationManager.requestPermissionIfNeeded(mock()) + + assertFalse(notificationManager.isNotificationPreferenceEnabled()) + assertEquals(0, notificationManager.permissionRequestCount) + } + + @Test + fun `notification permission is requested only on the first app launch`() { + val notificationManager = FakeNotificationManager(hasNotificationPermission = false) + val activity = mock() + + notificationManager.requestPermissionOnFirstAppLaunch(activity) + notificationManager.requestPermissionOnFirstAppLaunch(activity) + + assertEquals(1, notificationManager.permissionRequestCount) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/download/DownloadNotificationIdTest.kt b/app/src/test/java/org/mozilla/tryfox/download/DownloadNotificationIdTest.kt new file mode 100644 index 0000000..a5838a8 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/download/DownloadNotificationIdTest.kt @@ -0,0 +1,43 @@ +package org.mozilla.tryfox.download + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +class DownloadNotificationIdTest { + @Test + fun `returns the same positive notification ID for the same worker`() { + val workerId = UUID.fromString("6a9bd3fa-c00e-4c50-9647-d5d3c8d8a504") + + val firstId = DownloadNotificationId.forWorker(workerId) + + assertEquals(firstId, DownloadNotificationId.forWorker(workerId)) + assertTrue(firstId > 0) + } + + @Test + fun `returns distinct notification IDs for concurrent workers`() { + val firstWorkerId = UUID.fromString("6a9bd3fa-c00e-4c50-9647-d5d3c8d8a504") + val secondWorkerId = UUID.fromString("34e52d86-7179-41eb-8e09-c00eca920878") + + assertNotEquals( + DownloadNotificationId.forWorker(firstWorkerId), + DownloadNotificationId.forWorker(secondWorkerId), + ) + } + + @Test + fun `does not discard high worker hash bits`() { + assertNotEquals( + DownloadNotificationId.forWorker(UUID(0, 0)), + DownloadNotificationId.forWorker(UUID(0, 65_536)), + ) + } + + @Test + fun `never returns the invalid zero notification ID`() { + assertNotEquals(0, DownloadNotificationId.forWorker(UUID(0, 0))) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/download/DownloadNotificationTitleTest.kt b/app/src/test/java/org/mozilla/tryfox/download/DownloadNotificationTitleTest.kt new file mode 100644 index 0000000..ca4c06d --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/download/DownloadNotificationTitleTest.kt @@ -0,0 +1,16 @@ +package org.mozilla.tryfox.download + +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_DEBUG +import org.mozilla.tryfox.util.FOCUS_DEBUG + +class DownloadNotificationTitleTest { + @Test + fun `uses a friendly Home app name and version`() { + assertEquals("Fenix Nightly 155.0a1", homeDownloadNotificationTitle(FENIX, "155.0a1")) + assertEquals("Fenix debug 155.0a1", homeDownloadNotificationTitle(FENIX_DEBUG, "155.0a1")) + assertEquals("Focus debug 155.0a1", homeDownloadNotificationTitle(FOCUS_DEBUG, "155.0a1")) + } +} 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 e595627..28b6abe 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 @@ -161,6 +161,7 @@ class HistoryViewModelTest { val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() val workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId assertEquals(entry.downloadUrl, enqueuedRequest.downloadUrl) + assertEquals("Fenix nightly", enqueuedRequest.notificationTitle) assertNotNull(workId) val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") 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 809691a..419401d 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 @@ -920,6 +920,7 @@ class HomeViewModelTest { assertEquals(1, fakeDownloadCoordinator.enqueuedRequests.size) val enqueuedRequest = fakeDownloadCoordinator.enqueuedRequests.first() assertEquals(apkToDownload.uniqueKey, enqueuedRequest.uniqueKey) + assertEquals("Fenix Nightly $testVersion", enqueuedRequest.notificationTitle) var loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded var fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success