Skip to content

Commit

Permalink
Merge pull request #2451 from digitalfabrik/2438-fix-push-news-androi…
Browse files Browse the repository at this point in the history
…d-13

2438: Fix push notifications for android 13+
  • Loading branch information
steffenkleinle authored Sep 30, 2023
2 parents bb297f3 + 39b9974 commit 2bf3a56
Show file tree
Hide file tree
Showing 10 changed files with 69 additions and 53 deletions.
1 change: 1 addition & 0 deletions native/jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ global.fetch = require('jest-fetch-mock')
console.error = () => undefined

jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage)
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'))

// react-navigation jest setup
// https://reactnavigation.org/docs/testing#mocking-native-modules
Expand Down
7 changes: 6 additions & 1 deletion native/src/Navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import SprungbrettOfferContainer from './routes/SprungbrettOfferContainer'
import appSettings, { ASYNC_STORAGE_VERSION } from './utils/AppSettings'
import dataContainer from './utils/DefaultDataContainer'
import {
androidPushNotificationPermissionFix,
quitAppStatePushNotificationListener,
useForegroundPushNotificationListener,
} from './utils/PushNotificationsManager'
Expand Down Expand Up @@ -92,14 +93,18 @@ const Stack = createStackNavigator<RoutesParamsType>()

const Navigator = (): ReactElement | null => {
const showSnackbar = useSnackbar()
const { cityCode, changeCityCode } = useContext(AppContext)
const { cityCode, changeCityCode, languageCode } = useContext(AppContext)
const navigation = useNavigation<NavigationProps<RoutesType>>()
const { data: settings, error: settingsError, refresh: refreshSettings } = useLoadAsync(appSettings.loadSettings)
const [initialRoute, setInitialRoute] = useState<InitialRouteType>(null)

// Preload cities
const { data: cities, error: citiesError, refresh: refreshCities } = useLoadCities()

useEffect(() => {
androidPushNotificationPermissionFix(cityCode, languageCode).catch(reportError)
}, [cityCode, languageCode])

useForegroundPushNotificationListener({ showSnackbar, navigate: navigation.navigate })

useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions native/src/__tests__/Navigator.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ jest.mock('../utils/PushNotificationsManager', () => ({
pushNotificationsSupported: jest.fn(() => true),
quitAppStatePushNotificationListener: jest.fn(),
useForegroundPushNotificationListener: jest.fn(),
androidPushNotificationPermissionFix: jest.fn(async () => undefined),
}))
jest.mock('../utils/FetcherModule')

Expand Down
1 change: 0 additions & 1 deletion native/src/components/__tests__/NearbyCities.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ jest.mock('../../utils/LocationPermissionManager', () => ({
checkLocationPermission: jest.fn(),
requestLocationPermission: jest.fn(),
}))
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'))
jest.mock('@react-native-community/geolocation')
jest.mock('react-i18next')

Expand Down
30 changes: 7 additions & 23 deletions native/src/contexts/AppContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useLoadAsync } from 'api-client'
import buildConfig from '../constants/buildConfig'
import appSettings from '../utils/AppSettings'
import dataContainer from '../utils/DefaultDataContainer'
import * as PushNotificationsManager from '../utils/PushNotificationsManager'
import { subscribeNews, unsubscribeNews } from '../utils/PushNotificationsManager'
import { reportError } from '../utils/sentry'

type AppContextType = {
Expand Down Expand Up @@ -47,48 +47,32 @@ const AppContextProvider = ({ children }: AppContextProviderProps): ReactElement
}
}, [cityCode])

const subscribe = useCallback((cityCode: string, languageCode: string) => {
PushNotificationsManager.requestPushNotificationPermission()
.then(permissionGranted =>
permissionGranted
? PushNotificationsManager.subscribeNews(cityCode, languageCode)
: appSettings.setSettings({ allowPushNotifications: false }),
)
.catch(reportError)
}, [])

const unsubscribe = useCallback(
(cityCode: string, languageCode: string) =>
PushNotificationsManager.unsubscribeNews(cityCode, languageCode).catch(reportError),
[],
)

