Skip to content

Commit

Permalink
Push notifications (receiver side, initial work)
Browse files Browse the repository at this point in the history
  • Loading branch information
fjsj committed Feb 5, 2025
1 parent 6e31e15 commit 762c0b5
Show file tree
Hide file tree
Showing 7 changed files with 405 additions and 96 deletions.
97 changes: 97 additions & 0 deletions app.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
export default {
expo: {
name: "health-app",
slug: "health-app",
version: "1.0.0",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "myapp",
userInterfaceStyle: "automatic",
newArchEnabled: true,
jsEngine: "hermes",
ios: {
supportsTablet: true,
config: {
usesNonExemptEncryption: false,
},
bundleIdentifier: "com.vinta.healthapp",
infoPlist: {
UIBackgroundModes: ["remote-notification"],
},
},
android: {
adaptiveIcon: {
foregroundImage: "./assets/images/adaptive-icon.png",
backgroundColor: "#ffffff",
},
permissions: [
"android.permission.RECORD_AUDIO",
"android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.SCHEDULE_EXACT_ALARM",
"android.permission.POST_NOTIFICATIONS",
"android.permission.USE_FULL_SCREEN_INTENT",
],
package: "com.vinta.healthapp",
googleServicesFile: process.env.GOOGLE_SERVICES_JSON || "./_dev/google-services.json",
},
web: {
bundler: "metro",
output: "static",
favicon: "./assets/images/favicon.png",
},
plugins: [
"expo-router",
[
"expo-splash-screen",
{
image: "./assets/images/splash-icon.png",
imageWidth: 200,
resizeMode: "contain",
backgroundColor: "#ffffff",
},
],
[
"expo-secure-store",
{
requireAuthentication: false,
},
],
[
"expo-image-picker",
{
photosPermission:
"The app needs media access when you want to attach media to your messages.",
cameraPermission:
"The app needs camera access when you want to attach media to your messages.",
microphonePermission:
"The app needs microphone access when you want to attach media to your messages.",
},
],
[
"expo-video",
{
supportsBackgroundPlayback: true,
supportsPictureInPicture: true,
},
],
[
"expo-notifications",
{
icon: "./assets/images/adaptive-icon.png",
color: "#ffffff",
},
],
],
experiments: {
typedRoutes: true,
},
extra: {
router: {
origin: false,
},
eas: {
projectId: "b90347a9-ca6d-4949-9545-82fcce6ed6aa",
},
},
},
};
79 changes: 0 additions & 79 deletions app.json

This file was deleted.

10 changes: 10 additions & 0 deletions app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { useMedplumContext } from "@medplum/react-hooks";
import { Redirect, Slot } from "expo-router";
import { useEffect } from "react";

import { LoadingScreen } from "@/components/LoadingScreen";
import { PractitionerBanner } from "@/components/PractitionerBanner";
import { ChatProvider } from "@/contexts/ChatContext";
import { useNotifications } from "@/contexts/NotificationsContext";

