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/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/AirPlayPersistence.kt b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt index 2d4634ecf6..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,7 +75,9 @@ object AirPlayPersistence { const val DEFAULT_MANUFACTURER = "LoopLink" const val DEFAULT_MODEL = "LoopLink" - const val DEFAULT_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 { @@ -368,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().ifBlank { 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() @@ -444,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 f201df01bc..22ad9796dc 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 } @@ -2643,6 +2654,7 @@ class CarPlayHostActivity : ComponentActivity() { model = normalizedModel(), oemLabel = oemLabel, icons = listOf(loadAirPlayIcon()), + oemIconVisible = AirPlayPersistence.loadOemIconVisible(this), ) } @@ -3486,13 +3498,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..eb2d2d6ae5 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,116 @@ 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") { + // 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 + 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) 8 else 16) 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: three large icon tiles under a status bar. 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) { + // Head units range from ~500dp to 1000dp tall; the tiles take the height left under the top bar. + val height = resources.configuration.screenHeightDp + 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 - 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) } - 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 { - setImageResource(R.drawable.ic_looplink) - contentDescription = getString(R.string.carplay_icon) - scaleType = ImageView.ScaleType.FIT_CENTER - } - 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) }) } - content.addView(body) + 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, 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, tileHeight) { page = "settings"; render() }, LinearLayout.LayoutParams(0, tileHeight, 1f)) + actions.addView(tiles) + 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, 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 + // 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) { - 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.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) } } - 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 +258,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 +320,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() @@ -351,106 +359,120 @@ 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(label(getString(R.string.connection_setup), 34, TEXT, true)) - 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) { + 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(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)) + }.first) } - 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)) - } - 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)) + 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) { 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) { 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) { - 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 +495,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 +743,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 +759,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 +813,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 +930,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 +1013,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_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/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/raw/ic_car_home.png b/common/src/main/res/raw/ic_car_home.png index f7ae8147ed..160f070270 100644 Binary files a/common/src/main/res/raw/ic_car_home.png and b/common/src/main/res/raw/ic_car_home.png differ diff --git a/common/src/main/res/values-ar/strings.xml b/common/src/main/res/values-ar/strings.xml index ad3846fdb4..d99a1edba9 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,8 @@ يجب ألا يتجاوز اسم نقطة الاتصال 32 بايت يحتوي الاسم أو كلمة المرور على محرف غير صالح يجب أن تتكون كلمة مرور نقطة الاتصال من 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 c1e08a1e7a..038274ea73 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,8 @@ 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. + 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 038f347be4..ace051c8a5 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,8 @@ Имя точки доступа не должно превышать 32 байта Имя или пароль содержит недопустимый символ Пароль точки доступа должен содержать от 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 8f968b86b4..12ce182660 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,8 @@ 热点名称不能超过32字节 名称或密码包含无效字符 热点密码长度必须为8至63个字符 + 高级 + 视频质量、音频通道和诊断报告。仅在需要时更改。 + CarPlay 中的主页图标 + 显示返回车机屏幕的图标。更改后会重新连接 CarPlay。 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index f167596242..0dd9b5101c 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,8 @@ 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. + Home icon in CarPlay + Show the icon that returns to the car’s screen. Changing it reconnects CarPlay. 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 @@ + + + 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" } 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)