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() + } +}