export default function AppLayout() {
const { medplum, profile } = useMedplumContext();
const { setUpPushNotifications } = useNotifications();
const isPractitioner = profile?.resourceType === "Practitioner";

useEffect(() => {
if (profile && medplum.getActiveLogin()) {
// Set up push notifications when user is logged in
setUpPushNotifications();
}
}, [medplum, profile, setUpPushNotifications]);

if (medplum.isLoading()) {
return <LoadingScreen />;
}
Expand Down
21 changes: 12 additions & 9 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from "react-native-safe-area-context";

import { GluestackUIProvider } from "@/components/gluestack-ui-provider";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
import { oauth2ClientId } from "@/utils/medplum-oauth2";

export const unstable_settings = {
Expand Down Expand Up @@ -60,15 +61,17 @@ export default function RootLayout() {
<StatusBar />
<SafeAreaView className="h-full bg-background-0 md:w-full">
<MedplumProvider medplum={medplum}>
<GestureHandlerRootView className="flex-1">
<Stack
screenOptions={{
headerShown: false,
// Prevents flickering:
animation: "none",
}}
/>
</GestureHandlerRootView>
<NotificationsProvider>
<GestureHandlerRootView className="flex-1">
<Stack
screenOptions={{
headerShown: false,
// Prevents flickering:
animation: "none",
}}
/>
</GestureHandlerRootView>
</NotificationsProvider>
</MedplumProvider>
</SafeAreaView>
</SafeAreaProvider>
Expand Down
161 changes: 161 additions & 0 deletions contexts/NotificationsContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { MedplumClient } from "@medplum/core";
import { useMedplum } from "@medplum/react-hooks";
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { router } from "expo-router";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { Platform } from "react-native";

Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});

interface NotificationsContextType {
isNotificationsEnabled: boolean;
setUpPushNotifications: () => Promise<boolean>;
}

const NotificationsContext = createContext<NotificationsContextType>({
isNotificationsEnabled: false,
setUpPushNotifications: async () => false,
});

async function registerForPushNotificationsAsync() {
let token;

if (!Device.isDevice) {
return;
}

if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#FF231F7C",
});
}

try {
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
throw new Error("Missing EAS project ID");
}

token = await Notifications.getExpoPushTokenAsync({
projectId,
});
return token?.data;
} catch (error) {
console.error("Error getting push token:", error);
}
}

function updateMedplumPushToken(medplum: MedplumClient, token: string): Promise<void> {
return (async () => {
try {
const profile = medplum.getProfile();
if (!profile) return;

// Check if token already exists and matches
const existingToken = profile.extension?.find(
(e) => e.url === "https://medplum.com/push-token",
)?.valueString;

if (existingToken === token) {
return; // Token hasn't changed, no need to update
}

// Update the token
const extensions =
profile.extension?.filter((e) => e.url !== "https://medplum.com/push-token") || [];
extensions.push({
url: "https://medplum.com/push-token",
valueString: token,
});
await medplum.updateResource({
...profile,
extension: extensions,
});
} catch (error) {
console.error("Error updating push token in Medplum:", error);
}
})();
}

function handleMessageNotificationInteraction(response: Notifications.NotificationResponse) {
const data = response.notification.request.content.data;
if (data.threadId) {
router.push(`/thread/${data.threadId}`);
}
}

export function NotificationsProvider({ children }: { children: React.ReactNode }) {
const [isNotificationsEnabled, setIsNotificationsEnabled] = useState(false);
const responseListener = useRef<Notifications.EventSubscription>();
const medplum = useMedplum();

const setUpPushNotifications = useCallback(async () => {
// Check and request notification permissions
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
({ status: finalStatus } = await Notifications.requestPermissionsAsync());
}

// Set up push notifications if we have permission
const isEnabled = finalStatus === "granted";
setIsNotificationsEnabled(isEnabled);
if (isEnabled) {
const token = await registerForPushNotificationsAsync();
if (token) {
await updateMedplumPushToken(medplum, token);
}

// Set up notification listeners
responseListener.current = Notifications.addNotificationResponseReceivedListener(
handleMessageNotificationInteraction,
);
}
return isEnabled;
}, [medplum]);

// Effect to handle notifications when app is terminated
useEffect(() => {
const getInitialNotification = async () => {
const lastNotificationResponse = await Notifications.getLastNotificationResponseAsync();
if (lastNotificationResponse) {
handleMessageNotificationInteraction(lastNotificationResponse);
}
};

getInitialNotification();
}, []);

// Cleanup
useEffect(() => {
return () => {
responseListener.current?.remove();
};
}, [responseListener]);

return (
<NotificationsContext.Provider
value={{
isNotificationsEnabled,
setUpPushNotifications,
}}
>
{children}
</NotificationsContext.Provider>
);
}

export function useNotifications() {
return useContext(NotificationsContext);
}
Loading

0 comments on commit 762c0b5

Please sign in to comment.