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>
150 lines
4.0 KiB
TypeScript
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 });
|
|
},
|
|
}));
|