Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,11 @@ npx sentry-expo-upload-sourcemaps dist
## Voice backend behavior

The iOS client records short audio locally and sends it only to the Multica backend speech proxy. It does not receive ASR/TTS provider keys. If the backend has speech disabled or the provider is unavailable, speech calls return typed recoverable errors such as `provider_missing`, `rate_limited`, `quota_exceeded`, or `provider_timeout`; the app should let the user continue by typing the message.

## Push notifications

The iOS app uses `expo-notifications` to request APNs-backed Expo push permission after a signed-in user has an active workspace. Device tokens are registered against the current user + workspace through `/api/mobile/push-tokens`; sign-out unregisters the current token before clearing auth.

Server delivery is driven by existing inbox events and notification preferences. `system_notifications=muted` disables lock-screen delivery while keeping in-app inbox/realtime behavior intact. Without `EXPO_ACCESS_TOKEN`, the backend records deliveries as dry-run `skipped` rows and does not call Expo. Set `EXPO_ACCESS_TOKEN` for real delivery; `MULTICA_EXPO_PUSH_URL` can override the Expo endpoint for tests, and `MULTICA_PUSH_DRY_RUN=true` forces dry-run.

Manual iOS validation still requires a signed device build: allow/deny permission, receive a background notification for assignment/mention/comment/agent completion/failure/block, tap it, and confirm the app routes to the target workspace issue. If a workspace is unavailable, the app falls back to workspace selection.
6 changes: 6 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export default ({ config }: ConfigContext): ExpoConfig => {
microphonePermission: false,
},
],
[
"expo-notifications",
{
sounds: [],
},
],
[
"expo-build-properties",
{
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/app/(app)/[workspace]/more/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,11 @@
useColorScheme,
type ThemePreference,
} from "@/lib/use-color-scheme";
import { unregisterCurrentMobilePushToken } from "@/lib/mobile-push";
import { THEME } from "@/lib/theme";
import { cn } from "@/lib/utils";

const THEME_OPTIONS: Array<{ value: ThemePreference; label: string }> = [

Check warning on line 36 in apps/mobile/app/(app)/[workspace]/more/settings.tsx

View workflow job for this annotation

GitHub Actions / mobile

Array type using 'Array<T>' is forbidden. Use 'T[]' instead
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" },
Expand Down Expand Up @@ -75,6 +76,7 @@
text: "Sign out",
style: "destructive",
onPress: async () => {
await unregisterCurrentMobilePushToken();
await clearWorkspace();
await logout();
},
Expand Down
96 changes: 81 additions & 15 deletions apps/mobile/app/(app)/[workspace]/more/settings/notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
import { Text } from "@/components/ui/text";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { useWorkspaceStore } from "@/data/workspace-store";
import { notificationPreferenceOptions } from "@/data/queries/notification-preferences";
import { useUpdateNotificationPreferences } from "@/data/mutations/notification-preferences";
import {
useMobilePushActions,
useMobilePushStatus,
} from "@/lib/mobile-push";

const INBOX_GROUPS: Array<{

Check warning on line 28 in apps/mobile/app/(app)/[workspace]/more/settings/notifications.tsx

View workflow job for this annotation

GitHub Actions / mobile

Array type using 'Array<T>' is forbidden. Use 'T[]' instead
key: Exclude<NotificationGroupKey, "system_notifications">;
label: string;
description: string;
Expand Down Expand Up @@ -58,6 +63,8 @@
notificationPreferenceOptions(wsId),
);
const mutation = useUpdateNotificationPreferences();
const pushStatus = useMobilePushStatus();
const pushActions = useMobilePushActions();

const preferences: NotificationPreferences = data?.preferences ?? {};

Expand All @@ -73,6 +80,8 @@
};

const systemEnabled = preferences.system_notifications !== "muted";
const canUseSystemNotifications =
pushStatus.permission === "granted" && systemEnabled;

if (isLoading) {
return (
Expand Down Expand Up @@ -127,30 +136,87 @@
</Section>

<Section
title="System"
description="Multica-wide announcements and important account events."
title="System push"
description="Lock screen and background notifications for important inbox items."
>
<View className="flex-row items-center px-4 py-3 gap-3">
<View className="flex-1">
<Text className="text-base font-medium text-foreground">
System notifications
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
Account changes, security alerts, product updates.
</Text>
<View className="px-4 py-3 gap-3">
<View className="flex-row items-center gap-3">
<View className="flex-1">
<Text className="text-base font-medium text-foreground">
Push notifications
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
Assignments, mentions, comments, and agent status updates.
</Text>
</View>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) =>
onToggle("system_notifications", checked)
}
/>
</View>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) =>
onToggle("system_notifications", checked)
}
<PermissionStatus
enabled={canUseSystemNotifications}
permission={pushStatus.permission}
error={pushStatus.error}
registering={pushStatus.registering}
onRequest={pushActions.request}
onOpenSettings={pushActions.openSettings}
/>
</View>
</Section>
</ScrollView>
);
}

function PermissionStatus({
enabled,
permission,
error,
registering,
onRequest,
onOpenSettings,
}: {
enabled: boolean;
permission: string;
error: string | null;
registering: boolean;
onRequest: () => void;
onOpenSettings: () => void;
}) {
const message =
permission === "granted"
? enabled
? "iOS push is enabled for this workspace."
: "iOS permission is enabled. Turn on the switch to receive push."
: permission === "denied"
? "iOS notification permission is off. Re-enable it in Settings."
: permission === "undetermined"
? "Allow notifications to receive lock screen updates."
: permission === "unsupported"
? "Push notifications are available on iOS builds."
: "Checking notification permission.";

return (
<View className="gap-2">
<Text className="text-xs text-muted-foreground">
{error ?? message}
</Text>
{permission === "undetermined" || permission === "error" ? (
<Button variant="outline" onPress={onRequest} disabled={registering}>
<Text>{registering ? "Enabling..." : "Enable notifications"}</Text>
</Button>
) : null}
{permission === "denied" ? (
<Button variant="outline" onPress={onOpenSettings}>
<Text>Open iOS Settings</Text>
</Button>
) : null}
</View>
);
}

function Section({
title,
description,
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/app/(app)/select-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CardPressable } from "@/components/ui/card";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { unregisterCurrentMobilePushToken } from "@/lib/mobile-push";

export default function SelectWorkspace() {
const user = useAuthStore((s) => s.user);
Expand Down Expand Up @@ -79,7 +80,13 @@ export default function SelectWorkspace() {
</View>

<View className="pt-4 border-t border-border">
<Button variant="outline" onPress={() => logout()}>
<Button
variant="outline"
onPress={async () => {
await unregisterCurrentMobilePushToken();
await logout();
}}
>
<Text>Sign out</Text>
</Button>
</View>
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { initCrashReporting, withCrashReporting } from "@/lib/crash-reporting";
import { LightboxProvider, prewarmHighlighter } from "@/lib/markdown";
import {
unregisterCurrentMobilePushToken,
useMobilePushLifecycle,
} from "@/lib/mobile-push";
import { NAV_THEME } from "@/lib/theme";
import { useCrashContext } from "@/lib/use-crash-context";
import { useColorScheme } from "@/lib/use-color-scheme";
Expand Down Expand Up @@ -43,6 +47,7 @@ function AuthInitializer({ children }: { children: React.ReactNode }) {
if (signingOutRef.current) return;
signingOutRef.current = true;
void (async () => {
await unregisterCurrentMobilePushToken();
await useAuthStore.getState().logout();
await useWorkspaceStore.getState().clear();
qc.clear();
Expand All @@ -66,6 +71,11 @@ function CrashContextSync() {
return null;
}

function MobilePushInitializer() {
useMobilePushLifecycle();
return null;
}

function RootLayout() {
const { colorScheme, isDarkColorScheme } = useColorScheme();
return (
Expand All @@ -76,6 +86,7 @@ function RootLayout() {
<QueryClientProvider client={queryClient}>
<ThemeProvider value={NAV_THEME[colorScheme]}>
<AuthInitializer>
<MobilePushInitializer />
<LightboxProvider>
<CrashContextSync />
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
Expand Down
24 changes: 24 additions & 0 deletions apps/mobile/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,15 @@ export interface SpeechSynthesizeResponse {
content_type: string;
}

export interface MobilePushTokenRequest {
provider: "expo";
token: string;
device_id?: string | null;
platform: "ios";
app_version?: string | null;
environment: string;
}

/** Web mirrors this from `packages/core/constants/upload.ts`. Mobile keeps
* its own copy per the `mirror, don't import` rule in apps/mobile/CLAUDE.md. */
const MAX_FILE_SIZE = 100 * 1024 * 1024;
Expand Down Expand Up @@ -424,6 +433,21 @@ class ApiClient {
);
}

// --- Mobile push ---
async registerMobilePushToken(data: MobilePushTokenRequest): Promise<void> {
await this.fetch<void>("/api/mobile/push-tokens", {
method: "POST",
body: JSON.stringify(data),
});
}

async unregisterMobilePushToken(data: MobilePushTokenRequest): Promise<void> {
await this.fetch<void>("/api/mobile/push-tokens", {
method: "DELETE",
body: JSON.stringify(data),
});
}

// --- Workspaces ---
async listWorkspaces(opts?: {
signal?: AbortSignal;
Expand Down
Loading
Loading