diff --git a/ft8af/app/src/main/AndroidManifest.xml b/ft8af/app/src/main/AndroidManifest.xml
index 45d35d16c..40e3ca3d4 100644
--- a/ft8af/app/src/main/AndroidManifest.xml
+++ b/ft8af/app/src/main/AndroidManifest.xml
@@ -126,31 +126,6 @@
android:value="true" />
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt
index 1b9ff5502..4ec9274c6 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/QsoStatusScreen.kt
@@ -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
@@ -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
@@ -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 {
@@ -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())
@@ -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
/**
diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/RecentDecodesScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/RecentDecodesScreen.kt
index 344f05216..f60610762 100644
--- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/RecentDecodesScreen.kt
+++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/RecentDecodesScreen.kt
@@ -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.
diff --git a/ft8af/app/src/main/res/values-pt-rBR/strings_compose.xml b/ft8af/app/src/main/res/values-pt-rBR/strings_compose.xml
index a780dc795..aefe450d3 100644
--- a/ft8af/app/src/main/res/values-pt-rBR/strings_compose.xml
+++ b/ft8af/app/src/main/res/values-pt-rBR/strings_compose.xml
@@ -674,7 +674,8 @@
Tom de baixa potência
- Abra o FT8AF no seu celular para iniciar o motor FT8
+ O FT8AF ainda não está no ar. O status do QSO aparece aqui quando ele estiver em execução.
+ Iniciar o FT8AF
Monitorando — TX desligado
Etapa %1$s (%2$d/%3$d)
Slot de TX · %1$d s
diff --git a/ft8af/app/src/main/res/values/strings_compose.xml b/ft8af/app/src/main/res/values/strings_compose.xml
index 5110fa872..0c8fd2d8c 100644
--- a/ft8af/app/src/main/res/values/strings_compose.xml
+++ b/ft8af/app/src/main/res/values/strings_compose.xml
@@ -988,7 +988,8 @@
FT8AF
- Open FT8AF on your phone to start the FT8 engine
+ FT8AF is not on the air yet. QSO status appears here once it is running.
+ Start FT8AF
Monitoring — TX off
Step %1$s (%2$d/%3$d)
TX slot · %1$d s
diff --git a/ft8af/app/src/main/res/xml/automotive_app_desc.xml b/ft8af/app/src/main/res/xml/automotive_app_desc.xml
deleted file mode 100644
index 09d36d71c..000000000
--- a/ft8af/app/src/main/res/xml/automotive_app_desc.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarAppManifestWiringTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarAppManifestWiringTest.kt
index 448dfb361..68a143a2a 100644
--- a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarAppManifestWiringTest.kt
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarAppManifestWiringTest.kt
@@ -5,22 +5,36 @@ 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 {
@@ -28,43 +42,27 @@ class CarAppManifestWiringTest {
private val context = ApplicationProvider.getApplicationContext()
@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
diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarIdleTemplateTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarIdleTemplateTest.kt
new file mode 100644
index 000000000..1af15c55e
--- /dev/null
+++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarIdleTemplateTest.kt
@@ -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()
+
+ 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()
+ }
+}