From 39be3c9c57fb7b32cd16c1ec165dfc7c1b8305e3 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Sat, 14 Jun 2025 17:49:26 +0530 Subject: [PATCH 01/24] Implement notification handler and create notification channels --- app/src/main/AndroidManifest.xml | 1 + .../com/notifier/app/GithubNotifierApp.kt | 13 ++++- .../notification/AppNotificationChannel.kt | 13 +++++ .../notification/NotificationHandler.kt | 54 +++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/notifier/app/core/domain/notification/AppNotificationChannel.kt create mode 100644 app/src/main/java/com/notifier/app/core/domain/notification/NotificationHandler.kt 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"> + + val notificationChannel = NotificationChannel( + channel.id, + channel.displayName, + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = channel.description + } + + val notificationManager = applicationContext.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager + + notificationManager.createNotificationChannel(notificationChannel) + } + } +} From 60c05f8021877c30d8bf63c27fe6e76ff5ec535c Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Mon, 16 Jun 2025 23:24:32 +0530 Subject: [PATCH 02/24] Add notification permission handling and banner UI --- app/build.gradle.kts | 1 + .../java/com/notifier/app/MainActivity.kt | 5 +- .../NotificationPermissionBanner.kt | 139 ++++++++++++++++++ gradle/libs.versions.toml | 2 + 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt 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/main/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index 2a181c6..fc45c13 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -21,6 +21,7 @@ import com.notifier.app.auth.presentation.login.LoginScreen import com.notifier.app.auth.presentation.setup.SetupRoute import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.auth.presentation.util.GitHubAuthIntentProvider +import com.notifier.app.core.presentation.WithNotificationPermission import com.notifier.app.notification.presentation.NotificationRoute import com.notifier.app.notification.presentation.NotificationScreen import com.notifier.app.ui.theme.GitHubNotifierTheme @@ -92,7 +93,9 @@ class MainActivity : ComponentActivity() { } composable { - NotificationRoute() + WithNotificationPermission { + NotificationRoute() + } } } } diff --git a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt new file mode 100644 index 0000000..3e77fb1 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt @@ -0,0 +1,139 @@ +package com.notifier.app.core.presentation + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import com.notifier.app.ui.theme.GitHubNotifierTheme + +@Composable +fun WithNotificationPermission( + content: @Composable () -> Unit, +) { + Column { + NotificationPermissionHandler() + content() + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun NotificationPermissionHandler() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + + val context = LocalContext.current + val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + + LaunchedEffect(Unit) { + if (!permissionState.status.isGranted && !permissionState.status.shouldShowRationale) { + permissionState.launchPermissionRequest() + } + } + + if (!permissionState.status.isGranted) { + NotificationPermissionBanner( + shouldShowRationale = permissionState.status.shouldShowRationale, + onRequestPermission = { permissionState.launchPermissionRequest() }, + onOpenSettings = { + val intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null) + ) + context.startActivity(intent) + } + ) + } +} + +@Composable +fun NotificationPermissionBanner( + shouldShowRationale: Boolean, + onRequestPermission: () -> Unit, + onOpenSettings: () -> Unit, +) { + val message = if (shouldShowRationale) { + "To deliver GitHub notifications like pull requests, issues, and mentions, we need notification access. Please allow it." + } else { + "Notification access is required to show updates from your GitHub activity. Enable it from system settings." + } + + val actionButtonText = if (shouldShowRationale) { + "Allow Notifications" + } else { + "Open Settings" + } + + 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 + ) + } + } +} + +class NotificationPermissionPreviewProvider : 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) + ) +} + +@PreviewLightDark +@PreviewDynamicColors +@Composable +private fun NotificationPermissionBannerPreview( + @PreviewParameter(NotificationPermissionPreviewProvider::class) + shouldShowRationale: Boolean +) { + GitHubNotifierTheme { + NotificationPermissionBanner( + shouldShowRationale, + onRequestPermission = {}, + onOpenSettings = {} + ) + } +} 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" } From 272ad972958279a7b53fc09a75a20e8672d1fcb9 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:08:45 +0530 Subject: [PATCH 03/24] Enhance notification permission handling by adding request state management --- .../presentation/NotificationPermissionBanner.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt index 3e77fb1..45b7f0b 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt @@ -46,9 +46,14 @@ fun NotificationPermissionHandler() { val context = LocalContext.current val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + var hasRequested by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(Unit) { - if (!permissionState.status.isGranted && !permissionState.status.shouldShowRationale) { + LaunchedEffect(permissionState.status) { + if (!hasRequested && + !permissionState.status.isGranted && + !permissionState.status.shouldShowRationale + ) { + hasRequested = true permissionState.launchPermissionRequest() } } @@ -56,7 +61,10 @@ fun NotificationPermissionHandler() { if (!permissionState.status.isGranted) { NotificationPermissionBanner( shouldShowRationale = permissionState.status.shouldShowRationale, - onRequestPermission = { permissionState.launchPermissionRequest() }, + onRequestPermission = { + hasRequested = true + permissionState.launchPermissionRequest() + }, onOpenSettings = { val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, From 9cdf7941a273025587e7637120cd58dac00c8157 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:12:52 +0530 Subject: [PATCH 04/24] Add missing imports --- .../app/core/presentation/NotificationPermissionBanner.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt index 45b7f0b..070aa9b 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt @@ -14,6 +14,10 @@ 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 From 8001a970ddeb1e5a67e1991816e47021febf8f06 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:24:41 +0530 Subject: [PATCH 05/24] Rename NotificationPermissionBanner to NotificationPermissionPrompt and update permission rationale messages --- ...ner.kt => NotificationPermissionPrompt.kt} | 30 ++++++++++++------- app/src/main/res/values/strings.xml | 4 +++ 2 files changed, 23 insertions(+), 11 deletions(-) rename app/src/main/java/com/notifier/app/core/presentation/{NotificationPermissionBanner.kt => NotificationPermissionPrompt.kt} (82%) diff --git a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt similarity index 82% rename from app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt rename to app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt index 070aa9b..8caad1b 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionBanner.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt @@ -21,6 +21,7 @@ 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 @@ -31,6 +32,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale +import com.notifier.app.R import com.notifier.app.ui.theme.GitHubNotifierTheme @Composable @@ -52,6 +54,7 @@ fun NotificationPermissionHandler() { val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) var hasRequested by rememberSaveable { mutableStateOf(false) } + // Launch the system permission dialog only once, if applicable LaunchedEffect(permissionState.status) { if (!hasRequested && !permissionState.status.isGranted && @@ -63,7 +66,7 @@ fun NotificationPermissionHandler() { } if (!permissionState.status.isGranted) { - NotificationPermissionBanner( + NotificationPermissionPrompt ( shouldShowRationale = permissionState.status.shouldShowRationale, onRequestPermission = { hasRequested = true @@ -72,7 +75,11 @@ fun NotificationPermissionHandler() { onOpenSettings = { val intent = Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts("package", context.packageName, null) + Uri.fromParts( + /* scheme = */ "package", + /* ssp = */ context.packageName, + /* fragment = */ null + ) ) context.startActivity(intent) } @@ -80,22 +87,23 @@ fun NotificationPermissionHandler() { } } + @Composable -fun NotificationPermissionBanner( +fun NotificationPermissionPrompt( shouldShowRationale: Boolean, onRequestPermission: () -> Unit, onOpenSettings: () -> Unit, ) { val message = if (shouldShowRationale) { - "To deliver GitHub notifications like pull requests, issues, and mentions, we need notification access. Please allow it." + stringResource(R.string.notification_permission_rationale_message) } else { - "Notification access is required to show updates from your GitHub activity. Enable it from system settings." + stringResource(R.string.notification_permission_denied_message) } val actionButtonText = if (shouldShowRationale) { - "Allow Notifications" + stringResource(R.string.notification_permission_allow_button_text) } else { - "Open Settings" + stringResource(R.string.notification_permission_settings_button_text) } val onClick = if (shouldShowRationale) onRequestPermission else onOpenSettings @@ -126,7 +134,7 @@ fun NotificationPermissionBanner( } } -class NotificationPermissionPreviewProvider : PreviewParameterProvider { +class ShouldShowRationaleProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( true, // Should show rationale (e.g., user denied once) @@ -137,12 +145,12 @@ class NotificationPermissionPreviewProvider : PreviewParameterProvider @PreviewLightDark @PreviewDynamicColors @Composable -private fun NotificationPermissionBannerPreview( - @PreviewParameter(NotificationPermissionPreviewProvider::class) +private fun NotificationPermissionPromptPreview( + @PreviewParameter(ShouldShowRationaleProvider::class) shouldShowRationale: Boolean ) { GitHubNotifierTheme { - NotificationPermissionBanner( + NotificationPermissionPrompt( shouldShowRationale, onRequestPermission = {}, onOpenSettings = {} 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 From f7b2698230c5b34b113ca137372cd04ba6c7b909 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:29:34 +0530 Subject: [PATCH 06/24] Add documentation for AppNotificationChannel enum and GITHUB channel --- .../notification/AppNotificationChannel.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 index a09397f..b9c3561 100644 --- 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 @@ -1,10 +1,30 @@ 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", From 313a8a0b38d4538ca5811017df4502fd5ab63ab7 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:33:10 +0530 Subject: [PATCH 07/24] Add documentation for NotificationHandler class and its methods --- .../notification/NotificationHandler.kt | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) 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 index 0b467c0..9b1cb9d 100644 --- 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 @@ -9,10 +9,28 @@ 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. + */ fun showNotification( id: Int, title: String, @@ -34,7 +52,17 @@ class NotificationHandler @Inject constructor( 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, @@ -44,10 +72,6 @@ class NotificationHandler @Inject constructor( description = channel.description } - val notificationManager = applicationContext.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager - notificationManager.createNotificationChannel(notificationChannel) } } From 243d5c3b34407ed9565d38236efa19eeef30b5a7 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:51:30 +0530 Subject: [PATCH 08/24] Add documentation for notification permission handling and related composables --- .../NotificationPermissionPrompt.kt | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt index 8caad1b..6733677 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt @@ -35,6 +35,14 @@ import com.google.accompanist.permissions.shouldShowRationale import com.notifier.app.R import com.notifier.app.ui.theme.GitHubNotifierTheme +/** + * Wraps a composable with a notification permission check on Android 13+. + * + * Displays a prompt to the user requesting notification permission if not granted. + * + * @param content The actual content of the screen above which the notification permission + * prompt will be displayed. + */ @Composable fun WithNotificationPermission( content: @Composable () -> Unit, @@ -45,9 +53,15 @@ fun WithNotificationPermission( } } +/** + * Handles runtime notification permission request for Android 13+ devices. + * + * Shows the system dialog once and then falls back to a custom UI prompt + * to guide the user to settings if permission is denied permanently. + */ @OptIn(ExperimentalPermissionsApi::class) @Composable -fun NotificationPermissionHandler() { +private fun NotificationPermissionHandler() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return val context = LocalContext.current @@ -66,7 +80,7 @@ fun NotificationPermissionHandler() { } if (!permissionState.status.isGranted) { - NotificationPermissionPrompt ( + NotificationPermissionPrompt( shouldShowRationale = permissionState.status.shouldShowRationale, onRequestPermission = { hasRequested = true @@ -87,9 +101,18 @@ fun NotificationPermissionHandler() { } } - +/** + * 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 -fun NotificationPermissionPrompt( +private fun NotificationPermissionPrompt( shouldShowRationale: Boolean, onRequestPermission: () -> Unit, onOpenSettings: () -> Unit, @@ -134,6 +157,12 @@ fun NotificationPermissionPrompt( } } +/** + * 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( @@ -142,6 +171,11 @@ class ShouldShowRationaleProvider : PreviewParameterProvider { ) } +/** + * 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 From 54abc6f85c8e632cb4065878ae23c7df91bb2b76 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 10:54:11 +0530 Subject: [PATCH 09/24] Rename NotificationPermissionPrompt to WithNotificationPermission --- ...ificationPermissionPrompt.kt => WithNotificationPermission.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/src/main/java/com/notifier/app/core/presentation/{NotificationPermissionPrompt.kt => WithNotificationPermission.kt} (100%) diff --git a/app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt b/app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt similarity index 100% rename from app/src/main/java/com/notifier/app/core/presentation/NotificationPermissionPrompt.kt rename to app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt From 40dec2b1195272cf4a44b836bd93b75f73df42c8 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 21:42:56 +0530 Subject: [PATCH 10/24] Enhance NotificationPermissionPrompt with error handling for settings intent --- .../WithNotificationPermission.kt | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt b/app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt index 6733677..98ff00e 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt @@ -87,15 +87,18 @@ private fun NotificationPermissionHandler() { permissionState.launchPermissionRequest() }, onOpenSettings = { - val intent = Intent( - Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.fromParts( - /* scheme = */ "package", - /* ssp = */ context.packageName, - /* fragment = */ null - ) - ) - context.startActivity(intent) + 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) + } } ) } @@ -181,7 +184,7 @@ class ShouldShowRationaleProvider : PreviewParameterProvider { @Composable private fun NotificationPermissionPromptPreview( @PreviewParameter(ShouldShowRationaleProvider::class) - shouldShowRationale: Boolean + shouldShowRationale: Boolean, ) { GitHubNotifierTheme { NotificationPermissionPrompt( From b3459644f2109d05b54f8a481724ecd959bf45d1 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Tue, 17 Jun 2025 21:52:37 +0530 Subject: [PATCH 11/24] Add URL handling to showNotification in NotificationHandler --- .../domain/notification/NotificationHandler.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 index 9b1cb9d..d11516b 100644 --- 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 @@ -2,8 +2,11 @@ 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 @@ -22,7 +25,6 @@ import javax.inject.Singleton class NotificationHandler @Inject constructor( @ApplicationContext private val applicationContext: Context, ) { - /** * Displays a notification with the given details. * @@ -30,18 +32,28 @@ class NotificationHandler @Inject constructor( * @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() From 9205134bceb407e0dcfdc1dffd8fc1f2b9dd8264 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:18:43 +0530 Subject: [PATCH 12/24] Add notification permission state management and testing utilities --- .../FakeNotificationPermissionState.kt | 18 +++ .../WithNotificationPermissionTest.kt | 110 ++++++++++++++++++ .../java/com/notifier/app/MainActivity.kt | 2 +- .../NotificationPermissionState.kt | 30 +++++ .../WithNotificationPermission.kt | 57 +++++---- .../rememberNotificationPermissionState.kt | 39 +++++++ 6 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 app/src/androidTest/java/com/notifier/app/core/presentation/notification/FakeNotificationPermissionState.kt create mode 100644 app/src/androidTest/java/com/notifier/app/core/presentation/notification/WithNotificationPermissionTest.kt create mode 100644 app/src/main/java/com/notifier/app/core/presentation/notification/NotificationPermissionState.kt rename app/src/main/java/com/notifier/app/core/presentation/{ => notification}/WithNotificationPermission.kt (75%) create mode 100644 app/src/main/java/com/notifier/app/core/presentation/notification/rememberNotificationPermissionState.kt 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..69ae041 --- /dev/null +++ b/app/src/androidTest/java/com/notifier/app/core/presentation/notification/WithNotificationPermissionTest.kt @@ -0,0 +1,110 @@ +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 com.notifier.app.ui.theme.GitHubNotifierTheme +import org.junit.Rule +import org.junit.Test + +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/java/com/notifier/app/MainActivity.kt b/app/src/main/java/com/notifier/app/MainActivity.kt index fc45c13..c0258e5 100644 --- a/app/src/main/java/com/notifier/app/MainActivity.kt +++ b/app/src/main/java/com/notifier/app/MainActivity.kt @@ -21,7 +21,7 @@ import com.notifier.app.auth.presentation.login.LoginScreen import com.notifier.app.auth.presentation.setup.SetupRoute import com.notifier.app.auth.presentation.setup.SetupScreen import com.notifier.app.auth.presentation.util.GitHubAuthIntentProvider -import com.notifier.app.core.presentation.WithNotificationPermission +import com.notifier.app.core.presentation.notification.WithNotificationPermission import com.notifier.app.notification.presentation.NotificationRoute import com.notifier.app.notification.presentation.NotificationScreen import com.notifier.app.ui.theme.GitHubNotifierTheme 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/WithNotificationPermission.kt b/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt similarity index 75% rename from app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt rename to app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt index 98ff00e..669a13b 100644 --- a/app/src/main/java/com/notifier/app/core/presentation/WithNotificationPermission.kt +++ b/app/src/main/java/com/notifier/app/core/presentation/notification/WithNotificationPermission.kt @@ -1,6 +1,5 @@ -package com.notifier.app.core.presentation +package com.notifier.app.core.presentation.notification -import android.Manifest import android.content.Intent import android.net.Uri import android.os.Build @@ -28,63 +27,63 @@ 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.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState -import com.google.accompanist.permissions.shouldShowRationale import com.notifier.app.R import com.notifier.app.ui.theme.GitHubNotifierTheme /** - * Wraps a composable with a notification permission check on Android 13+. + * Wraps a screen or composable with logic to check and request notification permission on + * Android 13+. * - * Displays a prompt to the user requesting notification permission if not granted. + * 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 content The actual content of the screen above which the notification permission - * prompt will be displayed. + * @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 { - NotificationPermissionHandler() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + NotificationPermissionHandler( + permissionState = permissionState ?: rememberNotificationPermissionState() + ) + } content() } } /** - * Handles runtime notification permission request for Android 13+ devices. + * 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. * - * Shows the system dialog once and then falls back to a custom UI prompt - * to guide the user to settings if permission is denied permanently. + * @param permissionState Current state of the notification permission. */ -@OptIn(ExperimentalPermissionsApi::class) @Composable -private fun NotificationPermissionHandler() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - +private fun NotificationPermissionHandler( + permissionState: NotificationPermissionState +) { val context = LocalContext.current - val permissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) var hasRequested by rememberSaveable { mutableStateOf(false) } - // Launch the system permission dialog only once, if applicable - LaunchedEffect(permissionState.status) { - if (!hasRequested && - !permissionState.status.isGranted && - !permissionState.status.shouldShowRationale - ) { + LaunchedEffect(permissionState.isGranted, permissionState.shouldShowRationale) { + if (!hasRequested && !permissionState.isGranted && !permissionState.shouldShowRationale) { hasRequested = true - permissionState.launchPermissionRequest() + permissionState.requestPermission() } } - if (!permissionState.status.isGranted) { + if (!permissionState.isGranted) { NotificationPermissionPrompt( - shouldShowRationale = permissionState.status.shouldShowRationale, + shouldShowRationale = permissionState.shouldShowRationale, onRequestPermission = { hasRequested = true - permissionState.launchPermissionRequest() + permissionState.requestPermission() }, onOpenSettings = { runCatching { 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..b0aaed6 --- /dev/null +++ b/app/src/main/java/com/notifier/app/core/presentation/notification/rememberNotificationPermissionState.kt @@ -0,0 +1,39 @@ +package com.notifier.app.core.presentation.notification + +import android.Manifest +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +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 object : NotificationPermissionState { + override val shouldShowRationale: Boolean + get() = state.status.shouldShowRationale + + override val isGranted: Boolean + get() = state.status.isGranted + + override fun requestPermission() { + state.launchPermissionRequest() + } + } +} From 9893ff2b3255a7339aac1171c184dd348052f501 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:18:52 +0530 Subject: [PATCH 13/24] Update test matrix to include API level 33 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b004171..7f60b5d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [29] + api-level: [29, 33] steps: - uses: actions/checkout@v4 From b7f2bc7c08f8329e60873d980b455af7b19016e7 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:44:27 +0530 Subject: [PATCH 14/24] Update test configuration for Android emulator and Gradle setup --- .github/workflows/test.yml | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f60b5d..cf5b7ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: strategy: matrix: api-level: [29, 33] + fast-fail: false steps: - uses: actions/checkout@v4 @@ -44,12 +45,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 @@ -64,16 +71,20 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - force-avd-creation: false + target: google_apis + arch: x86_64 + 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 }}." - name: Run tests with AVD uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 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 || true From 625e891a601dd8167c4a9abb43b29c3cfb8dbb04 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:44:40 +0530 Subject: [PATCH 15/24] Add SdkSuppress annotation for minimum SDK version in notification permission tests --- .../presentation/notification/WithNotificationPermissionTest.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index 69ae041..6cac515 100644 --- 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 @@ -6,10 +6,12 @@ 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() From ca02adf724a99f1efb930134295c8d806fa22d6a Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:52:06 +0530 Subject: [PATCH 16/24] Fix indentation in test matrix configuration in test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf5b7ba..c3a5c9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: strategy: matrix: api-level: [29, 33] - fast-fail: false + fast-fail: false steps: - uses: actions/checkout@v4 From 78dd1258b7ec5db410251e6e50e6e87bb55b343e Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:54:05 +0530 Subject: [PATCH 17/24] Refactor notification permission state management to use remember for state retention and add error logging for app settings access failure --- .../notification/WithNotificationPermission.kt | 7 +++++++ .../rememberNotificationPermissionState.kt | 17 ++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) 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 index 669a13b..effae59 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -97,6 +98,12 @@ private fun NotificationPermissionHandler( ).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }.let(context::startActivity) + }.onFailure { throwable -> + Log.e( + "WithNotificationPermission", + "Unable to open app settings", + throwable + ) } } ) 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 index b0aaed6..bf3a267 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -25,15 +26,17 @@ import com.google.accompanist.permissions.shouldShowRationale fun rememberNotificationPermissionState(): NotificationPermissionState { val state = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) - return object : NotificationPermissionState { - override val shouldShowRationale: Boolean - get() = state.status.shouldShowRationale + return remember(state) { + object : NotificationPermissionState { + override val shouldShowRationale: Boolean + get() = state.status.shouldShowRationale - override val isGranted: Boolean - get() = state.status.isGranted + override val isGranted: Boolean + get() = state.status.isGranted - override fun requestPermission() { - state.launchPermissionRequest() + override fun requestPermission() { + state.launchPermissionRequest() + } } } } From dfc69a537d17b92c70932df54272037a8f297a5f Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 09:58:38 +0530 Subject: [PATCH 18/24] Remove fast-fail option from test matrix configuration in test.yml --- .github/workflows/test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c3a5c9b..8bb4f31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,6 @@ jobs: strategy: matrix: api-level: [29, 33] - fast-fail: false steps: - uses: actions/checkout@v4 From 644e4546ccfb09cf1ff21efbffe56dedb2643d51 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 10:12:15 +0530 Subject: [PATCH 19/24] Update test matrix configuration in test.yml to disable fail-fast and install required system images --- .github/workflows/test.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8bb4f31..b5cfe1b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - api-level: [29, 33] + api-level: [ 29, 33 ] + fail-fast: false steps: - uses: actions/checkout@v4 @@ -56,6 +57,12 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Install required system images + run: | + yes | "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" \ + "platforms;android-${{ matrix.api-level }}" \ + "system-images;android-${{ matrix.api-level }};google_apis;x86_64" + - name: AVD cache uses: actions/cache@v4 id: avd-cache @@ -86,4 +93,4 @@ jobs: 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 connectedDebugAndroidTest --daemon || true + script: ./gradlew connectedDebugAndroidTest --daemon From 05df88f96cb1d96a4ebe574864d500ff282cbf8e Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 10:16:31 +0530 Subject: [PATCH 20/24] Remove system images installation step from test matrix configuration in test.yml --- .github/workflows/test.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5cfe1b..8f414f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,12 +57,6 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Install required system images - run: | - yes | "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" \ - "platforms;android-${{ matrix.api-level }}" \ - "system-images;android-${{ matrix.api-level }};google_apis;x86_64" - - name: AVD cache uses: actions/cache@v4 id: avd-cache From 253e633128aa2fc4d6d29efcf1fc692b0d7a2c67 Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 10:20:42 +0530 Subject: [PATCH 21/24] Remove system images installation step from test matrix configuration in test.yml --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f414f1..bad1b7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,7 @@ jobs: strategy: matrix: api-level: [ 29, 33 ] + emulator-port: [5554, 5556] fail-fast: false steps: - uses: actions/checkout@v4 @@ -73,6 +74,7 @@ jobs: api-level: ${{ matrix.api-level }} target: google_apis arch: x86_64 + emulator-port: ${{ matrix.emulator-port }} force-avd-creation: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true @@ -84,6 +86,7 @@ jobs: api-level: ${{ matrix.api-level }} target: google_apis arch: x86_64 + emulator-port: ${{ matrix.emulator-port }} force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true From b332ef6ede01e9a7918eb85b3a860a4b1713075c Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 10:24:22 +0530 Subject: [PATCH 22/24] Remove emulator-port configuration from test matrix in test.yml --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bad1b7a..8f414f1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,6 @@ jobs: strategy: matrix: api-level: [ 29, 33 ] - emulator-port: [5554, 5556] fail-fast: false steps: - uses: actions/checkout@v4 @@ -74,7 +73,6 @@ jobs: api-level: ${{ matrix.api-level }} target: google_apis arch: x86_64 - emulator-port: ${{ matrix.emulator-port }} force-avd-creation: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true @@ -86,7 +84,6 @@ jobs: api-level: ${{ matrix.api-level }} target: google_apis arch: x86_64 - emulator-port: ${{ matrix.emulator-port }} force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true From fe8f4063f8c8424e591c0389e7ffd83195c70e2f Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 10:26:14 +0530 Subject: [PATCH 23/24] Update test.yml to conditionally set emulator architecture based on API level --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f414f1..71a984e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,12 +67,12 @@ jobs: key: avd-${{ matrix.api-level }} - name: Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' + if: steps.avd-cache.outputs.cache-hit != 'true' || matrix.api-level == 29 uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} target: google_apis - arch: x86_64 + arch: ${{ matrix.api-level == 29 && 'x86' || 'x86_64' }} force-avd-creation: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true @@ -83,7 +83,7 @@ jobs: with: api-level: ${{ matrix.api-level }} target: google_apis - arch: x86_64 + arch: ${{ matrix.api-level == 29 && 'x86' || 'x86_64' }} force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true From ef23e4c7324da618857ee6d487a8227782760d8f Mon Sep 17 00:00:00 2001 From: Saptak Manna Date: Wed, 18 Jun 2025 10:36:41 +0530 Subject: [PATCH 24/24] Update test.yml to conditionally set emulator architecture based on API level --- .github/workflows/test.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 71a984e..828b0f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,10 +32,16 @@ jobs: instrumented_tests: runs-on: ubuntu-latest + strategy: matrix: - api-level: [ 29, 33 ] + include: + - api-level: 29 + arch: x86 + - api-level: 33 + arch: x86_64 fail-fast: false + steps: - uses: actions/checkout@v4 @@ -64,26 +70,26 @@ 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' || matrix.api-level == 29 + if: steps.avd-cache.outputs.cache-hit != 'true' uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} + arch: ${{ matrix.arch }} target: google_apis - arch: ${{ matrix.api-level == 29 && 'x86' || 'x86_64' }} force-avd-creation: true emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: echo "AVD snapshot generated for API ${{ matrix.api-level }}." + 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 - arch: ${{ matrix.api-level == 29 && 'x86' || 'x86_64' }} force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true