diff --git a/native/jest.setup.ts b/native/jest.setup.ts index c8391b8327..20d3e46bdd 100644 --- a/native/jest.setup.ts +++ b/native/jest.setup.ts @@ -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 diff --git a/native/src/Navigator.tsx b/native/src/Navigator.tsx index 719100a3b6..2495cd4659 100644 --- a/native/src/Navigator.tsx +++ b/native/src/Navigator.tsx @@ -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' @@ -92,7 +93,7 @@ const Stack = createStackNavigator() const Navigator = (): ReactElement | null => { const showSnackbar = useSnackbar() - const { cityCode, changeCityCode } = useContext(AppContext) + const { cityCode, changeCityCode, languageCode } = useContext(AppContext) const navigation = useNavigation>() const { data: settings, error: settingsError, refresh: refreshSettings } = useLoadAsync(appSettings.loadSettings) const [initialRoute, setInitialRoute] = useState(null) @@ -100,6 +101,10 @@ const Navigator = (): ReactElement | 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(() => { diff --git a/native/src/__tests__/Navigator.spec.tsx b/native/src/__tests__/Navigator.spec.tsx index 99c6746119..5180d21e8a 100644 --- a/native/src/__tests__/Navigator.spec.tsx +++ b/native/src/__tests__/Navigator.spec.tsx @@ -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') diff --git a/native/src/components/__tests__/NearbyCities.spec.tsx b/native/src/components/__tests__/NearbyCities.spec.tsx index b621891bba..e610061c0a 100644 --- a/native/src/components/__tests__/NearbyCities.spec.tsx +++ b/native/src/components/__tests__/NearbyCities.spec.tsx @@ -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') diff --git a/native/src/contexts/AppContextProvider.tsx b/native/src/contexts/AppContextProvider.tsx index 76baa6d9db..7fac41bbd8 100644 --- a/native/src/contexts/AppContextProvider.tsx +++ b/native/src/contexts/AppContextProvider.tsx @@ -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 = { @@ -47,34 +47,18 @@ 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( @@ -82,13 +66,13 @@ const AppContextProvider = ({ children }: AppContextProviderProps): ReactElement 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( diff --git a/native/src/utils/PushNotificationsManager.ts b/native/src/utils/PushNotificationsManager.ts index 91626e95bb..276057259e 100644 --- a/native/src/utils/PushNotificationsManager.ts +++ b/native/src/utils/PushNotificationsManager.ts @@ -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' @@ -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) @@ -35,11 +37,15 @@ export const requestPushNotificationPermission = async (): Promise => { 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` @@ -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 => { + 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) + } + } +} diff --git a/native/src/utils/__tests__/PushNotificationsManager.spec.ts b/native/src/utils/__tests__/PushNotificationsManager.spec.ts index cbb594a289..25ae5a79ca 100644 --- a/native/src/utils/__tests__/PushNotificationsManager.spec.ts +++ b/native/src/utils/__tests__/PushNotificationsManager.spec.ts @@ -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(() => ({}))) @@ -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) }) }) diff --git a/native/src/utils/__tests__/createSettingsSections.spec.ts b/native/src/utils/__tests__/createSettingsSections.spec.ts index a0ad6d8958..70e1c56aba 100644 --- a/native/src/utils/__tests__/createSettingsSections.spec.ts +++ b/native/src/utils/__tests__/createSettingsSections.spec.ts @@ -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) diff --git a/native/src/utils/createSettingsSections.ts b/native/src/utils/createSettingsSections.ts index 74921fa0b3..f54100393d 100644 --- a/native/src/utils/createSettingsSections.ts +++ b/native/src/utils/createSettingsSections.ts @@ -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' @@ -74,15 +78,15 @@ const createSettingsSections = ({ }), async (newSettings): Promise => { 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() @@ -90,7 +94,7 @@ const createSettingsSections = ({ return false } } else { - await NotificationsManager.unsubscribeNews(cityCode, languageCode) + await unsubscribeNews(cityCode, languageCode) } return true }, diff --git a/release-notes/unreleased/2438-fix-push-news-android-13.yml b/release-notes/unreleased/2438-fix-push-news-android-13.yml new file mode 100644 index 0000000000..ec8cc433cc --- /dev/null +++ b/release-notes/unreleased/2438-fix-push-news-android-13.yml @@ -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.