commit 1dede5099590ba6ca4060107350761b59890a530 Author: Hank Date: Mon Jul 6 08:33:47 2026 -0700 Initial commit: parksmarter-client + BigBrainParking app Reverse-engineered ParkSmarter API client (TypeScript, live-verified) plus a de-Googled Expo/React Native app for GrapheneOS: MapLibre meter map with GPS, VisionCamera QR kiosk scanning with save/share, local session-expiry reminders, UnifiedPush wiring, and Gitea CI to publish signed APKs for Obtainium. Co-Authored-By: Claude Fable 5 diff --git a/.gitea/workflows/build-apk.yml b/.gitea/workflows/build-apk.yml new file mode 100644 index 0000000..88d763e --- /dev/null +++ b/.gitea/workflows/build-apk.yml @@ -0,0 +1,62 @@ +# Build a signed release APK and publish it as a Gitea/Forgejo release. +# Obtainium then tracks this repo's releases and offers updates. +# +# Triggers on any tag like v0.1.0. Requires a self-hosted Actions runner that has +# (or a container image that provides) the Android SDK + JDK 17 + Node 20. +# +# Required repo secrets (Settings -> Actions -> Secrets): +# ANDROID_KEYSTORE_B64 base64 of your release keystore (keep this key forever — +# Obtainium/F-Droid pin the signer; a new key = users must reinstall) +# ANDROID_KEYSTORE_PASSWORD +# ANDROID_KEY_ALIAS +# ANDROID_KEY_PASSWORD +# +# Generate the keystore once, locally: +# keytool -genkeypair -v -keystore bigbrainparking.keystore \ +# -alias bigbrainparking -keyalg RSA -keysize 2048 -validity 10000 +# base64 -w0 bigbrainparking.keystore # -> ANDROID_KEYSTORE_B64 + +name: build-apk +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + # A container with Android SDK + Node preinstalled keeps the runner simple: + container: + image: reactnativecommunity/react-native-android:latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: npm install + + - name: Build the API client + run: npm run build --workspace parksmarter-client + + - name: Restore signing keystore + run: | + echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > "$PWD/app/release.keystore" + + - name: Expo prebuild (generate android/) + working-directory: app + run: npx expo prebuild --platform android --no-install + + - name: Assemble signed release + working-directory: app/android + env: + BBP_UPLOAD_STORE_FILE: ../release.keystore + BBP_UPLOAD_STORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + BBP_UPLOAD_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + BBP_UPLOAD_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: ./gradlew assembleRelease --no-daemon + + - name: Publish release with APK + uses: akkuman/gitea-release-action@v1 + with: + files: app/android/app/build/outputs/apk/release/*.apk + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d24984d --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Dependencies +node_modules/ + +# Builds +dist/ +app/android/ +app/ios/ +app/.expo/ +*.apk +*.aab + +# Secrets / local capture +**/.creds.json +**/capture/ +*.keystore +*.jks + +# The original app artifact (local only, not source) +*.xapk + +# OS / editor +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..e21d5ab --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# BigBrainParking + +An unofficial, de-Googled client for the ParkSmarter (IPS Group) parking system, +built to run on **GrapheneOS** with **UnifiedPush** notifications and distributed +via **Obtainium** — no Google Play Services, no Firebase, no app store. + +## Monorepo layout + +``` +bigbrainparking/ +├── parksmarter-client/ # Zero-dep TypeScript API client (reverse-engineered, live-verified) +├── app/ # Expo / React Native app (BigBrainParking) +└── .gitea/workflows/ # CI: build signed APK -> publish release for Obtainium +``` + +- **`parksmarter-client`** — the API layer. All ~40 endpoints, the custom-header auth + model, and response models (most verified against production). Runs anywhere; the app + imports it directly. See its own README for the API details. +- **`app`** — the phone app. React Native (Expo prebuild), MapLibre maps, VisionCamera QR + scanning, expo-secure-store token storage, local session-expiry reminders, and + UnifiedPush wiring. + +## Features + +| Feature | Status | Notes | +| --- | --- | --- | +| Phone + password login | ✅ wired | tokens in OS keystore | +| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) | +| "Near my last location" proximity search | ✅ wired | caches last GPS fix | +| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera | +| Save / share kiosks | ✅ wired | local (no server favorites API exists) | +| Active / past sessions | ✅ wired | list views | +| Start a paid session | 🟡 gated | flow wired to `postStartParkingSession`, disabled pending review (real charge) | +| Session-expiry reminders | ✅ wired | **local** notifications — no FCM needed | +| UnifiedPush (ntfy) | 🟡 partial | endpoint registration done; server→ntfy bridge still needed (see below) | + +## Build & run (dev) + +Requires Node 20, JDK 17, Android SDK, and a GrapheneOS device (or any Android device) +with USB debugging. + +```bash +npm install # installs both workspaces +npm run build --workspace parksmarter-client # compile the client +cd app +npx expo prebuild --platform android # generate native project +npx expo run:android # build + install a dev client +``` + +The app talks to prod (`apiv2.parksmarter.com`) by default — change `extra.psEnvironment` +in `app/app.json` to point elsewhere. + +## Notifications on GrapheneOS + +Session-expiry reminders are scheduled **locally** from each session's end time, so they +need no push infrastructure and work fully offline of Google. `app/src/notifications/` +also wires **UnifiedPush** (distributor: ntfy) for any genuinely server-initiated push — +but note the ParkSmarter backend only pushes via **FCM**, so server push requires a small +**FCM→ntfy bridge** (a service holding an FCM token that forwards to your ntfy topic, then +registered via `PUT /api/Device`). Until that exists, local reminders cover the main case. + +## Distribution via Obtainium (self-hosted) + +Tag a release and CI builds a signed APK and publishes it to this repo's releases; your +phone's Obtainium tracks the repo and offers updates. See +[`docs/DISTRIBUTION.md`](docs/DISTRIBUTION.md) for the full server + CI + keystore setup. diff --git a/app/App.tsx b/app/App.tsx new file mode 100644 index 0000000..d0f9db1 --- /dev/null +++ b/app/App.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { StatusBar } from 'expo-status-bar'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { AuthProvider } from '@/auth/AuthContext'; +import { RootNavigator } from '@/navigation/RootNavigator'; + +export default function App() { + return ( + + + + + + + ); +} diff --git a/app/app.json b/app/app.json new file mode 100644 index 0000000..65e4c9d --- /dev/null +++ b/app/app.json @@ -0,0 +1,49 @@ +{ + "expo": { + "name": "BigBrainParking", + "slug": "bigbrainparking", + "scheme": "bigbrainparking", + "version": "0.1.0", + "orientation": "portrait", + "userInterfaceStyle": "automatic", + "newArchEnabled": true, + "android": { + "package": "top.mowden.bigbrainparking", + "permissions": [ + "ACCESS_COARSE_LOCATION", + "ACCESS_FINE_LOCATION", + "CAMERA", + "POST_NOTIFICATIONS", + "INTERNET", + "ACCESS_NETWORK_STATE" + ] + }, + "plugins": [ + "expo-secure-store", + [ + "expo-location", + { + "locationWhenInUsePermission": "Show nearby parking meters on the map." + } + ], + [ + "expo-notifications", + { + "icon": "./assets/notification-icon.png" + } + ], + [ + "react-native-vision-camera", + { + "cameraPermissionText": "Scan the QR code on a parking kiosk." + } + ], + "./plugins/withReleaseSigning" + ], + "extra": { + "psEnvironment": "prodv2", + "mapStyleUrl": "https://tiles.openfreemap.org/styles/liberty", + "unifiedPushDefaultDistributor": "io.heckel.ntfy" + } + } +} diff --git a/app/babel.config.js b/app/babel.config.js new file mode 100644 index 0000000..6ced3e5 --- /dev/null +++ b/app/babel.config.js @@ -0,0 +1,14 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + [ + 'module-resolver', + { + alias: { '@': './src' }, + }, + ], + ], + }; +}; diff --git a/app/index.ts b/app/index.ts new file mode 100644 index 0000000..5fd059f --- /dev/null +++ b/app/index.ts @@ -0,0 +1,4 @@ +import { registerRootComponent } from 'expo'; +import App from './App'; + +registerRootComponent(App); diff --git a/app/metro.config.js b/app/metro.config.js new file mode 100644 index 0000000..40d784b --- /dev/null +++ b/app/metro.config.js @@ -0,0 +1,20 @@ +// Metro config for an Expo app inside an npm-workspaces monorepo. +// Lets Metro resolve and watch the sibling `parksmarter-client` package. +const { getDefaultConfig } = require('expo/metro-config'); +const path = require('path'); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '..'); + +const config = getDefaultConfig(projectRoot); + +// Watch the whole monorepo so edits to parksmarter-client hot-reload. +config.watchFolders = [workspaceRoot]; + +// Resolve modules from both the app and the workspace root. +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; + +module.exports = config; diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..462a177 --- /dev/null +++ b/app/package.json @@ -0,0 +1,38 @@ +{ + "name": "bigbrainparking-app", + "version": "0.1.0", + "private": true, + "main": "index.ts", + "scripts": { + "start": "expo start --dev-client", + "android": "expo run:android", + "prebuild": "expo prebuild --platform android --clean", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "parksmarter-client": "*", + "expo": "~53.0.0", + "expo-secure-store": "~14.0.0", + "expo-location": "~18.0.0", + "expo-notifications": "~0.29.0", + "expo-constants": "~17.0.0", + "expo-localization": "~16.0.0", + "expo-linking": "~7.0.0", + "expo-status-bar": "~2.0.0", + "@react-navigation/native": "^7.0.0", + "@react-navigation/native-stack": "^7.0.0", + "@react-navigation/bottom-tabs": "^7.0.0", + "react-native-screens": "~4.4.0", + "react-native-safe-area-context": "4.12.0", + "@react-native-async-storage/async-storage": "1.23.1", + "@maplibre/maplibre-react-native": "^10.0.0", + "react-native-vision-camera": "^4.6.0", + "react-native-unifiedpush": "^2.0.0", + "react": "19.0.0", + "react-native": "0.79.0" + }, + "devDependencies": { + "@types/react": "~19.0.0", + "typescript": "~5.4.0" + } +} diff --git a/app/plugins/withReleaseSigning.js b/app/plugins/withReleaseSigning.js new file mode 100644 index 0000000..1f89242 --- /dev/null +++ b/app/plugins/withReleaseSigning.js @@ -0,0 +1,46 @@ +/** + * Expo config plugin: inject a release signingConfig into android/app/build.gradle + * that reads credentials from environment variables (set by CI). This survives + * `expo prebuild --clean`, so signed builds are reproducible without committing + * the generated android/ directory. + * + * Env vars (see .gitea/workflows/build-apk.yml): + * BBP_UPLOAD_STORE_FILE, BBP_UPLOAD_STORE_PASSWORD, + * BBP_UPLOAD_KEY_ALIAS, BBP_UPLOAD_KEY_PASSWORD + * + * If the env vars are absent (local dev), the build falls back to the debug key. + */ +const { withAppBuildGradle } = require('@expo/config-plugins'); + +const SIGNING_BLOCK = ` + release { + def storeFilePath = System.getenv("BBP_UPLOAD_STORE_FILE") + if (storeFilePath != null) { + storeFile file(storeFilePath) + storePassword System.getenv("BBP_UPLOAD_STORE_PASSWORD") + keyAlias System.getenv("BBP_UPLOAD_KEY_ALIAS") + keyPassword System.getenv("BBP_UPLOAD_KEY_PASSWORD") + } + }`; + +module.exports = function withReleaseSigning(config) { + return withAppBuildGradle(config, (cfg) => { + let gradle = cfg.modResults.contents; + + // 1) Add a `release` signingConfig next to the default `debug` one. + if (!gradle.includes('BBP_UPLOAD_STORE_FILE')) { + gradle = gradle.replace( + /signingConfigs \{/, + `signingConfigs {${SIGNING_BLOCK}`, + ); + // 2) Point the release buildType at it *only* when the keystore env is set. + gradle = gradle.replace( + /(buildTypes \{[\s\S]*?release \{)/, + `$1\n if (System.getenv("BBP_UPLOAD_STORE_FILE") != null) { signingConfig signingConfigs.release }`, + ); + } + + cfg.modResults.contents = gradle; + return cfg; + }); +}; diff --git a/app/src/api/client.ts b/app/src/api/client.ts new file mode 100644 index 0000000..9b18f61 --- /dev/null +++ b/app/src/api/client.ts @@ -0,0 +1,17 @@ +import Constants from 'expo-constants'; +import * as Localization from 'expo-localization'; +import { ParkSmarterClient, type EnvironmentName } from 'parksmarter-client'; +import { secureTokenStore } from './secureTokenStore'; + +const env = + (Constants.expoConfig?.extra?.psEnvironment as EnvironmentName) ?? 'prodv2'; + +/** + * The single app-wide API client. React Native ships a global `fetch`, so no + * fetchImpl override is needed. Tokens persist in the OS keystore. + */ +export const ps = new ParkSmarterClient({ + environment: env, + tokens: secureTokenStore, + localeCode: (Localization.getLocales()[0]?.languageCode ?? 'en').toLowerCase(), +}); diff --git a/app/src/api/secureTokenStore.ts b/app/src/api/secureTokenStore.ts new file mode 100644 index 0000000..daca525 --- /dev/null +++ b/app/src/api/secureTokenStore.ts @@ -0,0 +1,24 @@ +import * as SecureStore from 'expo-secure-store'; +import type { TokenStore } from 'parksmarter-client'; + +/** + * A TokenStore backed by the OS keystore (expo-secure-store). + * + * On GrapheneOS/Android this uses the hardware-backed Keystore, so the user's + * Auth_Token and SessionId are encrypted at rest — never plain AsyncStorage. + */ +const AUTH_KEY = 'ps_auth_token'; +const SESSION_KEY = 'ps_session_id'; + +export const secureTokenStore: TokenStore = { + getAuthToken: () => SecureStore.getItemAsync(AUTH_KEY), + setAuthToken: (token) => + token + ? SecureStore.setItemAsync(AUTH_KEY, token) + : SecureStore.deleteItemAsync(AUTH_KEY), + getSessionId: () => SecureStore.getItemAsync(SESSION_KEY), + setSessionId: (id) => + id + ? SecureStore.setItemAsync(SESSION_KEY, id) + : SecureStore.deleteItemAsync(SESSION_KEY), +}; diff --git a/app/src/auth/AuthContext.tsx b/app/src/auth/AuthContext.tsx new file mode 100644 index 0000000..270d824 --- /dev/null +++ b/app/src/auth/AuthContext.tsx @@ -0,0 +1,78 @@ +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { ps } from '@/api/client'; +import type { ApplicationValidityResponse } from 'parksmarter-client'; + +type AuthStatus = 'loading' | 'signedOut' | 'signedIn'; + +interface AuthState { + status: AuthStatus; + validity: ApplicationValidityResponse | null; + login: (phoneNumber: string, password: string) => Promise; + logout: () => Promise; + error: string | null; +} + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [status, setStatus] = useState('loading'); + const [validity, setValidity] = useState(null); + const [error, setError] = useState(null); + + // On launch: bootstrap (seeds SessionId + feature flags) and probe for an + // existing token by attempting an authenticated read. + useEffect(() => { + (async () => { + try { + const v = await ps.getApplicationValidity(); + setValidity(v); + const existing = await ps.tokens.getAuthToken(); + if (existing) { + await ps.getUserDetail(); // 401 throws -> treated as signed out + setStatus('signedIn'); + } else { + setStatus('signedOut'); + } + } catch { + setStatus('signedOut'); + } + })(); + }, []); + + const login = async (phoneNumber: string, password: string) => { + setError(null); + try { + await ps.loginWithPhone({ phoneNumber, password }); + setStatus('signedIn'); + } catch (e: any) { + // A 201 from /api/Auth means the account needs SMS verification first. + if (e?.status === 201) setError('Account needs verification. Check your texts.'); + else setError(e?.serverMessage ?? e?.message ?? 'Login failed.'); + throw e; + } + }; + + const logout = async () => { + await ps.logoutLocal(); + setStatus('signedOut'); + }; + + const value = useMemo( + () => ({ status, validity, login, logout, error }), + [status, validity, error], + ); + + return {children}; +} + +export function useAuth(): AuthState { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/app/src/features/favorites/favoritesStore.ts b/app/src/features/favorites/favoritesStore.ts new file mode 100644 index 0000000..b8cfb31 --- /dev/null +++ b/app/src/features/favorites/favoritesStore.ts @@ -0,0 +1,79 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Share } from 'react-native'; +import type { Zone } from 'parksmarter-client'; + +/** + * Saved kiosks live entirely on-device. The ParkSmarter server exposes no + * favorites API (the `FavoriteID` field never appears in live responses), so + * "save" and "share" are client-side features we own. + */ +export interface SavedKiosk { + /** Stable local id — the scanner code or serial identifies the kiosk. */ + key: string; + zoneName?: string; + scannerCode?: string; + terminalSerNo?: string; + zoneId?: number; + customerId?: number; + lat?: number; + long?: number; + savedAt: number; + note?: string; +} + +const KEY = 'ps_saved_kiosks'; + +export function kioskKey(z: Pick): string { + return String(z.ScannerCode ?? z.TerminalSerNo ?? z.ZoneId ?? 'unknown'); +} + +export function toSavedKiosk(z: Zone, note?: string): SavedKiosk { + return { + key: kioskKey(z), + zoneName: z.ZoneName, + scannerCode: z.ScannerCode, + terminalSerNo: z.TerminalSerNo, + zoneId: z.ZoneId, + customerId: z.CustomerId, + lat: z.Lat, + long: z.Long, + savedAt: Date.now(), + note, + }; +} + +export async function listKiosks(): Promise { + const raw = await AsyncStorage.getItem(KEY); + return raw ? (JSON.parse(raw) as SavedKiosk[]) : []; +} + +export async function saveKiosk(k: SavedKiosk): Promise { + const all = await listKiosks(); + const next = [k, ...all.filter((x) => x.key !== k.key)]; + await AsyncStorage.setItem(KEY, JSON.stringify(next)); + return next; +} + +export async function removeKiosk(key: string): Promise { + const next = (await listKiosks()).filter((x) => x.key !== key); + await AsyncStorage.setItem(KEY, JSON.stringify(next)); + return next; +} + +/** + * Share a saved kiosk. Uses a `parksmarteralt://` deep link so another install + * of this app can open straight to the kiosk; falls back to human-readable text. + */ +export async function shareKiosk(k: SavedKiosk): Promise { + const code = k.scannerCode ?? k.terminalSerNo ?? ''; + const deepLink = `bigbrainparking://kiosk?code=${encodeURIComponent(code)}`; + const geo = + k.lat != null && k.long != null ? `\nMap: geo:${k.lat},${k.long}` : ''; + await Share.share({ + message: + `Parking kiosk${k.zoneName ? `: ${k.zoneName}` : ''}` + + (code ? `\nCode: ${code}` : '') + + geo + + `\nOpen in ParkSmarter: ${deepLink}`, + }); +} diff --git a/app/src/features/location/useLocation.ts b/app/src/features/location/useLocation.ts new file mode 100644 index 0000000..43eb230 --- /dev/null +++ b/app/src/features/location/useLocation.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useState } from 'react'; +import * as Location from 'expo-location'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface Coords { + latitude: number; + longitude: number; +} + +const LAST_LOC_KEY = 'ps_last_location'; + +/** Persist the most recent fix so the "near my last location" button works cold. */ +async function saveLastLocation(c: Coords) { + await AsyncStorage.setItem(LAST_LOC_KEY, JSON.stringify(c)); +} +export async function getLastKnownSavedLocation(): Promise { + const raw = await AsyncStorage.getItem(LAST_LOC_KEY); + return raw ? (JSON.parse(raw) as Coords) : null; +} + +/** + * Foreground location. On GrapheneOS this uses the OS location provider directly + * (no Google Play Services). We prefer a fast last-known fix, then refine. + */ +export function useLocation() { + const [coords, setCoords] = useState(null); + const [granted, setGranted] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + const ok = status === 'granted'; + setGranted(ok); + if (!ok) { + setError('Location permission denied.'); + return null; + } + const last = await Location.getLastKnownPositionAsync(); + if (last) { + const c = { latitude: last.coords.latitude, longitude: last.coords.longitude }; + setCoords(c); + void saveLastLocation(c); + } + const cur = await Location.getCurrentPositionAsync({ + accuracy: Location.Accuracy.Balanced, + }); + const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude }; + setCoords(c); + void saveLastLocation(c); + return c; + } catch (e: any) { + setError(e?.message ?? 'Failed to get location.'); + return null; + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + return { coords, granted, error, refresh }; +} diff --git a/app/src/navigation/RootNavigator.tsx b/app/src/navigation/RootNavigator.tsx new file mode 100644 index 0000000..18db15c --- /dev/null +++ b/app/src/navigation/RootNavigator.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; +import { useAuth } from '@/auth/AuthContext'; +import { LoginScreen } from '@/screens/LoginScreen'; +import { MapScreen } from '@/screens/MapScreen'; +import { ScanScreen } from '@/screens/ScanScreen'; +import { FavoritesScreen } from '@/screens/FavoritesScreen'; +import { SessionsScreen } from '@/screens/SessionsScreen'; +import { MeterDetailScreen } from '@/screens/MeterDetailScreen'; +import type { Zone } from 'parksmarter-client'; + +export type RootStackParamList = { + Tabs: undefined; + MeterDetail: { zone: Zone }; +}; + +export type TabParamList = { + Map: undefined; + Scan: undefined; + Favorites: undefined; + Sessions: undefined; +}; + +const Stack = createNativeStackNavigator(); +const Tab = createBottomTabNavigator(); + +function Tabs() { + return ( + + + + + + + ); +} + +export function RootNavigator() { + const { status } = useAuth(); + + if (status === 'loading') { + return ( + + + + ); + } + + return ( + + {status === 'signedIn' ? ( + + + + + ) : ( + + )} + + ); +} diff --git a/app/src/notifications/localReminders.ts b/app/src/notifications/localReminders.ts new file mode 100644 index 0000000..4ef8821 --- /dev/null +++ b/app/src/notifications/localReminders.ts @@ -0,0 +1,58 @@ +import * as Notifications from 'expo-notifications'; + +/** + * Session-expiry reminders as LOCAL scheduled notifications. + * + * This is the key GrapheneOS win: because the app knows a session's end time, + * we schedule the reminder on-device — no server push, no FCM, no Play Services. + * UnifiedPush (see unifiedPush.ts) is reserved for genuinely server-initiated + * events that we can't predict locally. + */ + +export async function ensureNotificationPermission(): Promise { + const settings = await Notifications.getPermissionsAsync(); + if (settings.granted) return true; + const req = await Notifications.requestPermissionsAsync(); + return req.granted; +} + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}); + +export interface ScheduleReminderArgs { + transactionId: string | number; + zoneName: string; + /** When the parking session ends. */ + endTime: Date; + /** Fire this many minutes before end (default 10). */ + leadMinutes?: number; +} + +/** Schedule an expiry reminder. Returns the notification id (for later cancel). */ +export async function scheduleExpiryReminder( + args: ScheduleReminderArgs, +): Promise { + const lead = (args.leadMinutes ?? 10) * 60 * 1000; + const fireAt = new Date(args.endTime.getTime() - lead); + if (fireAt.getTime() <= Date.now()) return null; // already too late + + return Notifications.scheduleNotificationAsync({ + identifier: `session-${args.transactionId}`, + content: { + title: 'Parking expiring soon', + body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`, + data: { transactionId: String(args.transactionId) }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: fireAt }, + }); +} + +export async function cancelExpiryReminder(transactionId: string | number): Promise { + await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`); +} diff --git a/app/src/notifications/unifiedPush.ts b/app/src/notifications/unifiedPush.ts new file mode 100644 index 0000000..ce84cc6 --- /dev/null +++ b/app/src/notifications/unifiedPush.ts @@ -0,0 +1,62 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { ps } from '@/api/client'; + +/** + * UnifiedPush integration (distributor: ntfy). + * + * IMPORTANT ARCHITECTURE NOTE + * --------------------------- + * The ParkSmarter backend only delivers push via FCM, keyed on the device token + * registered through `PUT /api/Device`. A UnifiedPush endpoint (an ntfy URL) + * cannot receive FCM directly, so server-initiated push requires a BRIDGE: + * + * ParkSmarter server --FCM--> [bridge holding an FCM token] --HTTP--> ntfy topic + * | + * UnifiedPush distributor (ntfy app) + * v + * this app + * + * Until that bridge exists, session-expiry reminders are handled entirely by + * on-device local notifications (see localReminders.ts), which covers the main + * use case without any server push. This module wires the UnifiedPush side so + * the app is ready to be a push target once the bridge (or a self-hosted relay + * that we register with `PUT /api/Device`) is in place. + * + * The `react-native-unifiedpush` API surface varies by version; treat the calls + * below as the integration point to confirm against the installed version. + */ + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const UnifiedPush = require('react-native-unifiedpush'); + +const ENDPOINT_KEY = 'ps_unifiedpush_endpoint'; +const INSTANCE = 'default'; + +/** Kick off distributor discovery + registration (call after login). */ +export async function registerUnifiedPush(): Promise { + const distributors: string[] = await UnifiedPush.getDistributors(); + if (!distributors.length) { + // No UnifiedPush distributor installed (e.g. ntfy). Local reminders still work. + return; + } + const saved = await UnifiedPush.getSavedDistributor?.(); + const distributor = saved ?? distributors[0]; + await UnifiedPush.saveDistributor(distributor); + await UnifiedPush.registerDevice(INSTANCE); +} + +/** + * Handle the endpoint the distributor hands back (wire this to the library's + * `onNewEndpoint` event in the app root). We persist it and, once a bridge is + * available, register it with the backend so the server can reach us. + */ +export async function onNewEndpoint(endpoint: string): Promise { + await AsyncStorage.setItem(ENDPOINT_KEY, endpoint); + // When the FCM->ntfy bridge is live, register the bridge-issued token here: + // await ps.updateDeviceToken({ pushNotificationsToken: bridgeToken, deviceType: '1' }); + void ps; // referenced so the intended integration is explicit +} + +export async function getSavedEndpoint(): Promise { + return AsyncStorage.getItem(ENDPOINT_KEY); +} diff --git a/app/src/screens/FavoritesScreen.tsx b/app/src/screens/FavoritesScreen.tsx new file mode 100644 index 0000000..ba4df7d --- /dev/null +++ b/app/src/screens/FavoritesScreen.tsx @@ -0,0 +1,84 @@ +import React, { useCallback, useState } from 'react'; +import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { ps } from '@/api/client'; +import { + listKiosks, + removeKiosk, + shareKiosk, + type SavedKiosk, +} from '@/features/favorites/favoritesStore'; +import type { RootStackParamList } from '@/navigation/RootNavigator'; + +type Nav = NativeStackNavigationProp; + +export function FavoritesScreen() { + const navigation = useNavigation