Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ jobs:
- name: Test
run: |
./gradlew --max-workers=1 testDirectDebugUnitTest testPlayDebugUnitTest
node --test push-relay/relay.test.js push-relay/server.test.js
node --test push-relay/*.test.js
- name: Restore Direct signing key
if: steps.release.outputs.channel == 'direct'
env:
Expand Down Expand Up @@ -96,9 +96,13 @@ jobs:
COMPANION_UPLOAD_KEYSTORE: ${{ runner.temp }}/upload.jks
COMPANION_UPLOAD_KEY_PASSWORD: ${{ secrets.COMPANION_UPLOAD_KEY_PASSWORD }}
AA_COMPANION_CERT_SHA256: ${{ secrets.AA_COMPANION_CERT_SHA256 }}
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }}
ORG_GRADLE_PROJECT_requireSentryPush: 'true'
run: |
test -n "$COMPANION_UPLOAD_KEY_PASSWORD"
test -n "$AA_COMPANION_CERT_SHA256"
test -n "$GOOGLE_SERVICES_JSON"
printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json
./build_play.sh
- name: Remove keys
if: always()
Expand Down
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
plugins { id("com.android.application") }

val firebaseEnabled = file("google-services.json").exists()
check(providers.gradleProperty("requireSentryPush").orNull != "true" || firebaseEnabled) {
"Sentry push requires app/google-services.json. Follow push-relay/SETUP.md before building."
}
// Google Play app signing certificate of com.starpilot.auto (verified from the installed Play APK).
val playAaSignerSha256 = "13AE59E1A17F956D77E46F6E10C23913CBE47291164ECB43C5D9F107B157319A"
val aaSignerSha256 = (System.getenv("AA_COMPANION_CERT_SHA256") ?: playAaSignerSha256).uppercase()
Expand Down
45 changes: 45 additions & 0 deletions app/src/main/java/com/starpilot/galaxy/CabinWhiteBalance.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.starpilot.galaxy

import android.graphics.Bitmap
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

/** Same correction as Galaxy's white-balance.js: cancels the cabin camera's IR magenta cast. */
object CabinWhiteBalance {
fun gains(pixels: IntArray, width: Int, height: Int): FloatArray {
var r = 0.0; var g = 0.0; var b = 0.0; var count = 0
for (y in 0 until height step 8) for (x in 0 until width step 8) {
val c = pixels[y * width + x]
r += (c shr 16) and 0xFF; g += (c shr 8) and 0xFF; b += c and 0xFF; count++
}
if (count == 0) return floatArrayOf(1f, 1f, 1f)
val mean = doubleArrayOf(r / count / 255, g / count / 255, b / count / 255)
val luma = 0.299 * mean[0] + 0.587 * mean[1] + 0.114 * mean[2]
if (luma < 0.02) return floatArrayOf(1f, 1f, 1f)
return FloatArray(3) { min(1.6, max(0.6, luma / max(mean[it], 1e-3))).toFloat() }
}

fun apply(pixels: IntArray, width: Int, height: Int) {
val gains = gains(pixels, width, height)
val rgb = FloatArray(3)
for (i in pixels.indices) {
val c = pixels[i]
rgb[0] = ((c shr 16) and 0xFF) / 255f; rgb[1] = ((c shr 8) and 0xFF) / 255f; rgb[2] = (c and 0xFF) / 255f
val t = min(1f, max(0f, (max(rgb[0], max(rgb[1], rgb[2])) - 0.95f) / 0.05f))
for (k in 0..2) rgb[k] = rgb[k] * gains[k] * (1 - t) + rgb[k] * t
val gray = 0.299f * rgb[0] + 0.587f * rgb[1] + 0.114f * rgb[2]
val out = IntArray(3) { (min(1f, max(0f, gray + (rgb[it] - gray) * 0.75f)) * 255).roundToInt() }
pixels[i] = (c and 0xFF000000.toInt()) or (out[0] shl 16) or (out[1] shl 8) or out[2]
}
}

fun apply(bitmap: Bitmap): Bitmap {
val copy = bitmap.copy(Bitmap.Config.ARGB_8888, true)
val pixels = IntArray(copy.width * copy.height)
copy.getPixels(pixels, 0, copy.width, 0, 0, copy.width, copy.height)
apply(pixels, copy.width, copy.height)
copy.setPixels(pixels, 0, copy.width, 0, 0, copy.width, copy.height)
return copy
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/com/starpilot/galaxy/SentryApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ class SentryApi(val origin: String, private val cookie: String, private val netw
private val bridge: ((String, JSONObject?) -> JSONObject)? = null,
private val cookieProvider: (() -> String)? = null) {
fun request(path: String, body: JSONObject? = null): JSONObject = bridge?.invoke(path, body) ?: JSONObject(String(bytes(path, body)))
fun bytes(path: String, body: JSONObject? = null): ByteArray {
fun bytes(path: String, body: JSONObject? = null, readTimeoutMs: Int = 3000): ByteArray {
check(!Thread.currentThread().isInterrupted)
require(path.startsWith("/api/") && !path.contains(".."))
val connection = (network?.openConnection(URL(origin + path)) ?: URL(origin + path).openConnection()) as HttpURLConnection
try {
connection.connectTimeout = 2000; connection.readTimeout = 3000; connection.instanceFollowRedirects = false
connection.connectTimeout = 2000; connection.readTimeout = readTimeoutMs; connection.instanceFollowRedirects = false
connection.setRequestProperty("Cookie", cookieProvider?.invoke() ?: cookie)
if (body != null) {
connection.requestMethod = "POST"; connection.doOutput = true
Expand Down
7 changes: 6 additions & 1 deletion app/src/main/java/com/starpilot/galaxy/SentryPoller.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ object SentryPoller {
(0 until events.length()).map { events.getJSONObject(it) }.sortedBy { it.optLong("systemTimeMs") }.forEach { event ->
val ns = event.optLong("wallTimeNs", event.optLong("systemTimeMs") * 1000000)
if (ns > since) SentryPush.receive(ctx, mapOf("type" to "sentry", "eventId" to event.optString("eventId"),
"kind" to event.optString("kind"), "wallTimeNs" to ns.toString(), "label" to "Galaxy"), fromPush = false)
"kind" to event.optString("kind"), "wallTimeNs" to ns.toString(), "label" to "Galaxy") +
batteryDetail(event.optInt("voltage"), event.optInt("cutoff")), fromPush = false)
}
p.edit().putLong("sentryLastPollAt", System.currentTimeMillis()).apply()
} finally { busy.set(false) }
}
fun batteryDetail(voltage: Int, cutoff: Int): Map<String, String> =
if (voltage in 1..999 && cutoff in 1..999)
mapOf("detail" to "Car battery is low (%.1f V). The device and Sentry turn off at %.1f V.".format(java.util.Locale.US, voltage / 10.0, cutoff / 10.0))
else emptyMap()
}
107 changes: 87 additions & 20 deletions app/src/main/java/com/starpilot/galaxy/SentryPush.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.app.*
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import org.json.JSONArray
Expand All @@ -22,13 +23,15 @@ object SentryPush {
java.security.MessageDigest.getInstance("SHA-256").digest(it.toByteArray()).joinToString("") { byte -> "%02x".format(byte) }.take(12)
}.orEmpty()
return JSONObject().put("id", id).put("available", FirebaseRegistration.available).put("enabled", p.getBoolean("sentryPushEnabled", false))
.put("registered", p.getString("fcmRegisteredOrigin", "").orEmpty().isNotEmpty()).toString()
.put("registered", p.getString("fcmRegisteredOrigin", "").orEmpty().isNotEmpty())
.put("status", p.getString("fcmAcknowledgement", "Enable phone alerts, then enroll the relay in Galaxy → Sentry.")).toString()
}
fun enable(ctx: Context, on: Boolean): Boolean {
if (on && !permitted(ctx)) return false
val p = prefs(ctx)
if (on && !p.contains("sentryLastSeenNs")) p.edit().putLong("sentryLastSeenNs", System.currentTimeMillis() * 1000000).apply()
p.edit().putBoolean("sentryPushEnabled", on).apply()
p.edit().putBoolean("sentryPushEnabled", on).putBoolean("sentryEnrollRequested", on).apply()
if (on && !FirebaseRegistration.available) p.edit().putString("fcmAcknowledgement", "Polling only: Firebase is not configured in this build").apply()
if (on) { FirebaseRegistration.start(ctx); SentryPollJob.schedule(ctx); api?.let { registerIfNeeded(ctx, it) } }
else {
FirebaseRegistration.stop(ctx)
Expand All @@ -48,21 +51,69 @@ object SentryPush {
if (suspended) return
api = current
val p = prefs(ctx)
if (!p.getBoolean("sentryPushEnabled", false)) return
if (!p.getBoolean("sentryPushEnabled", false)) {
val registrationKey = "fcmSubscription:" + current.origin
Thread { synchronized(this) { runCatching {
if (!p.getBoolean("sentryPushEnabled", false)) {
val previous = p.getString(registrationKey, "").orEmpty()
if (previous.isNotEmpty()) {
current.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", previous))
p.edit().remove(registrationKey).apply()
}
}
} } }.start()
return
}
val token = p.getString("fcmToken", "").orEmpty()
if (!FirebaseRegistration.available || token.isEmpty()) return
Thread { runCatching {
val result = current.request("/api/sentry/notifications", JSONObject().put("action", "appSubscribe").put("token", token)
Thread { synchronized(this) { runCatching {
if (!p.getBoolean("sentryPushEnabled", false) || p.getString("fcmToken", "") != token) return@runCatching
val registrationKey = "fcmSubscription:" + current.origin
val previous = p.getString(registrationKey, "").orEmpty()
if (previous.isNotEmpty() && previous != token) {
current.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", previous))
p.edit().remove(registrationKey).apply()
}
var result = current.request("/api/sentry/notifications", JSONObject().put("action", "appSubscribe").put("token", token)
.put("appId", BuildConfig.APPLICATION_ID).put("label", Build.MODEL.take(64)))
p.edit().putString(registrationKey, token).apply()
result = enrollIfRequested(ctx, current, result)
if (p.getBoolean("sentryPushEnabled", false) && p.getString("fcmToken", "") == token) {
p.edit().putString("fcmRegisteredOrigin", current.origin).putString("fcmAcknowledgement", if (result.optBoolean("appPushAvailable")) "Registered" else "Polling; relay not configured").apply()
p.edit().putString("fcmRegisteredOrigin", current.origin).putString("fcmAcknowledgement", relayStatus(result)).apply()
} else {
current.request("/api/sentry/notifications", JSONObject().put("action", "appUnsubscribe").put("token", token))
p.edit().remove(registrationKey).apply()
}
}.onFailure { p.edit().putString("fcmAcknowledgement", "Registration failed").apply() } }.start()
}.onFailure { p.edit().putString("fcmAcknowledgement", "Registration failed; reconnect to retry").apply() } } }.start()
}

internal fun enrollIfRequested(ctx: Context, current: SentryApi, snapshot: JSONObject): JSONObject {
val p = prefs(ctx)
var result = snapshot
if (p.getBoolean("sentryEnrollRequested", false) && p.getBoolean("sentryPushEnabled", false)) {
val enrollment = result.optJSONObject("relayEnrollment")
if (enrollment?.optBoolean("automaticConfigured") == true || enrollment?.optString("state") in listOf("enrolling", "enrolled", "rotating", "confirming")) {
result = current.request("/api/sentry/notifications", JSONObject().put("action", "relayEnroll").put("confirmed", true))
p.edit().putBoolean("sentryEnrollRequested", false).apply()
} else if (enrollment == null) p.edit().putBoolean("sentryEnrollRequested", false).apply()
}
return result
}
internal fun relayStatus(result: JSONObject): String {
return when (result.optJSONObject("relayEnrollment")?.optString("state")) {
"enrolling", "rotating", "confirming" -> "Phone registered; waiting for relay enrollment in Galaxy → Sentry"
"unenrolled", "revoked" -> if (result.optJSONObject("relayEnrollment")?.optBoolean("automaticConfigured") == true) "Enable alerts to enroll this comma automatically" else "Phone registered; hosted alerts are not configured in this release yet"
"revoking" -> "Relay revocation pending; push alerts paused"
"actionRequired" -> "Relay authorization needs attention in Galaxy → Sentry"
else -> if (result.optBoolean("appPushAvailable")) "Registered; send a test from Galaxy → Sentry" else "Polling; relay not configured"
}
}
private fun decode(bytes: ByteArray, sample: Int): Bitmap? =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample })
@Synchronized fun receive(ctx: Context, data: Map<String, String>, fromPush: Boolean = true, demo: Boolean = false): Boolean {
val p = prefs(ctx)
if (!demo && suspended) return false
if (!demo && !p.getBoolean("sentryPushEnabled", false) || data["type"] != "sentry" || data["kind"] !in setOf("warning", "alarm", "test") || !permitted(ctx)) return false
if (!demo && !p.getBoolean("sentryPushEnabled", false) || data["type"] != "sentry" || data["kind"] !in setOf("warning", "alarm", "battery", "test") || !permitted(ctx)) return false
val id = data["eventId"]?.takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) } ?: return false
val seen = runCatching { JSONArray(p.getString("sentrySeenIds", "[]")) }.getOrDefault(JSONArray())
val dedupe = SentryDedupe((0 until seen.length()).map { seen.getString(it) })
Expand All @@ -72,25 +123,41 @@ object SentryPush {
val notificationId = Notifications.SENTRY_BASE + (id.hashCode() and 0x3FF)
val intent = Intent(ctx, MainActivity::class.java).putExtra("openRoute", "/cameras/events").putExtra("demo", demo)
val open = PendingIntent.getActivity(ctx, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val title = when (data["kind"]) { "alarm" -> "Sentry alarm"; "test" -> "Galaxy test alert"; else -> "Sentry: motion detected" }
val title = when (data["kind"]) { "alarm" -> "Sentry alarm"; "battery" -> "Sentry: battery low"; "test" -> "Galaxy test alert"; else -> "Sentry: motion detected" }
val builder = Notification.Builder(ctx, Notifications.SENTRY_CHANNEL).setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle(title).setContentText(data["label"].orEmpty().take(64)).setAutoCancel(true).setContentIntent(open)
.setContentTitle(title).setContentText(data["detail"]?.take(160) ?: data["label"].orEmpty().take(64)).setAutoCancel(true).setContentIntent(open)
.setOnlyAlertOnce(true)
if (data["kind"] == "alarm") builder.setCategory(Notification.CATEGORY_ALARM)
manager.notify(notificationId, builder.build())
if (!demo) {
if (demo) manager.notify(notificationId, builder.build())
else {
p.edit().putString("sentrySeenIds", JSONArray(dedupe.ids()).toString())
.putLong("sentryLastSeenNs", maxOf(p.getLong("sentryLastSeenNs", 0), (data["wallTimeNs"]?.toLongOrNull() ?: 0).coerceIn(0, System.currentTimeMillis() * 1000000)))
.apply { if (fromPush) putLong("sentryLastPushAt", System.currentTimeMillis()) }.commit()
val test = data["kind"] == "test"
Thread {
val image = java.util.concurrent.FutureTask {
val current = api ?: SentryApi.best(ctx)
current?.bytes("/api/sentry/image/$id/wide")?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
val images = java.util.concurrent.FutureTask {
if (data["kind"] == "battery") return@FutureTask null
val current = api ?: SentryApi.best(ctx) ?: return@FutureTask null
val wide = if (test) null else runCatching { decode(current.bytes("/api/sentry/image/$id/wide"), 1) }.getOrNull()
val cabin = runCatching {
decode(if (test) current.bytes("/api/cameras/snapshot", JSONObject().put("camera", "cabin"), 30000)
else current.bytes("/api/sentry/image/$id/cabin"), 4)
}.getOrNull()?.let { bitmap ->
val balanced = runCatching { current.request("/api/cameras/white-balance", JSONObject()).optBoolean("enabled") }.getOrDefault(false)
if (balanced) CabinWhiteBalance.apply(bitmap) else bitmap
}
wide to cabin
}
Thread(images).apply { isDaemon = true; start() }
fun post(result: Pair<Bitmap?, Bitmap?>?) {
result?.first?.let { builder.setStyle(Notification.BigPictureStyle().bigPicture(it)) }
result?.second?.let { builder.setLargeIcon(it) }
if (!suspended) manager.notify(notificationId, builder.build())
}
Thread(image).apply { isDaemon = true; start() }
try {
val bitmap = image.get(5, java.util.concurrent.TimeUnit.SECONDS)
if (bitmap != null && !suspended) manager.notify(notificationId, builder.setStyle(Notification.BigPictureStyle().bigPicture(bitmap)).build())
} catch (_: Exception) {} finally { image.cancel(true) }
val unit = java.util.concurrent.TimeUnit.SECONDS
try { post(images.get(if (test) 20L else 5L, unit)); return@Thread } catch (_: Exception) { post(null) }
try { images.get(if (test) 15L else 3L, unit)?.takeIf { it.first != null || it.second != null }?.let(::post) }
catch (_: Exception) {} finally { images.cancel(true) }
}.start()
}
return true
Expand Down
23 changes: 23 additions & 0 deletions app/src/test/java/com/starpilot/galaxy/CabinWhiteBalanceTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.starpilot.galaxy

import org.junit.Assert.*
import org.junit.Test

class CabinWhiteBalanceTest {
private fun rgb(r: Int, g: Int, b: Int) = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
private fun channels(c: Int) = intArrayOf((c shr 16) and 0xFF, (c shr 8) and 0xFF, c and 0xFF)

@Test fun magentaCastIsNeutralized() {
val pixels = IntArray(16 * 16) { rgb(170, 110, 160) }
CabinWhiteBalance.apply(pixels, 16, 16)
val (r, g, b) = channels(pixels[0]).toList()
assertTrue(maxOf(r, g, b) - minOf(r, g, b) < 10)
assertEquals(0xFF, pixels[0] ushr 24)
}
@Test fun darkFramesAndClippedHighlightsKeepTheirGains() {
assertArrayEquals(floatArrayOf(1f, 1f, 1f), CabinWhiteBalance.gains(IntArray(64) { rgb(2, 1, 2) }, 8, 8), 0f)
val pixels = IntArray(64) { if (it == 0) rgb(255, 255, 255) else rgb(170, 110, 160) }
CabinWhiteBalance.apply(pixels, 8, 8)
assertArrayEquals(intArrayOf(255, 255, 255), channels(pixels[0]))
}
}
Loading
Loading