From 4f963c129839bdfe43fb44ea3cae67329e9e832a Mon Sep 17 00:00:00 2001 From: Phloraxx Date: Fri, 28 Aug 2026 07:20:20 +0000 Subject: [PATCH] Gate relay readiness on power health --- internal/androidrelay/status.go | 145 +++++++++++++----- internal/androidrelay/status_test.go | 78 ++++++++++ .../20260828010000_relay_power_health.go | 42 +++++ migrations/migration_test.go | 9 ++ web/src/pages/Dashboard.tsx | 2 +- web/src/pages/Settings.tsx | 2 + web/src/types.ts | 8 + 7 files changed, 247 insertions(+), 39 deletions(-) create mode 100644 migrations/20260828010000_relay_power_health.go diff --git a/internal/androidrelay/status.go b/internal/androidrelay/status.go index 3d7e591..855d259 100644 --- a/internal/androidrelay/status.go +++ b/internal/androidrelay/status.go @@ -1,6 +1,7 @@ package androidrelay import ( + "fmt" "strings" "time" @@ -19,6 +20,10 @@ type HeartbeatInput struct { DeviceModel string `json:"deviceModel"` NotificationAccess bool `json:"notificationAccess"` ListenerConnected bool `json:"listenerConnected"` + BatteryOptimizationExempt *bool `json:"batteryOptimizationExempt"` + PowerSaveMode *bool `json:"powerSaveMode"` + BackgroundRestricted *bool `json:"backgroundRestricted"` + ForegroundService *bool `json:"foregroundService"` PendingCount int `json:"pendingCount"` FailedCount int `json:"failedCount"` LastSuccessfulDeliveryAtMs int64 `json:"lastSuccessfulDeliveryAtMs"` @@ -32,42 +37,49 @@ type HeartbeatResult struct { } type DeviceStatus struct { - ID string `json:"id"` - DeviceID string `json:"deviceId"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - AppVersion string `json:"appVersion"` - AndroidVersion string `json:"androidVersion"` - DeviceModel string `json:"deviceModel"` - LastSeenAt any `json:"lastSeenAt"` - LastHeartbeatAt any `json:"lastHeartbeatAt"` - HeartbeatGraceUntil any `json:"heartbeatGraceUntil"` - NotificationAccess bool `json:"notificationAccess"` - ListenerConnected bool `json:"listenerConnected"` - PendingCount int `json:"pendingCount"` - FailedCount int `json:"failedCount"` - LastClientError string `json:"lastClientError,omitempty"` - LastDeliveryAt any `json:"lastDeliveryAt"` - LastEventAt any `json:"lastEventAt"` - LastMatchedAt any `json:"lastMatchedAt"` - LastMatchedPaymentID string `json:"lastMatchedPaymentId,omitempty"` - RecentErrorCount int64 `json:"recentErrorCount"` - Active bool `json:"active"` + ID string `json:"id"` + DeviceID string `json:"deviceId"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + AppVersion string `json:"appVersion"` + AndroidVersion string `json:"androidVersion"` + DeviceModel string `json:"deviceModel"` + LastSeenAt any `json:"lastSeenAt"` + LastHeartbeatAt any `json:"lastHeartbeatAt"` + HeartbeatGraceUntil any `json:"heartbeatGraceUntil"` + NotificationAccess bool `json:"notificationAccess"` + ListenerConnected bool `json:"listenerConnected"` + PowerHealthReported bool `json:"powerHealthReported"` + BatteryOptimizationExempt bool `json:"batteryOptimizationExempt"` + PowerSaveMode bool `json:"powerSaveMode"` + BackgroundRestricted bool `json:"backgroundRestricted"` + ForegroundService bool `json:"foregroundService"` + PowerHealthy bool `json:"powerHealthy"` + PendingCount int `json:"pendingCount"` + FailedCount int `json:"failedCount"` + LastClientError string `json:"lastClientError,omitempty"` + LastDeliveryAt any `json:"lastDeliveryAt"` + LastEventAt any `json:"lastEventAt"` + LastMatchedAt any `json:"lastMatchedAt"` + LastMatchedPaymentID string `json:"lastMatchedPaymentId,omitempty"` + RecentErrorCount int64 `json:"recentErrorCount"` + Active bool `json:"active"` } type Status struct { - Ready bool `json:"ready"` - EnabledDevices int `json:"enabledDevices"` - ActiveDevices int `json:"activeDevices"` - LegacyGraceDevices int `json:"legacyGraceDevices"` - StaleAfterSeconds int64 `json:"staleAfterSeconds"` - LastSeenAt any `json:"lastSeenAt"` - LastHeartbeatAt any `json:"lastHeartbeatAt"` - LastEventAt any `json:"lastEventAt"` - LastMatchedAt any `json:"lastMatchedAt"` - RecentErrorCount int64 `json:"recentErrorCount"` - PendingQueueCount int `json:"pendingQueueCount"` - FailedQueueCount int `json:"failedQueueCount"` + Ready bool `json:"ready"` + EnabledDevices int `json:"enabledDevices"` + ActiveDevices int `json:"activeDevices"` + LegacyGraceDevices int `json:"legacyGraceDevices"` + StaleAfterSeconds int64 `json:"staleAfterSeconds"` + LastSeenAt any `json:"lastSeenAt"` + LastHeartbeatAt any `json:"lastHeartbeatAt"` + LastEventAt any `json:"lastEventAt"` + LastMatchedAt any `json:"lastMatchedAt"` + RecentErrorCount int64 `json:"recentErrorCount"` + PendingQueueCount int `json:"pendingQueueCount"` + FailedQueueCount int `json:"failedQueueCount"` + PowerUnhealthyDevices int `json:"powerUnhealthyDevices"` } func (s *Service) Heartbeat(device *core.Record, in HeartbeatInput) (HeartbeatResult, error) { @@ -96,6 +108,19 @@ func (s *Service) Heartbeat(device *core.Record, in HeartbeatInput) (HeartbeatRe device.Set("device_model", trimMax(in.DeviceModel, 255)) device.Set("notification_access", in.NotificationAccess) device.Set("listener_connected", in.ListenerConnected) + if in.BatteryOptimizationExempt != nil { + device.Set("battery_optimization_exempt", *in.BatteryOptimizationExempt) + } + if in.PowerSaveMode != nil { + device.Set("power_save_mode", *in.PowerSaveMode) + } + if in.BackgroundRestricted != nil { + device.Set("background_restricted", *in.BackgroundRestricted) + } + if in.ForegroundService != nil { + device.Set("foreground_service_active", *in.ForegroundService) + } + device.Set("power_health_reported", in.BatteryOptimizationExempt != nil && in.PowerSaveMode != nil && in.BackgroundRestricted != nil && in.ForegroundService != nil) device.Set("pending_count", in.PendingCount) device.Set("failed_count", in.FailedCount) device.Set("last_client_error", trimMax(in.LastClientError, 1024)) @@ -128,8 +153,7 @@ func (s *Service) ReadyInApp(app core.App, staleAfter time.Duration) (bool, erro } continue } - seen := device.GetDateTime("last_seen_at").Time() - if !seen.IsZero() && !seen.Before(cutoff) && device.GetBool("notification_access") && device.GetBool("listener_connected") { + if relayDeviceCurrentReady(device, cutoff) { return true, nil } } @@ -164,9 +188,12 @@ func (s *Service) Status(staleAfter time.Duration) (Status, error) { status.ActiveDevices++ status.LegacyGraceDevices++ } - } else if !seen.IsZero() && !seen.Before(cutoff) && device.GetBool("notification_access") && device.GetBool("listener_connected") { + } else if relayDeviceCurrentReady(device, cutoff) { status.ActiveDevices++ } + if !relayDevicePowerReady(device) { + status.PowerUnhealthyDevices++ + } status.PendingQueueCount += device.GetInt("pending_count") status.FailedQueueCount += device.GetInt("failed_count") } @@ -204,7 +231,7 @@ func (s *Service) Devices(staleAfter time.Duration) ([]DeviceStatus, error) { heartbeat := record.GetDateTime("last_heartbeat_at").Time() graceUntil := record.GetDateTime("heartbeat_grace_until").Time() legacyGraceActive := heartbeat.IsZero() && !graceUntil.IsZero() && s.now().Before(graceUntil) - listenerOK := !heartbeat.IsZero() && record.GetBool("notification_access") && record.GetBool("listener_connected") + powerHealthy := relayDevicePowerReady(record) lastEventAt, lastMatchedAt, lastMatchedPaymentID, recentErrorCount, statusErr := s.deviceEventStatus(record.Id, s.now()) if statusErr != nil { return nil, statusErr @@ -213,10 +240,11 @@ func (s *Service) Devices(staleAfter time.Duration) ([]DeviceStatus, error) { ID: record.Id, DeviceID: record.GetString("device_id"), Name: record.GetString("name"), Enabled: record.GetBool("enabled"), AppVersion: record.GetString("app_version"), AndroidVersion: record.GetString("android_version"), DeviceModel: record.GetString("device_model"), LastSeenAt: timeValue(seen), LastHeartbeatAt: timeValue(heartbeat), HeartbeatGraceUntil: timeValue(graceUntil), NotificationAccess: record.GetBool("notification_access"), ListenerConnected: record.GetBool("listener_connected"), + PowerHealthReported: record.GetBool("power_health_reported"), BatteryOptimizationExempt: record.GetBool("battery_optimization_exempt"), PowerSaveMode: record.GetBool("power_save_mode"), BackgroundRestricted: record.GetBool("background_restricted"), ForegroundService: record.GetBool("foreground_service_active"), PowerHealthy: powerHealthy, PendingCount: record.GetInt("pending_count"), FailedCount: record.GetInt("failed_count"), LastClientError: record.GetString("last_client_error"), LastDeliveryAt: timeValue(record.GetDateTime("last_client_delivery_at").Time()), LastEventAt: lastEventAt, LastMatchedAt: lastMatchedAt, LastMatchedPaymentID: lastMatchedPaymentID, RecentErrorCount: recentErrorCount, - Active: record.GetBool("enabled") && (legacyGraceActive || (!seen.IsZero() && !seen.Before(cutoff) && listenerOK)), + Active: record.GetBool("enabled") && (legacyGraceActive || relayDeviceCurrentReady(record, cutoff)), }) } return result, nil @@ -273,6 +301,47 @@ func (s *Service) SetEnabledInApp(app core.App, recordID string, enabled bool) ( return record, nil } +func relayDeviceCurrentReady(record *core.Record, cutoff time.Time) bool { + if record == nil || record.GetDateTime("last_heartbeat_at").Time().IsZero() { + return false + } + seen := record.GetDateTime("last_seen_at").Time() + return !seen.IsZero() && !seen.Before(cutoff) && + record.GetBool("notification_access") && record.GetBool("listener_connected") && + relayDevicePowerReady(record) +} + +func relayDevicePowerReady(record *core.Record) bool { + if record == nil || !requiresPowerHealth(record.GetString("app_version")) { + return true + } + return record.GetBool("power_health_reported") && + record.GetBool("battery_optimization_exempt") && + !record.GetBool("background_restricted") && + record.GetBool("foreground_service_active") +} + +func requiresPowerHealth(version string) bool { + version = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(version), "v")) + parts := strings.SplitN(version, "-", 2) + version = parts[0] + numbers := strings.Split(version, ".") + if len(numbers) < 3 { + return false + } + major, minor, patch := 0, 0, 0 + if _, err := fmt.Sscanf(numbers[0]+"."+numbers[1]+"."+numbers[2], "%d.%d.%d", &major, &minor, &patch); err != nil { + return false + } + if major != 0 { + return major > 0 + } + if minor != 3 { + return minor > 3 + } + return patch >= 1 +} + func normalizeStaleAfter(value time.Duration) time.Duration { if value <= 0 { return defaultStaleAfter diff --git a/internal/androidrelay/status_test.go b/internal/androidrelay/status_test.go index 019f4fa..523c265 100644 --- a/internal/androidrelay/status_test.go +++ b/internal/androidrelay/status_test.go @@ -242,3 +242,81 @@ func TestRelayStatusMarksStaleDeviceInactive(t *testing.T) { t.Fatalf("stale device status = %+v", status) } } + +func TestV031PowerHealthGatesReadinessButAllowsPowerSaver(t *testing.T) { + app, err := tests.NewTestApp() + if err != nil { + t.Fatal(err) + } + defer app.Cleanup() + now := time.Date(2026, 8, 28, 7, 30, 0, 0, time.UTC) + service := NewService(app, nil) + service.Now = func() time.Time { return now } + collection, _ := app.FindCollectionByNameOrId("relay_devices") + device := core.NewRecord(collection) + device.Set("device_id", "abababababababababababababababababababababababababababababababab") + device.Set("name", "Always-on phone") + device.Set("public_key_pem", "test-key") + device.Set("enabled", true) + if err := app.Save(device); err != nil { + t.Fatal(err) + } + + heartbeat := func(exempt, saver, restricted, foreground bool) { + t.Helper() + if _, err := service.Heartbeat(device, HeartbeatInput{ + SchemaVersion: 1, + AppVersion: "0.3.1", + NotificationAccess: true, + ListenerConnected: true, + BatteryOptimizationExempt: boolPointer(exempt), + PowerSaveMode: boolPointer(saver), + BackgroundRestricted: boolPointer(restricted), + ForegroundService: boolPointer(foreground), + }); err != nil { + t.Fatal(err) + } + } + + heartbeat(true, true, false, true) + status, err := service.Status(time.Hour) + if err != nil { + t.Fatal(err) + } + if !status.Ready || status.ActiveDevices != 1 || status.PowerUnhealthyDevices != 0 { + t.Fatalf("power saver should remain ready when v0.3.1 is exempt and foreground: %+v", status) + } + devices, err := service.Devices(time.Hour) + if err != nil { + t.Fatal(err) + } + if len(devices) != 1 || !devices[0].PowerHealthReported || !devices[0].PowerHealthy || !devices[0].BatteryOptimizationExempt || !devices[0].PowerSaveMode || !devices[0].ForegroundService { + t.Fatalf("unexpected power health: %+v", devices) + } + + heartbeat(false, true, false, true) + status, _ = service.Status(time.Hour) + if status.Ready || status.PowerUnhealthyDevices != 1 { + t.Fatalf("battery-optimized v0.3.1 must fail closed: %+v", status) + } + + heartbeat(true, true, true, true) + status, _ = service.Status(time.Hour) + if status.Ready { + t.Fatalf("background-restricted v0.3.1 must fail closed: %+v", status) + } + + heartbeat(true, true, false, false) + status, _ = service.Status(time.Hour) + if status.Ready { + t.Fatalf("v0.3.1 without foreground runtime must fail closed: %+v", status) + } + + heartbeat(true, true, false, true) + status, _ = service.Status(time.Hour) + if !status.Ready { + t.Fatalf("healthy always-on state should recover readiness: %+v", status) + } +} + +func boolPointer(value bool) *bool { return &value } diff --git a/migrations/20260828010000_relay_power_health.go b/migrations/20260828010000_relay_power_health.go new file mode 100644 index 0000000..a6ef675 --- /dev/null +++ b/migrations/20260828010000_relay_power_health.go @@ -0,0 +1,42 @@ +package migrations + +import ( + "github.com/pocketbase/pocketbase/core" + pbmigrations "github.com/pocketbase/pocketbase/migrations" +) + +func init() { + pbmigrations.Register(func(app core.App) error { + devices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + return err + } + for _, name := range []string{ + "power_health_reported", + "battery_optimization_exempt", + "power_save_mode", + "background_restricted", + "foreground_service_active", + } { + if devices.Fields.GetByName(name) == nil { + devices.Fields.Add(&core.BoolField{Name: name}) + } + } + return app.Save(devices) + }, func(app core.App) error { + devices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + return nil + } + for _, name := range []string{ + "power_health_reported", + "battery_optimization_exempt", + "power_save_mode", + "background_restricted", + "foreground_service_active", + } { + devices.Fields.RemoveByName(name) + } + return app.Save(devices) + }) +} diff --git a/migrations/migration_test.go b/migrations/migration_test.go index 1da3bfe..3ed3f0d 100644 --- a/migrations/migration_test.go +++ b/migrations/migration_test.go @@ -58,4 +58,13 @@ func TestDomainCollectionsOnlyExposeReadsToOperatorUsers(t *testing.T) { if reviews.Fields.GetByName("email_event") == nil { t.Fatal("review_cases.email_event migration field is missing") } + relayDevices, err := app.FindCollectionByNameOrId("relay_devices") + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"power_health_reported", "battery_optimization_exempt", "power_save_mode", "background_restricted", "foreground_service_active"} { + if relayDevices.Fields.GetByName(name) == nil { + t.Fatalf("relay_devices.%s migration field is missing", name) + } + } } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index ecb79f4..a6570b3 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -58,7 +58,7 @@ export function Dashboard() {

ANDROID RELAY

{relay?.ready ? "ready" : "unavailable"}

-

{relay ? `${relay.activeDevices}/${relay.enabledDevices} active · last heartbeat ${formatDate(relay.lastHeartbeatAt ?? undefined)} · queue ${relay.pendingQueueCount} pending / ${relay.failedQueueCount} failed · ${relay.recentErrorCount} server errors/24h` : "Relay status unavailable"}

+

{relay ? `${relay.activeDevices}/${relay.enabledDevices} active · ${relay.powerUnhealthyDevices} power-unhealthy · last heartbeat ${formatDate(relay.lastHeartbeatAt ?? undefined)} · queue ${relay.pendingQueueCount} pending / ${relay.failedQueueCount} failed · ${relay.recentErrorCount} server errors/24h` : "Relay status unavailable"}

diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 678c861..9c3f9e4 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -317,6 +317,7 @@ export function Settings({ notify }: { notify: (value: string) => void }) {

Paytm QR checkouts fail closed when no recently active relay device is available.

Active devices
{relay ? `${relay.activeDevices} / ${relay.enabledDevices}` : "—"}
+
Power unhealthy
{relay?.powerUnhealthyDevices ?? 0}
Last heartbeat
{formatDate(relay?.lastHeartbeatAt ?? undefined)}
Last relay event
{formatDate(relay?.lastEventAt ?? undefined)}
Last matched payment
{formatDate(relay?.lastMatchedAt ?? undefined)}
@@ -331,6 +332,7 @@ export function Settings({ notify }: { notify: (value: string) => void }) { {device.deviceModel || "Android"} · app {device.appVersion || "unknown"} · fingerprint {device.deviceId ? `${device.deviceId.slice(0, 12)}…` : "unknown"} Last seen {formatDate(device.lastSeenAt ?? undefined)} · phone delivered {formatDate(device.lastDeliveryAt ?? undefined)} · last event {formatDate(device.lastEventAt ?? undefined)} · last match {formatDate(device.lastMatchedAt ?? undefined)}{!device.lastHeartbeatAt && device.heartbeatGraceUntil ? ` · legacy heartbeat grace until ${formatDate(device.heartbeatGraceUntil)}` : ""} Notifications {device.notificationAccess ? "allowed" : device.lastHeartbeatAt ? "blocked" : "not reported"} · listener {device.listenerConnected ? "connected" : device.lastHeartbeatAt ? "disconnected" : "not reported"} · queue {device.pendingCount} pending / {device.failedCount} failed · {device.recentErrorCount} server errors/24h{device.lastClientError ? ` · ${device.lastClientError}` : ""} + Power {device.powerHealthReported ? (device.powerHealthy ? "ready" : "NOT ready") : "not required/reported"} · battery {device.batteryOptimizationExempt ? "unrestricted" : device.powerHealthReported ? "optimized" : "unknown"} · foreground {device.foregroundService ? "active" : device.powerHealthReported ? "inactive" : "unknown"} · saver {device.powerSaveMode ? "on" : "off"} · background {device.backgroundRestricted ? "RESTRICTED" : "allowed"} diff --git a/web/src/types.ts b/web/src/types.ts index f1ed2b4..515e878 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -37,6 +37,8 @@ export type RelayStatus = { ready: boolean; enabledDevices: number; activeDevices: number; + legacyGraceDevices: number; + powerUnhealthyDevices: number; staleAfterSeconds: number; lastSeenAt?: string | null; lastHeartbeatAt?: string | null; @@ -60,6 +62,12 @@ export type RelayDevice = { heartbeatGraceUntil?: string | null; notificationAccess: boolean; listenerConnected: boolean; + powerHealthReported: boolean; + batteryOptimizationExempt: boolean; + powerSaveMode: boolean; + backgroundRestricted: boolean; + foregroundService: boolean; + powerHealthy: boolean; pendingCount: number; failedCount: number; lastClientError?: string;