v0.2.3: true ticking countdown via a tiny native module (no deps)
Some checks failed
build-apk / build (push) Has been cancelled

Replaces the static status notification with a native Android chronometer that
counts DOWN to the session end — the system ticks it every second with zero app
CPU/battery, visible without opening the app.

- modules/bbp-notify: a ~50-line local Expo module using only NotificationCompat
  (setWhen + setUsesChronometer + setChronometerCountDown + ongoing). Zero
  third-party dependencies, no Firebase — fits the de-Googled ethos. Verified:
  autolinks + Kotlin compiles.
- sessionStatus.ts uses it when present, else falls back to the expo-notifications
  static expiry-time notification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 21:03:53 -07:00
parent b9bd6bd1c6
commit d0f31d5278
103 changed files with 204 additions and 14 deletions

View file

@ -0,0 +1,27 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
group = 'expo.modules.bbpnotify'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useDefaultAndroidSdkVersions()
useExpoPublishing()
android {
namespace "expo.modules.bbpnotify"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "androidx.core:core-ktx:1.13.1"
}

View file

@ -0,0 +1,10 @@
/**
* Automatically generated file. DO NOT MODIFY
*/
package expo.modules.bbpnotify;
public final class BuildConfig {
public static final boolean DEBUG = false;
public static final String LIBRARY_PACKAGE_NAME = "expo.modules.bbpnotify";
public static final String BUILD_TYPE = "release";
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/home/hank/Downloads/Software/ParkSmarter/app/modules/bbp-notify/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/home/hank/Downloads/Software/ParkSmarter/app/modules/bbp-notify/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="release$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/home/hank/Downloads/Software/ParkSmarter/app/modules/bbp-notify/android/src/release/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="release" generated-set="release$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/home/hank/Downloads/Software/ParkSmarter/app/modules/bbp-notify/android/src/release/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/home/hank/Downloads/Software/ParkSmarter/app/modules/bbp-notify/android/build/generated/res/resValues/release"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/home/hank/Downloads/Software/ParkSmarter/app/modules/bbp-notify/android/build/generated/res/resValues/release"/></dataSet><mergedItems/></merger>

View file

@ -0,0 +1,2 @@
R_DEF: Internal format may change without notice
local

View file

@ -0,0 +1,89 @@
package expo.modules.bbpnotify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
/**
* A tiny, self-contained module that posts an ongoing notification whose "time"
* is a native Android chronometer counting DOWN to the session's end. The system
* ticks it every second with no app CPU/battery works while the app is closed.
*
* Uses only platform APIs (NotificationCompat) no third-party dependencies.
*/
class BbpNotifyModule : Module() {
override fun definition() = ModuleDefinition {
Name("BbpNotify")
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
val ctx = appContext.reactContext ?: return@AsyncFunction
ensureChannel(ctx)
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(ctx.applicationInfo.icon)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(true)
.setWhen(endTimeMillis.toLong())
.setUsesChronometer(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_STATUS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setChronometerCountDown(true)
}
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
builder.setContentIntent(
PendingIntent.getActivity(
ctx,
0,
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
),
)
}
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, builder.build())
} catch (_: SecurityException) {
// POST_NOTIFICATIONS not granted yet; caller requests it separately.
}
}
AsyncFunction("clear") {
val ctx = appContext.reactContext
if (ctx != null) {
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
}
}
}
private fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val mgr = ctx.getSystemService(NotificationManager::class.java)
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
val channel = NotificationChannel(
CHANNEL_ID,
"Active parking",
NotificationManager.IMPORTANCE_LOW,
)
channel.setShowBadge(false)
mgr.createNotificationChannel(channel)
}
}
}
companion object {
private const val CHANNEL_ID = "session-status"
private const val NOTIF_ID = 42421
}
}

View file

@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.bbpnotify.BbpNotifyModule"]
}
}

View file

@ -0,0 +1,31 @@
import { Platform } from 'react-native';
import { requireOptionalNativeModule } from 'expo-modules-core';
interface BbpNotifyNative {
/** Post/replace an ongoing notification with a native chronometer counting down to endTimeMillis. */
showCountdown(title: string, body: string, endTimeMillis: number): Promise<void>;
/** Remove the countdown notification. */
clear(): Promise<void>;
}
// Android-only, and only present in a build that includes the native module
// (returns null in Expo Go / other platforms — callers degrade gracefully).
const native =
Platform.OS === 'android'
? (requireOptionalNativeModule('BbpNotify') as BbpNotifyNative | null)
: null;
/** True when the native ticking-countdown module is available. */
export const hasNativeCountdown = native != null;
export async function showCountdown(
title: string,
body: string,
endTimeMillis: number,
): Promise<void> {
await native?.showCountdown(title, body, endTimeMillis);
}
export async function clearCountdown(): Promise<void> {
await native?.clear();
}

Some files were not shown because too many files have changed in this diff Show more