diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b004171..828b0f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,16 @@ jobs: instrumented_tests: runs-on: ubuntu-latest + strategy: matrix: - api-level: [29] + include: + - api-level: 29 + arch: x86 + - api-level: 33 + arch: x86_64 + fail-fast: false + steps: - uses: actions/checkout@v4 @@ -44,12 +51,18 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' - name: Gradle cache uses: gradle/actions/setup-gradle@v3 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: AVD cache uses: actions/cache@v4 id: avd-cache @@ -57,23 +70,27 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-${{ matrix.api-level }} + key: avd-${{ matrix.api-level }}-${{ matrix.arch }} - name: Create AVD and generate snapshot for caching if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - force-avd-creation: false + arch: ${{ matrix.arch }} + target: google_apis + force-avd-creation: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." + disable-animations: true + script: echo "AVD snapshot generated for API ${{ matrix.api-level }} - ${{ matrix.arch }}." - name: Run tests with AVD uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} + arch: ${{ matrix.arch }} + target: google_apis force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew connectedAndroidTest --daemon && killall -INT crashpad_handler || true + script: ./gradlew connectedDebugAndroidTest --daemon diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 886c6bc..c807562 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,6 +102,7 @@ dependencies { implementation(libs.hilt.navigation.compose) implementation(libs.androidx.splash.screen) implementation(libs.androidx.junit.ktx) + implementation(libs.accompanist.permissions) ksp(libs.dagger.hilt.compiler) diff --git a/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt new file mode 100644 index 0000000..b1c9940 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt @@ -0,0 +1,18 @@ +package com.notifier.app.core.presentation.notification + +/** + * A fake implementation of [NotificationPermissionState] for use in tests. + * + * @param isGranted whether the notification permission is granted. + * @param shouldShowRationale whether the system should show a rationale for the permission. + * + * This implementation is used to simulate various permission states in UI tests without + * requiring actual system permission dialogs. + */ +class FakeNotificationPermissionState( + override val isGranted: Boolean, + override val shouldShowRationale: Boolean +) : NotificationPermissionState { + /** No-op implementation since this is only used in tests. */ + override fun requestPermission() { /* No-op for tests */ } +} diff --git a/app/src/androidTest/java/com/notifier/app/core/presentation/notification/WithNotificationPermissionTest.kt b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/WithNotificationPermissionTest.kt new file mode 100644 index 0000000..6cac515 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/WithNotificationPermissionTest.kt @@ -0,0 +1,112 @@ +package com.notifier.app.core.presentation.notification + +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.test.filters.SdkSuppress +import com.notifier.app.ui.theme.GitHubNotifierTheme +import org.junit.Rule +import org.junit.Test + +@SdkSuppress(minSdkVersion = 33) +class WithNotificationPermissionTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun permissionNotGranted_shouldShowRationale_showsRationalePrompt() { + composeTestRule.setContent { + GitHubNotifierTheme { + WithNotificationPermission( + content = { Text("Main Content") }, + permissionState = FakeNotificationPermissionState( + isGranted = false, + shouldShowRationale = true + ) + ) + } + } + + // Check that rationale message is shown + composeTestRule + .onNodeWithText( + "To deliver GitHub notifications like pull requests, issues, and mentions, " + + "we need notification access. Please allow it." + ) + .assertIsDisplayed() + + // Check that 'Allow' button is shown + composeTestRule + .onNodeWithText("Allow Notifications") + .assertIsDisplayed() + + // Check main content is still shown + composeTestRule + .onNodeWithText("Main Content") + .assertIsDisplayed() + } + + @Test + fun permissionNotGranted_shouldNotShowRationale_showsDeniedPrompt() { + composeTestRule.setContent { + GitHubNotifierTheme { + WithNotificationPermission( + content = { Text("Main Content") }, + permissionState = FakeNotificationPermissionState( + isGranted = false, + shouldShowRationale = false + ) + ) + } + } + + // Check that denied message is shown + composeTestRule + .onNodeWithText( + "Notification access is required to show updates from your GitHub activity. " + + "Enable it from system settings." + ) + .assertIsDisplayed() + + // Check that 'Open Settings' button is shown + composeTestRule + .onNodeWithText("Open Settings") + .assertIsDisplayed() + + // Check main content is still shown + composeTestRule + .onNodeWithText("Main Content") + .assertIsDisplayed() + } + + @Test + fun permissionGranted_doesNotShowPrompt() { + composeTestRule.setContent { + GitHubNotifierTheme { + WithNotificationPermission( + content = { Text("Main Content") }, + permissionState = FakeNotificationPermissionState( + isGranted = true, + shouldShowRationale = false + ) + ) + } + } + + // Should only show main content + composeTestRule + .onNodeWithText("Main Content") + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Allow Notifications") + .assertCountEquals(0) + + composeTestRule + .onAllNodesWithText("Open Settings") + .assertCountEquals(0) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8f685c1..52d0b87 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + { - NotificationRoute() + WithNotificationPermission { + NotificationRoute() + } } } } diff --git a/app/src/main/java/com/notifier/app/core/domain/notification/AppNotificationChannel.kt b/app/src/main/java/com/notifier/app/core/domain/notification/AppNotificationChannel.kt new file mode 100644 index 0000000..b9c3561 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/domain/notification/AppNotificationChannel.kt @@ -0,0 +1,33 @@ +package com.notifier.app.core.domain.notification + +/** + * Represents the various notification channels used within the app. + * + * Each enum entry defines the [id], [displayName], and [description] + * associated with a specific type of notification channel. + * + * These channels are used to categorize and manage notifications for the entire app. + */ +enum class AppNotificationChannel( + /** + * The unique ID for the notification channel. + * Used when creating and referencing the channel in the notification system. + */ + val id: String, + + /** The user-visible name of the notification channel. */ + val displayName: String, + + /** The user-visible description of what types of notifications this channel delivers. */ + val description: String, +) { + /** + * Channel for delivering notifications related to GitHub activity, + * such as pull requests, issues, and mentions. + */ + GITHUB( + id = "github_channel", + displayName = "GitHub Notifications", + description = "Channel for GitHub related notifications" + ), +} diff --git a/app/src/main/java/com/notifier/app/core/domain/notification/NotificationHandler.kt b/app/src/main/java/com/notifier/app/core/domain/notification/NotificationHandler.kt new file mode 100644 index 0000000..d11516b --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/domain/notification/NotificationHandler.kt @@ -0,0 +1,90 @@ +package com.notifier.app.core.domain.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import com.notifier.app.R +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Handles the creation and display of in-app notifications. + * + * This class is responsible for: + * - Creating required [NotificationChannel]s. + * - Showing notifications using [NotificationCompat.Builder]. + * + * @property applicationContext The application-level context used to access system services. + */ +@Singleton +class NotificationHandler @Inject constructor( + @ApplicationContext private val applicationContext: Context, +) { + /** + * Displays a notification with the given details. + * + * @param id The unique ID for this notification (used to update or cancel it). + * @param title The title displayed in the notification. + * @param message The body content of the notification. + * @param channel The [AppNotificationChannel] that defines the channel for this notification. + * @param url The URL to open in the browser when the notification is clicked. + */ + fun showNotification( + id: Int, + title: String, + message: String, + channel: AppNotificationChannel, + url: String, + ) { + val pendingIntent = PendingIntent.getActivity( + /* context = */ applicationContext, + /* requestCode = */ id, + /* intent = */ Intent(Intent.ACTION_VIEW, url.toUri()), + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(applicationContext, channel.id) + .setContentTitle(title) + .setContentText(message) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + val notificationManager = applicationContext.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + notificationManager.notify(id, notification) + } + + /** + * Creates all required notification channels used by the app. + * + * This must be called during app startup on Android O+ (API 26+) to register + * channels with the system before sending notifications. + */ + fun createNotificationChannels() { + val notificationManager = applicationContext.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + AppNotificationChannel.entries.forEach { channel -> + val notificationChannel = NotificationChannel( + channel.id, + channel.displayName, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = channel.description + } + + notificationManager.createNotificationChannel(notificationChannel) + } + } +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/notification/NotificationPermissionState.kt b/app/src/main/java/com/notifier/app/core/presentation/notification/NotificationPermissionState.kt new file mode 100644 index 0000000..8983924 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/notification/NotificationPermissionState.kt @@ -0,0 +1,30 @@ +package com.notifier.app.core.presentation.notification + +/** + * Represents the current state of the notification permission. + * + * This interface is used to check whether notification permission has been granted, + * whether a rationale should be shown to the user, and to trigger the permission request flow. + */ +interface NotificationPermissionState { + /** + * Indicates whether the app should display a rationale for requesting the notification permission. + * + * This is typically true if the user has previously denied the permission without selecting + * "Don't ask again." + */ + val shouldShowRationale: Boolean + + /** + * Indicates whether the notification permission has been granted. + */ + val isGranted: Boolean + + /** + * Requests the notification permission from the user. + * + * This should be called when the app determines that it needs permission + * and wants to prompt the user for it. + */ + fun requestPermission() +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt b/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt new file mode 100644 index 0000000..effae59 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt @@ -0,0 +1,202 @@ +package com.notifier.app.core.presentation.notification + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewDynamicColors +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.notifier.app.R +import com.notifier.app.ui.theme.GitHubNotifierTheme + +/** + * Wraps a screen or composable with logic to check and request notification permission on + * Android 13+. + * + * If the permission is not granted, it will prompt the user appropriately—either by showing a + * system dialog or a fallback message guiding them to enable it manually from settings. + * + * @param permissionState The [NotificationPermissionState] to use, primarily for testability. + * If null, the default state from [rememberNotificationPermissionState] is used. + * @param content The main content to be displayed alongside the permission prompt. + */ +@Composable +fun WithNotificationPermission( + permissionState: NotificationPermissionState? = null, + content: @Composable () -> Unit, +) { + Column { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + NotificationPermissionHandler( + permissionState = permissionState ?: rememberNotificationPermissionState() + ) + } + content() + } +} + +/** + * Displays the appropriate notification permission prompt if the permission isn't granted. + * + * - If permission can still be requested, it shows a rationale and a retry button. + * - If permanently denied, it shows a message guiding the user to system settings. + * + * @param permissionState Current state of the notification permission. + */ +@Composable +private fun NotificationPermissionHandler( + permissionState: NotificationPermissionState +) { + val context = LocalContext.current + var hasRequested by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(permissionState.isGranted, permissionState.shouldShowRationale) { + if (!hasRequested && !permissionState.isGranted && !permissionState.shouldShowRationale) { + hasRequested = true + permissionState.requestPermission() + } + } + + if (!permissionState.isGranted) { + NotificationPermissionPrompt( + shouldShowRationale = permissionState.shouldShowRationale, + onRequestPermission = { + hasRequested = true + permissionState.requestPermission() + }, + onOpenSettings = { + runCatching { + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts( + /* scheme = */ "package", + /* ssp = */ context.packageName, + /* fragment = */ null + ) + ).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }.let(context::startActivity) + }.onFailure { throwable -> + Log.e( + "WithNotificationPermission", + "Unable to open app settings", + throwable + ) + } + } + ) + } +} + +/** + * A UI prompt to guide the user for notification permission. + * + * If rationale should be shown, it allows retrying the permission request. + * Otherwise, it suggests navigating to system settings to enable permission manually. + * + * @param shouldShowRationale Whether to explain the need for permission again. + * @param onRequestPermission Callback for retrying the permission request. + * @param onOpenSettings Callback to open the app's settings screen. + */ +@Composable +private fun NotificationPermissionPrompt( + shouldShowRationale: Boolean, + onRequestPermission: () -> Unit, + onOpenSettings: () -> Unit, +) { + val message = if (shouldShowRationale) { + stringResource(R.string.notification_permission_rationale_message) + } else { + stringResource(R.string.notification_permission_denied_message) + } + + val actionButtonText = if (shouldShowRationale) { + stringResource(R.string.notification_permission_allow_button_text) + } else { + stringResource(R.string.notification_permission_settings_button_text) + } + + val onClick = if (shouldShowRationale) onRequestPermission else onOpenSettings + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + TextButton( + modifier = Modifier + .padding(top = 8.dp) + .align(Alignment.End), + onClick = onClick + ) { + Text( + text = actionButtonText, + fontWeight = FontWeight.Bold + ) + } + } +} + +/** + * Preview parameter provider for displaying different notification permission states in previews. + * + * Provides sample values for the `shouldShowRationale` flag to simulate UI scenarios + * where the user has either denied permission once or permanently. + */ +class ShouldShowRationaleProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + true, // Should show rationale (e.g., user denied once) + false // Should go to app settings (e.g., denied permanently) + ) +} + +/** + * Preview of the [NotificationPermissionPrompt] composable with dynamic colors and support for light/dark themes. + * + * This preview allows visualization of the permission prompt in both rationale and permanent denial states. + */ +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun NotificationPermissionPromptPreview( + @PreviewParameter(ShouldShowRationaleProvider::class) + shouldShowRationale: Boolean, +) { + GitHubNotifierTheme { + NotificationPermissionPrompt( + shouldShowRationale, + onRequestPermission = {}, + onOpenSettings = {} + ) + } +} diff --git a/app/src/main/java/com/notifier/app/core/presentation/notification/rememberNotificationPermissionState.kt b/app/src/main/java/com/notifier/app/core/presentation/notification/rememberNotificationPermissionState.kt new file mode 100644 index 0000000..bf3a267 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/notification/rememberNotificationPermissionState.kt @@ -0,0 +1,42 @@ +package com.notifier.app.core.presentation.notification + +import android.Manifest +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale + +/** + * Remembers and provides the current state of the notification permission using + * [Manifest.permission.POST_NOTIFICATIONS] (available from Android 13/TIRAMISU). + * + * This function wraps the Accompanist [rememberPermissionState] for notifications into + * a [NotificationPermissionState] interface for simplified use in UI components. + * + * @return a [NotificationPermissionState] instance containing the current permission status, + * rationale flag, and a method to launch the permission request. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun rememberNotificationPermissionState(): NotificationPermissionState { + val state = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + + return remember(state) { + object : NotificationPermissionState { + override val shouldShowRationale: Boolean + get() = state.status.shouldShowRationale + + override val isGranted: Boolean + get() = state.status.isGranted + + override fun requestPermission() { + state.launchPermissionRequest() + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ef71109..5ded50b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,4 +14,8 @@ Connected successfully! Continue Connection failed. Please try again. + To deliver GitHub notifications like pull requests, issues, and mentions, we need notification access. Please allow it. + Notification access is required to show updates from your GitHub activity. Enable it from system settings. + Allow Notifications + Open Settings diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 300103e..af8c98b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ navigation = "2.8.9" hiltNavigationCompose = "1.2.0" datastore = "1.1.4" splashScreen = "1.0.1" +accompanistPermissions = "0.37.3" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -51,6 +52,7 @@ dagger-hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compi dagger-hilt-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "daggerHilt" } hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-splash-screen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashScreen" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }