Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NotificationManager>(
DefaultNotificationManager(ApplicationProvider.getApplicationContext()),
)
}

@Test
fun appLaunch_requestsNotificationPermission_whenItIsMissing() {
val notificationManager = FakeNotificationManager(hasNotificationPermission = false)
GlobalContext.get().declare<NotificationManager>(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>(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>(notificationManager)

ActivityScenario.launch(MainActivity::class.java).use { }

assertEquals(0, notificationManager.permissionRequestCount)
}

@Test
fun appLaunch_requestsNotificationPermissionOnlyOnce() {
val notificationManager = FakeNotificationManager(hasNotificationPermission = false)
GlobalContext.get().declare<NotificationManager>(notificationManager)

ActivityScenario.launch(MainActivity::class.java).use { }
ActivityScenario.launch(MainActivity::class.java).use { }

assertEquals(1, notificationManager.permissionRequestCount)
}
}
Original file line number Diff line number Diff line change
@@ -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++
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MainActivity>()

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<MainActivity>()

@Test
Expand All @@ -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()
}
}
34 changes: 34 additions & 0 deletions app/src/main/java/org/mozilla/tryfox/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand All @@ -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
Expand All @@ -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<String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
notificationPermissionGranted = notificationManager.hasPermission()
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
Expand Down Expand Up @@ -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(),
)
}
Expand All @@ -234,6 +266,7 @@ class MainActivity : ComponentActivity() {
SettingsScreen(
onNavigateUp = { localNavController.popBackStack() },
settingsViewModel = koinViewModel(),
notificationManager = notificationManager,
)
}
composable(NavScreen.QrScanner.route) {
Expand All @@ -254,6 +287,7 @@ class MainActivity : ComponentActivity() {
)
},
receiveFromDesktopViewModel = koinViewModel(),
notificationManager = notificationManager,
startReceiverOnEnter = receiveFromDesktopStartRequested,
onStartReceiverOnEnterConsumed = {
receiveFromDesktopStartRequested = false
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading