chahinebrini 63fae25531 fix(android-protection): explicit specialUse FGS type — Samsung/Android 16 crash loop
RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop).

Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:33:28 +02:00

150 lines
4.0 KiB
TypeScript

import { create } from "zustand";
import { apiFetch } from "../lib/api";
import { supabase } from "../lib/supabase";
import { isRealtimeErrorReal } from "../lib/realtimeStatus";
import type { RealtimeChannel } from "@supabase/supabase-js";
export interface AppNotification {
id: string;
type: string;
actorName: string;
actorAvatar: string | null;
postId: string | null;
preview: string | null;
readAt: string | null;
createdAt: string;
}
type NotificationState = {
items: AppNotification[];
unread: number;
loaded: boolean;
load: () => Promise<void>;
markRead: () => Promise<void>;
remove: (id: string) => Promise<void>;
startRealtime: () => Promise<void>;
stopRealtime: () => void;
reset: () => void;
};
let realtimeSub: RealtimeChannel | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
export const useNotificationStore = create<NotificationState>((set, get) => ({
items: [],
unread: 0,
loaded: false,
load: async () => {
try {
const res = await apiFetch<{ items: AppNotification[]; unread: number }>(
"/api/notifications",
);
set({ items: res.items ?? [], unread: res.unread ?? 0, loaded: true });
} catch (err) {
console.warn("[notifications] load failed:", err);
}
},
markRead: async () => {
if (get().unread === 0) return;
const now = new Date().toISOString();
set((s) => ({
unread: 0,
items: s.items.map((n) => ({ ...n, readAt: n.readAt ?? now })),
}));
try {
await apiFetch("/api/notifications/read", { method: "POST" });
} catch (err) {
console.warn("[notifications] markRead failed:", err);
}
},
remove: async (id) => {
set((s) => ({ items: s.items.filter((n) => n.id !== id) }));
try {
await apiFetch(`/api/notifications/${id}`, { method: "DELETE" });
} catch (err) {
console.warn("[notifications] remove failed:", err);
}
},
startRealtime: async () => {
if (realtimeSub) return;
const { data } = await supabase.auth.getSession();
const session = data.session;
if (!session?.user?.id) return;
const myId = session.user.id;
realtimeSub = supabase
.channel(`notifications:${myId}:${Date.now()}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "rebreak",
table: "notifications",
filter: `recipient_id=eq.${myId}`,
},
(payload: any) => {
const r = payload.new;
const notif: AppNotification = {
id: r.id,
type: r.type,
actorName: r.actor_name,
actorAvatar: r.actor_avatar ?? null,
postId: r.post_id ?? null,
preview: r.preview ?? null,
readAt: null,
createdAt: r.created_at,
};
set((s) => {
if (s.items.find((n) => n.id === notif.id)) return s;
return { items: [notif, ...s.items], unread: s.unread + 1 };
});
},
)
.on(
"postgres_changes",
{
event: "DELETE",
schema: "rebreak",
table: "notifications",
filter: `recipient_id=eq.${myId}`,
},
(payload: any) => {
const id = payload.old?.id;
if (!id) return;
set((s) => ({ items: s.items.filter((n) => n.id !== id) }));
},
)
.subscribe((status) => {
if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
if (isRealtimeErrorReal()) console.warn("[notifRealtime] error:", status);
get().stopRealtime();
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
get().startRealtime();
}, 3000);
}
});
},
stopRealtime: () => {
if (realtimeSub) {
supabase.removeChannel(realtimeSub);
realtimeSub = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
},
reset: () => {
get().stopRealtime();
set({ items: [], unread: 0, loaded: false });
},
}));