diff --git a/README.md b/README.md
index 48dd63f..208e39c 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,119 @@
-# Welcome to your Expo app 👋
+# Notifier app
-This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
+A React Native mobile application for receiving push notifications.
-## Get started
+## Features
-1. Install dependencies
+- **Secure API Authentication** - API key-based authentication to connect to the `backend-api`
+- **Notification Storage** - Notifications are saved locally and deleted after 60 days
+- **Category Organization** - Notifications are organized into categories and a complete history is displayed in Home
+- **Drawer Navigation** - Easy navigation between different notification categories
+- **Interactive Links** - Tap on notification links to open them in your device's browser
+- **Logout Functionality** - Securely unregister your device and clear all local data
- ```bash
- npm install
- ```
+
+
+
+
+
-2. Start the app
+## Prerequisites
- ```bash
- npx expo start
- ```
+- Node.js (v14 or higher)
+- Firebase account with google-services.json
+- Expo account with Firebase Admin SDK private key imported
+- Installed eas-cli
+- Android SDK 36
+- Physical Android device (push notifications don't work on simulators/emulators)
+- API key
-In the output, you'll find options to open the app in a
-- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
-- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
-- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
-- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
-You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
-
-## Get a fresh project
-
-When you're ready, run:
+## Building from scratch
+1. Clone the repository:
```bash
-npm run reset-project
+git clone
+cd
```
-This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
+2. Install dependencies:
+```bash
+npm install
+```
-## Learn more
+3. Copy google-services.json to root directory and delete .gitignore file (or comment out google-services.json)
-To learn more about developing your project with Expo, look at the following resources:
+4. Use EAS to build and follow along
+```bash
+eas build --profile release --local --platform android
+```
-- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
-- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
+## Configuration
-## Join the community
+### Backend URL
-Join our community of developers creating universal apps.
+The app connects to the notification backend at:
+```
+https://notifier.gansejunge.com
+```
-- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
-- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
+To change this, modify the `API_URL` constant in `index.tsx`.
+
+### Categories
+
+Categories are defined in `types/Category.ts`. Special categories:
+- **Home** - View all notifications
+- **Utility** - Default category for uncategorized notifications
+
+
+
+## Technical Details
+
+### Notification Data Structure
+
+Notifications should include the following data fields from your backend:
+
+```json
+{
+ "title": "Notification Title",
+ "body": "Notification message text",
+ "data": {
+ "category": "utility",
+ "link": "https://example.com",
+ "timestamp": 1760734800
+ }
+}
+```
+
+- `category` - Optional. Defaults to "utility" if not provided
+- `link` - Optional. Set to "#" if no link should be shown
+- `timestamp` - Optional. Defaults to when the notification was received when empty
+
+
+## Project Structure
+
+```
+├── index.tsx # Main app component
+├── hooks/
+│ ├── usePushNotifications.ts # Push notification registration
+│ └── useNotificationListener.ts # Notification receiving and storage
+├── types/
+│ ├── Category.ts # Category definitions
+│ └── Item.ts # Notification item type
+└── styles/
+ └── styles.ts # App styling
+```
+
+
+## API Endpoints
+
+The app communicates with these backend endpoints:
+
+- **POST /register-token** - Register device for push notifications
+ - Headers: `X-API-Key: `
+ - Body: `{ token, platform, app_ver, locale, topics }`
+
+- **POST /unregister-token** - Unregister device
+ - Headers: `X-API-Key: `
+ - Body: `{ token, platform, app_ver, locale, topics }`
diff --git a/app/index.tsx b/app/index.tsx
index 34f7d02..b6f5da3 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -1,13 +1,13 @@
-import React, { useEffect, useState } from 'react';
-import { View, Text, TextInput, Button, Linking, TouchableOpacity, ScrollView } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createDrawerNavigator, DrawerContentScrollView, DrawerItemList } from '@react-navigation/drawer';
-import { categories, categoryTitles, Category } from '../types/Category';
-import { Item } from "../types/Item";
-import { styles } from '../styles/styles';
+import React, { useEffect, useState } from 'react';
+import { Button, Linking, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native';
+import { STORAGE_KEY, useNotificationListener } from '../hooks/useNotificationListener';
import { usePushNotifications } from '../hooks/usePushNotifications';
-import { useNotificationListener, STORAGE_KEY } from '../hooks/useNotificationListener';
import { version as appVersion } from '../package.json';
+import { styles } from '../styles/styles';
+import { categories, Category, categoryTitles } from '../types/Category';
+import { Item } from "../types/Item";
const Drawer = createDrawerNavigator();
const API_KEY_STORAGE = 'API_KEY';
@@ -86,7 +86,7 @@ function CategoryScreen({
)}
-
+
{categoryTitles[category]}
@@ -116,14 +116,14 @@ function CategoryScreen({
function CustomDrawerContent({ onLogout, ...props }: any) {
return (
-
-
+
+
{/* Logout Button */}
-
+
Logout
@@ -188,6 +188,7 @@ export default function App() {
drawerActiveTintColor: '#fff',
drawerInactiveTintColor: '#ccc',
drawerLabelStyle: { textAlign: 'center', fontSize: 16 },
+ drawerItemStyle: { paddingVertical: 5, marginVertical: 2 },
}}
>
{categories.map(c => (
diff --git a/assets/images/favicon.png b/assets/images/favicon.png
deleted file mode 100644
index d113773..0000000
Binary files a/assets/images/favicon.png and /dev/null differ
diff --git a/assets/images/main_menu.jpg b/assets/images/main_menu.jpg
new file mode 100644
index 0000000..0b71eb3
Binary files /dev/null and b/assets/images/main_menu.jpg differ
diff --git a/assets/images/rr_category.jpg b/assets/images/rr_category.jpg
new file mode 100644
index 0000000..e0b815c
Binary files /dev/null and b/assets/images/rr_category.jpg differ
diff --git a/assets/images/sidemenu.jpg b/assets/images/sidemenu.jpg
new file mode 100644
index 0000000..65d1d57
Binary files /dev/null and b/assets/images/sidemenu.jpg differ
diff --git a/hooks/useNotificationListener.ts b/hooks/useNotificationListener.ts
index c7fa03c..8baf2ab 100644
--- a/hooks/useNotificationListener.ts
+++ b/hooks/useNotificationListener.ts
@@ -1,53 +1,159 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
-import { useEffect } from "react";
+import { useEffect, useRef } from "react";
import { Category, categoryKeys } from "../types/Category";
import { Item } from "../types/Item";
export const STORAGE_KEY = "notifications-data";
+const MAX_STORED_NOTIFICATIONS = 100;
+const MAX_AGE_DAYS = 60;
+
+// Simple queue to prevent race conditions
+class StorageQueue {
+ private queue: Array<() => Promise> = [];
+ private processing = false;
+
+ async add(task: () => Promise) {
+ this.queue.push(task);
+ if (!this.processing) {
+ await this.process();
+ }
+ }
+
+ private async process() {
+ this.processing = true;
+ while (this.queue.length > 0) {
+ const task = this.queue.shift();
+ if (task) {
+ try {
+ await task();
+ } catch (e) {
+ console.error("Storage task failed:", e);
+ }
+ }
+ }
+ this.processing = false;
+ }
+}
+
+const storageQueue = new StorageQueue();
-// Hook: listens for incoming notifications → Item + persists them
export function useNotificationListener(onItems: (items: Item[]) => void) {
+ const onItemsRef = useRef(onItems);
+ const mountedRef = useRef(true);
+
+ // Keep ref updated but don't trigger effect re-runs
useEffect(() => {
- // Load saved notifications on mount
+ onItemsRef.current = onItems;
+ }, [onItems]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+
(async () => {
try {
const saved = await AsyncStorage.getItem(STORAGE_KEY);
- if (saved) {
- const parsed: Item[] = JSON.parse(saved);
- // sort newest first
- onItems(parsed.sort((a, b) => b.timestamp - a.timestamp));
+ let existing: Item[] = saved ? JSON.parse(saved) : [];
+
+ // Parse timestamp correctly
+ existing = existing.map((i) => ({
+ ...i,
+ timestamp: Number(i.timestamp),
+ }));
+
+ // Load delivered notifications from the system
+ const delivered = await Notifications.getPresentedNotificationsAsync?.();
+ const deliveredItems: Item[] = delivered
+ ?.map(mapNotificationToItem)
+ .filter((i): i is Item => i !== null) ?? [];
+
+ // Combine, deduplicate, and clean
+ const combined = deduplicateItems([...deliveredItems, ...existing]);
+ const cleaned = cleanOldItems(combined);
+
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(cleaned));
+
+ if (mountedRef.current) {
+ onItemsRef.current(cleaned);
}
} catch (e) {
- console.error("Failed to load notifications from storage:", e);
+ console.error("Failed to load notifications:", e);
}
})();
- // Listen for notification received while app is in foreground
const receivedSub = Notifications.addNotificationReceivedListener(async (notification) => {
const item = mapNotificationToItem(notification);
- if (item) {
- await addItem(item, onItems);
+ if (item && mountedRef.current) {
+ await addItem(item, (items) => {
+ if (mountedRef.current) {
+ onItemsRef.current(items);
+ }
+ });
}
});
- // Listen for when user taps a notification
const responseSub = Notifications.addNotificationResponseReceivedListener(async (response) => {
const item = mapNotificationToItem(response.notification);
- if (item) {
- await addItem(item, onItems);
+ if (item && mountedRef.current) {
+ await addItem(item, (items) => {
+ if (mountedRef.current) {
+ onItemsRef.current(items);
+ }
+ });
}
});
- // Cleanup on unmount
return () => {
+ mountedRef.current = false;
receivedSub.remove();
responseSub.remove();
};
- }, [onItems]);
+ }, []); // Empty deps - only run once
+}
+
+function normalizeTimestamp(timestamp: any): number {
+ const num = Number(timestamp);
+
+ if (!num || isNaN(num)) {
+ return Date.now();
+ }
+
+ // If timestamp appears to be in seconds, convert to milliseconds
+ if (num < 1000000000000) {
+ return num * 1000;
+ }
+
+ return num;
+}
+
+function generateItemId(item: Item): string {
+ // Create a unique ID based on timestamp, title, and info
+ return `${item.timestamp}-${item.title}-${item.info}`.substring(0, 100);
+}
+
+function deduplicateItems(items: Item[]): Item[] {
+ const seen = new Set();
+ const unique: Item[] = [];
+
+ for (const item of items) {
+ const id = generateItemId(item);
+ if (!seen.has(id)) {
+ seen.add(id);
+ unique.push(item);
+ }
+ }
+
+ return unique.sort((a, b) => b.timestamp - a.timestamp);
+}
+
+function cleanOldItems(items: Item[]): Item[] {
+ const maxAge = Date.now() - (MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
+
+ return items
+ .filter(item => item.timestamp > maxAge)
+ .slice(0, MAX_STORED_NOTIFICATIONS);
}
-// Convert Expo notification → Item
export function mapNotificationToItem(notification: Notifications.Notification): Item | null {
try {
const content = notification.request.content;
@@ -58,8 +164,12 @@ export function mapNotificationToItem(notification: Notifications.Notification):
? (rawCategory as Category)
: "utility";
+ const timestamp = data.timestamp
+ ? normalizeTimestamp(data.timestamp)
+ : Date.now();
+
return {
- timestamp: data.timestamp ? Number(data.timestamp) : Date.now(),
+ timestamp,
category,
title: content.title ?? "No title",
info: content.body ?? "No description",
@@ -71,19 +181,27 @@ export function mapNotificationToItem(notification: Notifications.Notification):
}
}
-// Save a new item into storage and update state
async function addItem(item: Item, onItems: (items: Item[]) => void) {
- try {
- const saved = await AsyncStorage.getItem(STORAGE_KEY);
- const prev: Item[] = saved ? JSON.parse(saved) : [];
+ await storageQueue.add(async () => {
+ try {
+ const saved = await AsyncStorage.getItem(STORAGE_KEY);
+ const prev: Item[] = saved ? JSON.parse(saved) : [];
- const updated = [item, ...prev].sort((a, b) => b.timestamp - a.timestamp);
+ const combined = [item, ...prev]
+ .map((i) => ({ ...i, timestamp: Number(i.timestamp) }));
- await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
- onItems(updated);
- } catch (e) {
- console.error("Failed to persist notification:", e);
- }
+ // Deduplicate and clean
+ const deduplicated = deduplicateItems(combined);
+ const cleaned = cleanOldItems(deduplicated);
+
+ await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(cleaned));
+ onItems(cleaned);
+ } catch (e) {
+ console.error("Failed to persist notification:", e);
+ // Still call onItems with the new item so UI updates even if persistence fails
+ onItems([item]);
+ }
+ });
}
export default null;
\ No newline at end of file