Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
39be3c9
Implement notification handler and create notification channels
theMr17 Jun 14, 2025
60c05f8
Add notification permission handling and banner UI
theMr17 Jun 16, 2025
272ad97
Enhance notification permission handling by adding request state mana…
theMr17 Jun 17, 2025
9cdf794
Add missing imports
theMr17 Jun 17, 2025
8001a97
Rename NotificationPermissionBanner to NotificationPermissionPrompt a…
theMr17 Jun 17, 2025
f7b2698
Add documentation for AppNotificationChannel enum and GITHUB channel
theMr17 Jun 17, 2025
313a8a0
Add documentation for NotificationHandler class and its methods
theMr17 Jun 17, 2025
243d5c3
Add documentation for notification permission handling and related co…
theMr17 Jun 17, 2025
54abc6f
Rename NotificationPermissionPrompt to WithNotificationPermission
theMr17 Jun 17, 2025
40dec2b
Enhance NotificationPermissionPrompt with error handling for settings…
theMr17 Jun 17, 2025
b345964
Add URL handling to showNotification in NotificationHandler
theMr17 Jun 17, 2025
9205134
Add notification permission state management and testing utilities
theMr17 Jun 18, 2025
9893ff2
Update test matrix to include API level 33
theMr17 Jun 18, 2025
b7f2bc7
Update test configuration for Android emulator and Gradle setup
theMr17 Jun 18, 2025
625e891
Add SdkSuppress annotation for minimum SDK version in notification pe…
theMr17 Jun 18, 2025
ca02adf
Fix indentation in test matrix configuration in test.yml
theMr17 Jun 18, 2025
78dd125
Refactor notification permission state management to use remember for…
theMr17 Jun 18, 2025
dfc69a5
Remove fast-fail option from test matrix configuration in test.yml
theMr17 Jun 18, 2025
644e454
Update test matrix configuration in test.yml to disable fail-fast and…
theMr17 Jun 18, 2025
05df88f
Remove system images installation step from test matrix configuration…
theMr17 Jun 18, 2025
253e633
Remove system images installation step from test matrix configuration…
theMr17 Jun 18, 2025
b332ef6
Remove emulator-port configuration from test matrix in test.yml
theMr17 Jun 18, 2025
fe8f406
Update test.yml to conditionally set emulator architecture based on A…
theMr17 Jun 18, 2025
ef23e4c
Update test.yml to conditionally set emulator architecture based on A…
theMr17 Jun 18, 2025
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
33 changes: 25 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -44,36 +51,46 @@ 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
with:
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
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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 */ }
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<application
android:name=".GithubNotifierApp"
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/com/notifier/app/GithubNotifierApp.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
package com.notifier.app

import android.app.Application
import com.notifier.app.core.domain.notification.NotificationHandler
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

@HiltAndroidApp
class GithubNotifierApp : Application()
class GithubNotifierApp : Application() {
@Inject
lateinit var notificationHandler: NotificationHandler

override fun onCreate() {
super.onCreate()

notificationHandler.createNotificationChannels()
}
}
5 changes: 4 additions & 1 deletion app/src/main/java/com/notifier/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.notification.WithNotificationPermission
import com.notifier.app.notification.presentation.NotificationRoute
import com.notifier.app.notification.presentation.NotificationScreen
import com.notifier.app.ui.theme.GitHubNotifierTheme
Expand Down Expand Up @@ -92,7 +93,9 @@ class MainActivity : ComponentActivity() {
}

composable<NotificationScreen> {
NotificationRoute()
WithNotificationPermission {
NotificationRoute()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
),
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading