Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 107 additions & 38 deletions internal/androidrelay/status.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package androidrelay

import (
"fmt"
"strings"
"time"

Expand All @@ -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"`
Expand All @@ -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) {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions internal/androidrelay/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
42 changes: 42 additions & 0 deletions migrations/20260828010000_relay_power_health.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
9 changes: 9 additions & 0 deletions migrations/migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
2 changes: 1 addition & 1 deletion web/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function Dashboard() {
<div>
<p className="eyebrow">ANDROID RELAY</p>
<h2>{relay?.ready ? "ready" : "unavailable"}</h2>
<p className="muted">{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"}</p>
<p className="muted">{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"}</p>
</div>
<Badge status={relay?.ready ? "connected" : "warning"} />
</section>
Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export function Settings({ notify }: { notify: (value: string) => void }) {
<p className="muted">Paytm QR checkouts fail closed when no recently active relay device is available.</p>
<dl className="settings compact">
<div><dt>Active devices</dt><dd>{relay ? `${relay.activeDevices} / ${relay.enabledDevices}` : "—"}</dd></div>
<div><dt>Power unhealthy</dt><dd>{relay?.powerUnhealthyDevices ?? 0}</dd></div>
<div><dt>Last heartbeat</dt><dd>{formatDate(relay?.lastHeartbeatAt ?? undefined)}</dd></div>
<div><dt>Last relay event</dt><dd>{formatDate(relay?.lastEventAt ?? undefined)}</dd></div>
<div><dt>Last matched payment</dt><dd>{formatDate(relay?.lastMatchedAt ?? undefined)}</dd></div>
Expand All @@ -331,6 +332,7 @@ export function Settings({ notify }: { notify: (value: string) => void }) {
<small>{device.deviceModel || "Android"} · app {device.appVersion || "unknown"} · fingerprint {device.deviceId ? `${device.deviceId.slice(0, 12)}…` : "unknown"}</small>
<small>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)}` : ""}</small>
<small>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}` : ""}</small>
<small>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"}</small>
</div>
<Badge status={device.active ? "connected" : device.enabled ? "warning" : "disabled"} />
<button className={device.enabled ? "danger" : ""} disabled={relayBusy} onClick={() => void setRelayEnabled(device, !device.enabled)}>{device.enabled ? "Disable" : "Enable"}</button>
Expand Down
Loading