const changeCityCode = useCallback(
(newCityCode: string | null): void => {
setCityCode(newCityCode)
appSettings.setSelectedCity(newCityCode).catch(reportError)
if (languageCode && cityCode) {
unsubscribe(cityCode, languageCode)
unsubscribeNews(cityCode, languageCode).catch(reportError)
}
if (languageCode && newCityCode) {
subscribe(newCityCode, languageCode)
subscribeNews(newCityCode, languageCode).catch(reportError)
}
},
[cityCode, languageCode, subscribe, unsubscribe],
[cityCode, languageCode],
)

const changeLanguageCode = useCallback(
(newLanguageCode: string): void => {
setLanguageCode(newLanguageCode)
appSettings.setContentLanguage(newLanguageCode).catch(reportError)
if (cityCode && languageCode) {
unsubscribe(cityCode, languageCode)
unsubscribeNews(cityCode, languageCode).catch(reportError)
}
if (cityCode) {
subscribe(cityCode, newLanguageCode)
subscribeNews(cityCode, newLanguageCode).catch(reportError)
}
},
[cityCode, languageCode, subscribe, unsubscribe],
[cityCode, languageCode],
)

const appContext = useMemo(
Expand Down
37 changes: 31 additions & 6 deletions native/src/utils/PushNotificationsManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
import { useEffect } from 'react'
import { Linking } from 'react-native'
import { Linking, Platform } from 'react-native'
import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions'

import { LOCAL_NEWS_TYPE, NEWS_ROUTE, NonNullableRouteInformationType } from 'api-client'

Expand All @@ -22,6 +23,7 @@ type Message = FirebaseMessagingTypes.RemoteMessage & {

const WAITING_TIME_FOR_CMS = 1000
const PUSH_NOTIFICATION_SHOW_DURATION = 10000
const ANDROID_PERMISSION_REQUEST_NEEDED_API_LEVEL = 33

const importFirebaseMessaging = async (): Promise<() => FirebaseMessagingTypes.Module> =>
import('@react-native-firebase/messaging').then(firebase => firebase.default)
Expand All @@ -35,11 +37,15 @@ export const requestPushNotificationPermission = async (): Promise<boolean> => {
return false
}

const messaging = await importFirebaseMessaging()
const authStatus = await messaging().requestPermission()
log(`Authorization status: ${authStatus}`)
// Firebase returns either 1 or 2 for granted or 0 for rejected permissions
return authStatus !== 0
const permissionStatus = (await requestNotifications(['alert'])).status
log(`Notification permission status: ${permissionStatus}`)

if (permissionStatus !== RESULTS.GRANTED) {
log(`Permission denied, disabling push notifications in settings.`)
await appSettings.setSettings({ allowPushNotifications: false })
}

return permissionStatus === RESULTS.GRANTED
}

const newsTopic = (city: string, language: string): string => `${city}-${language}-news`
Expand Down Expand Up @@ -155,3 +161,22 @@ export const backgroundAppStatePushNotificationListener = (listener: (url: strin

return undefined
}

// Since Android 13 an explicit permission request is needed, otherwise push notifications are not received.
// Therefore request the permissions once if not yet granted and subscribe to the current channel if successful.
// See https://github.com/digitalfabrik/integreat-app/issues/2438
export const androidPushNotificationPermissionFix = async (
cityCode: string | null,
languageCode: string,
): Promise<void> => {
const { allowPushNotifications } = await appSettings.loadSettings()
const pushNotificationPermissionGranted = (await checkNotifications()).status === RESULTS.GRANTED
const androidPermissionRequestRequired =
Platform.OS === 'android' && Platform.constants.Version >= ANDROID_PERMISSION_REQUEST_NEEDED_API_LEVEL
if (androidPermissionRequestRequired && !pushNotificationPermissionGranted && allowPushNotifications) {
const success = await requestPushNotificationPermission()
if (success && cityCode) {
await subscribeNews(cityCode, languageCode)
}
}
}
22 changes: 7 additions & 15 deletions native/src/utils/__tests__/PushNotificationsManager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging'
import { mocked } from 'jest-mock'
import { requestNotifications } from 'react-native-permissions'

import buildConfig from '../../constants/buildConfig'
import appSettings from '../AppSettings'
import * as PushNotificationsManager from '../PushNotificationsManager'

jest.mock('@react-native-firebase/messaging', () => jest.fn(() => ({})))
Expand Down Expand Up @@ -72,30 +74,20 @@ describe('PushNotificationsManager', () => {
expect(mockRequestPermission).not.toHaveBeenCalled()
})

it('should request permissions and return false if not granted', async () => {
it('should request permissions and return false and disable push notifications in settings if not granted', async () => {
mockBuildConfig(true, false)
const mockRequestPermission = jest.fn(async () => 0)
mockedFirebaseMessaging.mockImplementation(() => {
const previous = previousFirebaseMessaging
previous.requestPermission = mockRequestPermission
return previous
})
mocked(requestNotifications).mockImplementationOnce(async () => ({ status: 'blocked', settings: {} }))

expect(await PushNotificationsManager.requestPushNotificationPermission()).toBeFalsy()
expect(mockRequestPermission).toHaveBeenCalledTimes(1)
expect((await appSettings.loadSettings()).allowPushNotifications).toBe(false)
})

it('should request permissions and return true if granted', async () => {
mockBuildConfig(true, false)
const mockRequestPermission = jest.fn(async () => 1)
mockedFirebaseMessaging.mockImplementation(() => {
const previous = previousFirebaseMessaging
previous.requestPermission = mockRequestPermission
return previous
})
mocked(requestNotifications).mockImplementationOnce(async () => ({ status: 'granted', settings: {} }))

expect(await PushNotificationsManager.requestPushNotificationPermission()).toBeTruthy()
expect(mockRequestPermission).toHaveBeenCalledTimes(1)
expect((await appSettings.loadSettings()).allowPushNotifications).toBe(true)
})
})

Expand Down
1 change: 0 additions & 1 deletion native/src/utils/__tests__/createSettingsSections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ jest.mock('../../utils/PushNotificationsManager', () => ({
subscribeNews: jest.fn(),
unsubscribeNews: jest.fn(),
}))
jest.mock('react-native-permissions', () => require('react-native-permissions/mock'))
jest.mock('@react-native-community/geolocation')

const mockRequestPushNotificationPermission = mocked(requestPushNotificationPermission)
Expand Down
16 changes: 10 additions & 6 deletions native/src/utils/createSettingsSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ import NativeConstants from '../constants/NativeConstants'
import { NavigationProps } from '../constants/NavigationTypes'
import buildConfig from '../constants/buildConfig'
import { SettingsType } from './AppSettings'
import * as NotificationsManager from './PushNotificationsManager'
import { pushNotificationsEnabled } from './PushNotificationsManager'
import {
pushNotificationsEnabled,
requestPushNotificationPermission,
subscribeNews,
unsubscribeNews,
} from './PushNotificationsManager'
import openExternalUrl from './openExternalUrl'
import { initSentry } from './sentry'

Expand Down Expand Up @@ -74,23 +78,23 @@ const createSettingsSections = ({
}),
async (newSettings): Promise<boolean> => {
if (!cityCode) {
// No city selected so nothing to do here
// No city selected so nothing to do here (should not ever happen since settings are only available from city content routes)
return true
}

if (newSettings.allowPushNotifications) {
const status = await NotificationsManager.requestPushNotificationPermission()
const status = await requestPushNotificationPermission()

if (status) {
await NotificationsManager.subscribeNews(cityCode, languageCode, true)
await subscribeNews(cityCode, languageCode, true)
} else {
// If the user has rejected the permission once, it can only be changed in the system settings
openSettings()
// Not successful, reset displayed setting in app
return false
}
} else {
await NotificationsManager.unsubscribeNews(cityCode, languageCode)
await unsubscribeNews(cityCode, languageCode)
}
return true
},
Expand Down
6 changes: 6 additions & 0 deletions release-notes/unreleased/2438-fix-push-news-android-13.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
issue_key: 2438
show_in_stores: true
platforms:
- android
en: Support receiving push notifications on Android 13.
de: Das Empfangen von Push Nachrichten auf Android 13 wird nun unterstützt.

0 comments on commit 2bf3a56

Please sign in to comment.