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
25 changes: 0 additions & 25 deletions ft8af/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -126,31 +126,6 @@
android:value="true" />
</service>

<!-- Android Auto (phone projection): read-only QSO status screens rendered
with car-app-library templates. Exported so the Android Auto host can
bind; access is gated by FT8AFCarAppService's HostValidator.
IOT category on purpose — this exact wiring shipped in the approved
production release (versionCode 1327 / 2.0). NAVIGATION category +
map surface rendering was rejected by Play review (PR #600); do not
re-add it. -->
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- Level 1 = every templates-capable Android Auto host; we use no newer
template features. -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />

<service
android:name="radio.ks3ckc.ft8af.car.FT8AFCarAppService"
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.IOT" />
</intent-filter>
</service>

<activity
android:name="radio.ks3ckc.ft8af.ui.pota.PotaShareActivity"
android:exported="false" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package radio.ks3ckc.ft8af.car

import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
Expand All @@ -20,6 +21,7 @@ import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.ParkedOnlyOnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.versioning.CarAppApiLevels
Expand All @@ -46,8 +48,7 @@ import radio.ks3ckc.ft8af.ui.components.slotTimerState
*
* The engine is never started from here: until ComposeMainActivity has created
* the [MainViewModel] singleton, [MainViewModel.peekInstance] is null and the
* screen shows an "open the app on your phone" message, re-checking on its
* 1 Hz tick.
* screen shows [engineIdleTemplate], re-checking on its 1 Hz tick.
*/
class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifecycleObserver {

Expand Down Expand Up @@ -103,7 +104,7 @@ class QsoStatusScreen(carContext: CarContext) : Screen(carContext), DefaultLifec
}

override fun onGetTemplate(): Template {
val vm = MainViewModel.peekInstance() ?: return openPhoneTemplate(carContext)
val vm = MainViewModel.peekInstance() ?: return engineIdleTemplate(carContext)
val ts = vm.ft8TransmitSignal
val mode = ModeProfile.fromId(vm.mutableOperatingMode.value ?: GeneralVariables.operatingMode)
val slot = slotTimerState(UtcTimer.getSystemTime(), mode.slotMillis.toLong())
Expand Down Expand Up @@ -273,12 +274,62 @@ internal fun currentBandName(): String {
}

/** Shown while the engine singleton doesn't exist yet (phone app not opened). */
internal fun openPhoneTemplate(carContext: CarContext): MessageTemplate =
MessageTemplate.Builder(carContext.getString(R.string.car_open_phone))
.setTitle(carContext.getString(R.string.car_screen_title))
internal fun engineIdleTemplate(carContext: CarContext): MessageTemplate =
engineIdleTemplate(
title = carContext.getString(R.string.car_screen_title),
message = carContext.getString(R.string.car_engine_idle),
startActionTitle = carContext.getString(R.string.car_engine_idle_action),
onParkedStart = { startAppOnPhone(carContext) },
)

/**
* The idle template's content, split out from resource lookup so it can be unit
* tested.
*
* Play's Android Auto review rejected versionCode 2100 under "Visual info on
* phone — your app does not disable features requiring phone interaction while
* in driving mode": this screen used to read "Open FT8AF on your phone to start
* the FT8 engine", i.e. the car told the driver to pick up their phone, with
* nothing gating that on the car being stopped. Two rules follow, and both are
* pinned by CarIdleTemplateTest:
*
* 1. [message] is status only — never an instruction to touch the phone.
* 2. Starting the phone app is the one action here that needs phone
* interaction, so it is wrapped in [ParkedOnlyOnClickListener]. The host
* runs it only when the car is parked and shows its own "not available
* while driving" notice otherwise, which is exactly the "disable while
* driving" behaviour the guideline asks for.
*
* Anything added to the car screens later has to keep both properties.
*/
internal fun engineIdleTemplate(
title: String,
message: String,
startActionTitle: String,
onParkedStart: () -> Unit,
): MessageTemplate =
MessageTemplate.Builder(message)
.setTitle(title)
.setHeaderAction(Action.APP_ICON)
.addAction(
Action.Builder()
.setTitle(startActionTitle)
.setOnClickListener(ParkedOnlyOnClickListener.create { onParkedStart() })
.build(),
)
.build()

/**
* Bring the phone app up so it can create the engine. Only ever called from a
* [ParkedOnlyOnClickListener], so it cannot run while the car is moving.
* Resolved through the launcher intent rather than a hard-coded Activity class
* so it keeps working if the launcher Activity is renamed.
*/
private fun startAppOnPhone(carContext: CarContext) {
val launch = carContext.packageManager.getLaunchIntentForPackage(carContext.packageName) ?: return
carContext.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}

private const val DEFAULT_PANE_ROWS = 3

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class RecentDecodesScreen(carContext: CarContext) : Screen(carContext) {
}

override fun onGetTemplate(): Template {
val vm = MainViewModel.peekInstance() ?: return openPhoneTemplate(carContext)
val vm = MainViewModel.peekInstance() ?: return engineIdleTemplate(carContext)
maybeAttach(vm)
// publishFt8MessageList() posts a defensive copy that is never mutated
// after posting, so the value is safe to iterate directly.
Expand Down
3 changes: 2 additions & 1 deletion ft8af/app/src/main/res/values-pt-rBR/strings_compose.xml
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,8 @@
<string name="tune_method_tone">Tom de baixa potência</string>

<!-- Android Auto status screens (read-only). Headlines reuse the qsopanel_* strings. -->
<string name="car_open_phone">Abra o FT8AF no seu celular para iniciar o motor FT8</string>
<string name="car_engine_idle">O FT8AF ainda não está no ar. O status do QSO aparece aqui quando ele estiver em execução.</string>
<string name="car_engine_idle_action">Iniciar o FT8AF</string>
<string name="car_monitoring">Monitorando — TX desligado</string>
<string name="car_seq_step">Etapa %1$s (%2$d/%3$d)</string>
<string name="car_slot_tx">Slot de TX · %1$d s</string>
Expand Down
3 changes: 2 additions & 1 deletion ft8af/app/src/main/res/values/strings_compose.xml
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,8 @@

<!-- Android Auto status screens (read-only). Headlines reuse the qsopanel_* strings. -->
<string name="car_screen_title" translatable="false">FT8AF</string>
<string name="car_open_phone">Open FT8AF on your phone to start the FT8 engine</string>
<string name="car_engine_idle">FT8AF is not on the air yet. QSO status appears here once it is running.</string>
<string name="car_engine_idle_action">Start FT8AF</string>
<string name="car_monitoring">Monitoring — TX off</string>
<string name="car_seq_step">Step %1$s (%2$d/%3$d)</string>
<string name="car_slot_tx">TX slot · %1$d s</string>
Expand Down
8 changes: 0 additions & 8 deletions ft8af/app/src/main/res/xml/automotive_app_desc.xml

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,66 +5,64 @@ import android.content.Intent
import android.content.pm.PackageManager
import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import com.k1af.ft8af.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

/**
* Pins the Android Auto manifest wiring: the host discovers the app through the
* CarAppService intent filter plus the automotive_app_desc meta-data, and a
* silently dropped entry would only show up as "app missing from the car
* launcher" — a failure mode adb/unit tests can't otherwise see.
* Pins Android Auto as *unwired* in the manifest. The car-app screens
* ([FT8AFCarAppService], [QsoStatusScreen]) still exist in the tree and still
* compile, but the manifest entries the Android Auto host discovers them
* through are gone, so the app is not flagged as Android-Auto-enabled and Play
* does not put it through Auto app-quality review.
*
* It also pins the wiring to the exact shape Play approved in production
* (versionCode 1327 / 2.0): IOT category, no NAVIGATION/POI category, and no
* androidx.car.app template permissions. The NAVIGATION-category map variant
* was rejected by Play review ("does not load map and user location") and had
* to be pulled in PR #600 — these guards keep that shape from coming back.
* Two Auto rejections stand behind this, and re-adding either manifest entry
* puts the app back in front of both:
*
* - versionCode 1327 (2026-07-19), NAVIGATION category: "does not load map and
* user location in Android Auto Environment" — a QSO monitor can't meet
* navigation quality bars, and no approved Auto category fits the app.
* - versionCode 2100 (2026-09-09), IOT category: "Visual info on phone — your
* app does not disable features requiring phone interaction while in driving
* mode", against the idle screen's old "open FT8AF on your phone" message.
*
* The screens themselves were made compliant with the second finding (see
* `engineIdleTemplate` and CarIdleTemplateTest) so a future revival starts from
* a clean base — but the wiring stays out until someone decides to take Auto
* review on again.
*
* This runs against the debug variant's merged manifest, which still overlays
* the debug-only CarAppActivity used for on-emulator development. That is an
* Activity, not a CarAppService or an Auto descriptor, and never ships in
* release, so it doesn't count here.
*/
@RunWith(RobolectricTestRunner::class)
class CarAppManifestWiringTest {

private val context = ApplicationProvider.getApplicationContext<Context>()

@Test
fun carAppService_isDeclaredExported_withIotCategory() {
fun noCarAppService_isDeclared() {
val intent = Intent("androidx.car.app.CarAppService").setPackage(context.packageName)
val services = context.packageManager.queryIntentServices(
intent,
PackageManager.GET_RESOLVED_FILTER,
)
assertThat(services).hasSize(1)
val resolved = services[0]
assertThat(resolved.serviceInfo.name).isEqualTo("radio.ks3ckc.ft8af.car.FT8AFCarAppService")
assertThat(resolved.serviceInfo.exported).isTrue()
assertThat(resolved.filter.hasCategory("androidx.car.app.category.IOT")).isTrue()
assertThat(services).isEmpty()
}

@Test
fun automotiveAppDescriptor_andMinCarApiLevel_areDeclared() {
fun androidAutoDescriptorMetaData_isAbsent() {
val appInfo = context.packageManager.getApplicationInfo(
context.packageName,
PackageManager.GET_META_DATA,
)
// Read the (platform-nullable) meta-data bundle once into a non-null local so a
// dropped meta-data block fails here with an actionable message instead of an NPE
// on a later getInt().
val metaData = checkNotNull(appInfo.metaData) { "app has no meta-data — Android Auto wiring missing" }
assertThat(metaData.getInt("com.google.android.gms.car.application"))
.isEqualTo(R.xml.automotive_app_desc)
assertThat(metaData.getInt("androidx.car.app.minCarApiLevel")).isEqualTo(1)
}

@Test
fun rejectedNavigationCategory_isNotDeclared() {
val intent = Intent("androidx.car.app.CarAppService").setPackage(context.packageName)
val resolved = context.packageManager.queryIntentServices(
intent,
PackageManager.GET_RESOLVED_FILTER,
)[0]
assertThat(resolved.filter.hasCategory("androidx.car.app.category.NAVIGATION")).isFalse()
assertThat(resolved.filter.hasCategory("androidx.car.app.category.POI")).isFalse()
// Other application-level meta-data (e.g. io.sentry.auto-init) keeps this
// bundle non-null; what must be gone is the Android Auto descriptor and the
// car-app API-level floor that together mark the app as an AA app.
val meta = appInfo.metaData
assertThat(meta.containsKey("com.google.android.gms.car.application")).isFalse()
assertThat(meta.containsKey("androidx.car.app.minCarApiLevel")).isFalse()
}

@Test
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package radio.ks3ckc.ft8af.car

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import com.k1af.ft8af.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

/**
* Guards the Android Auto idle screen against the Play rejection of versionCode
* 2100 — "Auto App Quality Guidelines: Visual info on phone. Your app does not
* disable features requiring phone interaction while in driving mode."
*
* The screen used to render "Open FT8AF on your phone to start the FT8 engine",
* so the car both told the driver to use their phone and left that available
* while driving. These tests pin the two properties that fix it: the message is
* status-only, and the single phone-reaching action is parked-only.
*/
@RunWith(RobolectricTestRunner::class)
class CarIdleTemplateTest {

private val context = ApplicationProvider.getApplicationContext<Context>()

private fun template(onStart: () -> Unit = {}) = engineIdleTemplate(
title = "FT8AF",
message = "FT8AF is not on the air yet.",
startActionTitle = "Start FT8AF",
onParkedStart = onStart,
)

@Test
fun startAction_isParkedOnly_soTheHostBlocksItWhileDriving() {
val actions = template().actions
assertThat(actions).hasSize(1)
assertThat(actions[0].title?.toString()).isEqualTo("Start FT8AF")
// The whole point of the fix: the host refuses the click while the car
// is moving. Dropping the ParkedOnlyOnClickListener wrapper flips this.
assertThat(actions[0].onClickDelegate?.isParkedOnly).isTrue()
}

@Test
fun idleTemplate_showsTheStatusMessage() {
val built = template()
assertThat(built.message.toString()).isEqualTo("FT8AF is not on the air yet.")
}

@Test
fun parkedStart_isNotInvokedWhileBuildingTheTemplate() {
var started = false
template { started = true }
assertThat(started).isFalse()
}

/**
* The shipped string, not just the template shape: a translation or copy
* edit that reintroduces "open … on your phone" would pass the tests above
* and fail Play review again.
*/
@Test
fun idleMessage_doesNotDirectTheDriverToTheirPhone() {
val message = context.getString(R.string.car_engine_idle).lowercase()
listOf("phone", "handset", "tap", "touch").forEach { banned ->
assertThat(message).doesNotContain(banned)
}
assertThat(context.getString(R.string.car_engine_idle_action)).isNotEmpty()
}
}
Loading