From c95b4a05acca1bc8fc034d2f88e56c56d3d121e7 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 9 Sep 2026 13:03:49 -0500 Subject: [PATCH 1/2] Stop the Android Auto screen from asking the driver to use their phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Play rejected versionCode 2100 under "Auto App Quality Guidelines: Visual info on phone — your app does not disable features requiring phone interaction while in driving mode". The review evidence is the car-screen idle state, which read "Open FT8AF on your phone to start the FT8 engine": the car both instructed phone interaction and left it available while driving. The idle template is now status only ("FT8AF is not on the air yet. QSO status appears here once it is running."), and the one action that does reach the phone — starting the app so it can create the engine — is wrapped in ParkedOnlyOnClickListener, so the Auto host runs it only when the car is parked and otherwise shows its own "not available while driving" notice. Template content is split out from resource lookup so the guarantee is unit-testable: CarIdleTemplateTest pins the action as parked-only, and checks the shipped string itself for phone-interaction wording so a later copy edit can't quietly reintroduce the rejection. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B4bi57TiMribneqcSBddRB --- .../radio/ks3ckc/ft8af/car/QsoStatusScreen.kt | 63 +++++++++++++++-- .../ks3ckc/ft8af/car/RecentDecodesScreen.kt | 2 +- .../res/values-pt-rBR/strings_compose.xml | 3 +- .../src/main/res/values/strings_compose.xml | 3 +- .../ks3ckc/ft8af/car/CarIdleTemplateTest.kt | 69 +++++++++++++++++++ 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/car/CarIdleTemplateTest.kt 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/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() + } +} From 5177c3de20d010189391a5bc4eda8d7b29617078 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Wed, 9 Sep 2026 13:16:55 -0500 Subject: [PATCH 2/2] Unwire Android Auto from the shipping app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second Auto rejection in two months, so take Play's other path and stop shipping as an Auto app rather than keep answering review findings. versionCode 2100 was rejected under "Visual info on phone — your app does not disable features requiring phone interaction while in driving mode"; versionCode 1327 was rejected in July under the NAVIGATION category ("does not load map and user location"). The IOT/templates shape restored after that first removal is what drew this one. No approved Auto category fits a ham-radio QSO monitor well enough to be worth the review cycle right now. - AndroidManifest.xml: drop the com.google.android.gms.car.application descriptor, the androidx.car.app.minCarApiLevel meta-data and the FT8AFCarAppService service. Nothing marks the app as Auto-enabled, so Play no longer routes it through Auto app-quality review. - Delete res/xml/automotive_app_desc.xml (dead once the descriptor is gone). - CarAppManifestWiringTest: flip back from "AA is wired in the approved IOT shape" to a guard asserting AA is unwired, with both rejections recorded so a third revival is a deliberate act. The car/ Kotlin package, its tests and the androidx.car.app:app dependency stay in-tree (dead but compiling), as they did after the July removal, so the feature can be revived. It carries the previous commit's driving-mode fix, so a revival starts compliant with the finding that triggered this. The debug-only AAOS scaffolding (src/debug CarAppActivity + DebugInjectReceiver) is untouched; it never merges into release. Verified: full testDebugUnitTest suite (3724 tests, 0 failures) plus processReleaseMainManifest — the merged release manifest keeps only the car-app library's own entries (connection provider, permission activity, notification receiver), which carry no Auto descriptor or CarAppService. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B4bi57TiMribneqcSBddRB --- ft8af/app/src/main/AndroidManifest.xml | 25 ------- .../src/main/res/xml/automotive_app_desc.xml | 8 --- .../ft8af/car/CarAppManifestWiringTest.kt | 68 +++++++++---------- 3 files changed, 33 insertions(+), 68 deletions(-) delete mode 100644 ft8af/app/src/main/res/xml/automotive_app_desc.xml 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/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