v0.2.4: fix countdown notification not appearing + wrong time-remaining
All checks were successful
build-apk / build (push) Successful in 28m23s
All checks were successful
build-apk / build (push) Successful in 28m23s
Countdown notification "nothing appears" on session start: - The small icon was the adaptive launcher mipmap (applicationInfo.icon), which Android 13+/GrapheneOS rejects as an invalid notification small icon and drops the post silently. Ship a proper white-on-transparent vector small icon (bbp_stat_parking) inside the module and use it. - showCountdown now returns a diagnostic string (posted / notifications disabled / exception) instead of swallowing failures; JS logs the branch, permission result, and native outcome to the in-app diagnostics log. - Log the raw active-session payload in refreshSessionStatus (element shape was previously unconfirmed). Time remaining showed "7694 min": - API's TimeRemaining is SECONDS, not minutes. Format it as hours+minutes in SessionDetail; annotate the type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9323bbd6c6
commit
2b973348bc
7 changed files with 73 additions and 16 deletions
|
|
@ -3,14 +3,14 @@
|
||||||
"name": "BigBrainParking",
|
"name": "BigBrainParking",
|
||||||
"slug": "bigbrainparking",
|
"slug": "bigbrainparking",
|
||||||
"scheme": "bigbrainparking",
|
"scheme": "bigbrainparking",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"android": {
|
"android": {
|
||||||
"package": "top.mowden.bigbrainparking",
|
"package": "top.mowden.bigbrainparking",
|
||||||
"versionCode": 13,
|
"versionCode": 14,
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,21 @@ class BbpNotifyModule : Module() {
|
||||||
override fun definition() = ModuleDefinition {
|
override fun definition() = ModuleDefinition {
|
||||||
Name("BbpNotify")
|
Name("BbpNotify")
|
||||||
|
|
||||||
|
// Returns a short diagnostic string so the JS layer can log exactly what
|
||||||
|
// happened (posted / notifications-disabled / exception). "Nothing appears"
|
||||||
|
// bugs are otherwise invisible because notify() fails silently.
|
||||||
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
||||||
val ctx = appContext.reactContext ?: return@AsyncFunction
|
val ctx = appContext.reactContext
|
||||||
|
?: return@AsyncFunction "no-context"
|
||||||
ensureChannel(ctx)
|
ensureChannel(ctx)
|
||||||
|
|
||||||
|
val mgr = NotificationManagerCompat.from(ctx)
|
||||||
|
val enabled = mgr.areNotificationsEnabled()
|
||||||
|
|
||||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(body)
|
.setContentText(body)
|
||||||
.setSmallIcon(ctx.applicationInfo.icon)
|
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setShowWhen(true)
|
.setShowWhen(true)
|
||||||
|
|
@ -52,10 +59,14 @@ class BbpNotifyModule : Module() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return@AsyncFunction try {
|
||||||
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, builder.build())
|
mgr.notify(NOTIF_ID, builder.build())
|
||||||
} catch (_: SecurityException) {
|
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||||
// POST_NOTIFICATIONS not granted yet; caller requests it separately.
|
} catch (e: SecurityException) {
|
||||||
|
// POST_NOTIFICATIONS not granted.
|
||||||
|
"security-exception enabled=$enabled msg=${e.message}"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!--
|
||||||
|
Notification small icon: a white-on-transparent timer glyph (Material "timer").
|
||||||
|
Small icons MUST be a simple monochrome drawable — the app launcher mipmap
|
||||||
|
(applicationInfo.icon) is an adaptive/full-colour icon and is dropped as an
|
||||||
|
invalid small icon on Android 13+/GrapheneOS, so we ship our own here.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M15,1L9,1L9,3L15,3L15,1ZM11,14L13,14L13,8L11,8L11,14ZM19.03,7.39L20.45,5.97C20.02,5.46 19.55,4.98 19.04,4.56L17.62,5.98C16.07,4.74 14.12,4 12,4C7.03,4 3,8.03 3,13C3,17.97 7.02,22 12,22C16.98,22 21,17.97 21,13C21,10.88 20.26,8.93 19.03,7.39ZM12,20C8.13,20 5,16.87 5,13C5,9.13 8.13,6 12,6C15.87,6 19,9.13 19,13C19,16.87 15.87,20 12,20Z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -2,8 +2,11 @@ import { Platform } from 'react-native';
|
||||||
import { requireOptionalNativeModule } from 'expo-modules-core';
|
import { requireOptionalNativeModule } from 'expo-modules-core';
|
||||||
|
|
||||||
interface BbpNotifyNative {
|
interface BbpNotifyNative {
|
||||||
/** Post/replace an ongoing notification with a native chronometer counting down to endTimeMillis. */
|
/**
|
||||||
showCountdown(title: string, body: string, endTimeMillis: number): Promise<void>;
|
* Post/replace an ongoing notification with a native chronometer counting down
|
||||||
|
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
|
||||||
|
*/
|
||||||
|
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
|
||||||
/** Remove the countdown notification. */
|
/** Remove the countdown notification. */
|
||||||
clear(): Promise<void>;
|
clear(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -22,8 +25,9 @@ export async function showCountdown(
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
endTimeMillis: number,
|
endTimeMillis: number,
|
||||||
): Promise<void> {
|
): Promise<string> {
|
||||||
await native?.showCountdown(title, body, endTimeMillis);
|
if (!native) return 'no-native-module';
|
||||||
|
return native.showCountdown(title, body, endTimeMillis);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearCountdown(): Promise<void> {
|
export async function clearCountdown(): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,28 @@ function fmtLeft(min: number): string {
|
||||||
|
|
||||||
/** Post (or replace) the ongoing status notification for a session end time. */
|
/** Post (or replace) the ongoing status notification for a session end time. */
|
||||||
export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise<void> {
|
export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise<void> {
|
||||||
if (!(await getCountdownEnabled())) return;
|
if (!(await getCountdownEnabled())) {
|
||||||
await ensureNotificationPermission();
|
logLine('[STATUS] skip: countdown disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const granted = await ensureNotificationPermission();
|
||||||
const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||||
|
logLine(
|
||||||
|
`[STATUS] show native=${hasNativeCountdown} perm=${granted} end=${args.endTime.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasNativeCountdown) {
|
if (hasNativeCountdown) {
|
||||||
// Native chronometer: a live ticking countdown, updated by the OS.
|
// Native chronometer: a live ticking countdown, updated by the OS.
|
||||||
await showCountdown(`Parking · ${args.zoneName}`, `Expires ${ends}`, args.endTime.getTime());
|
try {
|
||||||
|
const diag = await showCountdown(
|
||||||
|
`Parking · ${args.zoneName}`,
|
||||||
|
`Expires ${ends}`,
|
||||||
|
args.endTime.getTime(),
|
||||||
|
);
|
||||||
|
logLine(`[STATUS] native result: ${diag}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
logLine(`[STATUS] native threw: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,6 +118,7 @@ export async function refreshSessionStatus(): Promise<void> {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await ps.getActiveParkingSessions();
|
const res = await ps.getActiveParkingSessions();
|
||||||
|
logLine(`[STATUS] active sessions raw: ${JSON.stringify(res.ParkingSession ?? [])}`);
|
||||||
const withEnd = (res.ParkingSession ?? [])
|
const withEnd = (res.ParkingSession ?? [])
|
||||||
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
||||||
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,16 @@ type DetailRoute = RouteProp<RootStackParamList, 'SessionDetail'>;
|
||||||
const show = (v: unknown) => (v == null || v === '' ? '—' : String(v));
|
const show = (v: unknown) => (v == null || v === '' ? '—' : String(v));
|
||||||
const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`);
|
const dollars = (v: unknown) => (v == null || v === '' ? undefined : `$${v}`);
|
||||||
|
|
||||||
|
/** API's TimeRemaining is in SECONDS — render it as a human "Xh Ym". */
|
||||||
|
const fmtRemaining = (secs: unknown): string | undefined => {
|
||||||
|
const n = Number(secs);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return undefined;
|
||||||
|
const total = Math.round(n / 60);
|
||||||
|
const h = Math.floor(total / 60);
|
||||||
|
const m = total % 60;
|
||||||
|
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
|
||||||
|
};
|
||||||
|
|
||||||
export function SessionDetailScreen() {
|
export function SessionDetailScreen() {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const { session, kind } = useRoute<DetailRoute>().params;
|
const { session, kind } = useRoute<DetailRoute>().params;
|
||||||
|
|
@ -90,7 +100,7 @@ export function SessionDetailScreen() {
|
||||||
['Space', s.SpaceName ?? s.Space],
|
['Space', s.SpaceName ?? s.Space],
|
||||||
['Started', s.StartTimeDisplay ?? s.StartTime],
|
['Started', s.StartTimeDisplay ?? s.StartTime],
|
||||||
['Ends', s.EndTimeDisplay ?? s.EndTime],
|
['Ends', s.EndTimeDisplay ?? s.EndTime],
|
||||||
['Time remaining', s.TimeRemaining != null ? `${s.TimeRemaining} min` : undefined],
|
['Time remaining', fmtRemaining(s.TimeRemaining)],
|
||||||
['Amount', dollars(s.Amount)],
|
['Amount', dollars(s.Amount)],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,7 @@ export interface ActiveSession {
|
||||||
EndTime?: string;
|
EndTime?: string;
|
||||||
StartTimeDisplay?: string;
|
StartTimeDisplay?: string;
|
||||||
EndTimeDisplay?: string;
|
EndTimeDisplay?: string;
|
||||||
|
/** Seconds left on the session (CONFIRMED via a live session — NOT minutes). */
|
||||||
TimeRemaining?: number | string;
|
TimeRemaining?: number | string;
|
||||||
VehiclePlate?: string;
|
VehiclePlate?: string;
|
||||||
VehicleNumber?: string;
|
VehicleNumber?: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue