From 18554aaa960b15d195a0ef35226c99d3ba724026 Mon Sep 17 00:00:00 2001 From: umarz317 <61620968+umarz317@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:05:11 +0500 Subject: [PATCH 1/3] Redesign the head-unit UI for quick use in the car Rebuild the home screen around the connection status and three large icon tiles (wireless, USB, settings), with problems shown as notices above them and Disconnect in the header. The tiles, the startup screen and the connecting screen scale to short head-unit displays. Give the settings an iOS-style grouped layout with section icons, disclosure rows and switches, and move video, audio-channel and diagnostic options to a new Advanced page. Add a play button to the LoopLink mark, launcher and notification icons, use dark rounded dialogs, and reword the car hotspot messages without the hotspot name. Label the CarPlay back-to-car icon "Car" instead of "BYD", including on cars that saved the old default, and remove the GitHub Pages workflow. Unit tests, lint and the debug build pass, and the home and settings screens were checked in a head-unit emulator; the short-screen layouts are not yet tested on a head unit. --- .github/workflows/pages.yml | 28 - .../shilapi/xcertplay/AirPlayPersistence.kt | 6 +- .../shilapi/xcertplay/CarPlayHostActivity.kt | 63 ++- .../com/shilapi/xcertplay/DiPlayActivity.kt | 524 ++++++++++++------ .../res/drawable/dp_dialog_background.xml | 7 + common/src/main/res/drawable/ic_dp_audio.xml | 1 + common/src/main/res/drawable/ic_dp_back.xml | 1 + .../src/main/res/drawable/ic_dp_bluetooth.xml | 1 + .../src/main/res/drawable/ic_dp_chevron.xml | 1 + common/src/main/res/drawable/ic_dp_close.xml | 1 + common/src/main/res/drawable/ic_dp_folder.xml | 1 + common/src/main/res/drawable/ic_dp_help.xml | 1 + common/src/main/res/drawable/ic_dp_home.xml | 1 + .../src/main/res/drawable/ic_dp_hotspot.xml | 1 + .../src/main/res/drawable/ic_dp_language.xml | 1 + common/src/main/res/drawable/ic_dp_notice.xml | 1 + common/src/main/res/drawable/ic_dp_phone.xml | 1 + common/src/main/res/drawable/ic_dp_save.xml | 1 + .../src/main/res/drawable/ic_dp_settings.xml | 1 + common/src/main/res/drawable/ic_dp_usb.xml | 1 + .../res/drawable/ic_launcher_foreground.xml | 4 +- common/src/main/res/drawable/ic_looplink.xml | 4 +- .../res/drawable/ic_looplink_notification.xml | 4 +- common/src/main/res/values-ar/strings.xml | 6 +- common/src/main/res/values-es/strings.xml | 6 +- common/src/main/res/values-ru/strings.xml | 6 +- common/src/main/res/values-zh-rCN/strings.xml | 6 +- common/src/main/res/values/strings.xml | 6 +- common/src/main/res/values/themes.xml | 17 +- 29 files changed, 448 insertions(+), 254 deletions(-) delete mode 100644 .github/workflows/pages.yml create mode 100644 common/src/main/res/drawable/dp_dialog_background.xml create mode 100644 common/src/main/res/drawable/ic_dp_audio.xml create mode 100644 common/src/main/res/drawable/ic_dp_back.xml create mode 100644 common/src/main/res/drawable/ic_dp_bluetooth.xml create mode 100644 common/src/main/res/drawable/ic_dp_chevron.xml create mode 100644 common/src/main/res/drawable/ic_dp_close.xml create mode 100644 common/src/main/res/drawable/ic_dp_folder.xml create mode 100644 common/src/main/res/drawable/ic_dp_help.xml create mode 100644 common/src/main/res/drawable/ic_dp_home.xml create mode 100644 common/src/main/res/drawable/ic_dp_hotspot.xml create mode 100644 common/src/main/res/drawable/ic_dp_language.xml create mode 100644 common/src/main/res/drawable/ic_dp_notice.xml create mode 100644 common/src/main/res/drawable/ic_dp_phone.xml create mode 100644 common/src/main/res/drawable/ic_dp_save.xml create mode 100644 common/src/main/res/drawable/ic_dp_settings.xml create mode 100644 common/src/main/res/drawable/ic_dp_usb.xml diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index e2ae430b36..0000000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Publish download site -on: - push: - branches: [main] - paths: ['site/**', 'scripts/build_site.py', '.github/workflows/pages.yml'] - workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write -concurrency: - group: pages - cancel-in-progress: true -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - run: python3 scripts/build_site.py - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 - with: - path: site - - uses: actions/deploy-pages@v4 - id: deployment diff --git a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt index 2d4634ecf6..014bbecf40 100644 --- a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt +++ b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt @@ -74,7 +74,9 @@ object AirPlayPersistence { const val DEFAULT_MANUFACTURER = "LoopLink" const val DEFAULT_MODEL = "LoopLink" - const val DEFAULT_OEM_LABEL = "BYD" + const val DEFAULT_OEM_LABEL = "Car" + // Earlier builds saved this default; it reads as a brand on non-BYD cars. + private const val LEGACY_OEM_LABEL = "BYD" const val DEFAULT_MFI_I2C_PATH = "/dev/i2c-1" fun loadDisplayScaleTenths(context: Context): Int { @@ -368,7 +370,7 @@ object AirPlayPersistence { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(KEY_OEM_LABEL, DEFAULT_OEM_LABEL) // iOS hides the car icon without a label. - .orEmpty().ifBlank { DEFAULT_OEM_LABEL } + .orEmpty().takeUnless { it.isBlank() || it == LEGACY_OEM_LABEL } ?: DEFAULT_OEM_LABEL fun saveOemLabel(context: Context, oemLabel: String) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index f201df01bc..8612febf98 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -631,7 +631,8 @@ class CarPlayHostActivity : ComponentActivity() { } private fun buildContentView(): View { - val root = FrameLayout(this).apply { setBackgroundColor(Color.rgb(7, 9, 13)) } + val compact = resources.configuration.screenHeightDp < 640 + val root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) } val video = TextureView(this).apply { isOpaque = false surfaceTextureListener = textureListener @@ -645,50 +646,60 @@ class CarPlayHostActivity : ComponentActivity() { val panel = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER - setPadding(dp(32), dp(32), dp(32), dp(32)) - setBackgroundColor(Color.rgb(7, 9, 13)) + setPadding(dp(32), dp(if (compact) 16 else 32), dp(32), dp(if (compact) 16 else 32)) + setBackgroundColor(Color.BLACK) isClickable = true } panel.addView(ImageView(this).apply { - setImageResource(R.drawable.ic_looplink); contentDescription = getString(R.string.carplay) - }, LinearLayout.LayoutParams(dp(88), dp(88))) - panel.addView(TextView(this).apply { - text = getString(R.string.diplay); textSize = 34f; setTextColor(Color.rgb(232, 236, 243)) - gravity = Gravity.CENTER; setPadding(0, dp(18), 0, dp(14)) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + setImageResource(R.drawable.ic_looplink); contentDescription = getString(R.string.diplay) + }, LinearLayout.LayoutParams(dp(if (compact) 64 else 88), dp(if (compact) 64 else 88))) + // The logo already names the app; short screens skip the title to keep everything on screen. + if (!compact) panel.addView(TextView(this).apply { + text = getString(R.string.diplay); textSize = 34f; setTextColor(Color.WHITE) + gravity = Gravity.CENTER; setPadding(0, dp(18), 0, 0) + typeface = Typeface.create(Typeface.SANS_SERIF, 700, false) }) val stage = TextView(this).apply { - text = getString(R.string.getting_carplay_ready); textSize = 22f; gravity = Gravity.CENTER - setTextColor(Color.rgb(232, 236, 243)) + text = getString(R.string.getting_carplay_ready); textSize = if (compact) 20f else 22f; gravity = Gravity.CENTER + setTextColor(Color.WHITE); setPadding(0, dp(if (compact) 12 else 14), 0, 0) + typeface = Typeface.create(Typeface.SANS_SERIF, 600, false) } panel.addView(stage) panel.addView(TextView(this).apply { text = if (wirelessEnabled) getString(R.string.keep_your_iphone_nearby_with_bluetooth_and_wi_fi_on_allow) else getString(R.string.use_a_usb_data_cable_and_unlock_your_iphone_allow_trust_an) - textSize = 17f; gravity = Gravity.CENTER; setTextColor(Color.rgb(139, 148, 167)) - setPadding(0, dp(14), 0, dp(24)) + textSize = if (compact) 15f else 17f; gravity = Gravity.CENTER; setTextColor(Color.rgb(152, 152, 159)) + setPadding(0, dp(if (compact) 8 else 14), 0, dp(if (compact) 16 else 24)) }) panel.addView(Button(this).apply { text = getString(R.string.reset_carplay_wi_fi); isAllCaps = false; textSize = 18f - visibility = View.GONE + visibility = View.GONE; stateListAnimator = null + setTextColor(Color.rgb(244, 122, 58)); typeface = Typeface.create(Typeface.SANS_SERIF, 600, false) + background = GradientDrawable().apply { setColor(Color.rgb(44, 44, 46)); cornerRadius = dp(18).toFloat() } setOnClickListener { showDiPlayHome("wireless-recovery") } wifiRecoveryButton = this - }, LinearLayout.LayoutParams(dp(300), dp(64)).apply { bottomMargin = dp(12) }) + }, LinearLayout.LayoutParams(dp(300), dp(if (compact) 52 else 64)).apply { bottomMargin = dp(12) }) panel.addView(Button(this).apply { text = getString(R.string.back_to_diplay); isAllCaps = false; textSize = 18f - setTextColor(Color.rgb(43, 18, 6)) - background = GradientDrawable().apply { setColor(Color.rgb(244, 122, 58)); cornerRadius = dp(20).toFloat() } + setTextColor(Color.WHITE); stateListAnimator = null + typeface = Typeface.create(Typeface.SANS_SERIF, 600, false) + background = GradientDrawable().apply { setColor(Color.rgb(244, 122, 58)); cornerRadius = dp(18).toFloat() } setOnClickListener { showDiPlayHome() } - }, LinearLayout.LayoutParams(dp(300), dp(64))) + }, LinearLayout.LayoutParams(dp(300), dp(if (compact) 52 else 64))) panel.addView(TextView(this).apply { text = getString(R.string.in_carplay_swipe_down_with_three_fingers_to_open_diplay_se) - textSize = 13f; gravity = Gravity.CENTER; setTextColor(Color.rgb(139, 148, 167)); setPadding(0, dp(20), 0, 0) + textSize = 13f; gravity = Gravity.CENTER; setTextColor(Color.rgb(152, 152, 159)); setPadding(0, dp(if (compact) 12 else 20), 0, 0) }) - root.addView(panel, FrameLayout.LayoutParams(-1, -1)) + // Scrolls only if a screen is shorter still; centred content never gets cut at both edges. + val panelScroll = ScrollView(this).apply { + isFillViewport = true; isVerticalScrollBarEnabled = false; setBackgroundColor(Color.BLACK) + addView(panel, FrameLayout.LayoutParams(-1, -1)) + } + root.addView(panelScroll, FrameLayout.LayoutParams(-1, -1)) videoView = video gestureOverlay = gestureLayer stageStatusView = stage - connectionPanel = panel + connectionPanel = panelScroll updateDebugOverlays() return root } @@ -3486,13 +3497,13 @@ class CarPlayHostActivity : ComponentActivity() { const val THREE_FINGER_SWIPE_DISTANCE_DP = 72 const val THREE_FINGER_SWIPE_DIRECTION_RATIO = 1.15f const val MAX_SETTINGS_MENU_WIDTH_PX = 1200 - val MENU_BACKGROUND = Color.rgb(17, 21, 29) - val MENU_SECONDARY = Color.rgb(139, 148, 167) + val MENU_BACKGROUND = Color.rgb(28, 28, 30) + val MENU_SECONDARY = Color.rgb(152, 152, 159) val MENU_ACCENT = Color.rgb(244, 122, 58) val MENU_ACCENT_TRACK = Color.rgb(150, 72, 32) - val MENU_TRACK_OFF = Color.rgb(35, 43, 56) - val MENU_BUTTON_TEXT = Color.rgb(43, 18, 6) - val MENU_DANGER = Color.rgb(190, 45, 45) + val MENU_TRACK_OFF = Color.rgb(57, 57, 61) + val MENU_BUTTON_TEXT = Color.WHITE + val MENU_DANGER = Color.rgb(255, 69, 58) val NO_VIDEO_BACKGROUND = Color.rgb(0x16, 0x16, 0x18) } diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 3636623260..7ea6ffe1ea 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -13,6 +13,7 @@ import android.content.res.Configuration import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable import android.media.AudioFormat import android.media.AudioTrack import android.net.Uri @@ -101,7 +102,7 @@ class DiPlayActivity : ComponentActivity() { handleWirelessRecovery() onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (page != "home") { page = "home"; render() } + if (page != "home") { page = parentPage(); render() } else { isEnabled = false; onBackPressedDispatcher.onBackPressed(); isEnabled = true } } }) @@ -133,151 +134,112 @@ class DiPlayActivity : ComponentActivity() { } override fun onPause() { handler.removeCallbacks(tick); super.onPause() } + private fun parentPage() = if (page == "settings") "home" else "settings" + private fun render() { status = null; connectButton = null; disconnectButton = null; lastRunning = null - val scroll = ScrollView(this).apply { setBackgroundColor(BG); isFillViewport = true; clipToPadding = false } - val content = column().apply { setPadding(dp(32), dp(24), dp(32), dp(32)) } - scroll.addView(content) - val header = row().apply { gravity = Gravity.CENTER_VERTICAL } - header.addView(ImageView(this).apply { setImageResource(R.drawable.ic_looplink); contentDescription = getString(R.string.carplay) }, LinearLayout.LayoutParams(dp(36), dp(36))) - header.addView(label(getString(R.string.diplay), 26, TEXT, true).apply { setPadding(dp(12), 0, 0, 0) }, LinearLayout.LayoutParams(0, dp(56), 1f)) - header.addView(button(if (page == "home") getString(R.string.car_home) else getString(R.string.back), false) { - if (page == "home") startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)) - else { page = "home"; render() } - }, LinearLayout.LayoutParams(dp(130), dp(56))) + val scroll = ScrollView(this).apply { + setBackgroundColor(BG); isFillViewport = true; clipToPadding = false; isVerticalScrollBarEnabled = false + } + val short = resources.configuration.screenHeightDp < 640 + val content = column().apply { setPadding(dp(32), dp(if (short) 12 else 20), dp(32), dp(if (short) 16 else 40)) } + // Settings-style pages read best as a centred column, like iOS on a wide display. + val width = if (page == "home") -1 else minOf(dp(1040), dp(resources.configuration.screenWidthDp)) + scroll.addView(content, FrameLayout.LayoutParams(width, -2, Gravity.CENTER_HORIZONTAL)) + val header = row().apply { gravity = Gravity.CENTER_VERTICAL; minimumHeight = dp(56) } + if (page == "home") { + header.addView(space(1), LinearLayout.LayoutParams(0, 1, 1f)) + // In the header, not under the tiles, so it never pushes them off a short screen. + disconnectButton = button(getString(R.string.disconnect), false, RED, radius = 24, icon = R.drawable.ic_dp_close) { + disconnectButton?.isEnabled = false + CarPlayBackgroundSession.stop { runOnUiThread { refreshStatus() } } + }.apply { textSize = 17f; setPadding(dp(24), 0, dp(24), 0); visibility = View.GONE } + header.addView(disconnectButton, LinearLayout.LayoutParams(-2, dp(48)).apply { marginEnd = dp(12) }) + header.addView(button(getString(R.string.car_home), false, TEXT, radius = 24, icon = R.drawable.ic_dp_home) { + startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)) + }.apply { textSize = 17f; setPadding(dp(24), 0, dp(24), 0) }, LinearLayout.LayoutParams(-2, dp(48))) + } else { + header.addView(label(getString(R.string.back), 19, ACCENT).apply { + setCompoundDrawablesRelativeWithIntrinsicBounds(icon(R.drawable.ic_dp_back, ACCENT, 30), null, null, null) + compoundDrawablePadding = dp(2); setPaddingRelative(0, 0, dp(16), 0) + background = pressable(Color.TRANSPARENT, 12); isClickable = true; isFocusable = true + setOnClickListener { page = parentPage(); render() } + }, LinearLayout.LayoutParams(-2, dp(48))) + } content.addView(header) - content.addView(space(24)) + content.addView(space(if (page == "home") (if (short) 4 else 28) else 12)) when (page) { "connection" -> connectionSetup(content) "settings" -> settings(content) "about" -> about(content) + "advanced" -> advanced(content) else -> home(content) } setContentView(scroll) refreshStatus() } + // Glanceable in a car: a status line and three large icon tiles. Details live in Settings. private fun home(content: LinearLayout) { - val wide = resources.configuration.screenWidthDp >= 850 - val body = column() - val left = column() - left.addView(label(getString(R.string.your_phone_your_drive), 12, ACCENT, true).apply { letterSpacing = .16f }) - left.addView(label(getString(R.string.a_familiar_drive), if (wide) 42 else 36, TEXT, true).apply { setPadding(0, dp(12), 0, dp(10)) }) - left.addView(label(getString(R.string.your_maps_music_and_conversations_carplay_right_here_on_yo), 19, MUTED)) - val card = card() - card.addView(label(getString(R.string.wireless_carplay), 12, ACCENT, true).apply { letterSpacing = .12f }) - status = label(getString(R.string.ready_when_you_are), 24, TEXT, true).apply { setPadding(0, dp(10), 0, dp(16)) } - card.addView(status) - connectButton = button(getString(R.string.connect_phone), true) { - if (CarPlayBackgroundSession.hasSession()) openProjection() - else connect(true) - } - card.addView(connectButton, matchButton()) - val connectionHint = when (AirPlayPersistence.loadWirelessHotspotMode(this)) { - WirelessHotspotMode.MANUAL -> getString(R.string.hotspot_hint_manual) - WirelessHotspotMode.LOCAL_ONLY_HOTSPOT -> getString(R.string.hotspot_hint_local) - else -> getString(R.string.hotspot_hint_p2p) - } - card.addView(label(connectionHint, 15, MUTED).apply { setPadding(0, dp(14), 0, 0) }) - if (carHotspotOff()) { - card.addView(label(getString(R.string.msg_car_hotspot_off, AirPlayPersistence.loadManualHotspotSsid(this)), 15, WARNING).apply { setPadding(0, dp(14), 0, 0) }) - card.addView(button(getString(R.string.open_car_hotspot_settings), false) { openCarWifiSettings() }, matchButton(10, 56)) - } - card.addView(button(getString(R.string.choose_iphone), false) { choosePhone() }, matchButton(16, 56)) - disconnectButton = button(getString(R.string.disconnect), false) { - disconnectButton?.isEnabled = false - CarPlayBackgroundSession.stop { runOnUiThread { refreshStatus() } } - }.apply { visibility = View.GONE } - card.addView(disconnectButton, matchButton(10, 56)) - val right = column().apply { gravity = Gravity.CENTER_HORIZONTAL } - val logo = ImageView(this).apply { + // Head units range from ~500dp to 1000dp tall; scale the stack so the tiles always fit. + val height = resources.configuration.screenHeightDp + val compact = height < 640 + val body = column().apply { gravity = Gravity.CENTER_HORIZONTAL } + body.addView(ImageView(this).apply { setImageResource(R.drawable.ic_looplink) - contentDescription = getString(R.string.carplay_icon) + contentDescription = getString(R.string.diplay) scaleType = ImageView.ScaleType.FIT_CENTER + }, LinearLayout.LayoutParams(dp(if (compact) 52 else 80), dp(if (compact) 52 else 80))) + status = label(getString(R.string.ready_when_you_are), if (compact) 26 else 30, TEXT, weight = 700).apply { + gravity = Gravity.CENTER; setPadding(0, dp(if (compact) 8 else 16), 0, dp(if (compact) 16 else 28)); compoundDrawablePadding = dp(14) } - val branding = column().apply { - gravity = Gravity.CENTER - addView(logo, LinearLayout.LayoutParams(dp(96), dp(96))) - } - right.addView(button(getString(R.string.connect_with_usb), false) { connect(false) }, matchButton()) - right.addView(label(getString(R.string.plug_your_iphone_into_a_usb_data_port_allow_carplay_when_y), 14, MUTED).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), dp(24)) }) - right.addView(button(getString(R.string.settings), false) { page = "settings"; render() }, matchButton()) - right.addView(label(getString(R.string.make_diplay_feel_right_for_your_car), 14, MUTED).apply { gravity = Gravity.CENTER; setPadding(0, dp(10), 0, dp(24)) }) - right.addView(label("${getString(R.string.home_public_preview)}${version()}", 12, MUTED).apply { letterSpacing = .08f }) - if (wide) { - // Both rows share column widths. The USB button starts at the wireless - // card's top edge, independently of hero wrapping or font scaling. - fun columns(first: View, second: View, stretchSecond: Boolean = false) = row().apply { - gravity = Gravity.TOP - addView(first, LinearLayout.LayoutParams(0, -2, 1.6f)) - addView(space(40), LinearLayout.LayoutParams(dp(40), 1)) - addView(second, LinearLayout.LayoutParams(0, if (stretchSecond) -1 else -2, 1f)) - } - body.addView(columns(left, branding, true)) - body.addView(space(26)) - body.addView(columns(card, right)) - } else { - body.addView(left) - body.addView(space(26)) - body.addView(card) - body.addView(space(26)) - body.addView(branding) - body.addView(space(24)) - body.addView(right) - } - setupError?.let { body.addView(label(it, 16, WARNING).apply { setPadding(0, dp(16), 0, 0) }) } + body.addView(status, LinearLayout.LayoutParams(-2, -2)) + val actions = column() + // Problems sit above the tiles they block, as one notice each, never as loose coloured text. + setupError?.let { actions.addView(notice(it)) } + if (carHotspotOff()) actions.addView(notice(getString(R.string.msg_car_hotspot_off), getString(R.string.open_car_hotspot_settings)) { openCarWifiSettings() }) + val tiles = row() + val tileHeight = dp((height * .34f).toInt().coerceIn(128, 184)) + connectButton = homeTile(getString(R.string.connect_phone), R.drawable.ic_dp_connection, true) { + if (CarPlayBackgroundSession.hasSession()) openProjection() + else connect(true) + } + tiles.addView(connectButton, LinearLayout.LayoutParams(0, tileHeight, 1.4f)) + tiles.addView(space(16), LinearLayout.LayoutParams(dp(16), 1)) + tiles.addView(homeTile(getString(R.string.connect_with_usb), R.drawable.ic_dp_usb) { connect(false) }, LinearLayout.LayoutParams(0, tileHeight, 1f)) + tiles.addView(space(16), LinearLayout.LayoutParams(dp(16), 1)) + tiles.addView(homeTile(getString(R.string.settings), R.drawable.ic_dp_settings) { page = "settings"; render() }, LinearLayout.LayoutParams(0, tileHeight, 1f)) + actions.addView(tiles) + body.addView(actions, LinearLayout.LayoutParams(minOf(dp(880), dp(resources.configuration.screenWidthDp) - dp(64)), -2)) content.addView(body) } + /** Large icon-over-label tile for the home screen. */ + private fun homeTile(title: String, icon: Int, primary: Boolean = false, click: () -> Unit) = + button(title, primary, if (primary) Color.WHITE else TEXT, radius = 24, click = click).apply { + textSize = 19f; contentDescription = title + val compact = resources.configuration.screenHeightDp < 640 + setPadding(dp(12), dp(if (compact) 12 else 24), dp(12), dp(if (compact) 10 else 20)); compoundDrawablePadding = dp(if (compact) 8 else 16) + setCompoundDrawablesRelative(null, icon(icon, if (primary) Color.WHITE else ACCENT, if (compact) 44 else 56), null, null) + } + private fun settings(content: LinearLayout) { - content.addView(label(getString(R.string.your_drive_your_way), 34, TEXT, true)) - content.addView(label(getString(R.string.apply_reconnects_carplay_for_size_resolution_music_buffer), 17, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) + content.addView(largeTitle(getString(R.string.settings), 40).apply { setPadding(0, 0, 0, dp(8)) }) section(content, getString(R.string.connection_setup), R.drawable.ic_dp_connection) { card -> - card.addView(label(getString(R.string.choose_how_to_connect_follow_the_setup_steps_and_save_your), 16, MUTED)) - card.addView(button(getString(R.string.open_connection_setup), false) { page = "connection"; render() }, matchButton(12, 60)) - } - section(content, getString(R.string.diagnostics), R.drawable.ic_dp_diagnostics) { card -> - exportButton = button(if (exportInProgress) getString(R.string.saving_report) else getString(R.string.save_diagnostic_report), false) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() - else chooseReportDestination() - }.apply { isEnabled = !exportInProgress } - card.addView(exportButton, matchButton(10, 60)) - card.addView(button(getString(R.string.choose_save_location), false) { chooseReportDestination() }, matchButton(10, 60)) - val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getString(R.string.reports_save_to_downloads_diplay) else getString(R.string.choose_where_to_save_your_report) - card.addView(label(destination + getString(R.string.nothing_is_sent_automatically_protocol_payloads_and_creden), 14, MUTED).apply { setPadding(0, dp(12), 0, 0) }) + card.addView(valueRow(getString(R.string.choose_iphone), DiPlayPreferences.phoneName(this), R.drawable.ic_dp_phone) { choosePhone() }.first) + card.addView(valueRow(getString(R.string.open_connection_setup), "", R.drawable.ic_dp_connection) { page = "connection"; render() }.first) } section(content, getString(R.string.automatic_connection), R.drawable.ic_dp_automation) { card -> toggle(card, getString(R.string.connect_when_diplay_opens), getString(R.string.use_your_last_connection_type_and_selected_iphone), DiPlayPreferences.autoConnect(this)) { DiPlayPreferences.saveAutoConnect(this, it) } toggle(card, getString(R.string.open_after_the_car_starts), getString(R.string.availability_depends_on_your_head_unit_s_startup_settings), AirPlayPersistence.loadAutoStartOnBoot(this)) { AirPlayPersistence.saveAutoStartOnBoot(this, it) } - card.addView(button("${getString(R.string.choose_iphone_prefix)}${DiPlayPreferences.phoneName(this)}", false) { choosePhone() }, matchButton(12, 60)) } section(content, getString(R.string.display_and_performance), R.drawable.ic_dp_display) { card -> carPlaySizeControl(card) - choice(card, getString(R.string.resolution), listOf(getString(R.string.resolution_native), getString(R.string.s_80_lighter_load), getString(R.string.s_60_lightest_load)), listOf(10, 8, 6).indexOf(AirPlayPersistence.loadDisplayScaleTenths(this)).coerceAtLeast(0)) { AirPlayPersistence.saveDisplayScaleTenths(this, listOf(10, 8, 6)[it]) } - val bufferPresets = com.shilapi.xcertplay.media.MediaAudioBuffer.presets - choice(card, getString(R.string.music_buffer), listOf(getString(R.string.s_300_ms_default), getString(R.string.s_500_ms), getString(R.string.s_1000_ms_most_stable)), - bufferPresets.indexOf(AirPlayPersistence.loadMediaBufferMillis(this)).coerceAtLeast(0)) { - AirPlayPersistence.saveMediaBufferMillis(this, bufferPresets[it]) - } - choice(card, getString(R.string.frame_rate), listOf(getString(R.string.s_30_fps_lighter_load), getString(R.string.s_60_fps_smoother_motion)), if (AirPlayPersistence.loadFps(this) == 60) 1 else 0) { AirPlayPersistence.saveFps(this, if (it == 1) 60 else 30) } - toggle(card, getString(R.string.efficient_video), getString(R.string.use_hevc_leave_off_for_the_widest_head_unit_compatibility), AirPlayPersistence.loadHevcEnabled(this)) { AirPlayPersistence.saveHevcEnabled(this, it) } toggle(card, getString(R.string.right_hand_drive), getString(R.string.place_carplay_s_controls_closer_to_the_driver), AirPlayPersistence.loadRightHandDrive(this)) { AirPlayPersistence.saveRightHandDrive(this, it) } toggle(card, getString(R.string.full_screen), getString(R.string.hide_the_car_s_system_bars_while_carplay_is_open), AirPlayPersistence.loadHideTopBar(this) && AirPlayPersistence.loadHideBottomBar(this)) { AirPlayPersistence.saveHideTopBar(this, it); AirPlayPersistence.saveHideBottomBar(this, it) } } - section(content, getString(R.string.audio_routing)) { card -> - toggle(card, getString(R.string.contrib_audio_home_toggle_audio_focus), getString(R.string.contrib_audio_home_toggle_audio_focus_desc), AirPlayPersistence.loadAudioFocusEnabled(this)) { AirPlayPersistence.saveAudioFocusEnabled(this, it) } - if (resources.getBoolean(R.bool.config_advanced_audio_channel_mapping)) { - toggle(card, getString(R.string.advanced_audio_channel_mapping), - getString(R.string.use_usage_content_type_routing_instead_of_stream_type), - AirPlayPersistence.loadAdvancedAudioChannelMapping(this)) { - AirPlayPersistence.saveAdvancedAudioChannelMapping(this, it) - } - } - mediaChannelControl(card) - navigationChannelControl(card) - } section(content, getString(R.string.location), R.drawable.ic_dp_navigation) { card -> toggle(card, getString(R.string.report_location_to_iphone), getString(R.string.sends_precise_android_location_as_carplay_gps_data_when_th), @@ -292,18 +254,58 @@ class DiPlayActivity : ComponentActivity() { } section(content, getString(R.string.permissions_and_connection_help), R.drawable.ic_dp_permissions) { card -> card.addView(label(getString(R.string.nearby_devices_connects_your_iphone_microphone_enables_sir), 16, MUTED)) - card.addView(button(getString(R.string.app_permissions), false) { openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) }, matchButton(16, 60)) - card.addView(button(getString(R.string.bluetooth_settings), false) { openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }, matchButton(10, 60)) - card.addView(button(getString(R.string.wireless_connection_help), false) { wirelessHelp() }, matchButton(10, 60)) - } - section(content, getString(R.string.about), R.drawable.ic_dp_about) { card -> - card.addView(button(getString(R.string.about_diplay), false) { page = "about"; render() }, matchButton(0, 60)) + card.addView(button(getString(R.string.app_permissions), false, icon = R.drawable.ic_dp_permissions) { openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) }, matchButton(16, 60)) + card.addView(button(getString(R.string.bluetooth_settings), false, icon = R.drawable.ic_dp_bluetooth) { openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }, matchButton(10, 60)) + card.addView(button(getString(R.string.wireless_connection_help), false, icon = R.drawable.ic_dp_help) { wirelessHelp() }, matchButton(10, 60)) } languageSettings(content) + section(content, "") { card -> + card.addView(valueRow(getString(R.string.about_diplay), "", R.drawable.ic_dp_about) { page = "about"; render() }.first) + card.addView(valueRow(getString(R.string.advanced), "", R.drawable.ic_dp_settings) { page = "advanced"; render() }.first) + } + content.addView(label(getString(R.string.advanced_hint), 14, MUTED).apply { setPaddingRelative(dp(4), 0, 0, 0) }) + } + + // Options most drivers never need: video tuning, audio channels and diagnostic reports. + private fun advanced(content: LinearLayout) { + content.addView(largeTitle(getString(R.string.advanced), 40)) + content.addView(label(getString(R.string.apply_reconnects_carplay_for_size_resolution_music_buffer), 17, MUTED).apply { setPadding(0, dp(8), 0, dp(8)) }) + section(content, getString(R.string.display_and_performance), R.drawable.ic_dp_display) { card -> + choice(card, getString(R.string.resolution), listOf(getString(R.string.resolution_native), getString(R.string.s_80_lighter_load), getString(R.string.s_60_lightest_load)), listOf(10, 8, 6).indexOf(AirPlayPersistence.loadDisplayScaleTenths(this)).coerceAtLeast(0)) { AirPlayPersistence.saveDisplayScaleTenths(this, listOf(10, 8, 6)[it]) } + val bufferPresets = com.shilapi.xcertplay.media.MediaAudioBuffer.presets + choice(card, getString(R.string.music_buffer), listOf(getString(R.string.s_300_ms_default), getString(R.string.s_500_ms), getString(R.string.s_1000_ms_most_stable)), + bufferPresets.indexOf(AirPlayPersistence.loadMediaBufferMillis(this)).coerceAtLeast(0)) { + AirPlayPersistence.saveMediaBufferMillis(this, bufferPresets[it]) + } + choice(card, getString(R.string.frame_rate), listOf(getString(R.string.s_30_fps_lighter_load), getString(R.string.s_60_fps_smoother_motion)), if (AirPlayPersistence.loadFps(this) == 60) 1 else 0) { AirPlayPersistence.saveFps(this, if (it == 1) 60 else 30) } + toggle(card, getString(R.string.efficient_video), getString(R.string.use_hevc_leave_off_for_the_widest_head_unit_compatibility), AirPlayPersistence.loadHevcEnabled(this)) { AirPlayPersistence.saveHevcEnabled(this, it) } + } + section(content, getString(R.string.audio_routing), R.drawable.ic_dp_audio) { card -> + toggle(card, getString(R.string.contrib_audio_home_toggle_audio_focus), getString(R.string.contrib_audio_home_toggle_audio_focus_desc), AirPlayPersistence.loadAudioFocusEnabled(this)) { AirPlayPersistence.saveAudioFocusEnabled(this, it) } + if (resources.getBoolean(R.bool.config_advanced_audio_channel_mapping)) { + toggle(card, getString(R.string.advanced_audio_channel_mapping), + getString(R.string.use_usage_content_type_routing_instead_of_stream_type), + AirPlayPersistence.loadAdvancedAudioChannelMapping(this)) { + AirPlayPersistence.saveAdvancedAudioChannelMapping(this, it) + } + } + mediaChannelControl(card) + navigationChannelControl(card) + } + section(content, getString(R.string.diagnostics), R.drawable.ic_dp_diagnostics) { card -> + exportButton = button(if (exportInProgress) getString(R.string.saving_report) else getString(R.string.save_diagnostic_report), false, icon = R.drawable.ic_dp_save) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() + else chooseReportDestination() + }.apply { isEnabled = !exportInProgress } + card.addView(exportButton, matchButton(10, 60)) + card.addView(button(getString(R.string.choose_save_location), false, icon = R.drawable.ic_dp_folder) { chooseReportDestination() }, matchButton(10, 60)) + val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) getString(R.string.reports_save_to_downloads_diplay) else getString(R.string.choose_where_to_save_your_report) + card.addView(label(destination + getString(R.string.nothing_is_sent_automatically_protocol_payloads_and_creden), 14, MUTED).apply { setPadding(0, dp(12), 0, 0) }) + } } private fun about(content: LinearLayout) { - content.addView(label(getString(R.string.diplay), 40, TEXT, true)) + content.addView(largeTitle(getString(R.string.diplay), 44)) content.addView(label(getString(R.string.carplay_at_home_in_your_car), 20, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) section(content, "${getString(R.string.about_public_preview_prefix)}${version()}") { card -> card.addView(label(getString(R.string.an_independent_carplay_receiver_for_android_head_units_wir), 17, TEXT)) @@ -314,13 +316,15 @@ class DiPlayActivity : ComponentActivity() { } // The car hotspot link needs the hotspot on; DiPlay only checks it (turning it on needs ADB-only permission). + // With no saved hotspot name, Connect already opens hotspot setup, so there is nothing to warn about. private fun carHotspotOff(): Boolean = AirPlayPersistence.loadWirelessHotspotMode(this) == WirelessHotspotMode.MANUAL && + storedSsid().isNotBlank() && com.shilapi.xcertplay.network.CarHotspotStatus.isEnabled(this) == false private fun carHotspotOffDialog() { AlertDialog.Builder(this).setTitle(getString(R.string.car_hotspot_is_off)) - .setMessage(getString(R.string.msg_car_hotspot_connect, AirPlayPersistence.loadManualHotspotSsid(this))) + .setMessage(getString(R.string.msg_car_hotspot_connect)) .setPositiveButton(getString(R.string.open_car_settings)) { _, _ -> openCarWifiSettings() } .setNeutralButton(getString(R.string.connect)) { _, _ -> connect(true) } .setNegativeButton(getString(R.string.cancel), null).show() @@ -352,23 +356,23 @@ class DiPlayActivity : ComponentActivity() { } private fun connectionSetup(content: LinearLayout) { - content.addView(label(getString(R.string.connection_setup), 34, TEXT, true)) + content.addView(largeTitle(getString(R.string.connection_setup), 40)) content.addView(label(getString(R.string.set_up_once_your_details_stay_saved_for_the_next_drive_cha), 17, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) section(content, getString(R.string.s_1_choose_your_connection)) { card -> wirelessLinkControls(card) } section(content, getString(R.string.s_2_pair_your_iphone)) { card -> card.addView(label(getString(R.string.keep_bluetooth_and_wi_fi_on_your_iphone_pair_with_the_car), 16, MUTED)) - card.addView(button("${getString(R.string.choose_iphone_prefix)}${DiPlayPreferences.phoneName(this)}", false) { choosePhone() }, matchButton(12, 60)) - card.addView(button(getString(R.string.review_app_permissions), false) { + card.addView(valueRow(getString(R.string.choose_iphone), DiPlayPreferences.phoneName(this), R.drawable.ic_dp_phone) { choosePhone() }.first) + card.addView(button(getString(R.string.review_app_permissions), false, icon = R.drawable.ic_dp_permissions) { openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) }, matchButton(12, 60)) } section(content, getString(R.string.s_3_connect)) { card -> card.addView(label(getString(R.string.return_from_car_settings_to_diplay_then_connect_accept_the), 16, MUTED)) - card.addView(button(getString(R.string.connect_phone), true) { connect(true) }, matchButton(12, 60)) + card.addView(button(getString(R.string.connect_phone), true, icon = R.drawable.ic_dp_phone) { connect(true) }, matchButton(12, 60)) } section(content, getString(R.string.prefer_a_cable)) { card -> card.addView(label(getString(R.string.use_a_usb_data_cable_and_the_car_s_usb_data_port_unlock_yo), 16, MUTED)) - card.addView(button(getString(R.string.connect_with_usb), false) { connect(false) }, matchButton(12, 60)) + card.addView(button(getString(R.string.connect_with_usb), false, icon = R.drawable.ic_dp_usb) { connect(false) }, matchButton(12, 60)) } } @@ -402,7 +406,7 @@ class DiPlayActivity : ComponentActivity() { if (mode == WirelessHotspotMode.MANUAL) { parent.addView(label(getString(R.string.hotspot_setup), 22, TEXT, true)) parent.addView(label(getString(R.string.s_1_open_car_hotspot_settings_turn_the_hotspot_on_and_sele), 16, MUTED).apply { setPadding(0, dp(8), 0, dp(12)) }) - parent.addView(button(getString(R.string.open_car_hotspot_settings), false) { openCarWifiSettings() }, matchButton(0, 60)) + parent.addView(button(getString(R.string.open_car_hotspot_settings), false, icon = R.drawable.ic_dp_hotspot) { openCarWifiSettings() }, matchButton(0, 60)) parent.addView(button(if (pendingCarHotspotSetup) getString(R.string.save_hotspot_details_and_use_this_mode) else "${getString(R.string.edit_saved_hotspot_prefix)}${storedSsid()}", false) { askHotspotCredentials { ssid, password -> saveHotspotCredentials(ssid, password) @@ -413,44 +417,40 @@ class DiPlayActivity : ComponentActivity() { parent.addView(label(if (pendingCarHotspotSetup) getString(R.string.finish_setup_save_your_hotspot_details_to_use_this_mode) else if (carHotspotOff()) getString(R.string.hotspot_details_off) else getString(R.string.hotspot_details_saved), 15, if (carHotspotOff()) WARNING else MUTED).apply { setPadding(0, dp(12), 0, 0) }) } else { parent.addView(label(getString(R.string.turn_the_car_s_wi_fi_switch_on_allow_location_nearby_devic), 16, MUTED)) - parent.addView(button(getString(R.string.open_car_wi_fi_settings), false) { openCarClientWifiSettings() }, matchButton(12, 60)) + parent.addView(button(getString(R.string.open_car_wi_fi_settings), false, icon = R.drawable.ic_dp_connection) { openCarClientWifiSettings() }, matchButton(12, 60)) } } private fun mediaChannelControl(parent: LinearLayout) { - val summary: (Int) -> String = { - getString(R.string.contrib_audio_home_choice_summary, getString(R.string.contrib_audio_home_media_channel_label), channelLabel(it)) - } - val control = button(summary(AirPlayPersistence.loadMediaAudioChannel(this)), false) {} - control.setOnClickListener { + lateinit var value: TextView + val (line, view) = valueRow(getString(R.string.contrib_audio_home_media_channel_label), channelLabel(AirPlayPersistence.loadMediaAudioChannel(this)), R.drawable.ic_dp_audio) { val current = AirPlayPersistence.loadMediaAudioChannel(this) showChannelDialog( title = getString(R.string.contrib_audio_home_media_channel_label), current = current, navigation = false, - onApply = { value -> applyMediaChannel(value, current, control, summary) }, + onApply = { selected -> applyMediaChannel(selected, current, value) }, ) } - parent.addView(control, matchButton(0, 60)) + value = view + parent.addView(line) } private fun navigationChannelControl(parent: LinearLayout) { - val summary: (Int) -> String = { - getString(R.string.contrib_audio_home_choice_summary, getString(R.string.contrib_audio_home_nav_channel_label), channelLabel(it)) - } - val control = button(summary(AirPlayPersistence.loadNavigationAudioChannel(this)), false) {} - control.setOnClickListener { + lateinit var value: TextView + val (line, view) = valueRow(getString(R.string.contrib_audio_home_nav_channel_label), channelLabel(AirPlayPersistence.loadNavigationAudioChannel(this)), R.drawable.ic_dp_navigation) { val current = AirPlayPersistence.loadNavigationAudioChannel(this) showChannelDialog( title = getString(R.string.contrib_audio_home_nav_channel_label), current = current, navigation = true, - onApply = { value -> applyNavigationChannel(value, current, control, summary) }, + onApply = { selected -> applyNavigationChannel(selected, current, value) }, ) } - parent.addView(control, matchButton(0, 60)) + value = view + parent.addView(line) parent.addView(label(getString(R.string.contrib_audio_home_nav_channel_note), 14, MUTED).apply { - setPadding(0, dp(8), 0, dp(18)) + setPadding(0, dp(4), 0, dp(14)) }) } @@ -473,17 +473,17 @@ class DiPlayActivity : ComponentActivity() { .show() } - private fun applyMediaChannel(value: Int, previous: Int, control: Button, summary: (Int) -> String) { + private fun applyMediaChannel(value: Int, previous: Int, control: TextView) { if (value == previous) return AirPlayPersistence.saveMediaAudioChannel(this, value) - control.text = summary(value) + control.text = channelLabel(value) if (CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this)) } - private fun applyNavigationChannel(value: Int, previous: Int, control: Button, summary: (Int) -> String) { + private fun applyNavigationChannel(value: Int, previous: Int, control: TextView) { if (value == previous) return AirPlayPersistence.saveNavigationAudioChannel(this, value) - control.text = summary(value) + control.text = channelLabel(value) if (CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this)) } @@ -721,6 +721,15 @@ class DiPlayActivity : ComponentActivity() { DiPlayPreferences.phoneAddress(this) != null -> "${getString(R.string.status_ready_for_prefix)}${DiPlayPreferences.phoneName(this)}" else -> getString(R.string.ready_when_you_are) } + val dot = when { + setupError != null -> RED + CarPlayBackgroundSession.active -> GREEN + running -> ACCENT + else -> GRAY + } + status?.setCompoundDrawablesRelativeWithIntrinsicBounds(GradientDrawable().apply { + shape = GradientDrawable.OVAL; setColor(dot); setSize(dp(12), dp(12)) + }, null, null, null) if (lastRunning != running) { connectButton?.text = if (running) getString(R.string.open_carplay) else getString(R.string.connect_phone) disconnectButton?.visibility = if (running) View.VISIBLE else View.GONE @@ -728,6 +737,7 @@ class DiPlayActivity : ComponentActivity() { lastRunning = running } connectButton?.isEnabled = setupError == null + connectButton?.compoundDrawablesRelative?.get(1)?.setTint(if (setupError == null) Color.WHITE else MUTED) } private fun reportFileName() = "LoopLink-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date())}.txt" @@ -781,7 +791,7 @@ class DiPlayActivity : ComponentActivity() { runOnUiThread { exportInProgress = false if (isFinishing || isDestroyed) return@runOnUiThread - exportButton?.apply { isEnabled = true; text = getString(R.string.save_diagnostic_report) } + exportButton?.apply { isEnabled = true; text = withIcon(getString(R.string.save_diagnostic_report), R.drawable.ic_dp_save, ACCENT) } if (result.isSuccess) { val savedUri = result.getOrThrow() AlertDialog.Builder(this).setTitle(getString(R.string.diagnostic_report_saved)) @@ -898,38 +908,82 @@ class DiPlayActivity : ComponentActivity() { } private fun version() = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-beta.1" private fun languageSettings(content: LinearLayout) { - section(content, getString(R.string.language_section_title)) { card -> + section(content, getString(R.string.language_section_title), R.drawable.ic_dp_language) { card -> card.addView(label(getString(R.string.language_hint), 14, MUTED)) val current = AppLocale.preference(this) - val languageButton = button("${getString(R.string.language_app_language)} · ${AppLocale.displayName(this, current)}", false) { } - languageButton.setOnClickListener { AppLocale.showPicker(this) } - card.addView(languageButton, matchButton(12, 60)) + card.addView(valueRow(getString(R.string.language_app_language), AppLocale.displayName(this, current), R.drawable.ic_dp_language) { AppLocale.showPicker(this) }.first) } } private fun section(parent: LinearLayout, title: String, icon: Int? = null, build: (LinearLayout) -> Unit) { + val heading = row().apply { gravity = Gravity.CENTER_VERTICAL; setPaddingRelative(dp(4), dp(18), 0, dp(10)) } + if (title.isEmpty()) heading.setPadding(0, dp(18), 0, 0) + else if (icon != null) heading.addView(tile(icon, tileColor(icon), 30), LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(12) }) + if (title.isNotEmpty()) heading.addView(label(title, 20, TEXT, weight = 600), LinearLayout.LayoutParams(0, -2, 1f)) + parent.addView(heading) val card = card() - val heading = row().apply { gravity = Gravity.CENTER_VERTICAL; setPadding(0, 0, 0, dp(16)) } - if (icon != null) heading.addView(ImageView(this).apply { - setImageResource(icon); imageTintList = ColorStateList.valueOf(ACCENT) - importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - }, LinearLayout.LayoutParams(dp(28), dp(28)).apply { marginEnd = dp(12) }) - heading.addView(label(title, 22, TEXT, true), LinearLayout.LayoutParams(0, -2, 1f)) - card.addView(heading) build(card) - parent.addView(card, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(18) }) + groupRows(card) + parent.addView(card, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(10) }) + } + + // iOS Settings tile colours, one per section. + private fun tileColor(icon: Int) = when (icon) { + R.drawable.ic_dp_connection, R.drawable.ic_dp_language -> Color.rgb(10, 132, 255) + R.drawable.ic_dp_diagnostics -> Color.rgb(94, 92, 230) + R.drawable.ic_dp_automation -> Color.rgb(191, 90, 242) + R.drawable.ic_dp_display -> Color.rgb(100, 160, 255) + R.drawable.ic_dp_navigation -> GREEN + R.drawable.ic_dp_permissions -> Color.rgb(255, 159, 10) + R.drawable.ic_dp_audio -> Color.rgb(255, 55, 95) + else -> GRAY + } + + // Plain buttons in a grouped card become full-width action rows; hairlines separate adjacent rows only, + // so descriptive text reads as part of the cell above it. + private fun groupRows(card: LinearLayout) { + val children = (0 until card.childCount).map(card::getChildAt) + card.removeAllViews() + var previousRow = false + for (child in children) { + if (child is Button && child.tag != PRIMARY) actionRow(child) + val isRow = child.tag == ROW + if (isRow && previousRow) card.addView(separator()) + card.addView(child) + previousRow = isRow + } + } + + private fun actionRow(button: Button) = button.apply { + background = pressable(Color.TRANSPARENT, 12) + gravity = Gravity.START or Gravity.CENTER_VERTICAL; textAlignment = View.TEXT_ALIGNMENT_VIEW_START + typeface = font(400); textSize = 18f; setPadding(0, 0, 0, 0) + minHeight = dp(60); minimumHeight = dp(60) + layoutParams = LinearLayout.LayoutParams(-1, -2) + tag = ROW } + private fun toggle(parent: LinearLayout, title: String, description: String, value: Boolean, save: (Boolean) -> Unit) { - val line = row().apply { gravity = Gravity.CENTER_VERTICAL; setPadding(0, dp(12), 0, dp(12)) } - val text = column(); text.addView(label(title, 18, TEXT, true)); text.addView(label(description, 14, MUTED).apply { setPadding(0, dp(6), dp(16), 0) }) + val line = row().apply { gravity = Gravity.CENTER_VERTICAL; minimumHeight = dp(64); setPadding(0, dp(14), 0, dp(14)); tag = ROW } + val text = column(); text.addView(label(title, 18, TEXT)); text.addView(label(description, 14, MUTED).apply { setPaddingRelative(0, dp(4), dp(16), 0) }) line.addView(text, LinearLayout.LayoutParams(0, -2, 1f)) - line.addView(Switch(this).apply { contentDescription = title; isChecked = value; minHeight = dp(56); buttonTintList = ColorStateList.valueOf(ACCENT); setOnCheckedChangeListener { _, checked -> save(checked) } }) + line.addView(Switch(this).apply { + contentDescription = title; isChecked = value; minHeight = dp(56); background = null + // iOS switch: white knob on a green (on) or grey (off) capsule. + thumbDrawable = InsetDrawable(GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.WHITE); setSize(dp(28), dp(28)) }, dp(2), dp(3), dp(2), dp(3)) + trackDrawable = GradientDrawable().apply { setColor(Color.WHITE); cornerRadius = dp(17).toFloat(); setSize(dp(64), dp(34)) } + thumbTintList = ColorStateList.valueOf(Color.WHITE) + trackTintList = ColorStateList(arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), intArrayOf(GREEN, Color.rgb(57, 57, 61))) + switchMinWidth = dp(64) + setOnCheckedChangeListener { _, checked -> save(checked) } + }) parent.addView(line) } + private fun choice(parent: LinearLayout, title: String, options: List, current: Int, reconnects: Boolean = true, save: (Int) -> Unit) { var selection = current - val button = button("$title · ${options[selection]}", false) {} - button.setOnClickListener { + lateinit var value: TextView + val (line, view) = valueRow(title, options[selection]) { var pendingSelection = selection AlertDialog.Builder(this).setTitle(title) .setSingleChoiceItems(options.toTypedArray(), selection) { _, index -> pendingSelection = index } @@ -937,41 +991,147 @@ class DiPlayActivity : ComponentActivity() { if (pendingSelection != selection) { selection = pendingSelection save(selection) - button.text = "$title · ${options[selection]}" + value.text = options[selection] if (reconnects && CarPlayBackgroundSession.hasSession()) { connect(AirPlayPersistence.loadWirelessEnabled(this)) } } }.setNegativeButton(getString(R.string.cancel), null).show() } - parent.addView(button, matchButton(0, 60)); parent.addView(space(12)) + value = view + parent.addView(line) + } + + /** iOS disclosure row: title, current value and a chevron. Returns the row and its value label. */ + private fun valueRow(title: String, value: String, icon: Int? = null, click: () -> Unit): Pair { + val valueView = label(value, 17, MUTED).apply { + maxLines = 1; ellipsize = android.text.TextUtils.TruncateAt.END; maxWidth = dp(380) + } + val line = row().apply { + gravity = Gravity.CENTER_VERTICAL; minimumHeight = dp(60); tag = ROW + background = pressable(Color.TRANSPARENT, 12); isClickable = true; isFocusable = true + setOnClickListener { click() } + if (icon != null) addView(ImageView(this@DiPlayActivity).apply { + setImageResource(icon); imageTintList = ColorStateList.valueOf(ACCENT) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginEnd = dp(14) }) + addView(label(title, 18, TEXT), LinearLayout.LayoutParams(0, -2, 1f)) + addView(valueView, LinearLayout.LayoutParams(-2, -2).apply { marginStart = dp(16) }) + addView(ImageView(this@DiPlayActivity).apply { + setImageResource(R.drawable.ic_dp_chevron); imageTintList = ColorStateList.valueOf(TERTIARY) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginStart = dp(4) }) + } + return line to valueView + } + + /** Inline notice: brand-tinted icon, plain readable text and an optional action. */ + private fun notice(message: String, actionTitle: String? = null, action: (() -> Unit)? = null) = column().apply { + background = GradientDrawable().apply { setColor(SURFACE); cornerRadius = dp(16).toFloat() } + setPadding(dp(16), dp(16), dp(16), dp(if (actionTitle == null) 16 else 8)) + layoutParams = LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(12) } + accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE + addView(row().apply { + gravity = Gravity.TOP + addView(ImageView(this@DiPlayActivity).apply { + setImageResource(R.drawable.ic_dp_notice); imageTintList = ColorStateList.valueOf(ACCENT) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(24), dp(24)).apply { marginEnd = dp(12) }) + addView(label(message, 16, TEXT), LinearLayout.LayoutParams(0, -2, 1f)) + }) + if (actionTitle != null && action != null) addView(label(actionTitle, 16, ACCENT, weight = 600).apply { + minHeight = dp(48); setPaddingRelative(dp(36), 0, 0, 0) + background = pressable(Color.TRANSPARENT, 8); isClickable = true; isFocusable = true + setOnClickListener { action() } + }, LinearLayout.LayoutParams(-1, -2)) + } + + private fun tile(icon: Int, color: Int, size: Int) = ImageView(this).apply { + setImageResource(icon); imageTintList = ColorStateList.valueOf(Color.WHITE) + background = GradientDrawable().apply { setColor(color); cornerRadius = dp(size).toFloat() * .26f } + val inset = (dp(size) * .2f).toInt(); setPadding(inset, inset, inset, inset) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + } + + private fun icon(resource: Int, color: Int, size: Int) = getDrawable(resource)!!.mutate().apply { + setTint(color); setBounds(0, 0, dp(size), dp(size)) + } + + private fun separator(insetStart: Int = 0) = View(this).apply { + setBackgroundColor(SEPARATOR) + layoutParams = LinearLayout.LayoutParams(-1, 1).apply { marginStart = insetStart } + } + + private fun card(radius: Int = 22) = column().apply { + background = GradientDrawable().apply { setColor(SURFACE); cornerRadius = dp(radius).toFloat() } + setPadding(dp(20), dp(10), dp(20), dp(10)) } - private fun card() = column().apply { background = rounded(SURFACE, BORDER); setPadding(dp(24), dp(24), dp(24), dp(24)) } private fun column() = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } private fun row() = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } - private fun label(value: String, size: Int, color: Int, bold: Boolean = false) = TextView(this).apply { - text = value; textSize = size.toFloat(); setTextColor(color); gravity = Gravity.CENTER_VERTICAL - typeface = if (bold) Typeface.create("sans-serif-medium", Typeface.NORMAL) else Typeface.create("sans-serif", Typeface.NORMAL) + private fun font(weight: Int) = Typeface.create(Typeface.SANS_SERIF, weight, false) + private fun label(value: String, size: Int, color: Int, bold: Boolean = false, weight: Int = if (bold) 600 else 400) = TextView(this).apply { + text = value; textSize = size.toFloat(); setTextColor(color); gravity = Gravity.CENTER_VERTICAL or Gravity.START + typeface = font(weight) setLineSpacing(dp(3).toFloat(), 1f) } - private fun button(title: String, primary: Boolean, click: () -> Unit) = Button(this).apply { - text = title; isAllCaps = false; textSize = 18f; setTextColor(if (primary) BG else TEXT) - typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) - background = android.graphics.drawable.RippleDrawable(ColorStateList.valueOf(0x33F47A3A), rounded(if (primary) ACCENT else SURFACE, if (primary) ACCENT else BORDER), null) + private fun largeTitle(value: String, size: Int) = label(value, size, TEXT, weight = 700).apply { letterSpacing = -.015f } + private fun button(title: String, primary: Boolean, textColor: Int = if (primary) Color.WHITE else ACCENT, radius: Int = 18, icon: Int? = null, click: () -> Unit) = Button(this).apply { + text = if (icon == null) title else withIcon(title, icon, textColor); isAllCaps = false + if (icon != null) contentDescription = title; textSize = 18f; typeface = font(600) + setTextColor(ColorStateList(arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), + // A disabled primary button dims as a whole (alpha), so only its text stays at full strength. + intArrayOf(if (primary) MUTED else Color.argb(100, Color.red(textColor), Color.green(textColor), Color.blue(textColor)), textColor))) + background = if (primary) android.graphics.drawable.RippleDrawable(ColorStateList.valueOf(PRESSED), GradientDrawable().apply { + // Disabled reads as unavailable (neutral grey), not as a faded brand colour. + color = ColorStateList(arrayOf(intArrayOf(-android.R.attr.state_enabled), intArrayOf()), intArrayOf(FILL, ACCENT)) + cornerRadius = dp(radius).toFloat() + }, null) else pressable(FILL, radius) setPadding(dp(16), 0, dp(16), 0); minHeight = dp(56); stateListAnimator = null + if (primary) tag = PRIMARY setOnClickListener { click() } } - private fun rounded(color: Int, stroke: Int) = GradientDrawable().apply { setColor(color); cornerRadius = dp(20).toFloat(); setStroke(dp(1), stroke) } - private fun matchButton(top: Int = 0, height: Int = 68) = LinearLayout.LayoutParams(-1, dp(height)).apply { topMargin = dp(top) } + // The icon rides in the text, so it stays beside the label in both centred buttons and start-aligned rows. + private fun withIcon(title: String, icon: Int, color: Int) = android.text.SpannableString("\u0000 $title").apply { + setSpan(CenteredIcon(icon(icon, color, 22)), 0, 1, android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + private class CenteredIcon(drawable: android.graphics.drawable.Drawable) : android.text.style.ImageSpan(drawable) { + override fun draw(canvas: android.graphics.Canvas, text: CharSequence?, start: Int, end: Int, x: Float, + top: Int, y: Int, bottom: Int, paint: android.graphics.Paint) { + val icon = drawable + canvas.save() + // Centre on the glyphs around the baseline, not the line box (which includes extra line spacing). + val metrics = paint.fontMetrics + canvas.translate(x, y + (metrics.ascent + metrics.descent) / 2f - icon.bounds.height() / 2f) + icon.draw(canvas) + canvas.restore() + } + } + + private fun pressable(color: Int, radius: Int) = android.graphics.drawable.RippleDrawable(ColorStateList.valueOf(PRESSED), + GradientDrawable().apply { setColor(color); cornerRadius = dp(radius).toFloat() }, null) + private fun rounded(color: Int, stroke: Int) = GradientDrawable().apply { setColor(color); cornerRadius = dp(18).toFloat(); setStroke(dp(1), stroke) } + private fun matchButton(top: Int = 0, height: Int = 64) = LinearLayout.LayoutParams(-1, dp(height)).apply { topMargin = dp(top) } private fun space(height: Int) = View(this).apply { layoutParams = LinearLayout.LayoutParams(1, dp(height)) } private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() companion object { - private val BG = Color.rgb(7, 9, 13) - private val SURFACE = Color.rgb(17, 21, 29) - private val BORDER = Color.rgb(35, 43, 56) + // iOS dark-mode system colours, tinted with the LoopLink orange. + private val BG = Color.BLACK + private val SURFACE = Color.rgb(28, 28, 30) + private val FILL = Color.rgb(44, 44, 46) + private val SEPARATOR = Color.rgb(56, 56, 58) + private val BORDER = SEPARATOR private val ACCENT = Color.rgb(244, 122, 58) - private val TEXT = Color.rgb(232, 236, 243) - private val MUTED = Color.rgb(139, 148, 167) - private val WARNING = Color.rgb(251, 191, 36) + private val TEXT = Color.WHITE + private val MUTED = Color.rgb(152, 152, 159) + private val TERTIARY = Color.rgb(99, 99, 102) + private val GRAY = Color.rgb(142, 142, 147) + private val GREEN = Color.rgb(48, 209, 88) + private val RED = Color.rgb(255, 69, 58) + // Soft peach: noticeable, but in the same family as the orange tint. + private val WARNING = Color.rgb(255, 184, 140) + private const val PRESSED = 0x33FFFFFF + private const val ROW = "row" + private const val PRIMARY = "primary" } } diff --git a/common/src/main/res/drawable/dp_dialog_background.xml b/common/src/main/res/drawable/dp_dialog_background.xml new file mode 100644 index 0000000000..d3145db93c --- /dev/null +++ b/common/src/main/res/drawable/dp_dialog_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/common/src/main/res/drawable/ic_dp_audio.xml b/common/src/main/res/drawable/ic_dp_audio.xml new file mode 100644 index 0000000000..fe91a8f9e9 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_audio.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_back.xml b/common/src/main/res/drawable/ic_dp_back.xml new file mode 100644 index 0000000000..1d82c29156 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_back.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_bluetooth.xml b/common/src/main/res/drawable/ic_dp_bluetooth.xml new file mode 100644 index 0000000000..a3e149ee00 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_bluetooth.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_chevron.xml b/common/src/main/res/drawable/ic_dp_chevron.xml new file mode 100644 index 0000000000..4313e73a67 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_chevron.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_close.xml b/common/src/main/res/drawable/ic_dp_close.xml new file mode 100644 index 0000000000..ef15874d45 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_close.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_folder.xml b/common/src/main/res/drawable/ic_dp_folder.xml new file mode 100644 index 0000000000..f25f6601f2 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_folder.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_help.xml b/common/src/main/res/drawable/ic_dp_help.xml new file mode 100644 index 0000000000..aca2193fe6 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_help.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_home.xml b/common/src/main/res/drawable/ic_dp_home.xml new file mode 100644 index 0000000000..e1afc55c79 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_home.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_hotspot.xml b/common/src/main/res/drawable/ic_dp_hotspot.xml new file mode 100644 index 0000000000..f17bdc98d6 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_hotspot.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_language.xml b/common/src/main/res/drawable/ic_dp_language.xml new file mode 100644 index 0000000000..86ee0082f4 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_language.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_notice.xml b/common/src/main/res/drawable/ic_dp_notice.xml new file mode 100644 index 0000000000..a852a1e07f --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_notice.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_phone.xml b/common/src/main/res/drawable/ic_dp_phone.xml new file mode 100644 index 0000000000..b274f4d0bf --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_phone.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_save.xml b/common/src/main/res/drawable/ic_dp_save.xml new file mode 100644 index 0000000000..4583060c9d --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_save.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_settings.xml b/common/src/main/res/drawable/ic_dp_settings.xml new file mode 100644 index 0000000000..41c96624f1 --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_settings.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_dp_usb.xml b/common/src/main/res/drawable/ic_dp_usb.xml new file mode 100644 index 0000000000..b4656db1cf --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_usb.xml @@ -0,0 +1 @@ + diff --git a/common/src/main/res/drawable/ic_launcher_foreground.xml b/common/src/main/res/drawable/ic_launcher_foreground.xml index c2d00176fb..74107227a4 100644 --- a/common/src/main/res/drawable/ic_launcher_foreground.xml +++ b/common/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,5 +1,5 @@ - + + diff --git a/common/src/main/res/drawable/ic_looplink.xml b/common/src/main/res/drawable/ic_looplink.xml index da94e41557..a5807c4cbc 100644 --- a/common/src/main/res/drawable/ic_looplink.xml +++ b/common/src/main/res/drawable/ic_looplink.xml @@ -1,5 +1,5 @@ - + + diff --git a/common/src/main/res/drawable/ic_looplink_notification.xml b/common/src/main/res/drawable/ic_looplink_notification.xml index f90c4d511e..46459da98f 100644 --- a/common/src/main/res/drawable/ic_looplink_notification.xml +++ b/common/src/main/res/drawable/ic_looplink_notification.xml @@ -1,5 +1,5 @@ - + + diff --git a/common/src/main/res/values-ar/strings.xml b/common/src/main/res/values-ar/strings.xml index ad3846fdb4..870bd125cb 100644 --- a/common/src/main/res/values-ar/strings.xml +++ b/common/src/main/res/values-ar/strings.xml @@ -48,11 +48,11 @@ بانتظار إعادة تعداد iPhone جارٍ توصيل شبكة NCM/AirPlay فشل: %1$s - نقطة اتصال السيارة «%1$s» مُغلقة. شغّلها في إعدادات السيارة قبل الاتصال. + نقطة اتصال السيارة مُغلقة. شغّلها في إعدادات السيارة، ثم اتصل. معاينة عامة ·\u0020\u0020 اختر iPhone ·\u0020 معاينة عامة ·\u0020 - يتصل LoopLink عبر نقطة اتصال السيارة «%1$s». شغّلها في إعدادات السيارة ثم اتصل. + يتصل LoopLink عبر نقطة اتصال السيارة. شغّلها في إعدادات السيارة، ثم اتصل. تعديل نقطة الاتصال المحفوظة ·\u0020 جاهز لـ\u0020 نوع البث %1$d غير متاح هنا (الحالة=%2$s) @@ -351,4 +351,6 @@ يجب ألا يتجاوز اسم نقطة الاتصال 32 بايت يحتوي الاسم أو كلمة المرور على محرف غير صالح يجب أن تتكون كلمة مرور نقطة الاتصال من 8 إلى 63 محرفًا + متقدم + جودة الفيديو وقنوات الصوت وتقارير التشخيص. غيّرها فقط عند الطلب. diff --git a/common/src/main/res/values-es/strings.xml b/common/src/main/res/values-es/strings.xml index c1e08a1e7a..465f99b554 100644 --- a/common/src/main/res/values-es/strings.xml +++ b/common/src/main/res/values-es/strings.xml @@ -48,11 +48,11 @@ Esperando la reenumeración del iPhone Conectando la red NCM/AirPlay Error: %1$s - El punto de acceso del coche «%1$s» está apagado. Actívalo en la configuración del coche antes de conectarte. + El punto de acceso del coche está apagado. Actívalo en la configuración del coche y luego conéctate. VISTA PREVIA PÚBLICA ·\u0020\u0020 Elegir iPhone ·\u0020 Vista previa ·\u0020 - LoopLink se conecta a través del punto de acceso del coche «%1$s». Actívalo en la configuración del coche y luego conéctate. + LoopLink se conecta a través del punto de acceso del coche. Actívalo en la configuración del coche y luego conéctate. Editar punto de acceso guardado ·\u0020 Listo para\u0020 El tipo de flujo %1$d no está disponible aquí (estado=%2$s) @@ -351,4 +351,6 @@ El nombre del punto de acceso no debe superar los 32 bytes El nombre o la contraseña contiene un carácter no válido La contraseña del punto de acceso debe tener entre 8 y 63 caracteres + Avanzado + Calidad de vídeo, canales de audio e informes de diagnóstico. Cámbialos solo si te lo piden. diff --git a/common/src/main/res/values-ru/strings.xml b/common/src/main/res/values-ru/strings.xml index 038f347be4..d469b2fa1a 100644 --- a/common/src/main/res/values-ru/strings.xml +++ b/common/src/main/res/values-ru/strings.xml @@ -48,11 +48,11 @@ Ожидание повторной нумерации iPhone Подключение сети NCM/AirPlay Ошибка: %1$s - Точка доступа автомобиля «%1$s» выключена. Включите её в настройках автомобиля перед подключением. + Точка доступа автомобиля выключена. Включите её в настройках автомобиля, затем подключитесь. ОТКРЫТАЯ ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ ·\u0020\u0020 Выбрать iPhone ·\u0020 Предварительная версия ·\u0020 - LoopLink подключается через точку доступа автомобиля «%1$s». Включите её в настройках автомобиля и подключитесь. + LoopLink подключается через точку доступа автомобиля. Включите её в настройках автомобиля, затем подключитесь. Изменить сохранённую точку доступа ·\u0020 Готов к\u0020 Тип потока %1$d недоступен здесь (состояние=%2$s) @@ -351,4 +351,6 @@ Имя точки доступа не должно превышать 32 байта Имя или пароль содержит недопустимый символ Пароль точки доступа должен содержать от 8 до 63 символов + Дополнительно + Качество видео, аудиоканалы и отчёты диагностики. Меняйте, только если вас попросили. diff --git a/common/src/main/res/values-zh-rCN/strings.xml b/common/src/main/res/values-zh-rCN/strings.xml index 8f968b86b4..b04c498f95 100644 --- a/common/src/main/res/values-zh-rCN/strings.xml +++ b/common/src/main/res/values-zh-rCN/strings.xml @@ -48,11 +48,11 @@ 等待 iPhone 重新枚举 正在连接 NCM/AirPlay 网络 失败:%1$s - 车机热点“%1$s”未开启。请在车机设置中开启后再连接。 + 车机热点未开启。请在车机设置中开启,然后连接。 公开预览 ·\u0020\u0020 选择 iPhone ·\u0020 公开预览 ·\u0020 - LoopLink 通过车机热点“%1$s”连接。请在车机设置中开启热点后连接。 + LoopLink 通过车机热点连接。请在车机设置中开启热点,然后连接。 编辑已保存的热点 ·\u0020 已准备连接: 流类型 %1$d 在此不可用(状态=%2$s) @@ -362,4 +362,6 @@ 热点名称不能超过32字节 名称或密码包含无效字符 热点密码长度必须为8至63个字符 + 高级 + 视频质量、音频通道和诊断报告。仅在需要时更改。 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index f167596242..b3616a776c 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -48,11 +48,11 @@ Waiting for iPhone re-enumeration Attaching NCM/AirPlay network Failed: %1$s - The car hotspot “%1$s” is off. Turn it on in the car settings before connecting. + Your car’s hotspot is off. Turn it on in the car settings, then connect. PUBLIC PREVIEW ·\u0020\u0020 Choose iPhone ·\u0020 Public preview ·\u0020 - LoopLink connects through the car hotspot “%1$s”. Turn it on in the car settings, then connect. + LoopLink connects through your car’s hotspot. Turn it on in the car settings, then connect. Edit saved hotspot ·\u0020 Ready for\u0020 Stream type %1$d is unavailable here (state=%2$s) @@ -362,4 +362,6 @@ The hotspot name must be at most 32 bytes The name or password contains an invalid character The hotspot password must be 8–63 characters + Advanced + Video quality, audio channels and diagnostic reports. Change these only if asked to. diff --git a/common/src/main/res/values/themes.xml b/common/src/main/res/values/themes.xml index 76ba65be5c..6a10c42f94 100644 --- a/common/src/main/res/values/themes.xml +++ b/common/src/main/res/values/themes.xml @@ -1,11 +1,22 @@ + + + From d7f9fe6c642790bd6637f00be9a470d6edbe1a8f Mon Sep 17 00:00:00 2001 From: umarz317 <61620968+umarz317@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:10:06 +0500 Subject: [PATCH 2/3] Add a switch for CarPlay's Home icon Add "Home icon in CarPlay" to Settings > Display, on by default. When off, the info plist sends oemIconVisible false and no icon or label, so the iPhone hides the back-to-car icon; changing it reconnects a running session. Label the icon "Home", including on cars that saved "BYD" or "Car", and replace its picture with a simple house on a frosted light background. Unit tests, lint and the debug build pass; not tested on a head unit. --- .../shilapi/xcertplay/AirPlayPersistence.kt | 19 ++++++++++++++---- .../shilapi/xcertplay/CarPlayHostActivity.kt | 1 + .../com/shilapi/xcertplay/DiPlayActivity.kt | 4 ++++ common/src/main/res/raw/ic_car_home.png | Bin 3828 -> 3384 bytes common/src/main/res/values-ar/strings.xml | 2 ++ common/src/main/res/values-es/strings.xml | 2 ++ common/src/main/res/values-ru/strings.xml | 2 ++ common/src/main/res/values-zh-rCN/strings.xml | 2 ++ common/src/main/res/values/strings.xml | 2 ++ .../xcertplay/airplay/AirPlayConfig.kt | 2 ++ .../xcertplay/airplay/AirPlayInfoPlist.kt | 4 +++- .../xcertplay/airplay/AirPlayInfoPlistTest.kt | 19 ++++++++++++++++++ 12 files changed, 54 insertions(+), 5 deletions(-) diff --git a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt index 014bbecf40..653496d487 100644 --- a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt +++ b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt @@ -53,6 +53,7 @@ object AirPlayPersistence { private const val KEY_MANUFACTURER = "manufacturer" private const val KEY_MODEL = "model" private const val KEY_OEM_LABEL = "oem_label" + private const val KEY_OEM_ICON_VISIBLE = "oem_icon_visible" private const val KEY_FPS = "display_fps" private const val KEY_MEDIA_BUFFER_MS = "media_buffer_ms" private const val KEY_WIDTH_PHYSICAL_MM = "display_width_physical_mm" @@ -74,9 +75,9 @@ object AirPlayPersistence { const val DEFAULT_MANUFACTURER = "LoopLink" const val DEFAULT_MODEL = "LoopLink" - const val DEFAULT_OEM_LABEL = "Car" - // Earlier builds saved this default; it reads as a brand on non-BYD cars. - private const val LEGACY_OEM_LABEL = "BYD" + const val DEFAULT_OEM_LABEL = "Home" + // Earlier builds saved these defaults; "BYD" reads as a brand on other cars. + private val LEGACY_OEM_LABELS = setOf("BYD", "Car") const val DEFAULT_MFI_I2C_PATH = "/dev/i2c-1" fun loadDisplayScaleTenths(context: Context): Int { @@ -370,7 +371,7 @@ object AirPlayPersistence { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getString(KEY_OEM_LABEL, DEFAULT_OEM_LABEL) // iOS hides the car icon without a label. - .orEmpty().takeUnless { it.isBlank() || it == LEGACY_OEM_LABEL } ?: DEFAULT_OEM_LABEL + .orEmpty().takeUnless { it.isBlank() || it in LEGACY_OEM_LABELS } ?: DEFAULT_OEM_LABEL fun saveOemLabel(context: Context, oemLabel: String) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() @@ -446,6 +447,16 @@ object AirPlayPersistence { .apply() } + fun loadOemIconVisible(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_OEM_ICON_VISIBLE, true) + + fun saveOemIconVisible(context: Context, visible: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_OEM_ICON_VISIBLE, visible) + .apply() + } + fun loadRightHandDrive(context: Context): Boolean = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) .getBoolean(KEY_RIGHT_HAND_DRIVE, false) diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt index 8612febf98..22ad9796dc 100644 --- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -2654,6 +2654,7 @@ class CarPlayHostActivity : ComponentActivity() { model = normalizedModel(), oemLabel = oemLabel, icons = listOf(loadAirPlayIcon()), + oemIconVisible = AirPlayPersistence.loadOemIconVisible(this), ) } diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 7ea6ffe1ea..87b2d5f5d9 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -236,6 +236,10 @@ class DiPlayActivity : ComponentActivity() { section(content, getString(R.string.display_and_performance), R.drawable.ic_dp_display) { card -> carPlaySizeControl(card) toggle(card, getString(R.string.right_hand_drive), getString(R.string.place_carplay_s_controls_closer_to_the_driver), AirPlayPersistence.loadRightHandDrive(this)) { AirPlayPersistence.saveRightHandDrive(this, it) } + toggle(card, getString(R.string.car_icon_in_carplay), getString(R.string.car_icon_in_carplay_description), AirPlayPersistence.loadOemIconVisible(this)) { + AirPlayPersistence.saveOemIconVisible(this, it) + if (CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this)) + } toggle(card, getString(R.string.full_screen), getString(R.string.hide_the_car_s_system_bars_while_carplay_is_open), AirPlayPersistence.loadHideTopBar(this) && AirPlayPersistence.loadHideBottomBar(this)) { AirPlayPersistence.saveHideTopBar(this, it); AirPlayPersistence.saveHideBottomBar(this, it) } diff --git a/common/src/main/res/raw/ic_car_home.png b/common/src/main/res/raw/ic_car_home.png index f7ae8147ed9746e3201a0179757ee24736fc3af4..160f070270dbf218a112745681485daed13b9d12 100644 GIT binary patch literal 3384 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 MP|r*kr|p*TKfjg_!~g&Q diff --git a/common/src/main/res/values-ar/strings.xml b/common/src/main/res/values-ar/strings.xml index 870bd125cb..d99a1edba9 100644 --- a/common/src/main/res/values-ar/strings.xml +++ b/common/src/main/res/values-ar/strings.xml @@ -353,4 +353,6 @@ يجب أن تتكون كلمة مرور نقطة الاتصال من 8 إلى 63 محرفًا متقدم جودة الفيديو وقنوات الصوت وتقارير التشخيص. غيّرها فقط عند الطلب. + أيقونة الرئيسية في CarPlay + إظهار الأيقونة التي تعود إلى شاشة السيارة. يؤدي التغيير إلى إعادة اتصال CarPlay. diff --git a/common/src/main/res/values-es/strings.xml b/common/src/main/res/values-es/strings.xml index 465f99b554..038274ea73 100644 --- a/common/src/main/res/values-es/strings.xml +++ b/common/src/main/res/values-es/strings.xml @@ -353,4 +353,6 @@ La contraseña del punto de acceso debe tener entre 8 y 63 caracteres Avanzado Calidad de vídeo, canales de audio e informes de diagnóstico. Cámbialos solo si te lo piden. + Icono de inicio en CarPlay + Muestra el icono que vuelve a la pantalla del coche. Cambiarlo vuelve a conectar CarPlay. diff --git a/common/src/main/res/values-ru/strings.xml b/common/src/main/res/values-ru/strings.xml index d469b2fa1a..ace051c8a5 100644 --- a/common/src/main/res/values-ru/strings.xml +++ b/common/src/main/res/values-ru/strings.xml @@ -353,4 +353,6 @@ Пароль точки доступа должен содержать от 8 до 63 символов Дополнительно Качество видео, аудиоканалы и отчёты диагностики. Меняйте, только если вас попросили. + Значок «Домой» в CarPlay + Показывать значок возврата на экран автомобиля. Изменение переподключает CarPlay. diff --git a/common/src/main/res/values-zh-rCN/strings.xml b/common/src/main/res/values-zh-rCN/strings.xml index b04c498f95..12ce182660 100644 --- a/common/src/main/res/values-zh-rCN/strings.xml +++ b/common/src/main/res/values-zh-rCN/strings.xml @@ -364,4 +364,6 @@ 热点密码长度必须为8至63个字符 高级 视频质量、音频通道和诊断报告。仅在需要时更改。 + CarPlay 中的主页图标 + 显示返回车机屏幕的图标。更改后会重新连接 CarPlay。 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index b3616a776c..0dd9b5101c 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -364,4 +364,6 @@ The hotspot password must be 8–63 characters Advanced Video quality, audio channels and diagnostic reports. Change these only if asked to. + Home icon in CarPlay + Show the icon that returns to the car’s screen. Changing it reconnects CarPlay. diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt index 2d4d4702f1..6122f8058b 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt @@ -48,6 +48,8 @@ data class AirPlayConfig( val model: String = "xcertplay", val oemLabel: String = "xcertplay", val icons: List = emptyList(), + /** Shows CarPlay's back-to-car icon; false asks the iPhone to hide it. */ + val oemIconVisible: Boolean = true, /** iOS 27 video in car (see [VideoInCar]); video plays only while [VideoInCar.allowed]. */ val videoInCar: Boolean = false, ) diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt index 9d8012b053..3b40b31765 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt @@ -55,7 +55,9 @@ object AirPlayInfoPlist { AirPlayHid.mediaHidDevice(MAIN_UUID), AirPlayHid.telephonyHidDevice(MAIN_UUID), ) - if (config.icons.isNotEmpty()) { + if (!config.oemIconVisible) { + info["oemIconVisible"] = false + } else if (config.icons.isNotEmpty()) { info["oemIconVisible"] = true info["oemIconLabel"] = config.oemLabel info["oemIcons"] = config.icons.map { icon -> diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt index 97c2a4ada2..304b292244 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt @@ -77,6 +77,25 @@ class AirPlayInfoPlistTest { assertEquals(70, display["heightPhysical"]) } + @Test + fun hiddenOemIconIsNotAdvertised() { + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + icons = listOf(AirPlayIcon(1, 1, byteArrayOf(0x01))), + oemIconVisible = false, + ), + ) + + assertEquals(false, info["oemIconVisible"]) + assertFalse(info.containsKey("oemIcons")) + assertFalse(info.containsKey("oemIconLabel")) + } + @Test fun squareOemIconIsAdvertisedWithItsOriginalBytes() { val iconBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) From 5d842cd30fd1f38b5edc7efe571e7b1054728be4 Mon Sep 17 00:00:00 2001 From: umarz317 <61620968+umarz317@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:30:41 +0500 Subject: [PATCH 3/3] Fit home to short screens and simplify connection setup Move the logo and status into the home top bar and size the three tiles from the height left under it, so they fit 600dp-tall head units. The tiles share one width and their icons stay level when a label wraps. Reduce connection setup to three short steps: picture cards for car hotspot or Wi-Fi Direct, the iPhone and permission rows, and the two connect buttons side by side. The long instructions and the separate cable section are removed; all text reuses existing translations. The debug build and lint pass; both screens were checked at 1024x600 in a head-unit emulator, including switching the connection type. --- CHANGELOG.md | 3 +- .../com/shilapi/xcertplay/DiPlayActivity.kt | 162 ++++++++++-------- common/src/main/res/drawable/ic_dp_check.xml | 1 + mobile/build.gradle.kts | 4 +- 4 files changed, 95 insertions(+), 75 deletions(-) create mode 100644 common/src/main/res/drawable/ic_dp_check.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index cb63e1616d..248be94a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ -# Unreleased — LoopLink +# LoopLink 0.2.9 — 2026-10-01 +- Redesign the head-unit UI for quick use in the car and add a switch for CarPlay's Home icon. - Fork DiPlay 0.2.8 as LoopLink with app ID `io.loopbreak.looplink`, so it installs alongside DiPlay. The head unit, notification, reports (Downloads/LoopLink) and the iPhone show the name LoopLink, in all five languages. - New LoopLink logo for the launcher, the app and the car's icon in CarPlay (replacing the bundled CarPlay and BYD icons), and a dark and orange palette. - Remove BYD-specific features: windshield HUD and instrument-cluster navigation, the dashboard map and ADB map pause, ADB battery, wheel-speed and parked-video reporting, the BYD steering-wheel keycodes and the BYD HUD icons. diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt index 87b2d5f5d9..eb2d2d6ae5 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -148,7 +148,14 @@ class DiPlayActivity : ComponentActivity() { scroll.addView(content, FrameLayout.LayoutParams(width, -2, Gravity.CENTER_HORIZONTAL)) val header = row().apply { gravity = Gravity.CENTER_VERTICAL; minimumHeight = dp(56) } if (page == "home") { - header.addView(space(1), LinearLayout.LayoutParams(0, 1, 1f)) + // Logo and status share the top bar so the tiles get the rest of a short screen. + header.addView(ImageView(this).apply { + setImageResource(R.drawable.ic_looplink); contentDescription = getString(R.string.diplay) + }, LinearLayout.LayoutParams(dp(40), dp(40)).apply { marginEnd = dp(16) }) + status = label(getString(R.string.ready_when_you_are), 22, TEXT, weight = 700).apply { + compoundDrawablePadding = dp(10); maxLines = 1; ellipsize = android.text.TextUtils.TruncateAt.END + } + header.addView(status, LinearLayout.LayoutParams(0, -2, 1f).apply { marginEnd = dp(12) }) // In the header, not under the tiles, so it never pushes them off a short screen. disconnectButton = button(getString(R.string.disconnect), false, RED, radius = 24, icon = R.drawable.ic_dp_close) { disconnectButton?.isEnabled = false @@ -167,7 +174,7 @@ class DiPlayActivity : ComponentActivity() { }, LinearLayout.LayoutParams(-2, dp(48))) } content.addView(header) - content.addView(space(if (page == "home") (if (short) 4 else 28) else 12)) + content.addView(space(if (page == "home") (if (short) 8 else 16) else 12)) when (page) { "connection" -> connectionSetup(content) "settings" -> settings(content) @@ -179,48 +186,41 @@ class DiPlayActivity : ComponentActivity() { refreshStatus() } - // Glanceable in a car: a status line and three large icon tiles. Details live in Settings. + // Glanceable in a car: three large icon tiles under a status bar. Details live in Settings. private fun home(content: LinearLayout) { - // Head units range from ~500dp to 1000dp tall; scale the stack so the tiles always fit. + // Head units range from ~500dp to 1000dp tall; the tiles take the height left under the top bar. val height = resources.configuration.screenHeightDp - val compact = height < 640 - val body = column().apply { gravity = Gravity.CENTER_HORIZONTAL } - body.addView(ImageView(this).apply { - setImageResource(R.drawable.ic_looplink) - contentDescription = getString(R.string.diplay) - scaleType = ImageView.ScaleType.FIT_CENTER - }, LinearLayout.LayoutParams(dp(if (compact) 52 else 80), dp(if (compact) 52 else 80))) - status = label(getString(R.string.ready_when_you_are), if (compact) 26 else 30, TEXT, weight = 700).apply { - gravity = Gravity.CENTER; setPadding(0, dp(if (compact) 8 else 16), 0, dp(if (compact) 16 else 28)); compoundDrawablePadding = dp(14) - } - body.addView(status, LinearLayout.LayoutParams(-2, -2)) + val body = column().apply { gravity = Gravity.CENTER } val actions = column() // Problems sit above the tiles they block, as one notice each, never as loose coloured text. setupError?.let { actions.addView(notice(it)) } if (carHotspotOff()) actions.addView(notice(getString(R.string.msg_car_hotspot_off), getString(R.string.open_car_hotspot_settings)) { openCarWifiSettings() }) val tiles = row() - val tileHeight = dp((height * .34f).toInt().coerceIn(128, 184)) - connectButton = homeTile(getString(R.string.connect_phone), R.drawable.ic_dp_connection, true) { + val tileHeight = dp(((height - 140) * .7f).toInt().coerceIn(120, 220)) + connectButton = homeTile(getString(R.string.connect_phone), R.drawable.ic_dp_connection, tileHeight, true) { if (CarPlayBackgroundSession.hasSession()) openProjection() else connect(true) } - tiles.addView(connectButton, LinearLayout.LayoutParams(0, tileHeight, 1.4f)) + tiles.addView(connectButton, LinearLayout.LayoutParams(0, tileHeight, 1f)) tiles.addView(space(16), LinearLayout.LayoutParams(dp(16), 1)) - tiles.addView(homeTile(getString(R.string.connect_with_usb), R.drawable.ic_dp_usb) { connect(false) }, LinearLayout.LayoutParams(0, tileHeight, 1f)) + tiles.addView(homeTile(getString(R.string.connect_with_usb), R.drawable.ic_dp_usb, tileHeight) { connect(false) }, LinearLayout.LayoutParams(0, tileHeight, 1f)) tiles.addView(space(16), LinearLayout.LayoutParams(dp(16), 1)) - tiles.addView(homeTile(getString(R.string.settings), R.drawable.ic_dp_settings) { page = "settings"; render() }, LinearLayout.LayoutParams(0, tileHeight, 1f)) + tiles.addView(homeTile(getString(R.string.settings), R.drawable.ic_dp_settings, tileHeight) { page = "settings"; render() }, LinearLayout.LayoutParams(0, tileHeight, 1f)) actions.addView(tiles) - body.addView(actions, LinearLayout.LayoutParams(minOf(dp(880), dp(resources.configuration.screenWidthDp) - dp(64)), -2)) - content.addView(body) + body.addView(actions, LinearLayout.LayoutParams(minOf(dp(960), dp(resources.configuration.screenWidthDp) - dp(64)), -2)) + // Fill the space under the header and centre the tiles in it. + content.addView(body, LinearLayout.LayoutParams(-1, 0, 1f)) } /** Large icon-over-label tile for the home screen. */ - private fun homeTile(title: String, icon: Int, primary: Boolean = false, click: () -> Unit) = + private fun homeTile(title: String, icon: Int, height: Int, primary: Boolean = false, click: () -> Unit) = button(title, primary, if (primary) Color.WHITE else TEXT, radius = 24, click = click).apply { textSize = 19f; contentDescription = title - val compact = resources.configuration.screenHeightDp < 640 - setPadding(dp(12), dp(if (compact) 12 else 24), dp(12), dp(if (compact) 10 else 20)); compoundDrawablePadding = dp(if (compact) 8 else 16) - setCompoundDrawablesRelative(null, icon(icon, if (primary) Color.WHITE else ACCENT, if (compact) 44 else 56), null, null) + // Pinned from the top so icons line up even when one label wraps to two lines. + gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP + val iconSize = (height / resources.displayMetrics.density * .3f).toInt().coerceIn(40, 64) + setPadding(dp(12), (height * .2f).toInt(), dp(12), dp(8)); compoundDrawablePadding = dp(12) + setCompoundDrawablesRelative(null, icon(icon, if (primary) Color.WHITE else ACCENT, iconSize), null, null) } private fun settings(content: LinearLayout) { @@ -359,72 +359,90 @@ class DiPlayActivity : ComponentActivity() { openSystem(wifi) } + // Three short steps; each is a picture choice or a single row, with the details kept in dialogs. private fun connectionSetup(content: LinearLayout) { - content.addView(largeTitle(getString(R.string.connection_setup), 40)) - content.addView(label(getString(R.string.set_up_once_your_details_stay_saved_for_the_next_drive_cha), 17, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) - section(content, getString(R.string.s_1_choose_your_connection)) { card -> wirelessLinkControls(card) } - section(content, getString(R.string.s_2_pair_your_iphone)) { card -> - card.addView(label(getString(R.string.keep_bluetooth_and_wi_fi_on_your_iphone_pair_with_the_car), 16, MUTED)) + content.addView(largeTitle(getString(R.string.connection_setup), 40).apply { setPadding(0, 0, 0, dp(8)) }) + section(content, getString(R.string.s_1_choose_your_connection), R.drawable.ic_dp_connection) { card -> wirelessLinkControls(card) } + section(content, getString(R.string.s_2_pair_your_iphone), R.drawable.ic_dp_bluetooth) { card -> card.addView(valueRow(getString(R.string.choose_iphone), DiPlayPreferences.phoneName(this), R.drawable.ic_dp_phone) { choosePhone() }.first) - card.addView(button(getString(R.string.review_app_permissions), false, icon = R.drawable.ic_dp_permissions) { + card.addView(valueRow(getString(R.string.review_app_permissions), "", R.drawable.ic_dp_permissions) { openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) - }, matchButton(12, 60)) - } - section(content, getString(R.string.s_3_connect)) { card -> - card.addView(label(getString(R.string.return_from_car_settings_to_diplay_then_connect_accept_the), 16, MUTED)) - card.addView(button(getString(R.string.connect_phone), true, icon = R.drawable.ic_dp_phone) { connect(true) }, matchButton(12, 60)) + }.first) } - section(content, getString(R.string.prefer_a_cable)) { card -> - card.addView(label(getString(R.string.use_a_usb_data_cable_and_the_car_s_usb_data_port_unlock_yo), 16, MUTED)) - card.addView(button(getString(R.string.connect_with_usb), false, icon = R.drawable.ic_dp_usb) { connect(false) }, matchButton(12, 60)) + section(content, getString(R.string.s_3_connect), R.drawable.ic_dp_phone) { card -> + card.addView(row().apply { + setPadding(0, dp(10), 0, dp(10)) + addView(button(getString(R.string.connect_phone), true, icon = R.drawable.ic_dp_connection) { connect(true) }, LinearLayout.LayoutParams(0, dp(64), 1f)) + addView(space(12), LinearLayout.LayoutParams(dp(12), 1)) + addView(button(getString(R.string.connect_with_usb), false, TEXT, icon = R.drawable.ic_dp_usb) { connect(false) }, LinearLayout.LayoutParams(0, dp(64), 1f)) + }) } } private fun wirelessLinkControls(parent: LinearLayout) { val mode = if (pendingCarHotspotSetup) WirelessHotspotMode.MANUAL else AirPlayPersistence.loadWirelessHotspotMode(this) - val modes = listOf(WirelessHotspotMode.MANUAL, WirelessHotspotMode.WIFI_P2P) - val titles = listOf(getString(R.string.built_in_car_hotspot), getString(R.string.wifi_direct)) - val descriptions = listOf( - getString(R.string.hotspot_mode_manual_desc), - getString(R.string.hotspot_mode_p2p_desc) - ) - val wide = resources.configuration.screenWidthDp >= 850 - val choices = if (wide) row().apply { gravity = Gravity.TOP } else column() + val choices = row().apply { setPadding(0, dp(10), 0, dp(10)); isBaselineAligned = false } + choices.addView(modeTile(R.drawable.ic_dp_hotspot, getString(R.string.built_in_car_hotspot), getString(R.string.hotspot_mode_manual_desc), mode == WirelessHotspotMode.MANUAL) { + pendingCarHotspotSetup = true + render() + }, LinearLayout.LayoutParams(0, -2, 1f)) + choices.addView(space(12), LinearLayout.LayoutParams(dp(12), 1)) + choices.addView(modeTile(R.drawable.ic_dp_connection, getString(R.string.wifi_direct), getString(R.string.hotspot_mode_p2p_desc), mode == WirelessHotspotMode.WIFI_P2P) { + pendingCarHotspotSetup = false + applyWirelessLink(WirelessHotspotMode.WIFI_P2P) + }, LinearLayout.LayoutParams(0, -2, 1f)) parent.addView(choices) - modes.forEachIndexed { index, candidate -> - val option = column() - choices.addView(option, if (wide) LinearLayout.LayoutParams(0, -2, 1f).apply { - if (index > 0) marginStart = dp(16) - } else LinearLayout.LayoutParams(-1, -2)) - option.addView(button("${if (mode == candidate) "✓ " else ""}${titles[index]}", mode == candidate) { - if (candidate == WirelessHotspotMode.MANUAL) { - pendingCarHotspotSetup = true - render() - } else { - pendingCarHotspotSetup = false - applyWirelessLink(candidate) - } - }, matchButton(12, 60)) - option.addView(label(descriptions[index], 15, MUTED).apply { setPadding(0, dp(6), 0, dp(12)) }) - } if (mode == WirelessHotspotMode.MANUAL) { - parent.addView(label(getString(R.string.hotspot_setup), 22, TEXT, true)) - parent.addView(label(getString(R.string.s_1_open_car_hotspot_settings_turn_the_hotspot_on_and_sele), 16, MUTED).apply { setPadding(0, dp(8), 0, dp(12)) }) - parent.addView(button(getString(R.string.open_car_hotspot_settings), false, icon = R.drawable.ic_dp_hotspot) { openCarWifiSettings() }, matchButton(0, 60)) - parent.addView(button(if (pendingCarHotspotSetup) getString(R.string.save_hotspot_details_and_use_this_mode) else "${getString(R.string.edit_saved_hotspot_prefix)}${storedSsid()}", false) { + val saveDetails = { askHotspotCredentials { ssid, password -> saveHotspotCredentials(ssid, password) pendingCarHotspotSetup = false applyWirelessLink(WirelessHotspotMode.MANUAL) } - }, matchButton(12, 60)) - parent.addView(label(if (pendingCarHotspotSetup) getString(R.string.finish_setup_save_your_hotspot_details_to_use_this_mode) else if (carHotspotOff()) getString(R.string.hotspot_details_off) else getString(R.string.hotspot_details_saved), 15, if (carHotspotOff()) WARNING else MUTED).apply { setPadding(0, dp(12), 0, 0) }) + } + if (pendingCarHotspotSetup || storedSsid().isBlank()) { + parent.addView(button(getString(R.string.save_hotspot_details_and_use_this_mode), true, icon = R.drawable.ic_dp_hotspot) { saveDetails() }, matchButton(4, 60)) + } else { + parent.addView(valueRow(getString(R.string.hotspot_name), storedSsid(), R.drawable.ic_dp_hotspot) { saveDetails() }.first) + } + parent.addView(button(getString(R.string.open_car_hotspot_settings), false, icon = R.drawable.ic_dp_settings) { openCarWifiSettings() }, matchButton(0, 60)) + if (carHotspotOff()) parent.addView(notice(getString(R.string.hotspot_details_off)).apply { + background = GradientDrawable().apply { setColor(FILL); cornerRadius = dp(16).toFloat() } + }) } else { - parent.addView(label(getString(R.string.turn_the_car_s_wi_fi_switch_on_allow_location_nearby_devic), 16, MUTED)) - parent.addView(button(getString(R.string.open_car_wi_fi_settings), false, icon = R.drawable.ic_dp_connection) { openCarClientWifiSettings() }, matchButton(12, 60)) + parent.addView(button(getString(R.string.open_car_wi_fi_settings), false, icon = R.drawable.ic_dp_settings) { openCarClientWifiSettings() }, matchButton(0, 60)) } } + /** Selectable picture card for a connection type; the chosen one is outlined and checked. */ + private fun modeTile(icon: Int, title: String, description: String, selected: Boolean, click: () -> Unit) = column().apply { + background = android.graphics.drawable.RippleDrawable(ColorStateList.valueOf(PRESSED), GradientDrawable().apply { + setColor(FILL); cornerRadius = dp(18).toFloat() + if (selected) setStroke(dp(2), ACCENT) + }, null) + // A shared minimum height keeps the two cards level when one description wraps. + setPadding(dp(18), dp(16), dp(18), dp(18)); minimumHeight = dp(150) + isClickable = true; isFocusable = true; isSelected = selected + contentDescription = "$title. $description" + setOnClickListener { click() } + addView(row().apply { + gravity = Gravity.CENTER_VERTICAL + addView(ImageView(this@DiPlayActivity).apply { + setImageResource(icon); imageTintList = ColorStateList.valueOf(ACCENT) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(36), dp(36))) + addView(space(1), LinearLayout.LayoutParams(0, 1, 1f)) + if (selected) addView(ImageView(this@DiPlayActivity).apply { + setImageResource(R.drawable.ic_dp_check); imageTintList = ColorStateList.valueOf(Color.WHITE) + background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(ACCENT) } + setPadding(dp(4), dp(4), dp(4), dp(4)) + importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO + }, LinearLayout.LayoutParams(dp(28), dp(28))) + }) + addView(label(title, 18, TEXT, weight = 600).apply { setPadding(0, dp(12), 0, dp(4)) }) + addView(label(description, 14, MUTED)) + } + private fun mediaChannelControl(parent: LinearLayout) { lateinit var value: TextView val (line, view) = valueRow(getString(R.string.contrib_audio_home_media_channel_label), channelLabel(AirPlayPersistence.loadMediaAudioChannel(this)), R.drawable.ic_dp_audio) { diff --git a/common/src/main/res/drawable/ic_dp_check.xml b/common/src/main/res/drawable/ic_dp_check.xml new file mode 100644 index 0000000000..7b5b3e014d --- /dev/null +++ b/common/src/main/res/drawable/ic_dp_check.xml @@ -0,0 +1 @@ + diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index a52261640d..e5c9b5a5d3 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "io.loopbreak.looplink" minSdk = 28 targetSdk = 37 - versionCode = 27 - versionName = "0.2.8" + versionCode = 28 + versionName = "0.2.9" }