From 24cfbee0c4b6d120dc2b83a039d3390703099208 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 20 Sep 2026 02:06:21 -0700 Subject: [PATCH] Gearslip: phone-side Android Auto client --- .gitignore | 18 + LICENSE | 663 +++++++++++++++ NOTICE.md | 16 + README.md | 56 ++ build.gradle.kts | 7 + docs/icon/archive/gearslip-icon-rounded.png | Bin 0 -> 17803 bytes docs/icon/archive/gearslip-icon-rounded.svg | 14 + docs/icon/gearslip-icon.png | Bin 0 -> 11474 bytes docs/icon/gearslip-icon.svg | 14 + docs/icon/generate.py | 176 ++++ docs/icon/on-device-verification.png | Bin 0 -> 81317 bytes gearslip/.gitignore | 10 + gearslip/build.gradle.kts | 84 ++ gearslip/src/debug/AndroidManifest.xml | 7 + .../gearslip/CarPreviewActivity.kt | 106 +++ gearslip/src/main/AndroidManifest.xml | 132 +++ .../app/seb3thehacker/gearslip/AppSettings.kt | 28 + .../seb3thehacker/gearslip/CertProvider.kt | 140 ++++ .../seb3thehacker/gearslip/EncoderSelfTest.kt | 96 +++ .../app/seb3thehacker/gearslip/Framing.kt | 135 +++ .../gearslip/GearslipActivity.kt | 298 +++++++ .../app/seb3thehacker/gearslip/GearslipLog.kt | 120 +++ .../seb3thehacker/gearslip/GearslipRunner.kt | 775 ++++++++++++++++++ .../app/seb3thehacker/gearslip/PhoneTls.kt | 219 +++++ .../gearslip/ProjectedContent.kt | 65 ++ .../app/seb3thehacker/gearslip/Projection.kt | 25 + .../app/seb3thehacker/gearslip/Protobuf.kt | 178 ++++ .../seb3thehacker/gearslip/ScreenProjector.kt | 183 +++++ .../seb3thehacker/gearslip/SelfSignedCert.kt | 124 +++ .../gearslip/ServiceDiscovery.kt | 180 ++++ .../seb3thehacker/gearslip/SessionStatus.kt | 46 ++ .../app/seb3thehacker/gearslip/TlsSelfTest.kt | 149 ++++ .../seb3thehacker/gearslip/VehicleProfile.kt | 114 +++ .../app/seb3thehacker/gearslip/VideoSource.kt | 202 +++++ .../gearslip/audio/AudioCaptureService.kt | 193 +++++ .../seb3thehacker/gearslip/audio/AudioLink.kt | 148 ++++ .../seb3thehacker/gearslip/audio/CarAudio.kt | 98 +++ .../gearslip/car/CalibrationApp.kt | 89 ++ .../seb3thehacker/gearslip/car/CarAppStage.kt | 191 +++++ .../app/seb3thehacker/gearslip/car/CarApps.kt | 40 + .../gearslip/car/CarEnvironment.kt | 140 ++++ .../gearslip/car/CarHomeScreen.kt | 183 +++++ .../seb3thehacker/gearslip/car/CarKeyboard.kt | 155 ++++ .../seb3thehacker/gearslip/car/CarLauncher.kt | 127 +++ .../gearslip/car/CarNavigator.kt | 56 ++ .../seb3thehacker/gearslip/car/CarServices.kt | 113 +++ .../seb3thehacker/gearslip/car/CarSettings.kt | 117 +++ .../gearslip/car/CarSettingsScreen.kt | 219 +++++ .../app/seb3thehacker/gearslip/car/CarUi.kt | 195 +++++ .../seb3thehacker/gearslip/car/MediaScreen.kt | 336 ++++++++ .../gearslip/car/NotificationsScreen.kt | 277 +++++++ .../gearslip/car/PhoneAppsScreen.kt | 130 +++ .../gearslip/car/TemplateChrome.kt | 340 ++++++++ .../gearslip/car/TemplateContent.kt | 498 +++++++++++ .../gearslip/car/TemplateParts.kt | 383 +++++++++ .../gearslip/car/WavyProgress.kt | 92 +++ .../app/seb3thehacker/gearslip/car/WebApp.kt | 83 ++ .../gearslip/host/AndroidAuto.kt | 17 + .../gearslip/host/CarAppCatalog.kt | 81 ++ .../gearslip/host/CarAppConnection.kt | 564 +++++++++++++ .../seb3thehacker/gearslip/host/CarModels.kt | 160 ++++ .../gearslip/host/LocationKeepAlive.kt | 58 ++ .../seb3thehacker/gearslip/media/CarMedia.kt | 234 ++++++ .../seb3thehacker/gearslip/media/Lyrics.kt | 95 +++ .../seb3thehacker/gearslip/media/MediaArt.kt | 34 + .../gearslip/media/MediaCatalog.kt | 57 ++ .../gearslip/mirror/MirrorService.kt | 117 +++ .../gearslip/mirror/PhoneApps.kt | 85 ++ .../gearslip/mirror/PhoneMirror.kt | 146 ++++ .../gearslip/mirror/TouchRelayService.kt | 139 ++++ .../gearslip/notify/CarMotion.kt | 47 ++ .../gearslip/notify/CarNotifications.kt | 124 +++ .../notify/GearslipNotificationListener.kt | 89 ++ .../gearslip/ui/CarPreviewScreen.kt | 255 ++++++ .../seb3thehacker/gearslip/ui/CertSummary.kt | 44 + .../seb3thehacker/gearslip/ui/GearslipApp.kt | 29 + .../gearslip/ui/GearslipTheme.kt | 16 + .../seb3thehacker/gearslip/ui/HomeScreen.kt | 172 ++++ .../seb3thehacker/gearslip/ui/LogsScreen.kt | 141 ++++ .../gearslip/ui/SettingsScreen.kt | 224 +++++ .../res/drawable/ic_launcher_background.xml | 13 + .../res/drawable/ic_launcher_foreground.xml | 30 + .../res/drawable/ic_launcher_monochrome.xml | 20 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + gearslip/src/main/res/values/strings.xml | 4 + gearslip/src/main/res/values/themes.xml | 12 + .../src/main/res/xml/accessory_filter.xml | 13 + gearslip/src/main/res/xml/touch_relay.xml | 11 + .../app/seb3thehacker/gearslip/CryptoTest.kt | 306 +++++++ .../gearslip/VehicleProfileTest.kt | 41 + gradle.properties | 24 + gradle/gradle-daemon-jvm.properties | 13 + gradle/libs.versions.toml | 58 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 185 +++++ gradlew.bat | 89 ++ settings.gradle.kts | 28 + 98 files changed, 12370 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 NOTICE.md create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 docs/icon/archive/gearslip-icon-rounded.png create mode 100644 docs/icon/archive/gearslip-icon-rounded.svg create mode 100644 docs/icon/gearslip-icon.png create mode 100644 docs/icon/gearslip-icon.svg create mode 100644 docs/icon/generate.py create mode 100644 docs/icon/on-device-verification.png create mode 100644 gearslip/.gitignore create mode 100644 gearslip/build.gradle.kts create mode 100644 gearslip/src/debug/AndroidManifest.xml create mode 100644 gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt create mode 100644 gearslip/src/main/AndroidManifest.xml create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/EncoderSelfTest.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ProjectedContent.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/Projection.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/SelfSignedCert.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/SessionStatus.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/VehicleProfile.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioCaptureService.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaArt.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/MirrorService.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneApps.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneMirror.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/TouchRelayService.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipTheme.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt create mode 100644 gearslip/src/main/res/drawable/ic_launcher_background.xml create mode 100644 gearslip/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 gearslip/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 gearslip/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 gearslip/src/main/res/values/strings.xml create mode 100644 gearslip/src/main/res/values/themes.xml create mode 100644 gearslip/src/main/res/xml/accessory_filter.xml create mode 100644 gearslip/src/main/res/xml/touch_relay.xml create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt create mode 100644 gearslip/src/test/java/app/seb3thehacker/gearslip/VehicleProfileTest.kt create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75731ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +*.iml +.gradle/ +.idea/ +.DS_Store +.kotlin +/build +local.properties + +# Reference checkouts only - never referenced by the build; safe to delete entirely. +/cloned-repos + +# The head unit certificate. Never committed or shared beyond who Seb sends it to directly. +/cert + +# Local investigation script; not meant for the public repo. +/docs/cert-sweep.sh +archive/ +CLAUDE.md \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..708a164 --- /dev/null +++ b/LICENSE @@ -0,0 +1,663 @@ +Copyright (C) 2026 seb + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..f8914c7 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,16 @@ +# License + +Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `LICENSE`). + +In short: you're free to use, run, and modify it. If you modify it — or run a modified +version as a service others use over a network — the AGPL requires you to make your source +changes available on the same terms. That obligation follows the code even when you don't +redistribute a binary, which is what makes the AGPL fit for something that can run as a +background service. + +## Commercial licensing + +If the AGPL's terms don't work for you — for example, you want to use Gearslip privately +without being required to publish your changes — a separate commercial license is available +directly from the author. Terms (including any restrictions on modification) are set on a +case-by-case basis. Contact me though Matrix (on my profile) to discuss one. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e30d4b --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +Gearslip icon + +# Gearslip + +Bring any app to your car screen. + +## What this is + +Gearslip is a phone-side Android Auto client. It speaks the Android Auto protocol directly +to a car head unit over USB and acts as a **Car App Library host**: apps that ship an +Android Auto car UI (navigation and media apps) hand Gearslip a template, and Gearslip +renders it natively on the car screen, full quality, no phone-screen mirroring. A separate +screen-sharing mode is included for apps that don't ship a car UI at all. + +Built by reverse-engineering the protocol for interoperability - getting a non-Google client +talking to a car head unit that only expects Google's own app. + +## Status + +Real progress, with one hard limit that's worth knowing before you build on this: + +- **Templated app hosting works.** All 16 template types the Car App Library defines are + rendered, including a keyboard Gearslip draws itself for search and sign-in. Verified + against several real navigation and media apps. +- **Media playback works.** Car-enabled media apps are browsable and controllable from the + car UI, with lyrics and playback state, over the same host connection. +- **The connection to a real head unit needs a certificate Google controls, and Gearslip + doesn't ship one.** A head unit only accepts a phone identity that chains to Google's + Automotive Link certificate authority - something only Google issues. Without one, Gearslip + still runs and renders everything correctly against a debug harness, but a real car will + reject the connection outright. See [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) for what was + tested, including one publicly-known leaked certificate that works on some older head + units and is rejected by newer firmware and by Google's own reference tooling. +- **No adb, root, or Shizuku is required to run it.** Every feature is gated on an ordinary + Android permission or an on-device consent dialog - nothing needs privileged access, on + the phone or in the car. + +## Building + +``` +./gradlew :gearslip:assembleDebug +``` + +The APK lands at `build/Gearslip-debug.apk`. + +## Further reading + +- [SPIKE_FINDINGS.md](SPIKE_FINDINGS.md) - the certificate investigation in full: what was + tried, what a real head unit does and doesn't accept, and why. +- [CUSTOM_HEADUNIT_SCOPING.md](CUSTOM_HEADUNIT_SCOPING.md) - the original scoping of what a + custom head unit client would need. + +## License + +AGPL-3.0 - see [LICENSE](LICENSE). A separate commercial license is available for private +use outside the AGPL's terms; see [NOTICE.md](NOTICE.md). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9c692e2 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false +} + diff --git a/docs/icon/archive/gearslip-icon-rounded.png b/docs/icon/archive/gearslip-icon-rounded.png new file mode 100644 index 0000000000000000000000000000000000000000..ad868c9a1421fb8863affe1ec6c655c674b92b4d GIT binary patch literal 17803 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 + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/icon/gearslip-icon.png b/docs/icon/gearslip-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a632e62ba237cd9e1df4c6b50652e6289798f08 GIT binary patch literal 11474 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 PRmM + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/icon/generate.py b/docs/icon/generate.py new file mode 100644 index 0000000..d2f5cdf --- /dev/null +++ b/docs/icon/generate.py @@ -0,0 +1,176 @@ +import math + +def pt(cx, cy, r, deg): + a = math.radians(deg - 90) + return (cx + r*math.cos(a), cy + r*math.sin(a)) + +def gear_points(cx, cy, teeth, outer_r, inner_r, tooth_frac=0.5): + slice_w = 360.0 / teeth + gap = slice_w * (1 - tooth_frac) + pts = [] + for i in range(teeth): + start = i * slice_w + a1 = start + gap/2 + a2 = a1 + slice_w*tooth_frac + pts.append(pt(cx, cy, inner_r, start)) + pts.append(pt(cx, cy, outer_r, a1)) + pts.append(pt(cx, cy, outer_r, a2)) + pts.append(pt(cx, cy, inner_r, start + slice_w)) + return pts + +def path_from_pts(pts): + d = f"M {pts[0][0]:.2f},{pts[0][1]:.2f} " + for p in pts[1:]: + d += f"L {p[0]:.2f},{p[1]:.2f} " + return d + "Z" + +def rotate(pts, ocx, ocy, deg): + a = math.radians(deg) + out = [] + for x, y in pts: + x0, y0 = x-ocx, y-ocy + out.append((x0*math.cos(a)-y0*math.sin(a)+ocx, x0*math.sin(a)+y0*math.cos(a)+ocy)) + return out + +def scale_pts(pts, ocx, ocy, s): + return [((x-ocx)*s+ocx, (y-ocy)*s+ocy) for x, y in pts] + +cx, cy = 54, 54 +teeth = 8 +outer_r = 36 +inner_r = 27 + +all_pts = gear_points(cx, cy, teeth, outer_r, inner_r) +broken_idx = 1 +body_pts = [p for i, p in enumerate(all_pts) if not (4*broken_idx <= i < 4*broken_idx+4)] + +tooth_pts = all_pts[4*broken_idx:4*broken_idx+4] +tooth_cx = sum(p[0] for p in tooth_pts)/4 +tooth_cy = sum(p[1] for p in tooth_pts)/4 + +dx, dy = 14, 2 +final_pts = rotate([(x+dx, y+dy) for x, y in tooth_pts], tooth_cx+dx, tooth_cy+dy, 26) +final_cx, final_cy = tooth_cx+dx, tooth_cy+dy + +trail_half_w_near = 1.2 +trail_half_w_far = 7.5 +perp = (-dy, dx) +plen = math.hypot(*perp) +perp = (perp[0]/plen, perp[1]/plen) + +gap_angle = math.atan2(tooth_cy - cy, tooth_cx - cx) +extend_r = inner_r - 6 +gap_x = cx + math.cos(gap_angle) * extend_r +gap_y = cy + math.sin(gap_angle) * extend_r + +near1 = (gap_x + perp[0]*trail_half_w_near, gap_y + perp[1]*trail_half_w_near) +near2 = (gap_x - perp[0]*trail_half_w_near, gap_y - perp[1]*trail_half_w_near) +far1 = (final_cx + perp[0]*trail_half_w_far, final_cy + perp[1]*trail_half_w_far) +far2 = (final_cx - perp[0]*trail_half_w_far, final_cy - perp[1]*trail_half_w_far) +trail_pts = [near1, far1, far2, near2] + +hub_r = 12 + +# --- scale everything down to fit the adaptive-icon safe zone (66dp diameter circle) --- +SCALE = 0.66 +def S(pts): + return scale_pts(pts, cx, cy, SCALE) + +body_s = S(body_pts) +final_s = S(final_pts) +trail_s = S(trail_pts) +hub_r_s = hub_r * SCALE +gap_x_s, gap_y_s = scale_pts([(gap_x, gap_y)], cx, cy, SCALE)[0] +final_cx_s, final_cy_s = scale_pts([(final_cx, final_cy)], cx, cy, SCALE)[0] + +body_path = path_from_pts(body_s) +final_path = path_from_pts(final_s) +trail_path = path_from_pts(trail_s) + +GREY = "#8B9096" +RED = "#8C1F1F" +BG = "#0c0c0d" +RING = "#131415" + +# ===== 1. Background: full-bleed, unscaled ===== +bg_xml = f''' + + + + +''' + +# ===== 2. Foreground: gear + broken piece + trail, scaled into the safe zone ===== +# Trail uses a gradient (narrow near the gear, fading out) via an gradient, +# same visual as the mockup. +fg_xml = f''' + + + + + + + + + + + + + +''' + +# ===== 3. Monochrome: same silhouette, single alpha-bearing color for Material You +# theming. The system substitutes its own tint - the fillColor value here is +# irrelevant, only the shape/alpha matters. Trail kept at a lower alpha so the +# fade effect survives tinting too. +mono_xml = f''' + + + + + + +''' + +out = '/tmp/claude-1000/-home-seb-AABrowser/dadb0938-9338-4349-968f-4a89bc8de777/scratchpad/icon' +open(f'{out}/ic_launcher_background.xml', 'w').write(bg_xml) +open(f'{out}/ic_launcher_foreground.xml', 'w').write(fg_xml) +open(f'{out}/ic_launcher_monochrome.xml', 'w').write(mono_xml) +print("written") diff --git a/docs/icon/on-device-verification.png b/docs/icon/on-device-verification.png new file mode 100644 index 0000000000000000000000000000000000000000..fcc2b87e131483f3427d529722da9d87dd7a24d0 GIT binary patch literal 81317 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

5*TKj7$JC3h87V+T($NyD-;o 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 C3VKEW literal 0 HcmV?d00001 diff --git a/gearslip/.gitignore b/gearslip/.gitignore new file mode 100644 index 0000000..5e70b9f --- /dev/null +++ b/gearslip/.gitignore @@ -0,0 +1,10 @@ +# Build output is redirected to /build/gearslip/ (see build.gradle.kts), already +# covered by the root .gitignore's /build - this is just a safety net in case that ever +# changes back. +build/ + +# User-supplied certificate material - never checked in (see CertProvider.kt). +phone.p12 +phone.pass +*.crt +*.key diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts new file mode 100644 index 0000000..ca5c88d --- /dev/null +++ b/gearslip/build.gradle.kts @@ -0,0 +1,84 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +/** + * Gearslip: a phone-side Android Auto client built against the reverse-engineered AA wire + * protocol, so a non-Google app can authenticate to and project onto a real car head unit + * with no gearhead in the path. No third-party dependencies: the X.509 certificate is + * generated by hand (see `SelfSignedCert`) so there is no BouncyCastle version to track and + * no second crypto provider to collide with Conscrypt. + */ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "app.seb3thehacker.gearslip" + compileSdk = 37 + + defaultConfig { + applicationId = "app.seb3thehacker.gearslip" + minSdk = 35 + targetSdk = 37 + versionCode = 1 + versionName = "0.1" + } + + // Keeps build output out of the module folder: /build/gearslip/... instead of + // /gearslip/build/..., so everything this project produces lands under one + // root-level build/ directory. + layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip")) + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + testOptions { + // GearslipLog calls android.util.Log, which is a stub on the local JVM. + unitTests.isReturnDefaultValues = true + } + +} + +// Renames the produced APK and moves it to the root build/ folder (/build/Gearslip-debug.apk) +// after the fact rather than fighting AGP's variant-output API across versions - simpler and +// doesn't break when that API moves again. +tasks.register("renameApk") { + doLast { + val rootBuildDir = rootProject.layout.buildDirectory.get().asFile + layout.buildDirectory.dir("outputs/apk").get().asFile.walkTopDown() + .filter { it.extension == "apk" } + .toList() + .forEach { apk -> + val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk") + apk.copyTo(renamed, overwrite = true) + apk.delete() + } + } +} +tasks.matching { it.name.startsWith("assemble") }.configureEach { finalizedBy("renameApk") } + +dependencies { + // Only for the XML window theme (Theme.Material3.DynamicColors); every screen is Compose. + implementation(libs.google.material) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.core) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + // Used from the host side, not the app side: Gearslip renders other apps' templates rather + // than publishing its own, so this is here for the AIDL interfaces and the model classes + // their Bundleables deserialize into. + implementation(libs.androidx.car.app) + implementation(libs.androidx.car.app.projected) + testImplementation(libs.junit) +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} diff --git a/gearslip/src/debug/AndroidManifest.xml b/gearslip/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..705f243 --- /dev/null +++ b/gearslip/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt b/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt new file mode 100644 index 0000000..98d483f --- /dev/null +++ b/gearslip/src/debug/java/app/seb3thehacker/gearslip/CarPreviewActivity.kt @@ -0,0 +1,106 @@ +package app.seb3thehacker.gearslip + +import android.graphics.Bitmap +import android.graphics.PixelFormat +import android.hardware.display.DisplayManager +import android.media.ImageReader +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import androidx.activity.ComponentActivity +import app.seb3thehacker.gearslip.car.CarEnvironment +import app.seb3thehacker.gearslip.car.CarSettings +import app.seb3thehacker.gearslip.car.CarUi +import java.io.File +import java.io.FileOutputStream + +/** + * Debug-only harness: runs the real car UI on a virtual display (the same path a head unit + * gets), taps where told, and saves what it rendered as PNGs in the external files dir. + * + * adb shell am start -n app.seb3thehacker.gearslip/.CarPreviewActivity \ + * --ei w 800 --ei h 480 --ei dpi 160 --es taps "400,440;90,440" + * + * Writes preview-0.png (before any tap), then one more per tap. + */ +class CarPreviewActivity : ComponentActivity() { + + private val main = Handler(Looper.getMainLooper()) + private var projector: ScreenProjector? = null + private var reader: ImageReader? = null + @Volatile private var latest: Bitmap? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + GearslipLog.init(this) + CarSettings.init(this) + CarEnvironment.start(this) + + val w = intent.getIntExtra("w", 800) + val h = intent.getIntExtra("h", 480) + val dpi = intent.getIntExtra("dpi", 160) + val taps = intent.getStringExtra("taps").orEmpty().split(";").filter { it.isNotBlank() } + .map { it.split(",").let { p -> p[0].toFloat() to p[1].toFloat() } } + + val imageReader = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 3) + reader = imageReader + imageReader.setOnImageAvailableListener({ r -> + r.acquireLatestImage()?.use { image -> + val plane = image.planes[0] + val padded = plane.rowStride / plane.pixelStride + val bmp = Bitmap.createBitmap(padded, h, Bitmap.Config.ARGB_8888) + bmp.copyPixelsFromBuffer(plane.buffer) + latest = Bitmap.createBitmap(bmp, 0, 0, w, h) + } + }, main) + + val p = ScreenProjector(this).also { projector = it } + p.start(imageReader.surface, w, h, dpi) { CarUi() } + + GearslipLog.i("preview: displayId=${p.displayId}") + if (intent.getBooleanExtra("hide", false)) p.setOverlayVisible(false) + + intent.getStringExtra("launch")?.let { pkg -> + main.postDelayed({ + packageManager.getLaunchIntentForPackage(pkg)?.let { p.launchApp(it) } + ?: GearslipLog.e("preview: no launch intent for $pkg") + }, 1500) + } + + var step = 0 + fun snap() { + val bmp = latest ?: return GearslipLog.e("preview: no frame yet for step $step") + val out = File(getExternalFilesDir(null), "preview-$step.png") + FileOutputStream(out).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } + GearslipLog.i("preview: wrote ${out.name}") + step++ + } + // Free-running mode: keep snapshotting on a timer so the display can be driven from + // outside (adb `am start --display` / `input -d`) while the harness records it. + val snaps = intent.getIntExtra("snaps", 0) + if (snaps > 0) { + val every = intent.getIntExtra("every", 3000).toLong() + repeat(snaps) { i -> main.postDelayed({ snap() }, 2000L + every * i) } + return + } + + main.postDelayed({ + snap() + taps.forEachIndexed { i, (x, y) -> + main.postDelayed({ + p.dispatchTouch(MotionEvent.ACTION_DOWN, x, y) + main.postDelayed({ p.dispatchTouch(MotionEvent.ACTION_UP, x, y) }, 80) + }, 2500L * (i + 1)) + main.postDelayed({ snap() }, 2500L * (i + 1) + 1500) + } + }, 2000) + } + + override fun onDestroy() { + super.onDestroy() + projector?.stop() + reader?.close() + CarEnvironment.stop() + } +} diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml new file mode 100644 index 0000000..383b767 --- /dev/null +++ b/gearslip/src/main/AndroidManifest.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt new file mode 100644 index 0000000..80e6d3c --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt @@ -0,0 +1,28 @@ +package app.seb3thehacker.gearslip + +import android.content.Context + +/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */ +object AppSettings { + + private const val PREFS = "gearslip_settings" + private const val KEY_STARTUP_URL = "startup_url" + + private fun prefs(context: Context) = + context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun getString(context: Context, key: String, default: String): String = + prefs(context).getString(key, default) ?: default + + fun putString(context: Context, key: String, value: String) { + prefs(context).edit().putString(key, value).apply() + } + + /** Blank means "use the built-in test page". */ + fun startupUrl(context: Context): String = + prefs(context).getString(KEY_STARTUP_URL, "").orEmpty() + + fun setStartupUrl(context: Context, value: String) { + prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply() + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt new file mode 100644 index 0000000..3bb642c --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt @@ -0,0 +1,140 @@ +package app.seb3thehacker.gearslip + +import android.content.Context +import android.net.Uri +import java.io.File +import java.security.KeyStore +import java.security.cert.X509Certificate + +/** + * Supplies the certificate the phone presents to the head unit. + * + * FOSS posture: this code ships with NO certificate. Whoever wants to test the trusted-cert + * path supplies their own. Sources, in priority order: + * + * 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password, + * copied into the app's private storage. + * 2. **Staged over adb** into the app's external files directory, the way the spike has + * always done it. Still honoured, so existing setups keep working untouched: + * + * openssl pkcs12 -export -in headunit.crt -inkey headunit.key \ + * -name phone -passout pass:aaspike -out phone.p12 + * adb push phone.p12 /sdcard/Android/data/app.seb3thehacker.gearslip/files/phone.p12 + * + * Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need + * regenerating); a `phone.pass` file next to it overrides it. + * 3. A freshly generated self-signed cert, which a real head unit rejects (see + * SPIKE_FINDINGS.md). + */ +object CertProvider { + + private const val P12_NAME = "phone.p12" + private const val PASS_NAME = "phone.pass" + private const val DEFAULT_PASSWORD = "aaspike" + private const val IMPORT_DIR = "identity" + + enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED } + + class Identity( + val keyStore: KeyStore, + val certificate: X509Certificate, + val password: CharArray, + val source: String, + val kind: Kind = Kind.SELF_SIGNED, + ) + + /** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */ + fun load(context: Context): Identity = loadFromFiles(context) ?: run { + val generated = SelfSignedCert.generate() + Identity( + generated.keyStore, + generated.certificate, + SelfSignedCert.PASSWORD, + "self-signed (generated) - a real head unit rejects this", + Kind.SELF_SIGNED, + ) + } + + /** + * The active identity without the cost of generating a self-signed key: null means "nothing + * supplied, a self-signed one would be generated". For the UI, which only wants to describe it. + */ + fun loadSupplied(context: Context): Identity? = loadFromFiles(context) + + private fun loadFromFiles(context: Context): Identity? { + val imported = importedFile(context, P12_NAME) + if (imported.isFile) { + runCatching { + return loadP12(imported, importedPassword(context), "imported certificate", Kind.IMPORTED) + }.onFailure { GearslipLog.e("could not load the imported certificate - trying the next source", it) } + } + + val dir = context.getExternalFilesDir(null) + val staged = dir?.let { File(it, P12_NAME) } + if (dir != null && staged != null && staged.isFile) { + runCatching { + val passFile = File(dir, PASS_NAME) + val password = if (passFile.isFile) { + passFile.readText().lineSequence().firstOrNull().orEmpty().trim() + } else { + DEFAULT_PASSWORD + } + return loadP12(staged, password, "$P12_NAME staged over adb", Kind.ADB_STAGED) + }.onFailure { GearslipLog.e("could not load $P12_NAME - falling back to self-signed", it) } + } + return null + } + + /** + * Validates [uri] as a PKCS#12 identity with [password] and, only if it is good, makes it + * the active certificate. A wrong password or a file with no private key throws and + * leaves whatever was active before untouched. + */ + fun importFrom(context: Context, uri: Uri, password: String): Identity { + val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalArgumentException("could not open the selected file") + + val staging = File(context.cacheDir, "import-$P12_NAME") + try { + staging.writeBytes(bytes) + val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED) + + val target = importedFile(context, P12_NAME) + target.parentFile?.mkdirs() + staging.copyTo(target, overwrite = true) + importedFile(context, PASS_NAME).writeText(password) + GearslipLog.i("imported a certificate: ${identity.certificate.subjectX500Principal}") + return identity + } finally { + staging.delete() + } + } + + fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile + + /** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */ + fun removeImported(context: Context) { + importedFile(context, P12_NAME).delete() + importedFile(context, PASS_NAME).delete() + GearslipLog.i("removed the imported certificate") + } + + // The imported files live in the app's private internal storage (not the external files + // dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected. + private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name) + + private fun importedPassword(context: Context): String = + importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty() + + private fun loadP12(file: File, password: String, label: String, kind: Kind): Identity { + val chars = password.toCharArray() + val keyStore = KeyStore.getInstance("PKCS12") + file.inputStream().use { keyStore.load(it, chars) } + + val alias = keyStore.aliases().asSequence().firstOrNull { keyStore.isKeyEntry(it) } + ?: throw IllegalStateException("the file contains no private-key entry") + val certificate = keyStore.getCertificate(alias) as X509Certificate + + return Identity(keyStore, certificate, chars, "$label (alias=$alias)", kind) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/EncoderSelfTest.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/EncoderSelfTest.kt new file mode 100644 index 0000000..419fc55 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/EncoderSelfTest.kt @@ -0,0 +1,96 @@ +package app.seb3thehacker.gearslip + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Proves the H.264 encoder actually produces a bitstream on this device, before a drive. + * + * Without this, an unsupported colour format, a rejected Baseline profile, or a null + * getInputImage() would all present in the car as "the head unit shows nothing" - the same + * symptom as a protocol fault, and impossible to tell apart from the log. Same reasoning as + * the TLS self-test: make our own failures loud on the bench. + */ +object EncoderSelfTest { + + fun run(width: Int = 800, height: Int = 480, frameRate: Int = 30): Boolean { + GearslipLog.i("=== encoder self-test starting (${width}x$height @ ${frameRate}fps) ===") + + var codecConfig: ByteArray? = null + var frames = 0 + var sawKeyFrame = false + var sawResyncKeyFrame = false + var resyncRequestedAtFrame = -1 + val done = CountDownLatch(1) + + val source = VideoSource( + width = width, + height = height, + frameRate = frameRate, + onCodecConfig = { codecConfig = it }, + onFrame = { _, _, keyFrame -> + frames++ + if (keyFrame) sawKeyFrame = true + // At frame 5, ask for a resync keyframe - the same call sendVideoFrame makes + // after a drop - and confirm THIS encoder actually honours it. Trusting an + // API we have never exercised on this device is exactly the mistake that + // cost two drives already (the CONTROL message-type bit, the touch scaling). + if (frames == 5) { + resyncRequestedAtFrame = frames + } + if (resyncRequestedAtFrame in 1 until frames && keyFrame) { + sawResyncKeyFrame = true + } + if (frames >= 12) done.countDown() + }, + ) + + return try { + source.start() + // Give it a couple of frames before requesting resync, matching how it is used + // for real: the request always follows at least one already-encoded frame. + Thread { + while (resyncRequestedAtFrame == -1) Thread.sleep(20) + source.requestSyncFrame() + }.apply { isDaemon = true; start() } + done.await(8, TimeUnit.SECONDS) + source.stop() + + val csd = codecConfig + when { + csd == null -> { + GearslipLog.e("encoder self-test FAILED: no CODEC_CONFIG (SPS/PPS) produced") + false + } + frames == 0 -> { + GearslipLog.e("encoder self-test FAILED: no frames produced") + false + } + !sawKeyFrame -> { + GearslipLog.e("encoder self-test FAILED: no keyframe - the car needs one first") + false + } + !sawResyncKeyFrame -> { + GearslipLog.e( + "encoder self-test FAILED: requestSyncFrame() produced no keyframe - " + + "the resync-after-drop fix would silently do nothing on this device", + ) + false + } + else -> { + GearslipLog.i( + "SPS/PPS = ${csd.size} bytes, $frames frames, keyframe present, " + + "requestSyncFrame() confirmed working", + ) + GearslipLog.hex(" csd", csd, limit = 48) + GearslipLog.i("=== encoder self-test PASSED ===") + true + } + } + } catch (t: Throwable) { + runCatching { source.stop() } + GearslipLog.e("encoder self-test FAILED with an exception", t) + false + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt new file mode 100644 index 0000000..4ecaf09 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Framing.kt @@ -0,0 +1,135 @@ +package app.seb3thehacker.gearslip + +import java.io.ByteArrayOutputStream + +/** + * Android Auto frame layout, transcribed from aasdk. + * + * FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55), + * FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and + * MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120): + * + * byte 0 channel id + * byte 1 frameType | encryption | messageType + * bytes 2-3 uint16 BE payload length of THIS frame + * bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST + * payload uint16 BE message id, then the body + */ +object Frames { + + const val CHANNEL_CONTROL = 0 + + const val TYPE_MIDDLE = 0 + const val TYPE_FIRST = 1 + const val TYPE_LAST = 2 + const val TYPE_BULK = 3 + + const val ENCRYPTED = 1 shl 3 + + /** + * Message type bit. Channel-open messages are CONTROL even when sent on a service + * channel - aasdk sets MessageType::CONTROL only in sendChannelOpenResponse and + * MessageType::SPECIFIC everywhere else (e.g. + * src/Channel/MediaSink/Video/VideoMediaSinkService.cpp:52 vs :66). Getting this wrong + * makes the head unit drop the USB link outright rather than answer. + */ + const val MESSAGE_SPECIFIC = 0 + const val MESSAGE_CONTROL = 1 shl 2 + + /** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */ + const val MAX_FRAME_PAYLOAD = 0x4000 + + class Frame( + val channel: Int, + val frameType: Int, + val encrypted: Boolean, + val messageType: Int, + val payload: ByteArray, + ) + + /** + * Builds one frame. [totalSize] is the full plaintext message length and is written only + * for FIRST frames, matching aasdk's MessageOutStream::setFrameSize + * (`FrameSize(payloadSize, totalSize)` for FIRST, `FrameSize(payloadSize)` otherwise). + */ + fun build( + channel: Int, + encrypted: Boolean, + payload: ByteArray, + messageType: Int = MESSAGE_SPECIFIC, + frameType: Int = TYPE_BULK, + totalSize: Int? = null, + ): ByteArray { + val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0) + val header = byteArrayOf( + channel.toByte(), + flags.toByte(), + ((payload.size shr 8) and 0xFF).toByte(), + (payload.size and 0xFF).toByte(), + ) + if (frameType != TYPE_FIRST || totalSize == null) return header + payload + val extended = byteArrayOf( + ((totalSize shr 24) and 0xFF).toByte(), + ((totalSize shr 16) and 0xFF).toByte(), + ((totalSize shr 8) and 0xFF).toByte(), + (totalSize and 0xFF).toByte(), + ) + return header + extended + payload + } + + /** + * Pulls whole frames out of an accumulating buffer. + * + * A read() on the accessory descriptor returns one bulk transfer, which is usually one + * frame - but nothing guarantees it, so this never assumes alignment. + */ + class Parser { + private var pending = ByteArray(0) + + fun append(data: ByteArray, length: Int) { + val grown = ByteArray(pending.size + length) + System.arraycopy(pending, 0, grown, 0, pending.size) + System.arraycopy(data, 0, grown, pending.size, length) + pending = grown + } + + fun next(): Frame? { + if (pending.size < 4) return null + val channel = pending[0].toInt() and 0xFF + val flags = pending[1].toInt() and 0xFF + val frameType = flags and TYPE_BULK + // Extended size is written only for FIRST - BULK (3) keeps the short header. + val headerLength = if (frameType == TYPE_FIRST) 8 else 4 + if (pending.size < headerLength) return null + + val frameSize = ((pending[2].toInt() and 0xFF) shl 8) or (pending[3].toInt() and 0xFF) + val total = headerLength + frameSize + if (pending.size < total) return null + + val payload = pending.copyOfRange(headerLength, total) + pending = pending.copyOfRange(total, pending.size) + return Frame( + channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, + ) + } + } + + /** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */ + class Assembler { + private val partial = HashMap() + + fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) { + TYPE_BULK -> payload + TYPE_FIRST -> { + partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) } + null + } + TYPE_MIDDLE -> { + partial[frame.channel]?.write(payload) + null + } + TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray() + else -> null + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt new file mode 100644 index 0000000..7cc8bc2 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -0,0 +1,298 @@ +package app.seb3thehacker.gearslip + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbAccessory +import android.hardware.usb.UsbManager +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.view.MotionEvent +import android.view.Surface +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import android.content.res.Configuration +import app.seb3thehacker.gearslip.audio.AudioCaptureService +import app.seb3thehacker.gearslip.audio.CarAudio +import app.seb3thehacker.gearslip.mirror.MirrorService +import app.seb3thehacker.gearslip.mirror.PhoneMirror +import app.seb3thehacker.gearslip.mirror.TouchRelayService +import app.seb3thehacker.gearslip.car.CarEnvironment +import app.seb3thehacker.gearslip.car.CarSettings +import app.seb3thehacker.gearslip.car.CarUi +import app.seb3thehacker.gearslip.ui.GearslipApp +import app.seb3thehacker.gearslip.ui.GearslipTheme +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Hosts the phone UI and, for now, the car session itself. + * + * Launched by the system when the head unit switches the phone into accessory mode. The + * session lives in this activity, so [android:configChanges] in the manifest keeps a + * rotation or theme change from recreating it and dropping the connection. + */ +class GearslipActivity : ComponentActivity(), Projection { + + private var descriptor: ParcelFileDescriptor? = null + private var runner: GearslipRunner? = null + private var screenProjector: ScreenProjector? = null + private var worker: Thread? = null + + /** The encoder's input Surface, kept so the car's picture can be switched to the mirror. */ + private var surface: Surface? = null + private var frameWidth = 0 + private var frameHeight = 0 + private var frameDensity = 0 + + /** + * Screen capture always begins with a consent dialog, which only an activity can show - + * so the car UI asks, and the request surfaces here on the phone. + */ + private val captureConsent = registerForActivityResult(StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + MirrorService.start(this, result.resultCode, data) + } else { + GearslipLog.w("screen capture consent declined") + } + } + + /** Media audio needs the microphone permission (a platform rule for playback capture) first. */ + private val audioPermission = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) askAudioCapture() else { + GearslipLog.w("audio: record permission declined") + CarAudio.denied() + } + } + + private val audioConsent = registerForActivityResult(StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + AudioCaptureService.start(this, result.resultCode, data) + } else { + GearslipLog.w("audio: capture consent declined") + CarAudio.denied() + } + } + + private val detachReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { + GearslipLog.w("accessory detached") + closeAccessory() + } + } + } + + /** Hosted map apps need Gearslip to hold location so they can keep it in the background. */ + private val locationPermission = registerForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), + ) { } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != + android.content.pm.PackageManager.PERMISSION_GRANTED + ) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION) + enableEdgeToEdge() + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + GearslipLog.init(this) + GearslipLog.i("Gearslip ready") + CarSettings.init(this) + CarEnvironment.setPhoneTheme(resources.configuration) + + setContent { + GearslipTheme { + GearslipApp(onDisconnect = ::closeAccessory) + } + } + + PhoneMirror.onStartRequested = { runOnUiThread { requestCapture() } } + PhoneMirror.onTokenReady = { token -> runOnUiThread { showMirror(token) } } + PhoneMirror.onEnded = { runOnUiThread { showCarUi() } } + CarAudio.onConsentRequested = { runOnUiThread { requestAudioCapture() } } + + registerReceiver(detachReceiver, IntentFilter(UsbManager.ACTION_USB_ACCESSORY_DETACHED), RECEIVER_NOT_EXPORTED) + handleIntent(intent) + } + + // The manifest opts out of recreation on uiMode changes, so a light/dark switch on the phone + // arrives here, and the car UI follows it. + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + CarEnvironment.setPhoneTheme(newConfig) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onDestroy() { + super.onDestroy() + runCatching { unregisterReceiver(detachReceiver) } + PhoneMirror.onStartRequested = null + PhoneMirror.onTokenReady = null + PhoneMirror.onEnded = null + CarAudio.onConsentRequested = null + CarAudio.release() + PhoneMirror.requestStop() + closeAccessory() + GearslipLog.flush() + } + + // --- Media audio -------------------------------------------------------------------- + + private fun requestAudioCapture() { + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) { + askAudioCapture() + } else { + audioPermission.launch(android.Manifest.permission.RECORD_AUDIO) + } + } + + private fun askAudioCapture() { + val manager = getSystemService(MediaProjectionManager::class.java) + runCatching { audioConsent.launch(manager.createScreenCaptureIntent()) } + .onFailure { + GearslipLog.e("could not ask for audio capture", it) + CarAudio.denied() + } + } + + // --- Phone mirroring ---------------------------------------------------------------- + + private fun requestCapture() { + val manager = getSystemService(MediaProjectionManager::class.java) + runCatching { captureConsent.launch(manager.createScreenCaptureIntent()) } + .onFailure { GearslipLog.e("could not ask for screen capture", it) } + } + + /** A Surface has a single producer, so our own display must go before the mirror arrives. */ + private fun showMirror(token: MediaProjection) { + val target = surface ?: return + screenProjector?.stop() + screenProjector = null + PhoneMirror.begin(token, target, frameWidth, frameHeight, frameDensity) + if (PhoneMirror.active.value) PhoneMirror.launchPending(this) else showCarUi() + } + + private fun showCarUi() { + val target = surface ?: return + if (screenProjector != null) return + val projector = ScreenProjector(this).also { screenProjector = it } + projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() } + } + + /** + * Sends a head-unit touch to the phone. Anything landing on the mirror's letterbox padding + * maps to nothing on the phone and is dropped rather than clamped onto a screen edge. + */ + private fun relayTouch(action: Int, x: Float, y: Float) { + val point = PhoneMirror.mapToPhone(this, x, y) + if (point == null) { + // The letterbox bars belong to no window, which makes them a safe way back: a tap + // on the padding leaves the mirror and returns the car to Gearslip's own UI. + if (action == MotionEvent.ACTION_UP) PhoneMirror.requestStop() + return + } + val relay = TouchRelayService.instance ?: return + when (action) { + MotionEvent.ACTION_DOWN -> relay.down(point.x, point.y) + MotionEvent.ACTION_MOVE -> relay.move(point.x, point.y) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> relay.up(point.x, point.y) + } + } + + private fun handleIntent(intent: Intent?) { + if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return + val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java) + if (accessory == null) { + GearslipLog.e("attach intent carried no accessory") + SessionStatus.failed("Accessory missing", "The system reported a USB attach but did not include the accessory.") + return + } + GearslipLog.i("accessory attached by the system - permission is implicit on this path") + start(accessory) + } + + private fun start(accessory: UsbAccessory) { + if (worker?.isAlive == true) { + GearslipLog.w("a run is already in progress") + return + } + describe(accessory) + + val manager = getSystemService(UsbManager::class.java) + val pfd = manager.openAccessory(accessory) + if (pfd == null) { + GearslipLog.e("openAccessory returned null - the session was claimed by someone else") + SessionStatus.failed("Accessory unavailable", "Another app claimed the USB accessory before Gearslip could open it.") + return + } + descriptor = pfd + + val runner = GearslipRunner( + input = FileInputStream(pfd.fileDescriptor), + output = FileOutputStream(pfd.fileDescriptor), + identityProvider = { CertProvider.load(this) }, + projection = this, + vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) }, + ) + this.runner = runner + worker = Thread { runner.run() }.apply { name = "gearslip"; start() } + } + + private fun describe(accessory: UsbAccessory) { + GearslipLog.i("accessory: manufacturer=${accessory.manufacturer} model=${accessory.model}") + GearslipLog.i(" version=${accessory.version} description=${accessory.description}") + } + + private fun closeAccessory() { + runner?.stop() + runCatching { descriptor?.close() } + descriptor = null + runner = null + } + + // --- Projection --------------------------------------------------------------------- + + override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) { + runOnUiThread { + this.surface = surface + frameWidth = width + frameHeight = height + frameDensity = densityDpi + CarEnvironment.setDisplay(width, height, densityDpi) + CarEnvironment.start(this) + showCarUi() + } + } + + override fun onTouch(action: Int, x: Float, y: Float) { + runOnUiThread { + if (PhoneMirror.active.value) relayTouch(action, x, y) + else screenProjector?.dispatchTouch(action, x, y) + } + } + + override fun onProjectionStopped() { + runOnUiThread { + PhoneMirror.requestStop() + screenProjector?.stop() + screenProjector = null + surface = null + CarEnvironment.stop() + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt new file mode 100644 index 0000000..6c8b2d6 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipLog.kt @@ -0,0 +1,120 @@ +package app.seb3thehacker.gearslip + +import android.content.Context +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * Logs to logcat and to a file at once. + * + * The file copy is the safety net: entering USB accessory mode tears down adb-over-USB, so + * if wireless adb drops in the car the file is the only surviving record of the run. It + * lives under getExternalFilesDir, which `adb pull` can reach without root. + */ +object GearslipLog { + + const val TAG = "Gearslip" + + private val stamp = SimpleDateFormat("HH:mm:ss.SSS", Locale.US) + private val io = Executors.newSingleThreadExecutor() + + private const val MAX_LIVE_LINES = 3000 + + private var file: File? = null + + // What the live log screen shows. Bounded, and separate from the file, which always keeps + // everything. The flow carries only a version number so a burst of lines costs the UI one + // snapshot per frame rather than one list copy per line. + private val live = ArrayDeque() + private val liveVersion = MutableStateFlow(0) + + val version: StateFlow = liveVersion + + fun init(context: Context) { + val dir = context.getExternalFilesDir(null) ?: return + val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + file = File(dir, "gearslip-$name.log") + i("log file: ${file?.absolutePath}") + } + + fun snapshot(): List = synchronized(live) { live.toList() } + + /** Clears the live view only. The log file on disk is left alone. */ + fun clearLive() { + synchronized(live) { live.clear() } + liveVersion.update { it + 1 } + } + + /** The whole current session's file, for sharing. Falls back to the live buffer. */ + fun fullText(): String { + flush() + val f = file + return if (f != null && f.isFile) f.readText() else snapshot().joinToString("\n") + } + + fun i(message: String) = write("I", message) + + fun w(message: String) = write("W", message) + + fun e(message: String, t: Throwable? = null) { + write("E", if (t == null) message else "$message: ${t.javaClass.simpleName}: ${t.message}") + if (t != null) Log.e(TAG, message, t) + } + + /** Unmissable in a scrolling logcat - this is what the whole spike exists to print. */ + fun verdict(headline: String, detail: String) { + val bar = "=".repeat(60) + i(bar) + i("VERDICT: $headline") + i(detail) + i(bar) + // The verdict is the one line the run exists to produce, and the app may be killed + // the moment the accessory detaches. Do not leave it sitting in the write queue. + flush() + } + + /** Blocks until everything queued has actually reached the file. */ + fun flush(timeoutMs: Long = 2_000) { + val latch = CountDownLatch(1) + runCatching { io.execute { latch.countDown() } }.onFailure { return } + runCatching { latch.await(timeoutMs, TimeUnit.MILLISECONDS) } + } + + fun hex(label: String, data: ByteArray, limit: Int = 256) { + val shown = minOf(data.size, limit) + val sb = StringBuilder() + for (idx in 0 until shown) { + sb.append(String.format("%02X", data[idx])) + if (idx % 16 == 15) sb.append('\n') else sb.append(' ') + } + val suffix = if (data.size > limit) "\n... (${data.size - limit} more bytes)" else "" + i("$label (${data.size} bytes)\n$sb$suffix") + } + + private fun write(level: String, message: String) { + when (level) { + "W" -> Log.w(TAG, message) + "E" -> Log.e(TAG, message) + else -> Log.i(TAG, message) + } + val line = "${stamp.format(Date())} $level $message" + synchronized(live) { + live.addLast(line) + while (live.size > MAX_LIVE_LINES) live.removeFirst() + } + liveVersion.update { it + 1 } + val target = file ?: return + io.execute { + runCatching { target.appendText(line + "\n") } + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt new file mode 100644 index 0000000..a330e23 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -0,0 +1,775 @@ +package app.seb3thehacker.gearslip + +import android.os.Build +import app.seb3thehacker.gearslip.audio.AudioLink +import app.seb3thehacker.gearslip.car.CarEnvironment +import java.io.InputStream +import java.io.OutputStream + +/** + * Drives the phone side of the control-channel handshake far enough to answer one question: + * does this head unit accept a self-signed certificate from the phone? + * + * The head unit initiates (aasdk's ControlServiceChannel::sendVersionRequest), so until + * step 5 this is purely reactive: + * + * 1 HU -> phone VersionRequest id 1 plain + * 2 phone -> HU VersionResponse id 2 plain + * 3 both EncapsulatedSSL id 3 plain (repeats until TLS completes) + * 4 HU -> phone AuthComplete id 4 plain <- the answer + * 5 phone -> HU ServiceDiscoveryRequest id 5 ENCRYPTED + * 6 HU -> phone ServiceDiscoveryResponse id 6 ENCRYPTED <- the confirmation + */ +class GearslipRunner( + private val input: InputStream, + private val output: OutputStream, + private val identityProvider: () -> CertProvider.Identity, + private val projection: Projection? = null, + private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null }, +) { + + private enum class State { WAIT_VERSION, TLS_HANDSHAKE, WAIT_AUTH, WAIT_SDR, DONE } + + private val parser = Frames.Parser() + private val assembler = Frames.Assembler() + private lateinit var tls: PhoneTls + + @Volatile private var state = State.WAIT_VERSION + @Volatile private var lastProgress = System.currentTimeMillis() + @Volatile private var running = true + + @Volatile private var videoChannelId: Int = -1 + private var videoConfigs: List = emptyList() + private var selectedConfigIndex: Int? = null + private var vehicleProfile: VehicleProfile? = null + @Volatile private var videoStarted = false + private var videoSource: VideoSource? = null + @Volatile private var inFlight = 0 + @Volatile private var maxUnacked = 4 + @Volatile private var framesSent = 0 + @Volatile private var dropped = 0 + @Volatile private var inputChannelId: Int = -1 + @Volatile private var audioLink: AudioLink? = null + private var touchWidth = 0 + private var touchHeight = 0 + private var touchesSeen = 0 + @Volatile private var acksSeen = 0 + @Volatile private var lastAckAt = 0L + @Volatile private var splitMessages = 0 + @Volatile private var resyncRequested = false + @Volatile private var resyncAttempts = 0 + @Volatile private var lastKeyframeSentAt = 0L + + fun run() { + GearslipLog.i("--- spike run starting ---") + SessionStatus.connecting("Connected to the head unit") + GearslipLog.i("device=${Build.MANUFACTURER} ${Build.MODEL} android=${Build.VERSION.RELEASE} (sdk ${Build.VERSION.SDK_INT})") + + val identity = identityProvider() + val cert = identity.certificate + GearslipLog.i("phone certificate source: ${identity.source}") + GearslipLog.i(" subject = ${cert.subjectX500Principal}") + GearslipLog.i(" issuer = ${cert.issuerX500Principal}") + GearslipLog.i(" serial = ${cert.serialNumber} valid ${cert.notBefore}..${cert.notAfter}") + tls = PhoneTls(identity.keyStore, identity.password) + + startWatchdog() + + val chunk = ByteArray(16 * 1024) // AOAP: one read returns one bulk transfer + try { + while (running) { + val read = input.read(chunk) + if (read < 0) { + GearslipLog.w("head unit closed the connection (EOF) while in state $state") + reportDisconnect() + return + } + if (read == 0) continue + parser.append(chunk, read) + while (true) { + val frame = parser.next() ?: break + handleFrame(frame) + } + } + } catch (t: Throwable) { + GearslipLog.e("transport failed in state $state", t) + reportDisconnect() + } finally { + running = false + GearslipLog.flush() + } + } + + fun stop() { + running = false + videoSource?.stop() + videoSource = null + audioLink?.close() + audioLink = null + projection?.onProjectionStopped() + SessionStatus.disconnected() + } + + private fun handleFrame(frame: Frames.Frame) { + val payload = if (frame.encrypted) { + if (!tls.handshakeComplete) { + GearslipLog.w("encrypted frame arrived before the handshake finished - ignoring") + return + } + tls.decrypt(frame.payload) + } else { + frame.payload + } + + val message = assembler.offer(frame, payload) ?: return + if (message.size < 2) { + GearslipLog.w("runt message on channel ${frame.channel}") + return + } + + val messageId = ((message[0].toInt() and 0xFF) shl 8) or (message[1].toInt() and 0xFF) + val body = message.copyOfRange(2, message.size) + + lastProgress = System.currentTimeMillis() + + if (frame.channel != Frames.CHANNEL_CONTROL) { + if (frame.channel == videoChannelId) { + onVideoMessage(messageId, body) + } else if (frame.channel == inputChannelId) { + onInputMessage(messageId, body) + } else if (frame.channel == audioLink?.channelId) { + audioLink?.onMessage(messageId, body) + } else { + GearslipLog.i("ignoring message id $messageId on channel ${frame.channel}") + } + return + } + + when (messageId) { + MSG_VERSION_REQUEST -> onVersionRequest(body) + MSG_ENCAPSULATED_SSL -> onHandshake(body) + MSG_AUTH_COMPLETE -> onAuthComplete(body) + MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body) + MSG_PING_REQUEST -> onPingRequest(body) + AudioLink.MSG_FOCUS_RESPONSE -> audioLink?.onFocus(Protobuf.readInt32Field(body, 1) ?: 0) + else -> GearslipLog.i("unhandled control message id=$messageId (${body.size} bytes) - continuing") + } + } + + private fun onVersionRequest(body: ByteArray) { + if (body.size < 4) { + GearslipLog.w("VersionRequest too short (${body.size} bytes)") + return + } + val major = ((body[0].toInt() and 0xFF) shl 8) or (body[1].toInt() and 0xFF) + val minor = ((body[2].toInt() and 0xFF) shl 8) or (body[3].toInt() and 0xFF) + GearslipLog.i("<- VersionRequest: head unit speaks $major.$minor") + + // Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than + // asserting 1.6 is the permissive choice: we only need to reach service discovery, + // not to honour whatever that version implies. + val response = byteArrayOf( + ((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(), + ((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(), + 0, 0, // STATUS_SUCCESS + ) + send(MSG_VERSION_RESPONSE, response, encrypted = false) + GearslipLog.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)") + state = State.TLS_HANDSHAKE + SessionStatus.connecting("Securing the link") + GearslipLog.i("awaiting ClientHello - the head unit is the TLS client, we are the server") + } + + private fun onHandshake(body: ByteArray) { + GearslipLog.i("<- EncapsulatedSSL (${body.size} bytes)") + val replies = try { + tls.pumpHandshake(body) + } catch (t: Throwable) { + GearslipLog.e("TLS handshake threw", t) + GearslipLog.verdict( + "NOT VIABLE (TLS rejected)", + "The head unit aborted the TLS handshake. A bad_certificate / unknown_ca / " + + "handshake_failure alert here means it validates the phone's certificate.", + ) + SessionStatus.failed( + "Head unit rejected the certificate", + "The head unit aborted the secure handshake. It validates the phone's certificate.", + ) + state = State.DONE + return + } + replies.forEach { + send(MSG_ENCAPSULATED_SSL, it, encrypted = false) + GearslipLog.i("-> EncapsulatedSSL (${it.size} bytes)") + } + if (tls.handshakeComplete && state == State.TLS_HANDSHAKE) { + state = State.WAIT_AUTH + GearslipLog.i("awaiting AuthComplete - this carries the certificate verdict") + } + } + + private fun onAuthComplete(body: ByteArray) { + val status = Protobuf.readInt32Field(body, 1) + GearslipLog.i("<- AuthComplete: status=$status") + + when (status) { + 0 -> { + GearslipLog.i("STATUS_SUCCESS - certificate accepted; confirming with service discovery") + state = State.WAIT_SDR + SessionStatus.connecting("Certificate accepted - discovering services") + sendServiceDiscoveryRequest() + } + -2 -> { + GearslipLog.verdict( + "NOT VIABLE (STATUS_CERTIFICATE_ERROR)", + "The head unit explicitly rejected the phone's self-signed certificate. " + + "This is a definitive no - it validates the phone against a trust chain " + + "we cannot issue from.", + ) + SessionStatus.failed( + "Head unit rejected the certificate", + "The head unit refused the phone's certificate (certificate error).", + ) + state = State.DONE + } + -3 -> { + GearslipLog.verdict( + "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", + "Authentication rejected. Broader than a pure certificate error, but the " + + "practical answer is the same.", + ) + SessionStatus.failed( + "Head unit rejected the certificate", + "Authentication failed. The head unit does not trust the certificate in use.", + ) + state = State.DONE + } + else -> { + GearslipLog.verdict( + "INCONCLUSIVE (AuthComplete status=$status)", + "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + + "before drawing any conclusion.", + ) + SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.") + state = State.DONE + } + } + } + + private fun sendServiceDiscoveryRequest() { + val body = Protobuf.stringField(4, "Gearslip") + // label_text + Protobuf.stringField(5, Build.MODEL) // device_name + send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true) + GearslipLog.i("-> ServiceDiscoveryRequest (encrypted)") + } + + private fun onServiceDiscoveryResponse(body: ByteArray) { + GearslipLog.i("<- ServiceDiscoveryResponse (${body.size} bytes, decrypted successfully)") + GearslipLog.i("head unit describes itself as:\n" + Protobuf.describe(body)) + GearslipLog.verdict( + "VIABLE", + "The head unit accepted the presented phone certificate, completed TLS, and is " + + "advertising its channels over the encrypted session. A phone-side client is " + + "possible against THIS unit - portability to newer units is still unproven.", + ) + state = State.DONE + val info = ServiceDiscovery.findHeadUnitInfo(body) + GearslipLog.i("head unit: $info") + vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull() + val profile = vehicleProfile + if (profile != null) { + GearslipLog.i("vehicle profile matched: \"${profile.name}\" resolution=${profile.resolution} insets=${profile.insets}") + } else { + GearslipLog.i("no vehicle profile matched - default display settings (add one to vehicles.json)") + } + CarEnvironment.setVehicle(profile?.name ?: "Unknown vehicle", profile?.insets ?: Insets.NONE) + SessionStatus.connecting("Starting video") + + startVideoChannel(body) + } + + // --- Phase A: video channel bring-up (no encoder yet) ------------------------------- + // + // Inverting aasdk's head-unit video sink (src/Channel/MediaSink/Video/ + // VideoMediaSinkService.cpp): every message that sink *receives* is one we must *send*. + // + // -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE + // -> SETUP <- CONFIG (status, max_unacked, configuration_indices) + // -> VIDEO_FOCUS_REQUEST <- VIDEO_FOCUS_NOTIFICATION + // + // Reaching VIDEO_FOCUS_NOTIFICATION means the head unit is waiting for frames, which is + // the Phase A milestone. Phase B then sends CODEC_CONFIG + DATA. + + private fun startVideoChannel(serviceDiscoveryResponse: ByteArray) { + val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) + if (video == null) { + GearslipLog.w("no video service in the discovery response - cannot start Phase A") + return + } + videoChannelId = video.serviceId + videoConfigs = video.configs + + GearslipLog.i("video service: channel=${video.serviceId} codec=${video.codecName}") + video.configs.forEachIndexed { index, config -> + GearslipLog.i(" config[$index] = $config") + } + if (video.codecType != CODEC_H264_BP) { + GearslipLog.w("head unit wants ${video.codecName}, not H264_BP - Phase B must match this") + } + + openChannel(video.serviceId) + startAudioChannel(serviceDiscoveryResponse) + + val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse) + if (input == null) { + GearslipLog.w("no input service advertised - projection will be output-only") + } else { + inputChannelId = input.serviceId + touchWidth = input.touchWidth + touchHeight = input.touchHeight + GearslipLog.i( + "input service: channel=${input.serviceId} touchscreen=" + + "${input.touchWidth}x${input.touchHeight}", + ) + openChannel(input.serviceId) + } + } + + /** Media audio rides its own sink; guidance, system and telephony sinks are left closed. */ + private fun startAudioChannel(serviceDiscoveryResponse: ByteArray) { + val sinks = ServiceDiscovery.findAudioServices(serviceDiscoveryResponse) + sinks.forEach { sink -> + GearslipLog.i( + "audio sink: channel=${sink.serviceId} stream=${sink.streamName} codec=${sink.codecName} " + + sink.configs.joinToString(prefix = "[", postfix = "]") { "${it.sampleRate}Hz/${it.bits}bit/x${it.channels}" }, + ) + } + val media = sinks.firstOrNull { it.streamType == STREAM_MEDIA } ?: run { + GearslipLog.w("no media audio sink advertised - media apps will play on the phone only") + return + } + audioLink = AudioLink( + media, + sendOnChannel = { id, body -> send(id, body, encrypted = true, channel = media.serviceId) }, + sendControl = { id, body -> send(id, body, encrypted = true) }, + ) + openChannel(media.serviceId) + } + + private fun openChannel(serviceId: Int) { + val request = Protobuf.varintField(1, 0L) + // priority (sint32 zigzag: 0 -> 0) + Protobuf.varintField(2, serviceId.toLong()) // service_id + send( + MSG_CHANNEL_OPEN_REQUEST, request, encrypted = true, + channel = serviceId, messageType = Frames.MESSAGE_CONTROL, + ) + GearslipLog.i("-> ChannelOpenRequest(service_id=$serviceId) on channel $serviceId") + } + + /** + * Touch events arrive **already in projected video coordinates**, not in the head unit's + * native touchscreen space. + * + * Confirmed on the 2018 Uconnect: its panel is advertised as 1258x708, but with 800x480 + * projected the raw x never exceeded 791 and raw y never exceeded ~351 even when tapping + * the far edges. Scaling by touchscreen/video (as an earlier version did) compressed the + * right-hand third of the screen away entirely - taps on a right-edge button landed on + * the middle one. The head unit maps its own panel onto our video before reporting. + * + * So: inject as-is, clamped to the video bounds. The advertised touchscreen size is kept + * only for diagnostics. + */ + private fun onInputMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> + GearslipLog.i("<- ChannelOpenResponse (input): status=${Protobuf.readInt32Field(body, 1)}") + + MSG_INPUT_REPORT -> { + val report = Wire.fields(body) + val touch = Wire.bytes(report, 3) ?: Wire.bytes(report, 7) + if (touch == null) { + GearslipLog.i("<- InputReport with no touch payload (key or rotary event)") + return + } + val touchFields = Wire.fields(touch) + val action = Wire.varint(touchFields, 3)?.toInt() ?: 0 + val pointers = Wire.allBytes(touchFields, 1) + if (pointers.isEmpty()) return + if (pointers.size > 1 && touchesSeen < 3) { + GearslipLog.i("(multi-touch: ${pointers.size} pointers; using the first)") + } + + val first = Wire.fields(pointers[0]) + val rawX = Wire.varint(first, 1)?.toInt() ?: return + val rawY = Wire.varint(first, 2)?.toInt() ?: return + + val size = videoConfigs.getOrNull(selectedConfigIndex ?: 0)?.pixelSize() + val x = clamp(rawX, size?.first) + val y = clamp(rawY, size?.second) + + touchesSeen++ + if (touchesSeen <= 10 || action == ACTION_DOWN || action == ACTION_UP) { + val note = if (x != rawX.toFloat() || y != rawY.toFloat()) " (clamped)" else "" + GearslipLog.i("<- Touch action=$action at ($rawX,$rawY)$note") + } + projection?.onTouch(action, x, y) + } + + else -> GearslipLog.i("<- unhandled input message id=$messageId (${body.size} bytes)") + } + } + + private fun onVideoMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) + GearslipLog.i("<- ChannelOpenResponse: status=$status") + if (status != 0) { + GearslipLog.verdict( + "PHASE A FAILED (channel open rejected)", + "The head unit refused to open the video channel (status=$status).", + ) + SessionStatus.failed("Video channel refused", "The head unit would not open the video channel (status $status).") + return + } + // Setup { required MediaCodecType type = 1 } + send(MSG_MEDIA_SETUP, Protobuf.varintField(1, CODEC_H264_BP.toLong()), + encrypted = true, channel = videoChannelId) + GearslipLog.i("-> Setup(codec=VIDEO_H264_BP)") + } + + MSG_MEDIA_CONFIG -> { + val fields = Wire.fields(body) + val status = Wire.varint(fields, 1)?.toInt() + val maxUnackedFromConfig = Wire.varint(fields, 2)?.toInt() + val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint } + GearslipLog.i( + "<- Config: status=$status (1=WAIT 2=READY) max_unacked=$maxUnackedFromConfig " + + "configuration_indices=$indices", + ) + if (maxUnackedFromConfig != null) maxUnacked = maxOf(1, maxUnackedFromConfig) + // Prefer the smallest offered resolution for the first attempt: fewer pixels, + // fewer ways for a first encode to go wrong. + val offered = indices.map { it.toInt() } + val preferred = vehicleProfile?.resolution?.let { want -> + offered.firstOrNull { videoConfigs.getOrNull(it)?.resolutionName == want } + } + selectedConfigIndex = preferred ?: offered.minByOrNull { idx -> + videoConfigs.getOrNull(idx)?.pixelCount() ?: Int.MAX_VALUE + } ?: offered.firstOrNull() + GearslipLog.i("selected config index $selectedConfigIndex from offered $offered") + // VideoFocusRequestNotification { mode = 2; reason = 3 } + val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) + + Protobuf.varintField(3, 1L) + send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId) + GearslipLog.i("-> VideoFocusRequest(mode=PROJECTED)") + } + + MSG_VIDEO_FOCUS_NOTIFICATION -> { + val fields = Wire.fields(body) + GearslipLog.i("<- VideoFocusNotification: mode=${Wire.varint(fields, 1)}") + // The head unit repeats this while it waits for frames; only act once. + if (!videoStarted) { + videoStarted = true + GearslipLog.i("PHASE A COMPLETE - video focus granted; starting video source") + startVideoSource() + } + } + + MSG_MEDIA_ACK -> { + val fields = Wire.fields(body) + val acked = Wire.varint(fields, 2)?.toInt() ?: 1 + inFlight = maxOf(0, inFlight - maxOf(1, acked)) + acksSeen++ + lastAckAt = System.currentTimeMillis() + if (framesSent <= 3 || framesSent % 60 == 0) { + GearslipLog.i("<- MediaAck(ack=$acked) inFlight=$inFlight after $framesSent frames") + } + } + + else -> GearslipLog.i("<- unhandled video message id=$messageId (${body.size} bytes)") + } + } + + // --- Phase B: encode and stream H.264 ---------------------------------------------- + // + // -> Start { session_id, configuration_index } + // -> CodecConfig (raw SPS/PPS, NO timestamp - aasdk routes it to onMediaIndication) + // -> Data (8-byte big-endian timestamp, then the H.264 access unit) + // <- Ack { session_id, ack } - at most max_unacked frames may be outstanding + + private fun startVideoSource() { + lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe + val index = selectedConfigIndex ?: 0 + val config = videoConfigs.getOrNull(index) + if (config == null) { + GearslipLog.e("no video config at index $index - cannot start the encoder") + return + } + val (width, height) = config.pixelSize() ?: run { + GearslipLog.e("unsupported resolution ${config.resolutionName}") + return + } + + val start = Protobuf.varintField(1, SESSION_ID.toLong()) + + Protobuf.varintField(2, index.toLong()) + send(MSG_MEDIA_START, start, encrypted = true, channel = videoChannelId) + GearslipLog.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)") + + val useProjection = projection != null + videoSource = VideoSource( + width = width, + height = height, + frameRate = if (config.frameRate == 1) 60 else 30, + mode = if (useProjection) VideoSource.Mode.SURFACE else VideoSource.Mode.TEST_CARD, + onCodecConfig = { csd -> + send(MSG_MEDIA_CODEC_CONFIG, csd, encrypted = true, channel = videoChannelId) + GearslipLog.i("-> CodecConfig (${csd.size} bytes SPS/PPS)") + GearslipLog.hex(" csd", csd, limit = 64) + }, + onFrame = { data, presentationTimeUs, keyFrame -> + sendVideoFrame(data, presentationTimeUs, keyFrame) + }, + ).also { source -> + runCatching { + source.start() + val surface = source.inputSurface + if (projection != null && surface != null) { + projection.onSurfaceReady(surface, width, height, config.density) + SessionStatus.projecting() + } + }.onFailure { e -> GearslipLog.e("encoder start failed", e) } + } + } + + private fun sendVideoFrame(data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) { + // Flow control: the head unit told us max_unacked in its Config reply. + if (inFlight >= maxUnacked) { + // A dropped or never-sent ack would otherwise wedge the stream forever, which + // looks identical to a frozen picture. Assume the window cleared and carry on. + val since = System.currentTimeMillis() - lastAckAt + if (since > ACK_STALL_MS) { + GearslipLog.w("no ack for ${since}ms with $inFlight in flight - resetting the window") + inFlight = 0 + } else { + dropped++ + if (dropped % 30 == 1) { + GearslipLog.w("dropping frame - $inFlight in flight (max $maxUnacked)") + } + // A dropped frame breaks the decoder's reference chain on the head unit - + // every frame after it is a P-frame assuming a picture that never arrived. + // Without a resync the picture free-runs on corrupted state indefinitely, + // which is the smeared/ghosted look. + // + // Ask on EVERY drop, not just the first in a streak: the resync keyframe we + // asked for is itself just another frame, and can be dropped by this exact + // same check. A one-shot request that gets swallowed by the burst that + // provoked it leaves the stream corrupted for good, with no further request + // ever made - confirmed on the road (docs/spike/phase-c-stuck-resync-2026-09-17.log, + // dropped=27 in one burst, video dead at 0fps for 100+s afterward). Repeated + // setParameters(REQUEST_SYNC_FRAME) calls before one lands are harmless - it + // just keeps the "next frame is a keyframe" flag set. + resyncRequested = true + resyncAttempts++ + videoSource?.requestSyncFrame() + return + } + } + val timestamp = ByteArray(8) + for (i in 0 until 8) { + timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte() + } + if (keyFrame) { + if (resyncRequested) { + GearslipLog.i("resync keyframe delivered after $resyncAttempts attempt(s)") + } + resyncRequested = false + resyncAttempts = 0 + lastKeyframeSentAt = System.currentTimeMillis() + } + try { + send(MSG_MEDIA_DATA, timestamp + data, encrypted = true, channel = videoChannelId) + } catch (e: java.io.IOException) { + if (running) GearslipLog.w("video send failed, stopping the encoder: ${e.message}") + running = false + videoSource?.stop() + return + } + inFlight++ + framesSent++ + if (framesSent <= 3 || keyFrame && framesSent % 30 == 0) { + GearslipLog.i( + "-> Data frame #$framesSent (${data.size} bytes, keyFrame=$keyFrame, " + + "ptsUs=$presentationTimeUs)", + ) + } + } + + private fun clamp(value: Int, limit: Int?): Float = when { + limit == null -> value.toFloat() + value < 0 -> 0f + value >= limit -> (limit - 1).toFloat() + else -> value.toFloat() + } + + private fun onPingRequest(body: ByteArray) { + val timestamp = Protobuf.readInt32Field(body, 1)?.toLong() ?: System.nanoTime() + send(MSG_PING_RESPONSE, Protobuf.varintField(1, timestamp), encrypted = tls.handshakeComplete) + GearslipLog.i("<- PingRequest / -> PingResponse (keeping the session alive)") + } + + private fun send( + messageId: Int, + body: ByteArray, + encrypted: Boolean, + channel: Int = Frames.CHANNEL_CONTROL, + messageType: Int = Frames.MESSAGE_SPECIFIC, + ) { + val payload = byteArrayOf( + ((messageId shr 8) and 0xFF).toByte(), (messageId and 0xFF).toByte(), + ) + body + + synchronized(output) { + if (payload.size <= Frames.MAX_FRAME_PAYLOAD) { + val framePayload = if (encrypted) tls.encrypt(payload) else payload + output.write(Frames.build(channel, encrypted, framePayload, messageType)) + } else { + // Split exactly as aasdk does: chunk the *plaintext*, encrypt each chunk + // separately, and carry the total plaintext length in the FIRST frame. + // Video keyframes for anything busier than a test card exceed 16 KB routinely. + var offset = 0 + while (offset < payload.size) { + val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset) + val chunk = payload.copyOfRange(offset, offset + size) + val frameType = when { + offset == 0 -> Frames.TYPE_FIRST + offset + size >= payload.size -> Frames.TYPE_LAST + else -> Frames.TYPE_MIDDLE + } + val framePayload = if (encrypted) tls.encrypt(chunk) else chunk + output.write( + Frames.build( + channel, encrypted, framePayload, messageType, frameType, + if (frameType == Frames.TYPE_FIRST) payload.size else null, + ), + ) + offset += size + } + splitMessages++ + } + output.flush() + } + } + + private fun reportDisconnect() { + when (state) { + State.DONE -> SessionStatus.disconnected() + State.WAIT_VERSION -> SessionStatus.failed( + "Head unit never started talking", + "The connection opened but the head unit sent nothing. Check the phone is set to use Gearslip for this USB accessory.", + ) + State.TLS_HANDSHAKE, State.WAIT_AUTH -> SessionStatus.failed( + "Connection dropped during setup", + "The head unit disconnected while securing the link. This usually means it rejected the certificate.", + ) + State.WAIT_SDR -> SessionStatus.failed( + "Connection dropped after authentication", + "The certificate was accepted but the head unit disconnected before finishing setup.", + ) + } + when (state) { + State.WAIT_VERSION -> GearslipLog.verdict( + "INCONCLUSIVE (no VersionRequest)", + "The head unit never opened the control channel. This is a connection problem, " + + "not a certificate verdict - check that the accessory was actually claimed.", + ) + State.TLS_HANDSHAKE -> GearslipLog.verdict( + "LIKELY NOT VIABLE (dropped during TLS)", + "The unit disconnected mid-handshake without sending an alert we could read. " + + "Rejection is the likeliest reading, but re-run once before trusting it.", + ) + State.WAIT_AUTH -> GearslipLog.verdict( + "INCONCLUSIVE (dropped after TLS, before AuthComplete)", + "TLS completed but the unit disconnected before stating a verdict. Re-run.", + ) + State.WAIT_SDR -> GearslipLog.verdict( + "INCONCLUSIVE (accepted, then dropped before service discovery)", + "AuthComplete said success, so the certificate was accepted - but our " + + "ServiceDiscoveryRequest drew no reply. Suspect our own encryption or " + + "framing rather than the head unit.", + ) + State.DONE -> GearslipLog.i("connection closed after the run completed") + } + } + + private fun startWatchdog() { + Thread { + var lastFrames = 0 + while (running) { + Thread.sleep(5_000) + if (!running) break + + if (state != State.DONE) { + val idle = System.currentTimeMillis() - lastProgress + if (idle > 10_000) GearslipLog.w("stalled in state $state for ${idle / 1000}s") + } + + // Stream health. Without this a drive cannot tell "streaming fine" from + // "stalled after three frames" - the per-frame logging is deliberately sparse. + if (videoStarted) { + val sent = framesSent + val fps = (sent - lastFrames) / 5.0 + lastFrames = sent + GearslipLog.i( + "video: ${"%.1f".format(fps)} fps over 5s | sent=$sent dropped=$dropped " + + "acked=$acksSeen inFlight=$inFlight split=$splitMessages", + ) + + // Self-healing safety net: force a fresh keyframe periodically regardless + // of whether we think one is owed. Protects against any future case where + // our own drop/resync bookkeeping is wrong or a corruption slips through + // some other path - a real decoder desync should never outlive this. + val idleSinceKeyframe = System.currentTimeMillis() - lastKeyframeSentAt + if (idleSinceKeyframe > PERIODIC_KEYFRAME_MS) { + videoSource?.requestSyncFrame() + } + } + } + }.apply { isDaemon = true }.start() + } + + private companion object { + // protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17 + const val MSG_VERSION_REQUEST = 1 + const val MSG_VERSION_RESPONSE = 2 + const val MSG_ENCAPSULATED_SSL = 3 + const val MSG_AUTH_COMPLETE = 4 + const val MSG_SERVICE_DISCOVERY_REQUEST = 5 + const val MSG_SERVICE_DISCOVERY_RESPONSE = 6 + const val MSG_PING_REQUEST = 11 + const val MSG_PING_RESPONSE = 12 + const val MSG_CHANNEL_OPEN_REQUEST = 7 + const val MSG_CHANNEL_OPEN_RESPONSE = 8 + + // aap_protobuf/service/media/.../MediaMessageId + const val MSG_MEDIA_DATA = 0 + const val MSG_MEDIA_CODEC_CONFIG = 1 + const val MSG_MEDIA_SETUP = 32768 + const val MSG_MEDIA_START = 32769 + const val MSG_MEDIA_STOP = 32770 + const val MSG_MEDIA_CONFIG = 32771 + const val MSG_MEDIA_ACK = 32772 + const val MSG_VIDEO_FOCUS_REQUEST = 32775 + const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776 + + const val STREAM_MEDIA = 3 + const val CODEC_H264_BP = 3 + const val VIDEO_FOCUS_PROJECTED = 1 + const val SESSION_ID = 1 + const val MSG_INPUT_REPORT = 32769 + const val ACTION_DOWN = 0 + const val ACTION_UP = 1 + const val ACK_STALL_MS = 2_000L + const val PERIODIC_KEYFRAME_MS = 5_000L + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt new file mode 100644 index 0000000..f6f19d3 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/PhoneTls.kt @@ -0,0 +1,219 @@ +package app.seb3thehacker.gearslip + +import java.net.Socket +import java.nio.ByteBuffer +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLEngineResult +import javax.net.ssl.SSLEngineResult.HandshakeStatus +import javax.net.ssl.SSLPeerUnverifiedException +import javax.net.ssl.X509ExtendedTrustManager + +/** + * The phone end of the Android Auto TLS session. + * + * Two things here are the opposite of what aasdk does, because aasdk implements the head + * unit and the head unit is the TLS *client*: it calls TLS_client_method() and + * SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87), + * and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS + * **server** and its certificate goes out as the server certificate - sent unconditionally + * on every handshake, which is precisely what we are testing. + * + * TLS here is not layered over the socket: handshake bytes travel inside + * MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes + * SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair - + * the right tool. + */ +class PhoneTls(keyStore: KeyStore, password: CharArray) { + + private val engine: SSLEngine + private var netIn = ByteArray(0) + private var appBuffer = ByteBuffer.allocate(32 * 1024) + private var netBuffer = ByteBuffer.allocate(32 * 1024) + + var handshakeComplete: Boolean = false + private set + + init { + val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + .apply { init(keyStore, password) } + .keyManagers + + val context = SSLContext.getInstance("TLSv1.2").apply { + init(keyManagers, arrayOf(LoggingTrustManager()), SecureRandom()) + } + + engine = context.createSSLEngine().apply { + useClientMode = false + // want, not need: we ask the head unit for its certificate so the run also tells + // us what real units present, but we never make the session depend on getting one. + wantClientAuth = true + enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2 + } + engine.beginHandshake() + } + + /** + * Feeds received handshake bytes in and returns whatever must be sent back, each element + * becoming one MESSAGE_ENCAPSULATED_SSL message. + */ + fun pumpHandshake(incoming: ByteArray?): List { + if (incoming != null) netIn += incoming + val outgoing = mutableListOf() + var source = ByteBuffer.wrap(netIn) + + loop@ while (true) { + when (engine.handshakeStatus) { + HandshakeStatus.NEED_TASK -> { + var task = engine.delegatedTask + while (task != null) { + task.run() + task = engine.delegatedTask + } + } + + HandshakeStatus.NEED_WRAP -> { + netBuffer.clear() + val result = engine.wrap(EMPTY, netBuffer) + if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) { + netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2) + continue@loop + } + netBuffer.flip() + if (netBuffer.hasRemaining()) { + outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) } + } + if (result.status == SSLEngineResult.Status.CLOSED) break@loop + } + + HandshakeStatus.NEED_UNWRAP -> { + appBuffer.clear() + val result = engine.unwrap(source, appBuffer) + when (result.status) { + SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop + SSLEngineResult.Status.BUFFER_OVERFLOW -> { + appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2) + continue@loop + } + SSLEngineResult.Status.CLOSED -> break@loop + else -> Unit + } + } + + HandshakeStatus.FINISHED, HandshakeStatus.NOT_HANDSHAKING -> break@loop + else -> break@loop + } + } + + netIn = ByteArray(source.remaining()).also { source.get(it) } + + if (!handshakeComplete && engine.handshakeStatus == HandshakeStatus.NOT_HANDSHAKING && + engine.session.cipherSuite != "SSL_NULL_WITH_NULL_NULL" + ) { + handshakeComplete = true + describeSession() + } + return outgoing + } + + /** Wraps a control-channel payload for an ENCRYPTED frame. */ + fun encrypt(plain: ByteArray): ByteArray { + val source = ByteBuffer.wrap(plain) + val out = java.io.ByteArrayOutputStream() + while (source.hasRemaining()) { + netBuffer.clear() + val result = engine.wrap(source, netBuffer) + if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) { + netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2) + continue + } + netBuffer.flip() + val chunk = ByteArray(netBuffer.remaining()) + netBuffer.get(chunk) + out.write(chunk) + } + return out.toByteArray() + } + + /** + * Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame + * (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is + * self-contained ciphertext. + */ + fun decrypt(cipher: ByteArray): ByteArray { + val source = ByteBuffer.wrap(cipher) + val out = java.io.ByteArrayOutputStream() + while (source.hasRemaining()) { + appBuffer.clear() + val result = engine.unwrap(source, appBuffer) + when (result.status) { + SSLEngineResult.Status.BUFFER_OVERFLOW -> { + appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2) + continue + } + SSLEngineResult.Status.BUFFER_UNDERFLOW -> { + GearslipLog.w("decrypt: record split across frames (${source.remaining()} bytes left over)") + break + } + SSLEngineResult.Status.CLOSED -> break + else -> Unit + } + appBuffer.flip() + val chunk = ByteArray(appBuffer.remaining()) + appBuffer.get(chunk) + out.write(chunk) + if (result.bytesConsumed() == 0 && result.bytesProduced() == 0) break + } + return out.toByteArray() + } + + private fun describeSession() { + val session = engine.session + GearslipLog.i("TLS handshake COMPLETE - protocol=${session.protocol} cipher=${session.cipherSuite}") + try { + val peers = session.peerCertificates + GearslipLog.i("head unit presented ${peers.size} certificate(s):") + peers.forEachIndexed { index, certificate -> + if (certificate is X509Certificate) { + GearslipLog.i(" [$index] subject=${certificate.subjectX500Principal}") + GearslipLog.i(" issuer =${certificate.issuerX500Principal}") + GearslipLog.i(" serial =${certificate.serialNumber} valid=${certificate.notBefore}..${certificate.notAfter}") + } + } + } catch (_: SSLPeerUnverifiedException) { + GearslipLog.i("head unit presented NO certificate (we asked but did not require one)") + } + } + + /** Accepts everything and reports what it saw. We want the data, not a verdict. */ + private class LoggingTrustManager : X509ExtendedTrustManager() { + override fun checkClientTrusted(chain: Array?, authType: String?) { + GearslipLog.i("peer certificate offered (authType=$authType, chain=${chain?.size ?: 0}) - accepting") + } + + override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) = + checkClientTrusted(chain, authType) + + override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = + checkClientTrusted(chain, authType) + + override fun checkServerTrusted(chain: Array?, authType: String?) = + checkClientTrusted(chain, authType) + + override fun checkServerTrusted(chain: Array?, authType: String?, socket: Socket?) = + checkClientTrusted(chain, authType) + + override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) = + checkClientTrusted(chain, authType) + + override fun getAcceptedIssuers(): Array = emptyArray() + } + + companion object { + private val EMPTY: ByteBuffer = ByteBuffer.allocate(0) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ProjectedContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ProjectedContent.kt new file mode 100644 index 0000000..f09d795 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ProjectedContent.kt @@ -0,0 +1,65 @@ +package app.seb3thehacker.gearslip + +import android.content.Context +import java.io.File + +/** Decides what the car screen shows. */ +object ProjectedContent { + + /** + * Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the + * pre-Settings way, still honoured so existing setups keep working), then the built-in + * test page. + */ + fun resolve(context: Context): String { + val saved = AppSettings.startupUrl(context) + if (saved.isNotEmpty()) { + val url = normalize(saved) + GearslipLog.i("projecting startup URL from settings: $url") + return url + } + val legacy = context.getExternalFilesDir(null)?.let { File(it, "url.txt") } + if (legacy != null && legacy.isFile) { + val url = legacy.readText().trim() + if (url.isNotEmpty()) { + GearslipLog.i("projecting url.txt override: $url") + return url + } + } + return TEST_PAGE + } + + /** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */ + private fun normalize(value: String): String = + if (value.startsWith("<") || "://" in value) value else "https://$value" + + /** Big targets, a live clock, and tap feedback - all verifiable at a glance in a car. */ + val TEST_PAGE = """ + + +

Gearslip — projected from the phone

+
starting…
+
+
TAP A
TAP B
+
TAP C
+
+ + """.trimIndent() +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Projection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Projection.kt new file mode 100644 index 0000000..f71a38a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Projection.kt @@ -0,0 +1,25 @@ +package app.seb3thehacker.gearslip + +import android.view.Surface + +/** + * Lets [GearslipRunner] drive on-screen content without owning any Android UI itself. + * + * The protocol side decides *when* a surface is needed and *where* touches landed; the + * activity decides *what* to render and how to inject events, because both need a UI thread + * and a Context. + */ +interface Projection { + + /** Called once the encoder's input Surface exists and content should start rendering. */ + fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) + + /** + * A touch from the head unit, already scaled into projected-video coordinates. + * [action] is an Android MotionEvent action - Android Auto's PointerAction enum uses + * identical values (DOWN=0, UP=1, MOVED=2, POINTER_DOWN=5, POINTER_UP=6). + */ + fun onTouch(action: Int, x: Float, y: Float) + + fun onProjectionStopped() +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt new file mode 100644 index 0000000..48af4fe --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt @@ -0,0 +1,178 @@ +package app.seb3thehacker.gearslip + +import java.io.ByteArrayOutputStream + +/** + * Just enough protobuf to run the spike, hand-rolled so there is no protoc step. + * + * Only three messages are ever written (AuthResponse is only read): + * ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; } + * PingResponse { required int64 timestamp = 1; } + * AuthResponse { required int32 status = 1; } + */ +object Protobuf { + + fun varint(value: Long): ByteArray { + val out = ByteArrayOutputStream() + var v = value + while (true) { + val b = (v and 0x7F).toInt() + v = v ushr 7 + if (v == 0L) { + out.write(b) + return out.toByteArray() + } + out.write(b or 0x80) + } + } + + fun stringField(field: Int, value: String): ByteArray { + val bytes = value.toByteArray(Charsets.UTF_8) + return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes + } + + fun varintField(field: Int, value: Long): ByteArray = + varint((field shl 3).toLong()) + varint(value) + + /** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */ + fun readInt32Field(data: ByteArray, field: Int): Int? { + var pos = 0 + while (pos < data.size) { + val (tag, afterTag) = readVarint(data, pos) ?: return null + pos = afterTag + val number = (tag ushr 3).toInt() + when ((tag and 7L).toInt()) { + 0 -> { + val (value, afterValue) = readVarint(data, pos) ?: return null + pos = afterValue + if (number == field) return value.toInt() + } + 2 -> { + val (len, afterLen) = readVarint(data, pos) ?: return null + pos = afterLen + len.toInt() + } + 5 -> pos += 4 + 1 -> pos += 8 + else -> return null + } + } + return null + } + + /** + * Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings + * (fields 2-4) and head-unit info (7-10) coming out readable is the clearest possible + * proof that the session is live and we are decrypting correctly. + */ + fun describe(data: ByteArray, indent: String = " "): String { + val sb = StringBuilder() + var pos = 0 + while (pos < data.size) { + val (tag, afterTag) = readVarint(data, pos) ?: break + pos = afterTag + val number = (tag ushr 3).toInt() + when ((tag and 7L).toInt()) { + 0 -> { + val (value, after) = readVarint(data, pos) ?: break + pos = after + sb.append("$indent#$number varint = $value\n") + } + 1 -> { + if (pos + 8 > data.size) break + sb.append("$indent#$number fixed64\n"); pos += 8 + } + 2 -> { + val (len, afterLen) = readVarint(data, pos) ?: break + val end = afterLen + len.toInt() + if (end > data.size) break + val body = data.copyOfRange(afterLen, end) + pos = end + val text = body.toString(Charsets.UTF_8) + if (body.isNotEmpty() && text.all { it == '\n' || it == '\t' || it.code in 32..126 }) { + sb.append("$indent#$number string = \"$text\"\n") + } else { + sb.append("$indent#$number message/bytes (${body.size})\n") + sb.append(describe(body, "$indent ")) + } + } + 5 -> { + if (pos + 4 > data.size) break + sb.append("$indent#$number fixed32\n"); pos += 4 + } + else -> break + } + } + return sb.toString() + } + + private fun readVarint(data: ByteArray, start: Int): Pair? { + var result = 0L + var shift = 0 + var pos = start + while (pos < data.size && shift <= 63) { + val b = data[pos].toInt() and 0xFF + result = result or ((b and 0x7F).toLong() shl shift) + pos++ + if (b and 0x80 == 0) return result to pos + shift += 7 + } + return null + } +} + +/** Structured field access, for walking ServiceDiscoveryResponse without protoc. */ +object Wire { + + class Field(val number: Int, val wireType: Int, val varint: Long, val bytes: ByteArray?) + + fun fields(data: ByteArray): List { + val out = mutableListOf() + var pos = 0 + while (pos < data.size) { + val tag = readVarint(data, pos) ?: break + pos = tag.second + val number = (tag.first ushr 3).toInt() + when ((tag.first and 7L).toInt()) { + 0 -> { + val v = readVarint(data, pos) ?: break + pos = v.second + out += Field(number, 0, v.first, null) + } + 1 -> { if (pos + 8 > data.size) break; pos += 8 } + 2 -> { + val len = readVarint(data, pos) ?: break + val end = len.second + len.first.toInt() + if (end > data.size || end < len.second) break + out += Field(number, 2, 0, data.copyOfRange(len.second, end)) + pos = end + } + 5 -> { if (pos + 4 > data.size) break; pos += 4 } + else -> break + } + } + return out + } + + fun varint(fields: List, number: Int): Long? = + fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint + + fun bytes(fields: List, number: Int): ByteArray? = + fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes + + fun allBytes(fields: List, number: Int): List = + fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes } + + private fun readVarint(data: ByteArray, start: Int): Pair? { + var result = 0L + var shift = 0 + var pos = start + while (pos < data.size && shift <= 63) { + val b = data[pos].toInt() and 0xFF + result = result or ((b and 0x7F).toLong() shl shift) + pos++ + if (b and 0x80 == 0) return result to pos + shift += 7 + } + return null + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt new file mode 100644 index 0000000..7bc0e5b --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ScreenProjector.kt @@ -0,0 +1,183 @@ +package app.seb3thehacker.gearslip + +import android.app.ActivityOptions +import android.app.Presentation +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.os.SystemClock +import android.view.MotionEvent +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner + +/** + * Renders the car UI into the encoder's input Surface. + * + * Uses the same mechanism as Android screen mirroring: a VirtualDisplay backed by the + * MediaCodec input Surface, with a Presentation hosting a Compose view on that display. + * Anything drawn there is encoded and projected - no hand-written EGL/GL. + * + * Everything here must run on the main thread: Presentation is a Dialog. + */ +class ScreenProjector(private val context: Context) { + + private var virtualDisplay: VirtualDisplay? = null + private var presentation: Presentation? = null + private var owner: ViewTreeOwner? = null + private var root: View? = null + + /** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */ + private var downTime = 0L + + fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) { + stop() + + val displayManager = context.getSystemService(DisplayManager::class.java) + + // OWN_CONTENT_ONLY, not PUBLIC: we render our own Presentation rather than mirroring + // the phone screen. PUBLIC marks the display as screen sharing, which the platform + // refuses without ADD_MIRROR_DISPLAY/CAPTURE_VIDEO_OUTPUT or a MediaProjection token - + // and going the MediaProjection route would put a consent dialog in front of the + // driver on every connect. + val display = try { + displayManager.createVirtualDisplay( + "gearslip-projection", width, height, densityDpi, surface, + DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or + DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY, + ) + } catch (t: Throwable) { + GearslipLog.e("createVirtualDisplay failed", t) + null + } + if (display == null) { + GearslipLog.e("createVirtualDisplay returned null - cannot project") + return + } + virtualDisplay = display + + val show = Presentation(context, display.display) + val window = checkNotNull(show.window) { "presentation has no window" } + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + + // A ComposeView outside an Activity has no lifecycle, saved-state or view-model owner + // to find up its view tree, and refuses to compose without them. Hand it ours. + val treeOwner = ViewTreeOwner() + val decor = window.decorView + decor.setViewTreeLifecycleOwner(treeOwner) + decor.setViewTreeSavedStateRegistryOwner(treeOwner) + decor.setViewTreeViewModelStoreOwner(treeOwner) + decor.setBackgroundColor(Color.BLACK) + + show.setContentView(ComposeView(show.context).apply { setContent(content) }) + show.show() + + presentation = show + owner = treeOwner + root = decor + + GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi") + } + + /** The projected display's id, for placing other apps' activities on it. */ + val displayId: Int? get() = virtualDisplay?.display?.displayId + + /** + * Shows or hides our own Compose UI. A Presentation is a TYPE_PRESENTATION window, so it + * draws above any activity on the same display and must be hidden to reveal one. + */ + fun setOverlayVisible(visible: Boolean) { + if (visible) presentation?.show() else presentation?.hide() + } + + /** + * Launches another app's activity onto the projected display. The Presentation is hidden + * first, since it would otherwise draw over the app. + * + * Does not work yet for third-party apps, and measuring why is recorded here so the next + * attempt doesn't repeat it. Two separate platform gates, both confirmed on Android 17: + * 1. `setLaunchDisplayId` requires INTERNAL_SYSTEM_WINDOW in the *caller*. We don't hold + * it, so this throws SecurityException. Launching the same activity from a shell-uid + * caller places the task on this display fine. + * 2. Even once placed, the task stays visible=false, because this display is created + * FLAG_PRIVATE - a private display only ever shows its owner's windows. + * Fixing (2) needs a display created with VIRTUAL_DISPLAY_FLAG_TRUSTED, which needs the + * signature permission ADD_TRUSTED_DISPLAY. The route that avoids shell privilege entirely + * is VirtualDeviceManager (a companion-device app-streaming association), which hands out + * a display built for hosting other apps plus its own virtual touchscreen. + */ + fun launchApp(intent: Intent): Boolean { + val display = virtualDisplay ?: return false + return try { + val options = ActivityOptions.makeBasic().setLaunchDisplayId(display.display.displayId) + presentation?.hide() + context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), options.toBundle()) + GearslipLog.i("launched ${intent.component ?: intent.`package`} on display ${display.display.displayId}") + true + } catch (t: Throwable) { + GearslipLog.e("launchApp failed", t) + presentation?.show() + false + } + } + + /** [action] is already an Android MotionEvent action; x/y are in projected coordinates. */ + fun dispatchTouch(action: Int, x: Float, y: Float) { + val target = root ?: return + val now = SystemClock.uptimeMillis() + if (action == MotionEvent.ACTION_DOWN || downTime == 0L) downTime = now + + val event = MotionEvent.obtain(downTime, now, action, x, y, 0) + try { + target.dispatchTouchEvent(event) + } finally { + event.recycle() + } + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L + } + + fun stop() { + runCatching { presentation?.dismiss() } + runCatching { owner?.destroy() } + runCatching { virtualDisplay?.release() } + presentation = null + owner = null + root = null + virtualDisplay = null + downTime = 0L + } + + private class ViewTreeOwner : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner { + private val registry = LifecycleRegistry(this) + private val savedState = SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle get() = registry + override val savedStateRegistry: SavedStateRegistry get() = savedState.savedStateRegistry + override val viewModelStore = ViewModelStore() + + init { + savedState.performRestore(null) + registry.currentState = Lifecycle.State.RESUMED + } + + fun destroy() { + registry.currentState = Lifecycle.State.DESTROYED + viewModelStore.clear() + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SelfSignedCert.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SelfSignedCert.kt new file mode 100644 index 0000000..a050f0a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SelfSignedCert.kt @@ -0,0 +1,124 @@ +package app.seb3thehacker.gearslip + +import java.io.ByteArrayInputStream +import java.math.BigInteger +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.SecureRandom +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Generates a self-signed RSA-2048 certificate - the thing under test. + * + * Written by hand rather than with BouncyCastle so the spike has no dependencies and no + * second security provider sitting next to Conscrypt. A self-signed X.509 is a fixed + * structure, so this is mechanical; the risk is a silent malformation, which + * [generate] closes off by parsing its own output back through CertificateFactory and + * verifying the signature before returning. A bad certificate therefore fails loudly on the + * bench instead of looking like a head-unit rejection in the car. + * + * RSA-2048 matches aasdk's own key (cert/headunit.key) - see cert/README.md. + */ +object SelfSignedCert { + + const val ALIAS = "phone" + val PASSWORD: CharArray = "spike".toCharArray() + + // Pre-encoded OIDs, so there is no OID encoder to get wrong. + private val OID_SHA256_RSA = byteArrayOf( + 0x06, 0x09, 0x2A, 0x86.toByte(), 0x48, 0x86.toByte(), 0xF7.toByte(), 0x0D, 0x01, 0x01, 0x0B + ) + private val OID_CN = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x03) + private val OID_O = byteArrayOf(0x06, 0x03, 0x55, 0x04, 0x0A) + + class Result(val keyStore: KeyStore, val certificate: X509Certificate) + + fun generate(commonName: String = "Gearslip", organisation: String = "Gearslip"): Result { + val keyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + + val now = System.currentTimeMillis() + val notBefore = Date(now - 86_400_000L) // a day of slack for head-unit clock skew + val notAfter = Date(now + 20L * 365 * 86_400_000L) // ~2046, still inside UTCTime's pre-2050 range + + val der = buildCertificate(keyPair, commonName, organisation, notBefore, notAfter) + + // Self-check: malformed DER throws here, on the bench, not in the car. + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(der)) as X509Certificate + certificate.verify(keyPair.public) + certificate.checkValidity() + + val keyStore = KeyStore.getInstance("PKCS12").apply { + load(null, null) + setKeyEntry(ALIAS, keyPair.private, PASSWORD, arrayOf(certificate)) + } + return Result(keyStore, certificate) + } + + private fun buildCertificate( + keyPair: KeyPair, + commonName: String, + organisation: String, + notBefore: Date, + notAfter: Date, + ): ByteArray { + val algorithmId = seq(OID_SHA256_RSA, tlv(0x05, ByteArray(0))) // sha256WithRSAEncryption, NULL params + val name = seq(rdn(OID_CN, commonName), rdn(OID_O, organisation)) + val serial = BigInteger(64, SecureRandom()).add(BigInteger.ONE) + + val tbs = seq( + tlv(0xA0, integer(BigInteger.valueOf(2))), // [0] EXPLICIT version, v3 + integer(serial), + algorithmId, + name, + seq(utcTime(notBefore), utcTime(notAfter)), + name, + keyPair.public.encoded, // already a DER SubjectPublicKeyInfo + ) + + val signature = Signature.getInstance("SHA256withRSA").run { + initSign(keyPair.private) + update(tbs) + sign() + } + return seq(tbs, algorithmId, bitString(signature)) + } + + // --- minimal DER writer ------------------------------------------------------------ + + private fun length(n: Int): ByteArray { + if (n < 0x80) return byteArrayOf(n.toByte()) + var value = n + val out = ArrayList() + while (value > 0) { + out.add(0, (value and 0xFF).toByte()) + value = value ushr 8 + } + return byteArrayOf((0x80 or out.size).toByte()) + out.toByteArray() + } + + private fun tlv(tag: Int, body: ByteArray) = byteArrayOf(tag.toByte()) + length(body.size) + body + + private fun seq(vararg parts: ByteArray) = tlv(0x30, parts.reduce { a, b -> a + b }) + + private fun integer(v: BigInteger) = tlv(0x02, v.toByteArray()) + + private fun bitString(body: ByteArray) = tlv(0x03, byteArrayOf(0x00) + body) + + private fun rdn(oid: ByteArray, value: String) = + tlv(0x31, seq(oid, tlv(0x0C, value.toByteArray(Charsets.UTF_8)))) + + private fun utcTime(date: Date): ByteArray { + val format = SimpleDateFormat("yyMMddHHmmss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return tlv(0x17, (format.format(date) + "Z").toByteArray(Charsets.US_ASCII)) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt new file mode 100644 index 0000000..24c2036 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -0,0 +1,180 @@ +package app.seb3thehacker.gearslip + +/** + * Pulls the video service out of a ServiceDiscoveryResponse. + * + * Shape from aasdk (`service/Service.proto`, `service/media/sink/MediaSinkService.proto`, + * `.../message/VideoConfiguration.proto`): + * + * ServiceDiscoveryResponse { repeated Service channels = 1; ... } + * Service { required int32 id = 1; optional MediaSinkService media_sink_service = 3; ... } + * MediaSinkService { optional MediaCodecType available_type = 1; + * repeated VideoConfiguration video_configs = 4; ... } + * VideoConfiguration { codec_resolution = 1; frame_rate = 2; density = 5; ... } + * + * The channel id used in the frame header is this service's `id` - real Android Auto assigns + * channel ids dynamically (aasdk's static ChannelId enum is an approximation; see the TODO + * in include/aasdk/Messenger/ChannelId.hpp). + */ +object ServiceDiscovery { + + /** + * [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3 + * and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a + * vehicle profile, but not applied automatically - what they mean varies by unit. + */ + class VideoConfig( + val resolution: Int, + val frameRate: Int, + val density: Int, + val widthMargin: Int = 0, + val heightMargin: Int = 0, + ) { + val resolutionName: String get() = RESOLUTIONS[resolution] ?: "unknown($resolution)" + val frameRateName: String get() = if (frameRate == 1) "60fps" else "30fps" + + /** Pixel dimensions for this resolution enum, or null if we don't know it. */ + fun pixelSize(): Pair? = PIXEL_SIZES[resolution] + + fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE + + override fun toString() = + "$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin" + } + + class VideoService(val serviceId: Int, val codecType: Int, val configs: List) { + val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)" + } + + class AudioConfig(val sampleRate: Int, val bits: Int, val channels: Int) + + class AudioService( + val serviceId: Int, + val streamType: Int, + val codecType: Int, + val configs: List, + ) { + val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)" + val streamName: String get() = STREAMS[streamType] ?: "unknown($streamType)" + } + + /** + * Every audio sink the head unit offers (media, guidance, system, telephony). Same + * MediaSinkService as video, told apart by carrying `audio_type = 2` and + * `repeated AudioConfiguration audio_configs = 3 { sampling_rate = 1; bits = 2; channels = 3 }`. + */ + fun findAudioServices(response: ByteArray): List = buildList { + for (channel in Wire.allBytes(Wire.fields(response), 1)) { + val service = Wire.fields(channel) + val id = Wire.varint(service, 1)?.toInt() ?: continue + val sink = Wire.bytes(service, 3) ?: continue + val sinkFields = Wire.fields(sink) + val audioConfigs = Wire.allBytes(sinkFields, 3) + if (audioConfigs.isEmpty()) continue + add( + AudioService( + serviceId = id, + streamType = Wire.varint(sinkFields, 2)?.toInt() ?: 0, + codecType = Wire.varint(sinkFields, 1)?.toInt() ?: 1, + configs = audioConfigs.map { + val f = Wire.fields(it) + AudioConfig( + sampleRate = Wire.varint(f, 1)?.toInt() ?: 48_000, + bits = Wire.varint(f, 2)?.toInt() ?: 16, + channels = Wire.varint(f, 3)?.toInt() ?: 2, + ) + }, + ), + ) + } + } + + private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY") + + private val RESOLUTIONS = mapOf( + 1 to "800x480", 2 to "1280x720", 3 to "1920x1080", 4 to "2560x1440", + 5 to "3840x2160", 6 to "720x1280", 7 to "1080x1920", 8 to "1440x2560", 9 to "2160x3840", + ) + private val PIXEL_SIZES = mapOf( + 1 to (800 to 480), 2 to (1280 to 720), 3 to (1920 to 1080), 4 to (2560 to 1440), + 5 to (3840 to 2160), 6 to (720 to 1280), 7 to (1080 to 1920), 8 to (1440 to 2560), + 9 to (2160 to 3840), + ) + private val CODECS = mapOf( + 1 to "AUDIO_PCM", 2 to "AUDIO_AAC_LC", 3 to "VIDEO_H264_BP", + 4 to "AUDIO_AAC_LC_ADTS", 5 to "VIDEO_VP9", 6 to "VIDEO_AV1", 7 to "VIDEO_H265", + ) + + /** + * The head unit's touchscreen, from InputSourceService (Service field 4): + * InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... } + * TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; } + * + * Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be + * scaled to the projected video size before injection. + */ + class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int) + + fun findInputService(response: ByteArray): InputService? { + for (channel in Wire.allBytes(Wire.fields(response), 1)) { + val service = Wire.fields(channel) + val id = Wire.varint(service, 1)?.toInt() ?: continue + val input = Wire.bytes(service, 4) ?: continue + val touch = Wire.bytes(Wire.fields(input), 2) ?: continue + val touchFields = Wire.fields(touch) + val width = Wire.varint(touchFields, 1)?.toInt() ?: continue + val height = Wire.varint(touchFields, 2)?.toInt() ?: continue + return InputService(id, width, height) + } + return null + } + + fun findVideoService(response: ByteArray): VideoService? { + for (channel in Wire.allBytes(Wire.fields(response), 1)) { + val service = Wire.fields(channel) + val id = Wire.varint(service, 1)?.toInt() ?: continue + val sink = Wire.bytes(service, 3) ?: continue + + val sinkFields = Wire.fields(sink) + val videoConfigs = Wire.allBytes(sinkFields, 4) + if (videoConfigs.isEmpty()) continue // audio sink, not video + + val codecType = Wire.varint(sinkFields, 1)?.toInt() ?: 0 + val configs = videoConfigs.map { + val f = Wire.fields(it) + VideoConfig( + resolution = Wire.varint(f, 1)?.toInt() ?: 0, + frameRate = Wire.varint(f, 2)?.toInt() ?: 0, + density = Wire.varint(f, 5)?.toInt() ?: 0, + widthMargin = Wire.varint(f, 3)?.toInt() ?: 0, + heightMargin = Wire.varint(f, 4)?.toInt() ?: 0, + ) + } + return VideoService(id, codecType, configs) + } + return null + } + + /** + * Who the head unit says it is, from the top-level strings of ServiceDiscoveryResponse. + * Field numbers as seen from a 2018 Dodge Challenger: 2="Uconnect", 3="Dodge Challenger", + * 4="18", 7="Delphi", 8="VP2_R Uconnect 7". + */ + class HeadUnitInfo( + val headUnitName: String, + val carModel: String, + val carYear: String, + val headUnitMake: String, + val headUnitModel: String, + ) { + override fun toString() = + "name=\"$headUnitName\" car=\"$carModel\" year=\"$carYear\" " + + "make=\"$headUnitMake\" model=\"$headUnitModel\"" + } + + fun findHeadUnitInfo(response: ByteArray): HeadUnitInfo { + val fields = Wire.fields(response) + fun text(number: Int) = Wire.bytes(fields, number)?.toString(Charsets.UTF_8).orEmpty() + return HeadUnitInfo(text(2), text(3), text(4), text(7), text(8)) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionStatus.kt new file mode 100644 index 0000000..9f80f59 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/SessionStatus.kt @@ -0,0 +1,46 @@ +package app.seb3thehacker.gearslip + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * What the phone UI shows about the current car session. + * + * [GearslipRunner] reports progress here as it moves through the handshake, so the UI reads + * a plain state instead of parsing log lines. A failure stays on screen until the next + * attach starts, so the reason is still there when the user picks the phone back up. + */ +object SessionStatus { + + enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED } + + data class Snapshot(val phase: Phase, val headline: String, val detail: String) + + private val flow = MutableStateFlow( + Snapshot( + Phase.IDLE, + "Not connected", + "Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.", + ), + ) + + val state: StateFlow = flow + + fun connecting(stage: String) { + flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage) + } + + fun projecting() { + flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.") + } + + fun failed(headline: String, detail: String) { + flow.value = Snapshot(Phase.FAILED, headline, detail) + } + + /** Keeps an earlier failure visible: a rejection is followed by a disconnect every time. */ + fun disconnected() { + if (flow.value.phase == Phase.FAILED) return + flow.value = Snapshot(Phase.DISCONNECTED, "Disconnected", "The head unit is no longer connected.") + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt new file mode 100644 index 0000000..d5146b9 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/TlsSelfTest.kt @@ -0,0 +1,149 @@ +package app.seb3thehacker.gearslip + +import java.net.Socket +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLEngineResult +import javax.net.ssl.SSLEngineResult.HandshakeStatus +import javax.net.ssl.X509ExtendedTrustManager + +/** + * Runs [PhoneTls] against an in-process TLS client and checks that a full handshake plus a + * data round trip works. + * + * This exists because of one specific hazard: a bug in the certificate, the key manager or + * the engine wiring produces a dead connection in the car that is indistinguishable from a + * head unit rejecting us - and the whole point of the spike is a trustworthy go/no-go + * signal. Run this on the bench first; if it passes, a failure in the car is about the head + * unit, not about this code. + */ +object TlsSelfTest { + + fun run(identity: CertProvider.Identity): Boolean { + GearslipLog.i("=== TLS self-test starting ===") + GearslipLog.i("cert source: ${identity.source}") + GearslipLog.i(" subject = ${identity.certificate.subjectX500Principal}") + GearslipLog.i(" issuer = ${identity.certificate.issuerX500Principal}") + return try { + val server = PhoneTls(identity.keyStore, identity.password) + val client = Driver(clientEngine()) + + var outbound = client.pump(null) // ClientHello + var rounds = 0 + while (rounds++ < 20) { + if (outbound.isEmpty()) break + val fromServer = mutableListOf() + outbound.forEach { fromServer += server.pumpHandshake(it) } + if (server.handshakeComplete && fromServer.isEmpty()) break + val fromClient = mutableListOf() + fromServer.forEach { fromClient += client.pump(it) } + outbound = fromClient + } + + if (!server.handshakeComplete) { + GearslipLog.e("self-test FAILED: handshake did not complete after $rounds rounds") + return false + } + + // Data round trip, exercising the same encrypt path the spike uses for + // ServiceDiscoveryRequest. + val plaintext = "gearslip round trip".toByteArray() + val decrypted = client.decrypt(server.encrypt(plaintext)) + if (!decrypted.contentEquals(plaintext)) { + GearslipLog.e("self-test FAILED: round trip mismatch (${decrypted.size} bytes back)") + return false + } + + GearslipLog.i("=== TLS self-test PASSED - certificate, key manager and engine are sound ===") + true + } catch (t: Throwable) { + GearslipLog.e("self-test FAILED with an exception", t) + false + } + } + + private fun clientEngine(): SSLEngine { + val context = SSLContext.getInstance("TLSv1.2") + context.init(null, arrayOf(TrustAll()), SecureRandom()) + return context.createSSLEngine().apply { + useClientMode = true + enabledProtocols = arrayOf("TLSv1.2") + beginHandshake() + } + } + + /** Deliberately a second, independent implementation - it should not share PhoneTls's bugs. */ + private class Driver(private val engine: SSLEngine) { + private var netIn = ByteArray(0) + private var app = ByteBuffer.allocate(32 * 1024) + private var net = ByteBuffer.allocate(32 * 1024) + + fun pump(incoming: ByteArray?): List { + if (incoming != null) netIn += incoming + val out = mutableListOf() + val source = ByteBuffer.wrap(netIn) + loop@ while (true) { + when (engine.handshakeStatus) { + HandshakeStatus.NEED_TASK -> { + var task = engine.delegatedTask + while (task != null) { task.run(); task = engine.delegatedTask } + } + HandshakeStatus.NEED_WRAP -> { + net.clear() + val r = engine.wrap(ByteBuffer.allocate(0), net) + if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) { + net = ByteBuffer.allocate(net.capacity() * 2); continue@loop + } + net.flip() + if (net.hasRemaining()) out += ByteArray(net.remaining()).also { net.get(it) } + if (r.status == SSLEngineResult.Status.CLOSED) break@loop + } + HandshakeStatus.NEED_UNWRAP -> { + app.clear() + val r = engine.unwrap(source, app) + when (r.status) { + SSLEngineResult.Status.BUFFER_UNDERFLOW -> break@loop + SSLEngineResult.Status.BUFFER_OVERFLOW -> { + app = ByteBuffer.allocate(app.capacity() * 2); continue@loop + } + SSLEngineResult.Status.CLOSED -> break@loop + else -> Unit + } + } + else -> break@loop + } + } + netIn = ByteArray(source.remaining()).also { source.get(it) } + return out + } + + fun decrypt(cipher: ByteArray): ByteArray { + val source = ByteBuffer.wrap(cipher) + val out = java.io.ByteArrayOutputStream() + while (source.hasRemaining()) { + app.clear() + val r = engine.unwrap(source, app) + if (r.status == SSLEngineResult.Status.BUFFER_OVERFLOW) { + app = ByteBuffer.allocate(app.capacity() * 2); continue + } + app.flip() + out.write(ByteArray(app.remaining()).also { app.get(it) }) + if (r.bytesConsumed() == 0 && r.bytesProduced() == 0) break + } + return out.toByteArray() + } + } + + private class TrustAll : X509ExtendedTrustManager() { + override fun checkClientTrusted(c: Array?, a: String?) = Unit + override fun checkClientTrusted(c: Array?, a: String?, s: Socket?) = Unit + override fun checkClientTrusted(c: Array?, a: String?, e: SSLEngine?) = Unit + override fun checkServerTrusted(c: Array?, a: String?) = Unit + override fun checkServerTrusted(c: Array?, a: String?, s: Socket?) = Unit + override fun checkServerTrusted(c: Array?, a: String?, e: SSLEngine?) = Unit + override fun getAcceptedIssuers(): Array = emptyArray() + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/VehicleProfile.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/VehicleProfile.kt new file mode 100644 index 0000000..6af4845 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/VehicleProfile.kt @@ -0,0 +1,114 @@ +package app.seb3thehacker.gearslip + +import android.content.Context +import java.io.File +import org.json.JSONArray +import org.json.JSONObject + +/** Pixels of the video frame the car UI keeps clear on each side (the frame is painted, just not used). */ +data class Insets(val top: Int = 0, val bottom: Int = 0, val left: Int = 0, val right: Int = 0) { + companion object { val NONE = Insets() } +} + +/** + * Per-vehicle display tuning. Head units disagree about how they crop, offset and scale the + * projected frame, so nothing here is applied unless a profile matches the connected unit. + * + * Profiles live in `vehicles.json` in the app's external files dir, so they can be edited and + * pushed with adb without a rebuild: + * + * adb push vehicles.json /sdcard/Android/data/app.seb3thehacker.gearslip/files/vehicles.json + * + * A profile matches when every key in [match] equals the head unit's value (case-insensitive); + * keys it leaves out match anything. The first matching profile wins. + */ +data class VehicleProfile( + val name: String, + val match: Map, + /** Preferred video resolution, e.g. "800x480". Null keeps the default choice. */ + val resolution: String?, + val insets: Insets, +) + +object VehicleProfiles { + + private const val FILE = "vehicles.json" + + private val DEFAULT_JSON = """ + { + "_comment": "Per-vehicle display tuning. match keys: headUnitName, carModel, carYear, headUnitMake, headUnitModel (see the log line 'head unit: ...'). insets are pixels of the video frame to keep clear.", + "vehicles": [ + { + "name": "2018 Dodge Challenger (Uconnect 4)", + "match": { "carModel": "Dodge Challenger", "headUnitName": "Uconnect" }, + "resolution": "800x480", + "insets": { "top": 30, "bottom": 30, "left": 0, "right": 0 } + } + ] + } + """.trimIndent() + + /** Reads the file, writing the default one first if there is none so there is something to edit. */ + fun load(context: Context): List { + val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return emptyList() + if (!file.isFile) runCatching { file.writeText(DEFAULT_JSON) } + return runCatching { parse(file.readText()) } + .onFailure { GearslipLog.e("could not read $FILE - ignoring vehicle profiles", it) } + .getOrDefault(emptyList()) + } + + /** Writes [insets] into the profile called [name] in the file, leaving everything else as it was. */ + fun saveInsets(context: Context, name: String, insets: Insets): Boolean { + val file = context.getExternalFilesDir(null)?.let { File(it, FILE) } ?: return false + return runCatching { + val root = JSONObject(file.readText()) + val vehicles = root.getJSONArray("vehicles") + var found = false + for (i in 0 until vehicles.length()) { + val v = vehicles.getJSONObject(i) + if (v.optString("name") == name) { + v.put( + "insets", + JSONObject() + .put("top", insets.top).put("bottom", insets.bottom) + .put("left", insets.left).put("right", insets.right), + ) + found = true + } + } + if (found) file.writeText(root.toString(2)) + found + }.onFailure { GearslipLog.e("could not save insets to $FILE", it) }.getOrDefault(false) + } + + fun parse(json: String): List { + val vehicles: JSONArray = JSONObject(json).optJSONArray("vehicles") ?: return emptyList() + return (0 until vehicles.length()).map { i -> + val v = vehicles.getJSONObject(i) + val match = v.optJSONObject("match")?.let { m -> m.keys().asSequence().associateWith { m.getString(it) } } + ?: emptyMap() + val ins = v.optJSONObject("insets") + VehicleProfile( + name = v.optString("name", "vehicle $i"), + match = match, + resolution = v.optString("resolution").ifEmpty { null }, + insets = if (ins == null) Insets.NONE else Insets( + ins.optInt("top"), ins.optInt("bottom"), ins.optInt("left"), ins.optInt("right"), + ), + ) + } + } + + fun find(profiles: List, info: ServiceDiscovery.HeadUnitInfo): VehicleProfile? { + val values = mapOf( + "headUnitName" to info.headUnitName, + "carModel" to info.carModel, + "carYear" to info.carYear, + "headUnitMake" to info.headUnitMake, + "headUnitModel" to info.headUnitModel, + ) + return profiles.firstOrNull { p -> + p.match.isNotEmpty() && p.match.all { (k, want) -> values[k].equals(want, ignoreCase = true) } + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt new file mode 100644 index 0000000..59484de --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/VideoSource.kt @@ -0,0 +1,202 @@ +package app.seb3thehacker.gearslip + +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.os.Bundle +import android.media.Image +import android.view.Surface + +/** + * Encodes a generated test card to H.264 Baseline and hands the bitstream to a callback. + * + * Feeds the encoder YUV420 buffers via getInputImage() rather than a Surface: a MediaCodec + * input Surface requires an EGL/OpenGL context to render into (Surface.lockCanvas is not + * supported on it), and for a static test card that is a lot of machinery for no benefit. + * Byte-buffer input keeps the spike dependency-free and fully deterministic. Phase C swaps + * this for a real Surface + GL path when the source becomes a live WebView. + * + * The head unit negotiated MEDIA_CODEC_VIDEO_H264_BP, so the profile is pinned to Baseline. + */ +class VideoSource( + private val width: Int, + private val height: Int, + private val frameRate: Int, + private val mode: Mode = Mode.TEST_CARD, + private val onCodecConfig: (ByteArray) -> Unit, + private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit, +) { + + /** + * TEST_CARD feeds generated YUV buffers; SURFACE exposes a MediaCodec input Surface for + * something else to render into (Phase C drives it from a VirtualDisplay + Presentation, + * which is how Android screen mirroring works and avoids hand-written EGL entirely). + */ + enum class Mode { TEST_CARD, SURFACE } + + /** Non-null in SURFACE mode once [start] has run. */ + var inputSurface: Surface? = null + private set + + private var codec: MediaCodec? = null + private var worker: Thread? = null + @Volatile private var running = false + private var frameIndex = 0L + + fun start() { + val format = MediaFormat.createVideoFormat(MIME, width, height).apply { + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + if (mode == Mode.SURFACE) { + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface + } else { + MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible + }, + ) + setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000) + setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) + setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) + setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31) + } + + val encoder = MediaCodec.createEncoderByType(MIME) + encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface() + encoder.start() + codec = encoder + running = true + + GearslipLog.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode") + + worker = Thread { + runCatching { pump(encoder) } + .onFailure { if (running) GearslipLog.e("encoder loop failed", it) } + }.apply { name = "gearslip-encoder"; start() } + } + + /** + * Forces the next encoded frame to be a full keyframe (IDR) rather than a P-frame. + * + * Needed whenever a frame gets dropped between us and the head unit: every frame after + * the drop is a P-frame that assumes the decoder still has the reference picture we + * never delivered. Without a resync, the decoder free-runs on a corrupted reference + * forever - visually, exactly the smeared/ghosted look of a partial e-ink refresh, + * because nothing ever forces a clean full-frame redraw. + */ + fun requestSyncFrame() { + runCatching { + codec?.setParameters(Bundle().apply { + putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0) + }) + }.onFailure { GearslipLog.w("requestSyncFrame failed: ${it.message}") } + } + + fun stop() { + running = false + worker?.join(1_000) + runCatching { inputSurface?.release() } + inputSurface = null + runCatching { codec?.stop() } + runCatching { codec?.release() } + codec = null + } + + private fun pump(encoder: MediaCodec) { + val info = MediaCodec.BufferInfo() + val frameIntervalUs = 1_000_000L / frameRate + + while (running) { + // SURFACE mode has no input buffers to fill - frames arrive from the display. + val inIndex = if (mode == Mode.SURFACE) -1 else encoder.dequeueInputBuffer(TIMEOUT_US) + if (inIndex >= 0) { + val image = encoder.getInputImage(inIndex) + if (image != null) { + drawTestCard(image, frameIndex) + encoder.queueInputBuffer( + inIndex, 0, width * height * 3 / 2, frameIndex * frameIntervalUs, 0, + ) + } else { + encoder.queueInputBuffer(inIndex, 0, 0, frameIndex * frameIntervalUs, 0) + } + frameIndex++ + } + + var outIndex = encoder.dequeueOutputBuffer(info, TIMEOUT_US) + while (outIndex >= 0) { + val buffer = encoder.getOutputBuffer(outIndex) + if (buffer != null && info.size > 0) { + val data = ByteArray(info.size) + buffer.position(info.offset) + buffer.get(data) + if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + onCodecConfig(data) + } else { + onFrame( + data, + info.presentationTimeUs, + info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0, + ) + } + } + encoder.releaseOutputBuffer(outIndex, false) + outIndex = encoder.dequeueOutputBuffer(info, 0) + } + + Thread.sleep(maxOf(1L, frameIntervalUs / 2000)) + } + } + + /** + * Colour bars with a bar that advances each second, so the car screen shows something + * unmistakably ours and obviously live rather than a frozen or stale frame. + */ + private fun drawTestCard(image: Image, frame: Long) { + val yPlane = image.planes[0] + val uPlane = image.planes[1] + val vPlane = image.planes[2] + + val bars = BAR_COLOURS.size + val barHeight = maxOf(1, height / bars) + val marker = ((frame / frameRate) % bars).toInt() + + val yBuf = yPlane.buffer + val yStride = yPlane.rowStride + for (row in 0 until height) { + val bar = minOf(bars - 1, row / barHeight) + val (luma, _, _) = BAR_COLOURS[bar] + val value = if (bar == marker) 235 else luma + val base = row * yStride + for (col in 0 until width) { + yBuf.put(base + col, value.toByte()) + } + } + + val uBuf = uPlane.buffer + val vBuf = vPlane.buffer + for (row in 0 until height / 2) { + val bar = minOf(bars - 1, (row * 2) / barHeight) + val (_, cb, cr) = BAR_COLOURS[bar] + for (col in 0 until width / 2) { + uBuf.put(row * uPlane.rowStride + col * uPlane.pixelStride, cb.toByte()) + vBuf.put(row * vPlane.rowStride + col * vPlane.pixelStride, cr.toByte()) + } + } + } + + private companion object { + const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC + const val TIMEOUT_US = 10_000L + + /** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */ + val BAR_COLOURS = listOf( + Triple(180, 128, 128), + Triple(162, 44, 142), + Triple(131, 156, 44), + Triple(112, 72, 58), + Triple(84, 184, 198), + Triple(65, 100, 212), + Triple(35, 212, 114), + ) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioCaptureService.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioCaptureService.kt new file mode 100644 index 0000000..2888d0c --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioCaptureService.kt @@ -0,0 +1,193 @@ +package app.seb3thehacker.gearslip.audio + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioPlaybackCaptureConfiguration +import android.media.AudioRecord +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.IBinder +import app.seb3thehacker.gearslip.GearslipLog +import kotlin.math.abs + +/** + * Hears one app's audio so it can be played on the car. + * + * Playback capture is the one public route to another app's sound, and it hangs off a + * MediaProjection - so, as with [app.seb3thehacker.gearslip.mirror.MirrorService], the consent + * result is carried into a foreground service of type mediaProjection before it can be redeemed. + * Capture is scoped to the media app's uid: notification pings and other apps stay on the phone. + */ +class AudioCaptureService : Service() { + + @Volatile private var reading = false + private var projection: MediaProjection? = null + private var thread: Thread? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } + val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0 + val data = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) + if (data == null) { + GearslipLog.e("audio: started without a consent result") + CarAudio.denied() + stopSelf() + return START_NOT_STICKY + } + + startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION) + projection = try { + getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data) + } catch (t: Throwable) { + GearslipLog.e("audio: getMediaProjection failed", t) + null + } + val token = projection + if (token == null) { + CarAudio.denied() + stopSelf() + return START_NOT_STICKY + } + token.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { + GearslipLog.w("audio: projection stopped") + stopSelf() + } + }, null) + beginCapture(token) + return START_NOT_STICKY + } + + private fun beginCapture(token: MediaProjection) { + val rate = CarAudio.sampleRate + val channels = CarAudio.channels + val uid = CarAudio.uid + val record = try { + val config = AudioPlaybackCaptureConfiguration.Builder(token) + .addMatchingUsage(AudioAttributes.USAGE_MEDIA) + .addMatchingUsage(AudioAttributes.USAGE_GAME) + .addMatchingUsage(AudioAttributes.USAGE_UNKNOWN) + .apply { if (uid >= 0) addMatchingUid(uid) } + .build() + val mask = if (channels == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO + val format = AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(rate) + .setChannelMask(mask) + .build() + val minimum = AudioRecord.getMinBufferSize(rate, mask, AudioFormat.ENCODING_PCM_16BIT) + AudioRecord.Builder() + .setAudioPlaybackCaptureConfig(config) + .setAudioFormat(format) + .setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4)) + .build() + } catch (t: Throwable) { + GearslipLog.e("audio: could not set up playback capture", t) + CarAudio.denied() + stopSelf() + return + } + + reading = true + thread = Thread({ + val buffer = ByteArray(CHUNK_BYTES) + var peak = 0 + var heardAt = System.currentTimeMillis() + try { + record.startRecording() + GearslipLog.i("audio: capturing uid=$uid at ${rate}Hz x$channels") + CarAudio.captureStarted() + while (reading) { + val n = record.read(buffer, 0, buffer.size) + if (n <= 0) break + for (i in 0 until n - 1 step 2) { + val sample = (buffer[i].toInt() and 0xFF) or (buffer[i + 1].toInt() shl 8) + peak = maxOf(peak, abs(sample.toShort().toInt())) + } + val now = System.currentTimeMillis() + val level = peak / 32768f + if (now - heardAt >= LEVEL_LOG_MS) { + GearslipLog.i("audio: peak %.3f".format(level)) + heardAt = now + peak = 0 + } + CarAudio.deliver(buffer, n, level) + } + } catch (t: Throwable) { + GearslipLog.e("audio: capture loop ended", t) + } finally { + runCatching { record.stop() } + record.release() + CarAudio.captureStopped() + } + }, "gearslip-audio").apply { isDaemon = true; start() } + } + + override fun onDestroy() { + super.onDestroy() + reading = false + runCatching { thread?.join(500) } + runCatching { projection?.stop() } + projection = null + } + + private fun notification(): Notification { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL, "Car audio", NotificationManager.IMPORTANCE_LOW), + ) + val stop = PendingIntent.getService( + this, 0, + Intent(this, AudioCaptureService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE, + ) + return Notification.Builder(this, CHANNEL) + .setContentTitle("Playing to the car") + .setContentText("Gearslip is sending a media app's audio to the car.") + .setSmallIcon(android.R.drawable.ic_lock_silent_mode_off) + .setOngoing(true) + .addAction(Notification.Action.Builder(null, "Stop", stop).build()) + .build() + } + + companion object { + private const val CHANNEL = "gearslip_audio" + private const val NOTIFICATION_ID = 43 + private const val ACTION_STOP = "app.seb3thehacker.gearslip.STOP_AUDIO" + private const val EXTRA_RESULT_CODE = "resultCode" + private const val EXTRA_RESULT_DATA = "resultData" + + /** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */ + private const val CHUNK_BYTES = 4096 + private const val LEVEL_LOG_MS = 3_000L + + private var appContext: Context? = null + + fun start(context: Context, resultCode: Int, data: Intent) { + appContext = context.applicationContext + context.startForegroundService( + Intent(context, AudioCaptureService::class.java) + .putExtra(EXTRA_RESULT_CODE, resultCode) + .putExtra(EXTRA_RESULT_DATA, data), + ) + } + + fun stop() { + val context = appContext ?: return + context.startService(Intent(context, AudioCaptureService::class.java).setAction(ACTION_STOP)) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt new file mode 100644 index 0000000..497eafc --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/AudioLink.kt @@ -0,0 +1,148 @@ +package app.seb3thehacker.gearslip.audio + +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.ServiceDiscovery +import app.seb3thehacker.gearslip.Wire + +/** + * The phone end of the head unit's media audio sink, the audio twin of the video channel: + * + * -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE + * -> Setup { PCM } <- Config { status, max_unacked, configuration_indices } + * -> AudioFocusRequest <- AudioFocusResponse (control channel) + * -> Start { session_id, configuration_index } + * -> Data { 8-byte timestamp, PCM }... <- Ack { session_id, ack } + * -> Stop + * + * Inverted from aasdk's AudioMediaSinkService. Bench-verified only as far as capture goes: + * nothing on this side of the wire has met a head unit yet. + */ +class AudioLink( + private val service: ServiceDiscovery.AudioService, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, + private val sendControl: (messageId: Int, body: ByteArray) -> Unit, +) : CarAudio.Link { + + private var configIndex = 0 + @Volatile private var ready = false + @Volatile private var started = false + @Volatile private var inFlight = 0 + @Volatile private var maxUnacked = 8 + @Volatile private var lastAckAt = 0L + private var chunks = 0 + + private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() + override val sampleRate get() = config?.sampleRate ?: 48_000 + override val channels get() = config?.channels ?: 2 + + val channelId get() = service.serviceId + + fun opened(status: Int) { + if (status != 0) { + GearslipLog.w("audio: the head unit refused the media audio channel (status $status)") + return + } + sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong())) + GearslipLog.i("audio: -> Setup(codec=${service.codecName})") + } + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0) + + MSG_CONFIG -> { + val fields = Wire.fields(body) + val status = Wire.varint(fields, 1)?.toInt() + val window = Wire.varint(fields, 2)?.toInt() + val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() } + GearslipLog.i("audio: <- Config status=$status max_unacked=$window indices=$indices") + if (window != null) maxUnacked = maxOf(1, window) + configIndex = indices.firstOrNull() ?: 0 + if (status == STATUS_READY) { + ready = true + GearslipLog.i("audio: ready - ${sampleRate}Hz x$channels ${config?.bits}bit") + CarAudio.attach(this) + } + } + + MSG_ACK -> { + val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1 + inFlight = maxOf(0, inFlight - maxOf(1, acked)) + lastAckAt = System.currentTimeMillis() + } + + else -> GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + fun onFocus(state: Int) { + GearslipLog.i("audio: <- AudioFocus state=$state (1=GAIN, 2=GAIN_TRANSIENT, 3=LOSS, 4=LOSS_TRANSIENT)") + } + + override fun start() { + if (!ready || started) return + sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong())) + sendOnChannel( + MSG_START, + Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()), + ) + inFlight = 0 + lastAckAt = System.currentTimeMillis() + started = true + GearslipLog.i("audio: -> AudioFocusRequest(GAIN), Start(config=$configIndex)") + } + + override fun stop() { + if (!started) return + started = false + runCatching { + sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong())) + sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) + } + GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks") + chunks = 0 + } + + override fun write(pcm: ByteArray, size: Int) { + if (!started) return + if (inFlight >= maxUnacked) { + // A lost ack must not silence the stream for good; after a stall assume the window cleared. + if (System.currentTimeMillis() - lastAckAt > STALL_MS) inFlight = 0 else return + } + val timestamp = ByteArray(8) + val micros = System.nanoTime() / 1_000 + for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte() + try { + sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size)) + } catch (e: java.io.IOException) { + started = false + return + } + inFlight++ + chunks++ + } + + fun close() { + stop() + ready = false + CarAudio.detach(this) + } + + companion object { + const val MSG_FOCUS_REQUEST = 18 + const val MSG_FOCUS_RESPONSE = 19 + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_DATA = 0 + private const val MSG_SETUP = 32768 + private const val MSG_START = 32769 + private const val MSG_STOP = 32770 + private const val MSG_CONFIG = 32771 + private const val MSG_ACK = 32772 + private const val STATUS_READY = 2 + private const val FOCUS_GAIN = 1 + private const val FOCUS_RELEASE = 4 + private const val SESSION_ID = 2 + private const val STALL_MS = 2_000L + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt new file mode 100644 index 0000000..362fb2b --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/audio/CarAudio.kt @@ -0,0 +1,98 @@ +package app.seb3thehacker.gearslip.audio + +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Joins the two ends of the audio path: [AudioCaptureService] on the phone, which hears what a + * media app plays, and the car's audio channel, which is where it has to end up. + * + * They come and go independently. The phone side needs on-device consent and can be running on + * the bench with no car attached; the car side exists only while a head unit is connected and + * offers a media audio sink. Whichever pair is present, samples flow. + */ +object CarAudio { + + /** What the head unit's media sink accepts, and how to hand it audio. */ + interface Link { + val sampleRate: Int + val channels: Int + fun start() + fun stop() + fun write(pcm: ByteArray, size: Int) + } + + enum class Capture { OFF, ASKING, ON, DENIED } + + private val _capture = MutableStateFlow(Capture.OFF) + val capture: StateFlow = _capture.asStateFlow() + + /** "-12 dB" style summary of what was last heard, for the media screen. */ + private val _level = MutableStateFlow(0f) + val level: StateFlow = _level.asStateFlow() + + /** Set by the activity: shows the permission and consent dialogs, which only it can. */ + var onConsentRequested: (() -> Unit)? = null + + /** The uid being captured (or waited on). */ + @Volatile var uid: Int = -1 + private set + + @Volatile private var link: Link? = null + + /** Rate and channel count the capture should use: the car's, or a sensible default. */ + val sampleRate get() = link?.sampleRate ?: 48_000 + val channels get() = link?.channels ?: 2 + + fun attach(link: Link) { + this.link = link + if (_capture.value == Capture.ON) link.start() + } + + fun detach(link: Link) { + if (this.link === link) this.link = null + } + + /** A media app was opened: start hearing it. */ + fun request(uid: Int) { + if (this.uid == uid && _capture.value != Capture.OFF && _capture.value != Capture.DENIED) return + if (this.uid != uid) AudioCaptureService.stop() + this.uid = uid + _capture.value = Capture.ASKING + val ask = onConsentRequested + if (ask == null) { + GearslipLog.w("audio: nothing on the phone can ask for capture consent") + _capture.value = Capture.DENIED + } else { + ask() + } + } + + fun release() { + uid = -1 + AudioCaptureService.stop() + _capture.value = Capture.OFF + } + + fun denied() { + _capture.value = Capture.DENIED + } + + internal fun captureStarted() { + _capture.value = Capture.ON + link?.start() + } + + internal fun captureStopped() { + link?.stop() + _level.value = 0f + if (_capture.value == Capture.ON) _capture.value = Capture.OFF + } + + internal fun deliver(pcm: ByteArray, size: Int, peak: Float) { + _level.value = peak + link?.write(pcm, size) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt new file mode 100644 index 0000000..ca5d244 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt @@ -0,0 +1,89 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.sp + +/** + * Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows. + * + * Numbers are frame pixels. Read the last number visible on each edge to see how much the head + * unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and + * tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch + * with your finger is the touch offset. Ignores vehicle insets on purpose. + */ +@Composable +fun CalibrationApp() { + val navigator = LocalCarNavigator.current + val measurer = rememberTextMeasurer() + var touch by remember { mutableStateOf(null) } + + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + touch = down.position + } + }, + ) { + Canvas(Modifier.fillMaxSize()) { + val label = TextStyle(color = Color.White, fontSize = 11.sp) + val faint = Color(0x55FFFFFF) + val w = size.width + val h = size.height + + var y = 0 + while (y <= h) { + drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f) + if (y % 20 == 0) { + drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label) + drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label) + } + y += 20 + } + var x = 0 + while (x <= w) { + drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f) + if (x % 50 == 0 && x > 0) { + drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label) + drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label) + } + x += 20 + } + // Frame border, drawn last and inset by 1px so it is the outermost thing that can show. + drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f)) + drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp)) + + touch?.let { t -> + drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f) + drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f) + drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp)) + } + } + Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) { + Text("Exit calibration") + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt new file mode 100644 index 0000000..dfc80d6 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarAppStage.kt @@ -0,0 +1,191 @@ +package app.seb3thehacker.gearslip.car + +import android.view.SurfaceHolder +import android.view.SurfaceView +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.car.app.model.Template +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.host.CarAppConnection + +/** + * Shows a connected templated app: its own drawing surface, with host chrome over the top. + * + * The surface is a plain SurfaceView. Because the whole car UI is a Presentation on a + * VirtualDisplay wired to the encoder, a SurfaceView here is composited by SurfaceFlinger into + * that display like any other layer - so the app's map reaches the head unit down the existing + * H.264 pipe without Gearslip ever touching a pixel of it. + */ +@Composable +fun CarAppStage( + connection: CarAppConnection, + status: CarAppConnection.Status, + frame: CarEnvironment.Frame, + /** Inside the home screen's map pane: no title bar, the template gets the whole area. */ + embedded: Boolean = false, + onDisconnect: () -> Unit, +) { + val template by connection.template.collectAsStateWithLifecycle() + + Box(Modifier.fillMaxSize()) { + // The app's own drawing goes underneath; everything else is the host's rendering of + // the template it described, laid over the top. + if (status.surfaceRequested) { + AppSurface(connection, frame, template, Modifier.fillMaxSize()) + } + TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR)) + if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background) + if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter)) + } +} + +/** + * A SurfaceView lent to the app for as long as it is on screen. + * + * The app writes into this from its own process, so the surface must outlive the handoff; + * [SurfaceHolder.Callback] is what tells us when that stops being true. + */ +@Composable +private fun AppSurface( + connection: CarAppConnection, + frame: CarEnvironment.Frame, + template: Template?, + modifier: Modifier = Modifier, +) { + var size by remember { mutableStateOf(0 to 0) } + + // The app lays its map out around whatever the host covers, so a template change moves the + // visible area even though the surface itself hasn't changed. + LaunchedEffect(template, size) { + val (width, height) = size + if (width > 0 && height > 0) { + val visible = visibleAreaFor(template, width, height) + connection.reportAreas(visible = visible, stable = visible) + } + } + + AndroidView( + modifier = modifier + .pointerInput(connection) { + detectTapGestures { connection.surfaceClick(it.x, it.y) } + } + .pointerInput(connection) { + detectTransformGestures { centroid, pan, zoom, _ -> + // GestureDetector reports scroll as previous-minus-current; a Compose pan + // is the other way round. + if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y) + if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom) + } + }, + factory = { context -> + SurfaceView(context).apply { + holder.addCallback(object : SurfaceHolder.Callback { + override fun surfaceCreated(holder: SurfaceHolder) = Unit + + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int, + ) { + GearslipLog.i("stage: surface ready at ${width}x$height") + size = width to height + connection.attachSurface(holder.surface, width, height, frame.densityDpi) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + GearslipLog.i("stage: surface destroyed") + connection.detachSurface() + } + }) + } + }, + ) + + DisposableEffect(connection) { onDispose { connection.detachSurface() } } +} + +@Composable +private fun ChromeBar( + status: CarAppConnection.Status, + onDisconnect: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + color = Color.Black.copy(alpha = 0.55f), + contentColor = Color.White, + modifier = modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + status.app ?: "Car app", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + val detail = buildList { + status.template?.let { add(it) } + add(if (status.surfaceAttached) "drawing" else "waiting for the app") + }.joinToString(" - ") + Text(detail, style = MaterialTheme.typography.bodySmall) + } + TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) } + } + } +} + +/** Corner radius of the home screen's map pane and player. */ +val EMBEDDED_RADIUS = 8.dp + +/** + * Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own + * layer, so the corners are covered from above with the surrounding colour instead. + */ +@Composable +private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) { + androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { + val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height) + val cut = androidx.compose.ui.graphics.Path().apply { + addRect(bounds) + addRoundRect( + androidx.compose.ui.geometry.RoundRect( + bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()), + ), + ) + fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd + } + drawPath(cut, color) + } +} + +/** Height of [ChromeBar]; the template's own chrome starts below it. */ +private val CHROME_BAR = 44.dp diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt new file mode 100644 index 0000000..3e32423 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt @@ -0,0 +1,40 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import app.seb3thehacker.gearslip.ProjectedContent + +/** One tile in the launcher and the screen it opens. */ +class CarApp( + val id: String, + val label: String, + val icon: ImageVector, + val content: @Composable () -> Unit, +) + +/** Built-in apps. Third-party (Android Auto) apps are a later step and will register here too. */ +object CarApps { + + val all: List = listOf( + CarApp("web", "Web", Icons.Filled.Search) { + WebApp(content = ProjectedContent.resolve(LocalContext.current)) + }, + CarApp("phone", "Screen sharing", Icons.Filled.Share) { + PhoneAppsScreen() + }, + CarApp("calibrate", "Display test", Icons.Filled.Info) { + CalibrationApp() + }, + CarApp("settings", "Settings", Icons.Filled.Settings) { + CarSettingsScreen() + }, + ) + + fun find(id: String): CarApp? = all.firstOrNull { it.id == id } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt new file mode 100644 index 0000000..cebbe68 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt @@ -0,0 +1,140 @@ +package app.seb3thehacker.gearslip.car + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.os.BatteryManager +import android.os.Handler +import android.os.Looper +import java.util.Calendar +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * What the car UI and the apps inside it can know about the world outside the UI. + * + * [phoneDark] is the phone's own theme and picks the car UI's colours. [darkOutside] is a + * separate signal, "is it dark out", for apps that want to adapt (a map, a web page). + * + * Where [darkOutside] comes from is deliberately swappable. Today it is a time-of-day guess. + * The right source is the head unit's own night-mode sensor, which arrives on the AA sensor + * channel once that exists; feed it in through [setSensorNight] and it takes over. + */ +object CarEnvironment { + + data class Battery(val percent: Int, val charging: Boolean) + + private val phoneDarkFlow = MutableStateFlow(false) + private val darkOutsideFlow = MutableStateFlow(false) + private val batteryFlow = MutableStateFlow(Battery(-1, false)) + private val displayFlow = MutableStateFlow("") + + /** The projected frame's real geometry, for anything that has to lay out against it. */ + data class Frame(val width: Int, val height: Int, val densityDpi: Int) + + private val frameFlow = MutableStateFlow(Frame(800, 480, 160)) + val frame: StateFlow = frameFlow + + val phoneDark: StateFlow = phoneDarkFlow + val darkOutside: StateFlow = darkOutsideFlow + val battery: StateFlow = batteryFlow + + /** Human-readable projected resolution, for the connection info in car Settings. */ + val display: StateFlow = displayFlow + + private val vehicleFlow = MutableStateFlow("") + private val insetsFlow = MutableStateFlow(app.seb3thehacker.gearslip.Insets.NONE) + + /** Name of the matched vehicle profile, for the connection info in car Settings. */ + val vehicle: StateFlow = vehicleFlow + + /** How much of the video frame the car UI keeps clear, from the matched vehicle profile. */ + val insets: StateFlow = insetsFlow + + /** Live adjustment from car Settings; not persisted until saved to the vehicle profile. */ + fun setInsets(insets: app.seb3thehacker.gearslip.Insets) { + insetsFlow.value = insets + } + + fun setVehicle(name: String, insets: app.seb3thehacker.gearslip.Insets) { + vehicleFlow.value = name + insetsFlow.value = insets + } + + @Volatile private var sensorNight: Boolean? = null + private var appContext: Context? = null + private var running = false + + private val handler = Handler(Looper.getMainLooper()) + private val tick = object : Runnable { + override fun run() { + refresh() + handler.postDelayed(this, 60_000) + } + } + + private val batteryReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + intent?.let(::readBattery) + } + } + + fun setPhoneTheme(configuration: Configuration) { + phoneDarkFlow.value = + (configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + } + + /** For the AA sensor channel: null goes back to the time-of-day guess. */ + fun setSensorNight(night: Boolean?) { + sensorNight = night + refresh() + } + + fun setDisplay(width: Int, height: Int, densityDpi: Int) { + displayFlow.value = "${width}x$height @ ${densityDpi}dpi" + frameFlow.value = Frame(width, height, densityDpi) + } + + fun start(context: Context) { + if (running) return + running = true + appContext = context.applicationContext + setPhoneTheme(context.resources.configuration) + val sticky = context.applicationContext.registerReceiver( + batteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), Context.RECEIVER_NOT_EXPORTED, + ) + sticky?.let(::readBattery) + handler.post(tick) + } + + fun stop() { + if (!running) return + running = false + handler.removeCallbacks(tick) + runCatching { appContext?.unregisterReceiver(batteryReceiver) } + } + + fun refresh() { + darkOutsideFlow.value = when (CarSettings.nightMode.value) { + NightMode.DAY -> false + NightMode.NIGHT -> true + NightMode.AUTO -> sensorNight ?: guessNightFromClock() + } + } + + private fun guessNightFromClock(): Boolean { + val hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY) + return hour >= 20 || hour < 6 + } + + private fun readBattery(intent: Intent) { + val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + batteryFlow.value = Battery(if (level >= 0 && scale > 0) level * 100 / scale else -1, charging) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt new file mode 100644 index 0000000..fa710e7 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarHomeScreen.kt @@ -0,0 +1,183 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip +import android.media.session.PlaybackState +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.host.CarAppConnection +import app.seb3thehacker.gearslip.media.CarMedia +import app.seb3thehacker.gearslip.media.MediaArt +import kotlinx.coroutines.delay + +/** + * The home screen: the map on the left, what is playing on the right. + * + * The map is whatever navigation app was used last, brought back on its own. The player only + * takes its share of the screen while something is loaded; otherwise the map has all of it. + */ +@Composable +fun CarHome() { + val frame by CarEnvironment.frame.collectAsStateWithLifecycle() + val navStatus by CarServices.nav.status.collectAsStateWithLifecycle() + val phase by CarServices.media.phase.collectAsStateWithLifecycle() + val now by CarServices.media.now.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { CarServices.autostart(frame) } + LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() } + + val showPlayer = phase == CarMedia.Phase.READY && now.hasTrack + + Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box( + Modifier + .weight(if (showPlayer) 1.8f else 1f) + .fillMaxHeight() + .clip(RoundedCornerShape(EMBEDDED_RADIUS)), + ) { + MapPane(navStatus, frame) + } + if (showPlayer) { + MiniPlayer(Modifier.weight(1f).fillMaxHeight()) + } + } +} + +@Composable +private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) { + val navigator = LocalCarNavigator.current + if (status.phase == CarAppConnection.Phase.RUNNING) { + CarAppStage(CarServices.nav, status, frame, embedded = true) { CarServices.stopNav() } + return + } + + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val hasLast = CarSettings.lastNav.value != null + when (status.phase) { + CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> + Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium) + CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> { + Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium) + status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + TextButton(onClick = navigator::apps) { Text("Choose another app") } + } + else -> { + Text( + if (hasLast) "Starting the map…" else "No map app chosen yet.", + style = MaterialTheme.typography.titleMedium, + ) + if (!hasLast) { + Text( + "Open Car apps and connect a navigation app; it will start here next time.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { navigator.apps() }) { Text("Car apps") } + } + } + } + } +} + +/** Play/pause and a seek bar, and nothing else: the full controls are in the media screen. */ +@Composable +private fun MiniPlayer(modifier: Modifier) { + val media = CarServices.media + val now by media.now.collectAsStateWithLifecycle() + val context = LocalContext.current + val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) } + + var position by remember { mutableStateOf(0L) } + LaunchedEffect(now) { + while (true) { + position = now.currentPosition() + delay(500) + } + } + + val navigator = LocalCarNavigator.current + Surface( + onClick = navigator::media, + modifier = modifier, + shape = RoundedCornerShape(EMBEDDED_RADIUS), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + Modifier.fillMaxSize().padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box(Modifier.size(120.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) { + art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + } + Spacer(Modifier.height(10.dp)) + Text( + now.title.ifEmpty { "Nothing playing" }, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + Text( + now.artist, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + + if (now.durationMs > 0) { + WavyProgress( + fraction = position.toFloat() / now.durationMs, + playing = now.state == PlaybackState.STATE_PLAYING, + onSeek = { media.seek((it * now.durationMs).toLong()) }, + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + Spacer(Modifier.height(16.dp)) + } + + FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(64.dp)) { + Icon( + if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, + if (now.playing) "Pause" else "Play", + Modifier.size(38.dp), + ) + } + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt new file mode 100644 index 0000000..31e5956 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt @@ -0,0 +1,155 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.clickable +import androidx.compose.foundation.shape.RoundedCornerShape + +/** + * A keyboard the host draws itself. + * + * Text entry in a car can't use the phone's IME: the car UI lives in a Presentation on a + * virtual display, where a system keyboard has nowhere sensible to appear, and a phone keyboard + * is the wrong shape for a screen a driver glances at anyway. Android Auto draws its own for + * the same reasons, so this does too - large keys, no long-press, no prediction. + * + * It is a pure function of [text]: every key reports the new string and holds no state beyond + * which layer is showing. + */ +@Composable +fun CarKeyboard( + text: String, + onTextChange: (String) -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier, + submitIcon: Boolean = true, + submitImage: ImageVector? = null, +) { + var symbols by remember { mutableStateOf(false) } + var shifted by remember { mutableStateOf(false) } + + val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = modifier.fillMaxWidth(), + ) { + Column( + Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + rows.forEach { row -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + row.forEach { key -> + val label = if (shifted && !symbols) key.uppercase() else key + Key(label, Modifier.weight(1f)) { + onTextChange(text + label) + shifted = false + } + } + } + } + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols } + if (!symbols) { + Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted } + } + Key("space", Modifier.weight(3f)) { onTextChange("$text ") } + Key("⌫", Modifier.weight(1.4f)) { + if (text.isNotEmpty()) onTextChange(text.dropLast(1)) + } + SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit) + } + } + } +} + +@Composable +private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(8.dp), + modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } +} + +@Composable +private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) { + Surface( + color = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = RoundedCornerShape(8.dp), + modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack, + contentDescription = "Submit", + ) + } + } +} + +/** + * Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few + * results all fit a 480px-tall screen - which is the real constraint here, not the key size. + */ +private val KEY_HEIGHT = 34.dp + +private val LETTER_ROWS = listOf( + "qwertyuiop".map { it.toString() }, + "asdfghjkl".map { it.toString() }, + "zxcvbnm".map { it.toString() }, +) + +private val SYMBOL_ROWS = listOf( + "1234567890".map { it.toString() }, + listOf("-", "/", ":", ";", "(", ")", "$", "&", "@", "\""), + listOf(".", ",", "?", "!", "'", "#", "%", "*", "+", "="), +) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt new file mode 100644 index 0000000..eb9a250 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -0,0 +1,127 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toBitmap +import app.seb3thehacker.gearslip.host.CarAppCatalog +import app.seb3thehacker.gearslip.media.MediaCatalog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** One tile on the launcher: either a phone app's own icon or a built-in glyph. */ +private class Tile( + val label: String, + val icon: ImageBitmap? = null, + val glyph: ImageVector? = null, + val onClick: () -> Unit, +) + +/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */ +@Composable +fun CarLauncher() { + val context = LocalContext.current + val navigator = LocalCarNavigator.current + val frame by CarEnvironment.frame.collectAsState() + + val installed by produceState(initialValue = emptyList(), context, frame) { + value = withContext(Dispatchers.IO) { + fun iconOf(pkg: String) = runCatching { + context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap() + }.getOrNull() + + val nav = CarAppCatalog.installed(context).map { app -> + Tile(app.label, iconOf(app.component.packageName)) { + CarServices.connectNav(app, frame) + navigator.home() + } + } + val media = MediaCatalog.installed(context).map { app -> + Tile(app.label, iconOf(app.component.packageName)) { + CarServices.openMedia(app) + navigator.media() + } + } + (nav + media).sortedBy { it.label.lowercase() } + } + } + + val tiles = listOf( + Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }, + Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }, + ) + installed + listOf( + Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() }, + ) + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 108.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(20.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(tiles) { AppTile(it) } + } +} + +@Composable +private fun AppTile(tile: Tile) { + Surface( + onClick = tile.onClick, + modifier = Modifier.height(120.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Column( + Modifier.fillMaxSize().padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + when { + tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp)) + tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp)) + } + Spacer(Modifier.height(8.dp)) + Text( + tile.label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt new file mode 100644 index 0000000..a936b8a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarNavigator.kt @@ -0,0 +1,56 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +sealed interface CarScreen { + data object Home : CarScreen + data object Settings : CarScreen + data object Apps : CarScreen + + /** The current media app, full size: browse, lyrics and controls. */ + data object Media : CarScreen + + /** [replyTo] is a notification key to open straight into its reply pane. */ + data class Notifications(val replyTo: String? = null) : CarScreen + data class App(val id: String) : CarScreen +} + +/** + * Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by + * the on-screen one; an open app can claim it first (a browser going back a page) through + * [backInterceptor]. + */ +class CarNavigator(startId: String) { + + var current: CarScreen by mutableStateOf( + if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home, + ) + private set + + var backInterceptor: (() -> Boolean)? = null + + fun home() { current = CarScreen.Home } + + fun media() { current = CarScreen.Media } + + fun apps() { current = CarScreen.Apps } + + fun settings() { current = CarScreen.Settings } + + fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) } + + fun open(id: String) { current = CarScreen.App(id) } + + fun back() { + if (backInterceptor?.invoke() == true) return + current = CarScreen.Home + } +} + +val LocalCarNavigator = staticCompositionLocalOf { error("no CarNavigator provided") } + +/** "Is it dark outside", for apps that adapt to it. See [CarEnvironment]. */ +val LocalDarkOutside = staticCompositionLocalOf { false } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt new file mode 100644 index 0000000..ce89324 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -0,0 +1,113 @@ +package app.seb3thehacker.gearslip.car + +import android.content.ComponentName +import android.content.Context +import app.seb3thehacker.gearslip.audio.CarAudio +import app.seb3thehacker.gearslip.host.CarAppCatalog +import app.seb3thehacker.gearslip.host.CarAppConnection +import app.seb3thehacker.gearslip.host.TemplateApp +import app.seb3thehacker.gearslip.media.CarMedia +import app.seb3thehacker.gearslip.media.MediaApp +import app.seb3thehacker.gearslip.media.MediaCatalog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext + +/** + * The two long-lived things the car keeps running: the map app and the media app. + * + * They belong to the car session rather than to whichever screen is showing, so the home + * screen, the Car apps list and the media screen all look at the same connection. Leaving a + * screen must not drop the route being followed or the song being played. + */ +object CarServices { + + private var context: Context? = null + + val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } + val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } + + private val _mediaApp = MutableStateFlow(null) + val mediaApp: StateFlow = _mediaApp.asStateFlow() + + /** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */ + private var navStopped = false + private var autoplayPending = false + + fun init(appContext: Context) { + context = appContext.applicationContext + app.seb3thehacker.gearslip.notify.CarMotion.start(appContext) + } + + // --- map ----------------------------------------------------------------------------- + + fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame) { + navStopped = false + CarSettings.setLastNav(app.component.flattenToString()) + nav.connect(app, frame.width, frame.height, frame.densityDpi) + } + + fun stopNav() { + navStopped = true + nav.disconnect() + } + + // --- media --------------------------------------------------------------------------- + + fun openMedia(app: MediaApp, autoplay: Boolean = false) { + CarSettings.setLastMedia(app.component.flattenToString()) + val phase = media.phase.value + val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING + if (_mediaApp.value?.component == app.component && live) return + + CarAudio.release() + _mediaApp.value = app + autoplayPending = autoplay + media.connect(app) + if (CarSettings.pipeAudio.value) CarAudio.request(app.uid) + } + + /** Called once the media app is connected: starts playback if that was asked for. */ + fun autoplayIfDue() { + if (!autoplayPending) return + autoplayPending = false + if (!media.now.value.playing) media.togglePlay() + } + + // --- start and stop ------------------------------------------------------------------ + + /** Brings back whatever was in use last time. Safe to call repeatedly. */ + suspend fun autostart(frame: CarEnvironment.Frame) { + val ctx = context ?: return + + if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) { + val last = CarSettings.lastNav.value + val app = last?.let { id -> + withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) } + .firstOrNull { it.component == ComponentName.unflattenFromString(id) } + } + if (app != null) connectNav(app, frame) + } + + if (_mediaApp.value == null) { + val last = CarSettings.lastMedia.value + val app = last?.let { id -> + withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) } + .firstOrNull { it.component == ComponentName.unflattenFromString(id) } + } + if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value) + } + } + + /** The car went away: nothing should keep running for a screen that no longer exists. */ + fun shutdown() { + context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) } + runCatching { nav.disconnect() } + runCatching { media.disconnect() } + CarAudio.release() + _mediaApp.value = null + navStopped = false + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt new file mode 100644 index 0000000..bdfa6d5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -0,0 +1,117 @@ +package app.seb3thehacker.gearslip.car + +import android.content.Context +import app.seb3thehacker.gearslip.AppSettings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +enum class NightMode { AUTO, DAY, NIGHT } + +/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */ +enum class AppTheme { PHONE, LIGHT, DARK } + +/** + * Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a + * button is tapped, and persisted through [AppSettings] so they survive the next connection. + */ +object CarSettings { + + private const val KEY_SCALE = "car_scale" + private const val KEY_OPEN = "car_open_on_connect" + private const val KEY_NIGHT = "car_night_mode" + private const val KEY_PIPE_AUDIO = "car_pipe_audio" + private const val KEY_LAST_NAV = "car_last_nav" + private const val KEY_LAST_MEDIA = "car_last_media" + private const val KEY_AUTOPLAY = "car_autoplay" + private const val KEY_THEME = "car_app_theme" + + /** Id of the app to open on connect; [HOME] opens the launcher. */ + const val HOME = "home" + + private lateinit var app: Context + + private val scaleFlow = MutableStateFlow(1f) + private val openFlow = MutableStateFlow(HOME) + private val nightFlow = MutableStateFlow(NightMode.AUTO) + private val pipeAudioFlow = MutableStateFlow(false) + private val lastNavFlow = MutableStateFlow(null) + private val lastMediaFlow = MutableStateFlow(null) + private val autoplayFlow = MutableStateFlow(false) + private val themeFlow = MutableStateFlow(AppTheme.PHONE) + val appTheme: StateFlow = themeFlow + + /** Flattened component names of the map and media apps used last, brought back on connect. */ + val lastNav: StateFlow = lastNavFlow + val lastMedia: StateFlow = lastMediaFlow + + /** Whether the media app starts playing by itself when the car connects. */ + val autoplay: StateFlow = autoplayFlow + + val scale: StateFlow = scaleFlow + val openOnConnect: StateFlow = openFlow + val nightMode: StateFlow = nightFlow + + /** + * Whether a media app's sound is captured and sent over the car link. Off by default: the + * platform only allows that behind a screen-capture consent dialog, and without it the app + * simply plays through the phone's normal output (e.g. Bluetooth to the car). + */ + val pipeAudio: StateFlow = pipeAudioFlow + + fun init(context: Context) { + app = context.applicationContext + scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f + openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME) + lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null } + lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null } + themeFlow.value = runCatching { + AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name)) + }.getOrDefault(AppTheme.PHONE) + autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true" + pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "false") == "true" + nightFlow.value = runCatching { + NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name)) + }.getOrDefault(NightMode.AUTO) + } + + fun setScale(value: Float) { + scaleFlow.value = value + AppSettings.putString(app, KEY_SCALE, value.toString()) + } + + fun setOpenOnConnect(id: String) { + openFlow.value = id + AppSettings.putString(app, KEY_OPEN, id) + } + + fun setLastNav(component: String) { + lastNavFlow.value = component + AppSettings.putString(app, KEY_LAST_NAV, component) + } + + fun setLastMedia(component: String) { + lastMediaFlow.value = component + AppSettings.putString(app, KEY_LAST_MEDIA, component) + } + + fun setAppTheme(theme: AppTheme) { + themeFlow.value = theme + AppSettings.putString(app, KEY_THEME, theme.name) + } + + fun setAutoplay(on: Boolean) { + autoplayFlow.value = on + AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) + } + + fun setPipeAudio(on: Boolean) { + pipeAudioFlow.value = on + AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) + } + + fun setNightMode(mode: NightMode) { + nightFlow.value = mode + AppSettings.putString(app, KEY_NIGHT, mode.name) + CarEnvironment.refresh() + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt new file mode 100644 index 0000000..75c3abd --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -0,0 +1,219 @@ +package app.seb3thehacker.gearslip.car + +import android.os.Build +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.ui.CertSummary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Settings that make sense while sitting in the car. Anything that needs a keyboard (the + * startup URL, certificates) lives on the phone, not here. + */ +@Composable +fun CarSettingsScreen() { + val scale by CarSettings.scale.collectAsState() + val open by CarSettings.openOnConnect.collectAsState() + val night by CarSettings.nightMode.collectAsState() + val pipeAudio by CarSettings.pipeAudio.collectAsState() + val autoplay by CarSettings.autoplay.collectAsState() + val theme by CarSettings.appTheme.collectAsState() + val display by CarEnvironment.display.collectAsState() + val vehicle by CarEnvironment.vehicle.collectAsState() + val context = LocalContext.current + val cert by produceState(null) { + value = withContext(Dispatchers.Default) { CertSummary.read(context) } + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) + + ChoiceRow( + "Open on connect", + listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label }, + open, + ) { CarSettings.setOpenOnConnect(it) } + + ChoiceRow( + "Size", + listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"), + scale, + ) { CarSettings.setScale(it) } + + ChoiceRow( + "App theme", + listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"), + theme, + ) { CarSettings.setAppTheme(it) } + + ChoiceRow( + "Map day/night", + listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"), + night, + ) { CarSettings.setNightMode(it) } + + ChoiceRow( + "Autoplay on connect", + listOf(false to "Off", true to "On"), + autoplay, + ) { CarSettings.setAutoplay(it) } + + ChoiceRow( + "Media audio", + listOf(false to "Phone output", true to "Through Gearslip"), + pipeAudio, + ) { CarSettings.setPipeAudio(it) } + Text( + "Phone output needs no permission: the app plays as it normally would, e.g. over " + + "Bluetooth to the car. Through Gearslip sends the sound over the car link, and " + + "Android asks for screen-capture consent each time.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ScreenFit(vehicle) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}") + Info("Vehicle", vehicle.ifEmpty { "-" }) + Info("Video", display.ifEmpty { "-" }) + Info("Certificate", cert?.headline ?: "Checking…") + } + val navigator = LocalCarNavigator.current + Button(onClick = { navigator.open("calibrate") }) { Text("Display test") } + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +private fun ChoiceRow(title: String, options: List>, selected: T, onSelect: (T) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + options.forEach { (value, label) -> + val on = value == selected + Surface( + onClick = { onSelect(value) }, + modifier = Modifier + .weight(1f) + .height(64.dp), + shape = MaterialTheme.shapes.large, + color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + ) { + Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal) + } + } + } + } + } +} + +@Composable +private fun Info(label: String, value: String) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text(label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 4.dp)) + Text(value, style = MaterialTheme.typography.bodyLarge) + } +} + +/** + * Nudges how much of the frame the UI keeps clear at the top and bottom, live, so a car's + * display can be fitted from the driver's seat. Save writes it to this vehicle's profile. + */ +@Composable +private fun ScreenFit(vehicle: String) { + val insets by CarEnvironment.insets.collectAsState() + val context = LocalContext.current.applicationContext + var saved by remember { mutableStateOf(null) } + val known = vehicle.isNotEmpty() && vehicle != "Unknown vehicle" + + fun change(new: app.seb3thehacker.gearslip.Insets) { + saved = null + CarEnvironment.setInsets(new) + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) } + Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) } + Text( + "Usable height ${480 - insets.top - insets.bottom}px of 480", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets) + saved = if (ok) "Saved to $vehicle" else "Could not save" + }, + enabled = known, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") } + saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + } +} + +@Composable +private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1.1f)) + StepButton("−5", Modifier.weight(1f)) { onStep(-5) } + StepButton("−1", Modifier.weight(1f)) { onStep(-1) } + Text( + "${value}px", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1.3f), + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + StepButton("+1", Modifier.weight(1f)) { onStep(1) } + StepButton("+5", Modifier.weight(1f)) { onStep(5) } + } +} + +@Composable +private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = modifier.height(64.dp), + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Text(text, style = MaterialTheme.typography.titleLarge) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt new file mode 100644 index 0000000..7c4d50f --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -0,0 +1,195 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.background +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.notify.CarNotifications +import kotlinx.coroutines.delay +import java.text.DateFormat +import java.util.Date + +/** + * The whole car screen: the current screen on top, the nav bar along the bottom. + * + * Colours follow the phone's theme (Material You). The size setting scales everything by + * changing the density, so text, icons and touch targets grow together. + */ +@Composable +fun CarUi() { + val scale by CarSettings.scale.collectAsState() + val phoneDark by CarEnvironment.phoneDark.collectAsState() + val appTheme by CarSettings.appTheme.collectAsState() + val mediaApp by CarServices.mediaApp.collectAsState() + val darkOutside by CarEnvironment.darkOutside.collectAsState() + val appContext = LocalContext.current.applicationContext + val dark = when (appTheme) { + AppTheme.PHONE -> phoneDark + AppTheme.LIGHT -> false + AppTheme.DARK -> true + } + val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext) + val base = LocalDensity.current + val insets by CarEnvironment.insets.collectAsState() + val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) } + remember(appContext) { CarServices.init(appContext) } + DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } + // The map follows the light outside, whatever the app's own theme is set to. + LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } + + CompositionLocalProvider( + LocalDensity provides Density(base.density * scale, base.fontScale), + LocalCarNavigator provides navigator, + LocalDarkOutside provides darkOutside, + ) { + MaterialTheme(colorScheme = colors) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + // Insets are in video-frame pixels, so convert with the un-scaled density. + val pad = with(base) { + PaddingValues( + start = insets.left.toDp(), top = insets.top.toDp(), + end = insets.right.toDp(), bottom = insets.bottom.toDp(), + ) + } + // The calibration screen is the raw frame: no insets, no nav bar. + val screen = navigator.current + if (screen == CarScreen.App("calibrate")) { + CarApps.find("calibrate")?.content?.invoke() + } else { + Column(Modifier.fillMaxSize().padding(pad)) { + Box(Modifier.weight(1f).fillMaxWidth()) { + when (screen) { + CarScreen.Home -> CarHome() + CarScreen.Apps -> CarLauncher() + CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } } + CarScreen.Settings -> CarSettingsScreen() + is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) + is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() + } + // The history screen is already showing them, so it needs no popup. + if (screen !is CarScreen.Notifications) { + NotificationPopup(Modifier.align(Alignment.TopCenter)) + } + } + CarNavBar(navigator) + } + } + } + } + } +} + +/** Back, Home and Apps on the left; clock, phone battery and the darkness signal on the right. */ +@Composable +private fun CarNavBar(navigator: CarNavigator) { + val battery by CarEnvironment.battery.collectAsState() + val darkOutside by CarEnvironment.darkOutside.collectAsState() + val now by rememberNow() + + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + Row( + Modifier + .fillMaxWidth() + .height(80.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NavItem(Icons.AutoMirrored.Filled.ArrowBack, "Back", selected = false) { navigator.back() } + Spacer(Modifier.width(8.dp)) + NavItem(Icons.Filled.Home, "Home", navigator.current == CarScreen.Home) { navigator.home() } + Spacer(Modifier.width(8.dp)) + NavItem(Icons.Filled.Menu, "Apps", navigator.current == CarScreen.Apps) { navigator.apps() } + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.End) { + Text( + DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) + val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else "" + val light = if (darkOutside) "Night" else "Day" + Text( + listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(16.dp)) + NavItem( + Icons.Filled.Notifications, null, navigator.current is CarScreen.Notifications, + ) { navigator.notifications() } + } + } +} + +@Composable +private fun NavItem(icon: ImageVector, label: String?, selected: Boolean, onClick: () -> Unit) { + val scheme = MaterialTheme.colorScheme + Column( + Modifier + .clip(RoundedCornerShape(24.dp)) + .background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer) + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box { + Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp)) + } + if (label != null) Text(label, style = MaterialTheme.typography.labelLarge) + } +} + +/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */ +@Composable +internal fun rememberNow(): State = produceState(System.currentTimeMillis()) { + while (true) { + value = System.currentTimeMillis() + delay(60_000 - value % 60_000) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt new file mode 100644 index 0000000..64c8238 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -0,0 +1,336 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Button +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.audio.CarAudio +import app.seb3thehacker.gearslip.media.CarMedia +import app.seb3thehacker.gearslip.media.MediaApp +import app.seb3thehacker.gearslip.media.MediaArt +import app.seb3thehacker.gearslip.media.LyricsResult +import app.seb3thehacker.gearslip.media.Lyrics +import app.seb3thehacker.gearslip.media.MediaEntry +import app.seb3thehacker.gearslip.media.NowPlaying +import android.media.session.PlaybackState + +/** + * A media app on the car screen: what it is playing and its controls on the left, the tree it + * offers to browse on the right. The app owns the content; this only draws it. + */ +@Composable +fun MediaScreen(app: MediaApp, onExit: () -> Unit) { + val context = LocalContext.current + val media = CarServices.media + val phase by media.phase.collectAsStateWithLifecycle() + val now by media.now.collectAsStateWithLifecycle() + val browse by media.browse.collectAsStateWithLifecycle() + val capture by CarAudio.capture.collectAsStateWithLifecycle() + var tab by remember { mutableStateOf(Tab.BROWSE) } + + // The connection outlives this screen: the home screen's player is the same one. + LaunchedEffect(app) { CarServices.openMedia(app) } + + Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onExit) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back to apps") } + Text(app.label, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.weight(1f)) + AudioBadge(capture) + } + + when (phase) { + CarMedia.Phase.REJECTED -> Notice( + "${app.label} would not let Gearslip browse it.", + "Some media apps only accept Google's own host.", + ) + CarMedia.Phase.CONNECTING, CarMedia.Phase.IDLE -> Notice("Connecting to ${app.label}…", null) + CarMedia.Phase.READY -> Row( + Modifier.fillMaxSize().padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight()) + Column(Modifier.weight(1f).fillMaxHeight()) { + Row { + TextButton(onClick = { tab = Tab.BROWSE }) { + Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal) + } + TextButton(onClick = { tab = Tab.LYRICS }) { + Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal) + } + } + when (tab) { + Tab.BROWSE -> BrowsePanel(browse, media, Modifier.fillMaxSize()) + Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) + } + } + } + } + } +} + +private enum class Tab { BROWSE, LYRICS } + +/** + * The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with + * the current line in the accent colour; unsynced ones are plain scrolling text. + */ +@Composable +private fun LyricsPanel(now: NowPlaying, modifier: Modifier) { + val lyrics by produceState(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) { + value = LyricsState.Loading + value = Lyrics.find(now.title, now.artist, now.album, now.durationMs) + ?.let { LyricsState.Found(it) } ?: LyricsState.Missing + } + val list = rememberLazyListState() + + // Ticks with playback so the highlighted line moves; only synced lyrics need it. + var position by remember { mutableLongStateOf(0L) } + LaunchedEffect(now) { + while (true) { + position = now.currentPosition() + kotlinx.coroutines.delay(300) + } + } + + when (val state = lyrics) { + LyricsState.Loading -> Notice("Looking for lyrics…", null) + LyricsState.Missing -> Notice( + if (now.title.isEmpty()) "Nothing playing." else "No lyrics found for this track.", + null, + ) + is LyricsState.Found -> { + val lines = state.result.lines + val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1 + LaunchedEffect(current) { + if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0)) + } + LazyColumn(modifier, state = list, verticalArrangement = Arrangement.spacedBy(6.dp)) { + itemsIndexed(lines) { index, line -> + Text( + line.text.ifEmpty { " " }, + style = MaterialTheme.typography.titleLarge, + fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal, + color = when { + !state.result.synced -> MaterialTheme.colorScheme.onSurface + index == current -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } +} + +private sealed interface LyricsState { + data object Loading : LyricsState + data object Missing : LyricsState + class Found(val result: LyricsResult) : LyricsState +} + +@Composable +private fun AudioBadge(capture: CarAudio.Capture) { + val text = when (capture) { + CarAudio.Capture.ON -> "Audio to car" + CarAudio.Capture.ASKING -> "Allow audio capture on the phone" + CarAudio.Capture.DENIED -> "Audio stays on the phone" + CarAudio.Capture.OFF -> return + } + Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +private fun Notice(title: String, detail: String?) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) { + Text(title, style = MaterialTheme.typography.titleMedium) + detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } +} + +// --- now playing ----------------------------------------------------------------------------- + +@Composable +private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) { + val context = LocalContext.current + val art by produceState(now.art, now.art, now.artUri) { + value = now.art ?: MediaArt.load(context, now.artUri) + } + var position by remember { mutableLongStateOf(0L) } + LaunchedEffect(now) { + while (true) { + position = now.currentPosition() + kotlinx.coroutines.delay(500) + } + } + + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.size(170.dp), + ) { + art?.let { + Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop) + } + } + Spacer(Modifier.height(8.dp)) + Text( + now.title.ifEmpty { "Nothing playing" }, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + Text( + listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) } + + if (now.durationMs > 0) { + WavyProgress( + fraction = position.toFloat() / now.durationMs, + playing = now.state == PlaybackState.STATE_PLAYING, + onSeek = { media.seek((it * now.durationMs).toLong()) }, + modifier = Modifier.padding(vertical = 4.dp), + ) + } else { + Spacer(Modifier.height(14.dp)) + } + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) { + Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp)) + } + FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) { + Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp)) + } + IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) { + Icon(MediaIcons.Next, "Next", Modifier.size(32.dp)) + } + } + + // Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their + // icons are resources inside the app's own package. + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + now.custom.take(3).forEach { action -> + TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) } + } + } + } +} + +// --- browsing -------------------------------------------------------------------------------- + +@Composable +private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, modifier: Modifier) { + Column(modifier) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (media.canGoUp) { + IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") } + } + Text( + browse.trail.lastOrNull() ?: "Browse", + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + when { + browse.loading -> Notice("Loading…", null) + browse.failed -> Notice("Could not load this list.", null) + browse.entries.isEmpty() -> Notice("Nothing here.", null) + else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, media) } + } + } + } +} + +@Composable +private fun EntryRow(entry: MediaEntry, media: CarMedia) { + val context = LocalContext.current + val icon by produceState(entry.iconBitmap, entry.iconUri) { + value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri) + } + Surface( + onClick = { media.select(entry) }, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) { + icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) } + } + Column(Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (entry.subtitle.isNotEmpty()) { + Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (entry.browsable && entry.playable) { + IconButton(onClick = { media.playAll(entry) }) { Icon(Icons.Filled.PlayArrow, "Play") } + } + if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) + } + } +} + +/** Material's transport icons, from their published path data (they live in the extended set). */ +internal object MediaIcons { + val Pause = icon("Pause", "M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z") + val Next = icon("SkipNext", "M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z") + val Previous = icon("SkipPrevious", "M6,6h2v12L6,18zM9.5,12l8.5,6V6z") + + private fun icon(name: String, path: String) = ImageVector.Builder( + name, 24.dp, 24.dp, 24f, 24f, + ).addPath(addPathNodes(path), fill = SolidColor(androidx.compose.ui.graphics.Color.Black)).build() +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt new file mode 100644 index 0000000..7395c2a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/NotificationsScreen.kt @@ -0,0 +1,277 @@ +package app.seb3thehacker.gearslip.car + +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.notify.CarNotification +import app.seb3thehacker.gearslip.notify.CarNotifications +import kotlinx.coroutines.delay +import java.text.DateFormat +import java.util.Date + +/** The notification history, with a reply pane for anything the sending app lets us answer. */ +@Composable +fun NotificationsScreen(replyTo: String?) { + val history by CarNotifications.history.collectAsState() + var replying by remember(replyTo) { mutableStateOf(replyTo) } + + // Anything arriving while this is open is being looked at, so it never counts as unread. + LaunchedEffect(history) { CarNotifications.markRead() } + + val target = history.firstOrNull { it.key == replying && it.reply != null } + if (target != null) { + ReplyPane(target, onDone = { replying = null }) + } else { + NotificationList(history, onReply = { replying = it.key }) + } +} + +@Composable +private fun NotificationList(history: List, onReply: (CarNotification) -> Unit) { + val listening by CarNotifications.listening.collectAsState() + val context = LocalContext.current.applicationContext + + Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Notifications", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (history.isNotEmpty()) TextButton(onClick = CarNotifications::clear) { Text("Clear all") } + } + + if (!listening) { + AccessBanner { + context.startActivity( + Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + } + } + + if (history.isEmpty()) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Nothing yet. New notifications from the phone will show up here.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(history, key = { it.key }) { NotificationRow(it, onReply) } + } + } + } +} + +@Composable +private fun AccessBanner(onOpen: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Filled.Warning, contentDescription = null) + Text( + "Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Button(onClick = onOpen) { Text("Open") } + } + } +} + +@Composable +private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Unit) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NotificationIcon(n) + Column(Modifier.weight(1f)) { + Text( + "${n.appLabel} · ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(n.postedAt))}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (n.title.isNotBlank()) { + Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (n.text.isNotBlank()) { + Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + when { + n.replied -> Icon(Icons.Filled.Check, contentDescription = "Replied", tint = MaterialTheme.colorScheme.primary) + n.reply != null -> FilledTonalButton(onClick = { onReply(n) }) { Text("Reply") } + } + } + } +} + +@Composable +private fun NotificationIcon(n: CarNotification, size: androidx.compose.ui.unit.Dp = 44.dp) { + val icon = n.icon + if (icon != null) { + Image(icon.asImageBitmap(), contentDescription = null, modifier = Modifier.size(size).clip(RoundedCornerShape(10.dp))) + } else { + Icon(Icons.Filled.Notifications, contentDescription = null, modifier = Modifier.size(size)) + } +} + +/** Same shape as search: a field on top, the host's own keyboard below. */ +@Composable +private fun ReplyPane(n: CarNotification, onDone: () -> Unit) { + val context = LocalContext.current.applicationContext + var text by remember(n.key) { mutableStateOf("") } + + fun send() { + if (text.isBlank()) return + CarNotifications.reply(context, n, text.trim()) + onDone() + } + + Column(Modifier.fillMaxSize()) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onDone) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.weight(1f), + ) { + Text( + if (text.isEmpty()) "Reply to ${n.title.ifBlank { n.appLabel }}" else "$text|", + color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + } + } + + // What is being answered, so the driver isn't replying blind. + Row( + Modifier.weight(1f).fillMaxWidth().padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NotificationIcon(n) + Text(n.text.ifBlank { n.title }, style = MaterialTheme.typography.bodyLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) + } + + CarKeyboard(text, onTextChange = { text = it }, onSubmit = ::send, submitImage = Icons.AutoMirrored.Filled.Send) + } +} + +/** + * The popup shown over whatever the car is displaying. It times itself out, and tapping it opens + * the history - or straight into a reply, when the app allows one. + */ +@Composable +fun NotificationPopup(modifier: Modifier = Modifier) { + val popup by CarNotifications.popup.collectAsState() + val navigator = LocalCarNavigator.current + val n = popup ?: return + + LaunchedEffect(n.id) { + delay(POPUP_MS) + CarNotifications.dismissPopup(n.id) + } + + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + shadowElevation = 8.dp, + modifier = modifier + .padding(top = 8.dp) + .fillMaxWidth(0.7f) + .clickable { navigator.notifications() }, + ) { + Row( + Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + NotificationIcon(n) + Column(Modifier.weight(1f)) { + Text(n.appLabel, style = MaterialTheme.typography.labelMedium, maxLines = 1) + if (n.title.isNotBlank()) { + Text(n.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (n.text.isNotBlank()) { + Text(n.text, style = MaterialTheme.typography.bodyMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + if (n.reply != null) { + FilledTonalButton(onClick = { navigator.notifications(replyTo = n.key) }) { Text("Reply") } + } + IconButton(onClick = { CarNotifications.dismissPopup(n.id) }) { + Icon(Icons.Filled.Close, contentDescription = "Dismiss") + } + } + } +} + +private const val POPUP_MS = 8_000L diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt new file mode 100644 index 0000000..042485b --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneAppsScreen.kt @@ -0,0 +1,130 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import app.seb3thehacker.gearslip.mirror.PhoneApp +import app.seb3thehacker.gearslip.mirror.PhoneApps +import app.seb3thehacker.gearslip.mirror.PhoneMirror +import app.seb3thehacker.gearslip.mirror.TouchRelayService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Starts a phone app on the car screen. + * + * Picking one hands the whole frame over to the mirror, so this screen is the last thing + * Gearslip draws until mirroring stops - either from the notification on the phone, or by + * tapping the black bars beside the mirrored picture. + */ +@Composable +fun PhoneAppsScreen() { + val context = LocalContext.current + val apps by produceState(initialValue = emptyList(), context) { + value = withContext(Dispatchers.IO) { PhoneApps.installed(context) } + } + + Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) { + if (!TouchRelayService.enabled) { + TouchRelayWarning() + Spacer(Modifier.height(12.dp)) + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 108.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(apps) { app -> + PhoneAppTile(app) { PhoneMirror.open(context, app.packageName) } + } + } + } +} + +/** + * Mirroring shows the phone whether or not touch works, so this warns rather than blocks - + * the picture is still useful, it just cannot be driven from the car yet. + */ +@Composable +private fun TouchRelayWarning() { + val context = LocalContext.current + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(Icons.Filled.Warning, contentDescription = null) + Text( + "Touch is off. Enable Gearslip under Settings > Accessibility on the phone.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Button(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") } + } + } +} + +@Composable +private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.height(120.dp), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) { + Column( + Modifier.fillMaxSize().padding(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + app.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(52.dp)) } + Spacer(Modifier.height(8.dp)) + Text( + app.label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt new file mode 100644 index 0000000..4215d46 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateChrome.kt @@ -0,0 +1,340 @@ +package app.seb3thehacker.gearslip.car + +import android.graphics.Rect +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.car.app.model.Template +import androidx.car.app.model.PlaceListMapTemplate +import androidx.car.app.navigation.model.MapTemplate +import androidx.car.app.navigation.model.MapWithContentTemplate +import androidx.car.app.navigation.model.NavigationTemplate +import androidx.car.app.navigation.model.PlaceListNavigationTemplate +import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate +import androidx.car.app.navigation.model.RoutingInfo +import androidx.car.app.navigation.model.TravelEstimate +import app.seb3thehacker.gearslip.host.color +import app.seb3thehacker.gearslip.host.display +import app.seb3thehacker.gearslip.host.remainingTime +import app.seb3thehacker.gearslip.host.text + +/** + * Draws the template an app handed us, over the surface it is drawing its map into. + * + * A templated app never draws its own chrome: it describes a screen in the Car App Library's + * model types and every host renders those to suit its own screen. This is Gearslip's + * rendering. It is deliberately laid out for a wide, short car display - controls to the + * right where a hand reaches from the driver's seat, information to the left. + * + * Templates split into two kinds. The six map-backed ones here leave room for the app's own + * drawing and lay chrome around it; everything else fills the screen and is drawn by + * [ContentTemplate]. + */ +@Composable +fun TemplateChrome(template: Template?, modifier: Modifier = Modifier) { + when (template) { + null -> Unit + is NavigationTemplate -> NavigationChrome(template, modifier) + is MapWithContentTemplate -> MapWithContentChrome(template, modifier) + is MapTemplate -> MapChrome(template, modifier) + is PlaceListNavigationTemplate -> PlaceListNavigationChrome(template, modifier) + is RoutePreviewNavigationTemplate -> RoutePreviewChrome(template, modifier) + is PlaceListMapTemplate -> PlaceListMapChrome(template, modifier) + else -> ContentTemplate(template, modifier) + } +} + +/** + * How much of the surface Gearslip's chrome covers, so the app can keep what matters clear of + * it. Expressed as the visible rectangle within a [width] x [height] surface. + * + * A template that isn't map-backed covers the surface completely, and reporting an empty + * rectangle is how the app is told to stop bothering. + */ +fun visibleAreaFor(template: Template?, width: Int, height: Int): Rect = when (template) { + is NavigationTemplate -> Rect(0, 0, width, (height * (1f - ESTIMATE_FRACTION)).toInt()) + is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate, + is RoutePreviewNavigationTemplate, is PlaceListMapTemplate, + -> Rect((width * CONTENT_PANE_FRACTION).toInt(), 0, width, height) + null -> Rect(0, 0, width, height) + else -> Rect(0, 0, 0, 0) +} + +// --- navigation ------------------------------------------------------------------------------ + +@Composable +private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) { + Box(modifier.fillMaxWidth()) { + (template.navigationInfo as? RoutingInfo)?.let { + RoutingCard(it, Modifier.align(Alignment.TopStart).padding(12.dp).width(320.dp)) + } + RightEdgeControls( + template.actionStrip, + template.mapActionStrip, + Modifier.align(Alignment.TopEnd).padding(12.dp), + ) + template.destinationTravelEstimate?.let { + TravelEstimateBar(it, Modifier.align(Alignment.BottomStart)) + } + } +} + +/** The manoeuvre card: what to do next, how far away it is, and onto what road. */ +@Composable +private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) { + if (info.isLoading) { + ChromeSurface(modifier) { + Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium) + } + return + } + + val step = info.currentStep ?: return + ChromeSurface(modifier) { + Column(Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + CarGlyph(step.maneuver?.icon, Modifier.size(44.dp)) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + val distance = info.currentDistance.display() + if (distance.isNotEmpty()) { + Text( + distance, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + } + Text( + step.cue.text(), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + val road = step.road.text() + if (road.isNotEmpty()) { + Text( + road, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + info.nextStep?.let { next -> + Spacer(Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "then", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + CarGlyph(next.maneuver?.icon, Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + next.cue.text(), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** Time and distance left, along the bottom where it stays out of the map's way. */ +@Composable +private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) { + val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() + val time = remainingTime(estimate.remainingTimeSeconds) + val distance = estimate.remainingDistance.display() + val trip = estimate.tripText.text() + if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return + + Surface( + color = Color.Black.copy(alpha = 0.6f), + contentColor = Color.White, + modifier = modifier.fillMaxWidth(), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + if (time.isNotEmpty()) { + Text( + time, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = estimate.remainingTimeColor.color(dark, Color.White), + ) + } + if (distance.isNotEmpty()) { + Text( + distance, + style = MaterialTheme.typography.titleMedium, + color = estimate.remainingDistanceColor.color(dark, Color.White), + ) + } + if (trip.isNotEmpty()) { + Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1) + } + } + } +} + +// --- map with a side pane ---------------------------------------------------------------------- + +/** + * The shape every remaining map-backed template shares: a pane down the left, the app's map + * showing through on the right, and both action strips stacked at the right edge. + */ +@Composable +private fun MapPaneLayout( + modifier: Modifier, + actionStrip: androidx.car.app.model.ActionStrip?, + mapActionStrip: androidx.car.app.model.ActionStrip?, + pane: @Composable (Modifier) -> Unit, +) { + Box(modifier.fillMaxWidth()) { + pane( + Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .fillMaxWidth(CONTENT_PANE_FRACTION), + ) + RightEdgeControls( + actionStrip, + mapActionStrip, + Modifier.align(Alignment.TopEnd).padding(12.dp), + ) + } +} + +@Composable +private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Modifier) { + MapPaneLayout( + modifier, + template.actionStrip, + template.mapController?.mapActionStrip, + ) { paneModifier -> + ContentTemplate(template.contentTemplate, paneModifier) + } +} + +@Composable +private fun MapChrome(template: MapTemplate, modifier: Modifier) { + MapPaneLayout( + modifier, + template.actionStrip, + template.mapController?.mapActionStrip, + ) { paneModifier -> + ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) { + Column { + HeaderBar( + headerTitle(template.header, ""), + template.header?.startHeaderAction, + template.header?.endHeaderActions.orEmpty(), + ) + if (template.pane != null) PaneRows(template.pane) + else ItemListColumn(template.itemList) + } + } + } +} + +@Composable +private fun PlaceListNavigationChrome(template: PlaceListNavigationTemplate, modifier: Modifier) { + MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier -> + ListPane( + headerTitle(template.header, template.title.text()), + template.header?.startHeaderAction ?: template.headerAction, + template.isLoading, + template.itemList, + paneModifier, + ) + } +} + +@Composable +private fun PlaceListMapChrome(template: PlaceListMapTemplate, modifier: Modifier) { + MapPaneLayout(modifier, template.actionStrip, null) { paneModifier -> + ListPane( + template.title.text(), + template.headerAction, + template.isLoading, + template.itemList, + paneModifier, + ) + } +} + +/** Route choices down the side, with the app's "start" action pinned under them. */ +@Composable +private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifier: Modifier) { + MapPaneLayout(modifier, template.actionStrip, template.mapActionStrip) { paneModifier -> + ChromeSurface(paneModifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) { + Column(Modifier.fillMaxHeight()) { + HeaderBar( + headerTitle(template.header, template.title.text()), + template.header?.startHeaderAction ?: template.headerAction, + template.header?.endHeaderActions.orEmpty(), + ) + Box(Modifier.weight(1f)) { + if (template.isLoading) { + Text("Finding routes…", Modifier.padding(14.dp)) + } else { + ItemListColumn(template.itemList) + } + } + template.navigateAction?.let { + ActionRow(listOf(it), Modifier.padding(12.dp)) + } + } + } + } +} + +@Composable +private fun ListPane( + title: String, + startAction: androidx.car.app.model.Action?, + loading: Boolean, + itemList: androidx.car.app.model.ItemList?, + modifier: Modifier, +) { + ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) { + Column { + HeaderBar(title, startAction) + if (loading) Text("Loading…", Modifier.padding(14.dp)) + else ItemListColumn(itemList) + } + } +} + +/** Fraction of the surface the content pane covers beside a map. */ +private const val CONTENT_PANE_FRACTION = 0.42f + +/** Fraction of the surface the travel-estimate bar covers along the bottom. */ +private const val ESTIMATE_FRACTION = 0.14f diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt new file mode 100644 index 0000000..3b3ffb0 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -0,0 +1,498 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.car.app.model.GridTemplate +import androidx.car.app.model.Item +import androidx.car.app.model.ItemList +import androidx.car.app.model.ListTemplate +import androidx.car.app.model.LongMessageTemplate +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.PaneTemplate +import androidx.car.app.model.Row as CarRow +import androidx.car.app.model.SearchTemplate +import androidx.car.app.model.Section +import androidx.car.app.model.SectionedItemTemplate +import androidx.car.app.model.TabTemplate +import androidx.car.app.model.Template +import androidx.car.app.model.signin.InputSignInMethod +import androidx.car.app.model.signin.PinSignInMethod +import androidx.car.app.model.signin.ProviderSignInMethod +import androidx.car.app.model.signin.QRCodeSignInMethod +import androidx.car.app.model.signin.SignInTemplate +import androidx.car.app.media.model.MediaPlaybackTemplate +import androidx.car.app.OnDoneCallback +import androidx.car.app.serialization.Bundleable +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.host.inputChanged +import app.seb3thehacker.gearslip.host.inputSubmitted +import app.seb3thehacker.gearslip.host.submitted +import app.seb3thehacker.gearslip.host.tabSelected +import app.seb3thehacker.gearslip.host.text +import app.seb3thehacker.gearslip.host.textChanged + +/** + * Templates that fill a screen with content rather than sitting over a map. + * + * A map-backed template can hold one of these in its side pane, which is why they are drawn by + * the same function either way - the only difference is how much room they get. + */ +@Composable +fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) { + when (template) { + null -> Unit + is ListTemplate -> ListContent(template, modifier) + is GridTemplate -> GridContent(template, modifier) + is PaneTemplate -> PaneContent(template, modifier) + is MessageTemplate -> MessageContent(template, modifier) + is LongMessageTemplate -> LongMessageContent(template, modifier) + is SearchTemplate -> SearchContent(template, modifier) + is SignInTemplate -> SignInContent(template, modifier) + is TabTemplate -> TabContent(template, modifier) + is SectionedItemTemplate -> SectionedContent(template, modifier) + is MediaPlaybackTemplate -> MediaPlaybackContent(template, modifier) + else -> UnsupportedChrome(template, modifier) + } +} + +/** True when this template is one [ContentTemplate] knows how to draw. */ +fun isContentTemplate(template: Template?): Boolean = when (template) { + is ListTemplate, is GridTemplate, is PaneTemplate, is MessageTemplate, + is LongMessageTemplate, is SearchTemplate, is SignInTemplate, is TabTemplate, + is SectionedItemTemplate, is MediaPlaybackTemplate, + -> true + else -> false +} + +// --- lists, grids and panes --------------------------------------------------------------------- + +@Composable +private fun ListContent(template: ListTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + headerTitle(template.header, template.title.text()), + template.header?.startHeaderAction ?: template.headerAction, + template.header?.endHeaderActions.orEmpty(), + template.actionStrip, + ) + if (template.isLoading) Loading() + else ItemListColumn(template.singleList, template.sectionedLists?.map { it.itemList }) + ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp)) + } +} + +@Composable +private fun GridContent(template: GridTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + headerTitle(template.header, template.title.text()), + template.header?.startHeaderAction ?: template.headerAction, + template.header?.endHeaderActions.orEmpty(), + template.actionStrip, + ) + if (template.isLoading) Loading() else GridItems(template.singleList) + ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp)) + } +} + +@Composable +private fun PaneContent(template: PaneTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + headerTitle(template.header, template.title.text()), + template.header?.startHeaderAction ?: template.headerAction, + template.header?.endHeaderActions.orEmpty(), + template.actionStrip, + ) + if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane) + } +} + +// --- messages ------------------------------------------------------------------------------- + +@Composable +private fun MessageContent(template: MessageTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + headerTitle(template.header, template.title.text()), + template.header?.startHeaderAction ?: template.headerAction, + template.header?.endHeaderActions.orEmpty(), + template.actionStrip, + ) + if (template.isLoading) { + Loading() + return@ContentSurface + } + Row( + Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + template.icon?.let { + CarGlyph(it, Modifier.size(32.dp)) + Spacer(Modifier.width(12.dp)) + } + Text(template.message.text(), style = MaterialTheme.typography.bodyLarge) + } + ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp)) + } +} + +/** + * The long form is the same idea with a scroll: it's what apps use for terms and conditions, + * which is also why it is only ever shown parked. + */ +@Composable +private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + template.title.text(), + template.headerAction, + actionStrip = template.actionStrip, + ) + Text( + template.message.text(), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + ) + ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp)) + } +} + +// --- search --------------------------------------------------------------------------------- + +/** + * Search: the host owns the text and the keyboard, and the app owns the results. + * + * Every keystroke goes to the app through [SearchTemplate.getSearchCallbackDelegate], which is + * what lets results update as you type. The text lives here rather than in the template + * because the app never sends it back - it only ever sends a new list of results. + */ +@Composable +private fun SearchContent(template: SearchTemplate, modifier: Modifier) { + // Seeded once. The app refreshes this template on every keystroke, so re-seeding from + // initialSearchText would fight whatever is being typed. + var query by remember { mutableStateOf(template.initialSearchText.orEmpty()) } + val delegate = template.searchCallbackDelegate + + ContentSurface(modifier) { + // Back button and field share a row: on a 480px screen the keyboard and the results + // are already competing for every pixel, so the header can't have one to itself. + Row( + Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + template.headerAction?.let { ActionButton(it) } + SearchField(query, template.searchHint.orEmpty(), Modifier.weight(1f)) + ActionRow(template.actionStrip?.actions.orEmpty()) + } + + Box(Modifier.weight(1f)) { + if (template.isLoading) Loading() + else ItemListColumn(template.itemList, modifier = Modifier.fillMaxSize()) + } + + CarKeyboard( + text = query, + onTextChange = { + query = it + delegate.textChanged(it) + }, + onSubmit = { delegate.submitted(query) }, + ) + } +} + +@Composable +private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(10.dp), + modifier = modifier.fillMaxWidth(), + ) { + Text( + query.ifEmpty { hint.ifEmpty { "Search" } }, + style = MaterialTheme.typography.titleMedium, + color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } +} + +// --- sign-in -------------------------------------------------------------------------------- + +/** + * Sign-in comes in four shapes and the host has to draw whichever the app picked. Only the + * typed one needs the keyboard; a PIN or QR code is something the driver reads off the screen + * and completes on their phone. + */ +@Composable +private fun SignInContent(template: SignInTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + template.title.text(), + template.headerAction, + actionStrip = template.actionStrip, + ) + if (template.isLoading) { + Loading() + return@ContentSurface + } + + val instructions = template.instructions.text() + if (instructions.isNotEmpty()) { + Text( + instructions, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + + when (val method = template.signInMethod) { + is InputSignInMethod -> InputSignIn(method, Modifier.weight(1f)) + is PinSignInMethod -> Callout(method.pinCode.toString(), Modifier.weight(1f)) + is QRCodeSignInMethod -> Callout(method.uri.toString(), Modifier.weight(1f)) + is ProviderSignInMethod -> Box(Modifier.weight(1f), Alignment.Center) { + ActionRow(listOf(method.action)) + } + else -> Spacer(Modifier.weight(1f)) + } + + val additional = template.additionalText.text() + if (additional.isNotEmpty()) { + Text( + additional, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp)) + } +} + +@Composable +private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) { + var value by remember { mutableStateOf(method.defaultValue.text()) } + val delegate = method.inputCallbackDelegate + val masked = method.inputType == InputSignInMethod.INPUT_TYPE_PASSWORD + + Column(modifier) { + SearchField(if (masked) "•".repeat(value.length) else value, method.hint.text()) + val error = method.errorMessage.text() + if (error.isNotEmpty()) { + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.weight(1f)) + CarKeyboard( + text = value, + onTextChange = { + value = it + delegate.inputChanged(it) + }, + onSubmit = { delegate.inputSubmitted(value) }, + submitIcon = false, + ) + } +} + +/** A code the driver reads off the screen. Big, centred, nothing else competing with it. */ +@Composable +private fun Callout(value: String, modifier: Modifier) { + Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } +} + +// --- tabs ----------------------------------------------------------------------------------- + +@Composable +private fun TabContent(template: TabTemplate, modifier: Modifier) { + val active = template.activeTabContentId + ContentSurface(modifier) { + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp) + .verticalScrollNone(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + template.headerAction?.let { ActionButton(it) } + template.tabs.forEach { tab -> + val selected = tab.contentId == active + Surface( + color = if (selected) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer + else MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(20.dp), + modifier = Modifier.clickable { + template.tabCallbackDelegate.tabSelected(tab.contentId) + }, + ) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + tab.icon?.let { + CarGlyph(it, Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + } + Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1) + } + } + } + } + if (template.isLoading) Loading() + else ContentTemplate(template.tabContents?.template, Modifier.weight(1f)) + } +} + +/** Tabs share a row; this keeps the modifier chain readable where no scroll is wanted. */ +private fun Modifier.verticalScrollNone(): Modifier = this + +// --- sectioned items -------------------------------------------------------------------------- + +/** + * Sections fetch their items across the binder on demand rather than carrying them, so each one + * is asked for its full range once and the answer is held while the section is on screen. + */ +@Composable +private fun SectionedContent(template: SectionedItemTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + headerTitle(template.header, ""), + template.header?.startHeaderAction, + template.header?.endHeaderActions.orEmpty(), + ) + if (template.isLoading) { + Loading() + return@ContentSurface + } + Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) { + template.sections.forEach { section -> + PaneTitle(section.title.text()) + SectionRows(section) + } + } + ActionRow(template.actions.orEmpty(), Modifier.padding(12.dp)) + } +} + +@Composable +private fun SectionRows(section: Section<*>) { + var items by remember(section) { mutableStateOf>(emptyList()) } + + LaunchedEffect(section) { + val delegate = section.itemsDelegate + val size = runCatching { delegate.size }.getOrDefault(0) + if (size <= 0) return@LaunchedEffect + runCatching { + delegate.requestItemRange(0, size - 1, object : OnDoneCallback { + override fun onSuccess(response: Bundleable?) { + val value = response?.let { runCatching { it.get() }.getOrNull() } + @Suppress("UNCHECKED_CAST") + items = (value as? List).orEmpty() + } + + override fun onFailure(response: Bundleable) = + GearslipLog.w("host: a section refused to hand over its items") + }) + }.onFailure { GearslipLog.w("host: could not fetch section items: ${it.message}") } + } + + if (items.isEmpty()) { + val message = section.noItemsMessage.text() + if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp)) + return + } + items.filterIsInstance().forEach { RowItem(it) } +} + +// --- media ---------------------------------------------------------------------------------- + +/** + * Media playback is drawn from the app's MediaSession rather than from the template, which + * carries only a header. Gearslip has no media channel yet, so this says so plainly instead of + * showing an empty player. + */ +@Composable +private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modifier) { + ContentSurface(modifier) { + HeaderBar( + headerTitle(template.header, ""), + template.header?.startHeaderAction, + template.header?.endHeaderActions.orEmpty(), + ) + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + "Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + } +} + +// --- shared --------------------------------------------------------------------------------- + +@Composable +private fun ContentSurface(modifier: Modifier, content: @Composable ColumnScopeAlias.() -> Unit) { + ChromeSurface(modifier, shape = RoundedCornerShape(0.dp)) { + Column(Modifier.fillMaxSize(), content = content) + } +} + +private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnScope + +@Composable +private fun Loading() { + Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) { + Text("Loading…", style = MaterialTheme.typography.bodyLarge) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt new file mode 100644 index 0000000..d8a9197 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateParts.kt @@ -0,0 +1,383 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.runtime.remember +import androidx.compose.material3.LocalContentColor +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.car.app.model.Action +import androidx.car.app.model.ActionStrip +import androidx.car.app.model.CarIcon +import androidx.car.app.model.GridItem +import androidx.car.app.model.Header +import androidx.car.app.model.ItemList +import androidx.car.app.model.Pane +import androidx.car.app.model.Row as CarRow +import androidx.car.app.model.Template +import app.seb3thehacker.gearslip.host.click +import app.seb3thehacker.gearslip.host.color +import app.seb3thehacker.gearslip.host.image +import app.seb3thehacker.gearslip.host.isDrawable +import app.seb3thehacker.gearslip.host.standardType +import app.seb3thehacker.gearslip.host.text + +/** + * The pieces every template is built from. + * + * The Car App Library's templates are combinations of a small set of parts - a header, an + * action strip, a list of rows, a grid of tiles - so the host only has to decide once how each + * part looks, and each template is then mostly an arrangement of them. + */ + +// --- surfaces and text ------------------------------------------------------------------------- + +/** + * The app keeps drawing its map across the whole surface - the visible area we report tells it + * where to put its own markers, not where to stop painting. So anything the host lays over the + * map is opaque by default, or the map reads through it. + */ +@Composable +internal fun ChromeSurface( + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(16.dp), + alpha: Float = 1f, + content: @Composable () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = alpha), + contentColor = MaterialTheme.colorScheme.onSurface, + shape = shape, + modifier = modifier, + content = content, + ) +} + +@Composable +internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) { + androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) { + ChromeSurface { + Text( + "${template.javaClass.simpleName} isn't drawn yet", + Modifier.padding(16.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + } +} + +/** + * A template's header: a back or app action on the left, a title, and the app's own actions on + * the right. Templates carry this either as a [Header] or as the older title-plus-action pair. + */ +@Composable +internal fun HeaderBar( + title: String, + startAction: Action? = null, + endActions: List = emptyList(), + actionStrip: ActionStrip? = null, +) { + val end = (endActions + actionStrip?.actions.orEmpty()).filter { it.isDrawable() } + if (title.isEmpty() && startAction == null && end.isEmpty()) return + + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) } + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + end.forEach { ActionButton(it) } + } +} + +/** Reads a header whichever way the template carries it. */ +internal fun headerTitle(header: Header?, fallback: String): String = + header?.title.text().ifEmpty { fallback } + +// --- actions ------------------------------------------------------------------------------------ + +/** + * Both strips share the right edge, where a hand reaches from the driver's seat: the app's own + * actions in a row along the top, its map controls stacked under them. Keeping them in one + * column is what stops them colliding on a screen only 480px tall. + */ +@Composable +internal fun RightEdgeControls( + actionStrip: ActionStrip?, + mapActionStrip: ActionStrip?, + modifier: Modifier = Modifier, +) { + val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() } + val mapActions = mapActionStrip?.actions.orEmpty().filter { it.isDrawable() } + if (actions.isEmpty() && mapActions.isEmpty()) return + + Column(modifier, horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (actions.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + actions.forEach { ActionButton(it) } + } + } + mapActions.forEach { ActionButton(it) } + } +} + +@Composable +internal fun ActionRow(actions: List, modifier: Modifier = Modifier) { + val drawable = actions.filter { it.isDrawable() } + if (drawable.isEmpty()) return + Row(modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + drawable.forEach { ActionButton(it) } + } +} + +@Composable +internal fun ActionButton(action: Action) { + val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() + val title = action.title.text() + val background = action.backgroundColor.color(dark, Color.Black.copy(alpha = 0.6f)) + val hasGlyph = action.icon != null || action.type != Action.TYPE_CUSTOM + + Surface( + color = background, + contentColor = Color.White, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.clickable(enabled = action.isEnabled) { + action.onClickDelegate.click("action") + }, + ) { + Row( + Modifier.padding(horizontal = if (title.isEmpty()) 10.dp else 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action)) + if (title.isNotEmpty()) { + if (hasGlyph) Spacer(Modifier.width(8.dp)) + Text(title, style = MaterialTheme.typography.labelLarge, maxLines = 1) + } + } + } +} + +/** A standard Action carries no icon of its own - the host is expected to supply the glyph. */ +private fun standardFor(action: Action): Int? = when (action.type) { + Action.TYPE_BACK -> CarIcon.TYPE_BACK + Action.TYPE_PAN -> CarIcon.TYPE_PAN + Action.TYPE_APP_ICON -> CarIcon.TYPE_APP_ICON + else -> null +} + +// --- icons ---------------------------------------------------------------------------------- + +/** + * Draws an icon the app described, falling back to our own glyph for the standard types the + * host owns. A custom icon that fails to load leaves the space empty rather than a placeholder, + * since a wrong glyph on a map control is worse than none. + */ +@Composable +internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: Int? = null) { + val context = LocalContext.current + val bitmap = icon.image(context) + if (bitmap != null) { + // An icon that names a tint (DEFAULT included) expects the host to colour it for the + // surface it sits on; without this a white glyph vanishes on a light theme. + val dark = MaterialTheme.colorScheme.surface.luminanceIsDark() + val content = LocalContentColor.current + val white = remember(bitmap) { bitmap.isPlainWhite() } + // Untinted white artwork is a template-app habit from dark surfaces; it is coloured like + // text so it stays readable on a light one. + val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white } + Image( + bitmap, contentDescription = null, modifier = modifier, + colorFilter = tint?.let { ColorFilter.tint(it) }, + ) + return + } + val vector = when (standard ?: icon.standardType) { + CarIcon.TYPE_BACK -> Icons.Filled.ArrowBack + CarIcon.TYPE_ALERT, CarIcon.TYPE_ERROR -> Icons.Filled.Warning + // No four-way pan glyph in material-icons-core; this is the closest honest stand-in. + CarIcon.TYPE_PAN -> Icons.Filled.Menu + else -> null + } ?: return + Icon(vector, contentDescription = null, modifier = modifier) +} + +// --- lists, grids and panes --------------------------------------------------------------------- + +@Composable +internal fun PaneTitle(title: String) { + if (title.isEmpty()) return + Text( + title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + ) +} + +@Composable +internal fun ItemListColumn( + single: ItemList?, + sectioned: List? = null, + modifier: Modifier = Modifier, +) { + val rows = buildList { + single?.items?.filterIsInstance()?.let(::addAll) + sectioned?.forEach { list -> addAll(list.items.filterIsInstance()) } + } + if (rows.isEmpty()) { + val message = single?.noItemsMessage.text() + if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp)) + return + } + LazyColumn(modifier, contentPadding = PaddingValues(bottom = 8.dp)) { + items(rows) { RowItem(it) } + } +} + +/** A grid of large tappable tiles - what apps use for a home screen beside the map. */ +@Composable +internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) { + val items = list?.items?.filterIsInstance().orEmpty() + if (items.isEmpty()) return + LazyVerticalGrid( + columns = GridCells.Adaptive(96.dp), + modifier = modifier, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items) { item -> + Column( + Modifier + .clickable { item.onClickDelegate.click("grid item") } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CarGlyph(item.image, Modifier.size(36.dp)) + Spacer(Modifier.height(6.dp)) + Text( + item.title.text(), + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = item.text.text() + if (subtitle.isNotEmpty()) { + Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@Composable +internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) { + val rows = pane?.rows.orEmpty() + Column(modifier) { + rows.forEach { RowItem(it) } + ActionRow( + pane?.actions.orEmpty(), + Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + ) + } +} + +@Composable +internal fun RowItem(row: CarRow) { + val clickable = row.onClickDelegate != null && row.isEnabled + Row( + Modifier + .fillMaxWidth() + .let { if (clickable) it.clickable { row.onClickDelegate.click("row") } else it } + .padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + row.image?.let { + CarGlyph(it, Modifier.size(28.dp)) + Spacer(Modifier.width(12.dp)) + } + Column(Modifier.weight(1f)) { + Text( + row.title.text(), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + row.texts.orEmpty().take(2).forEach { line -> + Text( + line.text(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + ActionRow(row.actions.orEmpty()) + } +} + +internal fun Color.luminanceIsDark(): Boolean = + (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f + + +/** True for a glyph drawn entirely in white (any transparency): a mask, not a picture. */ +private fun androidx.compose.ui.graphics.ImageBitmap.isPlainWhite(): Boolean { + val pixels = IntArray(width * height) + readPixels(pixels) + var seen = 0 + for (pixel in pixels) { + val alpha = pixel ushr 24 + if (alpha < 40) continue + seen++ + if ((pixel shr 16 and 0xFF) < 225 || (pixel shr 8 and 0xFF) < 225 || (pixel and 0xFF) < 225) return false + } + return seen > 0 +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt new file mode 100644 index 0000000..5384a71 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WavyProgress.kt @@ -0,0 +1,92 @@ +package app.seb3thehacker.gearslip.car + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.sin + +/** + * A progress bar whose played part is a wave, and which flattens out while paused. + * + * The wave runs only while [playing], so a paused player costs the encoder nothing. Tapping or + * dragging seeks; the position is committed on release rather than on every pixel of a drag, + * because each commit is a request to the media app. + */ +@Composable +fun WavyProgress( + fraction: Float, + playing: Boolean, + onSeek: (Float) -> Unit, + modifier: Modifier = Modifier, +) { + val played = MaterialTheme.colorScheme.primary + val remaining = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + val amplitude by animateFloatAsState(if (playing) 1f else 0f, tween(400), label = "wave") + val phase = if (playing) { + rememberInfiniteTransition(label = "phase").animateFloat( + 0f, (2 * PI).toFloat(), + infiniteRepeatable(tween(1400, easing = LinearEasing), RepeatMode.Restart), + label = "phase", + ).value + } else 0f + + var dragging by remember { mutableStateOf(null) } + val shown = (dragging ?: fraction).coerceIn(0f, 1f) + + Canvas( + modifier + .fillMaxWidth() + .height(32.dp) + .pointerInput(Unit) { detectTapGestures { onSeek((it.x / size.width).coerceIn(0f, 1f)) } } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { dragging = (it.x / size.width).coerceIn(0f, 1f) }, + onDragEnd = { dragging?.let(onSeek); dragging = null }, + onDragCancel = { dragging = null }, + ) { change, _ -> dragging = (change.position.x / size.width).coerceIn(0f, 1f) } + }, + ) { + val stroke = 4.dp.toPx() + val mid = size.height / 2 + val end = size.width * shown + val wavelength = 22.dp.toPx() + val amp = 3.5.dp.toPx() * amplitude + + // Unplayed part: a straight line, so the wave reads as "this much is done". + drawLine(remaining, Offset(end, mid), Offset(size.width, mid), stroke, StrokeCap.Round) + + val wave = Path().apply { + moveTo(0f, mid + amp * sin(phase.toDouble()).toFloat()) + var x = 0f + while (x < end) { + x = minOf(end, x + 2f) + lineTo(x, mid + amp * sin(2 * PI * x / wavelength + phase).toFloat()) + } + } + drawPath(wave, played, style = Stroke(stroke, cap = StrokeCap.Round)) + drawLine(played, Offset(end, mid - 10.dp.toPx()), Offset(end, mid + 10.dp.toPx()), stroke, StrokeCap.Round) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt new file mode 100644 index 0000000..32e01bb --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -0,0 +1,83 @@ +package app.seb3thehacker.gearslip.car + +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import app.seb3thehacker.gearslip.GearslipLog + +/** + * A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one. + * The bottom bar's Back walks the page history first and only leaves the app at the start. + */ +@Composable +fun WebApp(content: String) { + val navigator = LocalCarNavigator.current + val darkOutside = LocalDarkOutside.current + var web by remember { mutableStateOf(null) } + + DisposableEffect(web) { + navigator.backInterceptor = { + val view = web + if (view != null && view.canGoBack()) { + view.goBack() + true + } else { + false + } + } + onDispose { navigator.backInterceptor = null } + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + webViewClient = LoggingClient() + if (content.startsWith("http://") || content.startsWith("https://")) { + loadUrl(content) + } else { + loadDataWithBaseURL(null, content, "text/html", "utf-8", null) + } + web = this + } + }, + // Pages that honour it follow the darkness outside; a first, tweakable use of the signal. + update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside }, + onRelease = { it.destroy() }, + ) +} + +private class LoggingClient : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + GearslipLog.i("car web page loaded: $url") + } + + // onPageFinished fires for error pages too, so without this a blocked load (missing + // INTERNET permission, no network) looks like a successful one. + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + if (request?.isForMainFrame == true) { + GearslipLog.e("car web page FAILED: ${request.url} - ${error?.errorCode} ${error?.description}") + } + } + + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, response: WebResourceResponse?) { + if (request?.isForMainFrame == true) { + GearslipLog.w("car web page HTTP ${response?.statusCode} for ${request.url}") + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt new file mode 100644 index 0000000..7c1069f --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt @@ -0,0 +1,17 @@ +package app.seb3thehacker.gearslip.host + +/** + * Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries + * (there are many: one per screen it registers) are noise in every list of apps to open. + */ +object AndroidAuto { + + private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto") + + fun matches(packageName: String, label: String): Boolean { + val pkg = packageName.lowercase() + if (PACKAGE_MARKERS.any { it in pkg }) return true + val name = label.lowercase().replace(Regex("[^a-z ]"), " ") + return "android auto" in name || "androidauto" in name.replace(" ", "") + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt new file mode 100644 index 0000000..a23337b --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt @@ -0,0 +1,81 @@ +package app.seb3thehacker.gearslip.host + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import androidx.car.app.CarAppService + +/** A templated app installed on the phone, and the category it asked to be treated as. */ +class TemplateApp( + val component: ComponentName, + val label: String, + val categories: List, +) { + val isNavigation: Boolean get() = CarAppService.CATEGORY_NAVIGATION_APP in categories + + /** "navigation", "media", ... - what the app told the system it is. */ + val kind: String + get() = categories.firstOrNull { it.startsWith(CATEGORY_PREFIX) } + ?.removePrefix(CATEGORY_PREFIX)?.lowercase() + ?: "app" + + private companion object { + const val CATEGORY_PREFIX = "androidx.car.app.category." + } +} + +/** + * Finds apps that can be rendered by a host. + * + * A templated app exports a CarAppService and never draws anything itself - it hands the host + * a template and lets the host decide how it looks. That is what makes hosting worth doing: + * the app arrives already designed for a car screen, rather than a phone layout squeezed onto + * one, which is all mirroring could ever manage. + */ +object CarAppCatalog { + + fun installed(context: Context): List { + val manager = context.packageManager + val intent = Intent(CarAppService.SERVICE_INTERFACE) + return manager.queryIntentServices(intent, 0) + .asSequence() + .filter { it.serviceInfo.packageName != context.packageName } + .filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) } + .map { resolved -> + val info = resolved.serviceInfo + TemplateApp( + component = ComponentName(info.packageName, info.name), + label = info.applicationInfo?.loadLabel(manager)?.toString() ?: info.packageName, + categories = categoriesOf(context, info.packageName, info.name), + ) + } + .sortedBy { it.label.lowercase() } + .toList() + } + + /** + * queryIntentServices does not report the matched filter's categories, so the categories + * are recovered by re-querying with each one and seeing which still match. + */ + private fun categoriesOf(context: Context, packageName: String, className: String): List { + val manager = context.packageManager + return KNOWN_CATEGORIES.filter { category -> + val probe = Intent(CarAppService.SERVICE_INTERFACE) + .setPackage(packageName) + .addCategory(category) + manager.queryIntentServices(probe, 0).any { it.serviceInfo.name == className } + } + } + + private val KNOWN_CATEGORIES = listOf( + CarAppService.CATEGORY_NAVIGATION_APP, + CarAppService.CATEGORY_POI_APP, + CarAppService.CATEGORY_PARKING_APP, + CarAppService.CATEGORY_CHARGING_APP, + CarAppService.CATEGORY_MESSAGING_APP, + CarAppService.CATEGORY_CALLING_APP, + CarAppService.CATEGORY_SETTINGS_APP, + CarAppService.CATEGORY_IOT_APP, + CarAppService.CATEGORY_WEATHER_APP, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt new file mode 100644 index 0000000..ab14b28 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppConnection.kt @@ -0,0 +1,564 @@ +package app.seb3thehacker.gearslip.host + +import app.seb3thehacker.gearslip.car.CarEnvironment +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.res.Configuration +import android.graphics.Rect +import android.location.Location +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.view.Surface +import androidx.car.app.CarAppService +import androidx.car.app.CarContext +import androidx.car.app.HandshakeInfo +import androidx.car.app.IAppHost +import androidx.car.app.IAppManager +import androidx.car.app.ICarApp +import androidx.car.app.ICarHost +import androidx.car.app.IOnDoneCallback +import androidx.car.app.model.Template +import androidx.car.app.model.TemplateWrapper +import androidx.car.app.ISurfaceCallback +import androidx.car.app.SurfaceContainer +import androidx.car.app.AppInfo +import androidx.car.app.constraints.ConstraintManager +import androidx.car.app.constraints.IConstraintHost +import androidx.car.app.suggestion.ISuggestionHost +import androidx.car.app.hardware.ICarHardwareHost +import androidx.car.app.hardware.ICarHardwareResult +import androidx.car.app.navigation.INavigationHost +import androidx.car.app.serialization.Bundleable +import androidx.car.app.versioning.CarAppApiLevels +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Speaks the Car App Library's host half, so a templated app can be rendered by Gearslip. + * + * This is the opposite end of the interface an app implements: the app exports a + * CarAppService and describes a screen as a template, and whichever host it is bound to + * decides how that template looks. Android Auto is one such host. So is this. + * + * Whether an app will talk to us at all is decided by its HostValidator, which accepts a host + * that either holds android.car.permission.TEMPLATE_RENDERER or is signed by a key on its + * allow-list. Gearslip declares that permission in its manifest, which is the whole reason + * this can work from an ordinary app - see AndroidManifest.xml. + * + * Every binder callback lands on a binder thread and is hopped to the main thread, because + * the state here feeds Compose. + */ +class CarAppConnection(private val context: Context) { + + enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED } + + data class Status( + val phase: Phase = Phase.IDLE, + val app: String? = null, + val detail: String? = null, + val apiLevel: Int = 0, + val template: String? = null, + val surfaceRequested: Boolean = false, + val surfaceAttached: Boolean = false, + ) + + /** The Surface lent to the app, plus the size the app should draw at. */ + private data class Lent(val surface: Surface, val width: Int, val height: Int, val dpi: Int) + + private val _status = MutableStateFlow(Status()) + val status: StateFlow = _status.asStateFlow() + + /** The app's current screen, as it described it. Null until the first one arrives. */ + private val _template = MutableStateFlow(null) + val template: StateFlow = _template.asStateFlow() + + private val main = Handler(Looper.getMainLooper()) + + private var carApp: ICarApp? = null + private var appManager: IAppManager? = null + /** Main thread only, both of these - the handoff is a race between them either way round. */ + private var surfaceCallback: ISurfaceCallback? = null + private var lent: Lent? = null + + private var binding: ServiceConnection? = null + private var component: ComponentName? = null + + private var frameWidth = 800 + private var frameHeight = 480 + private var frameDensity = 160 + + // --- connection --------------------------------------------------------------------- + + fun connect(app: TemplateApp, width: Int, height: Int, densityDpi: Int) { + disconnect() + frameWidth = width + frameHeight = height + frameDensity = densityDpi + component = app.component + update { Status(phase = Phase.BINDING, app = app.label) } + + val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component) + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + main.post { onBound(service) } + } + + override fun onServiceDisconnected(name: ComponentName) { + main.post { fail(Phase.FAILED, "the app's service disconnected") } + } + + override fun onNullBinding(name: ComponentName) { + // A templated app that refuses a host returns no binder at all. + main.post { fail(Phase.REJECTED, "the app refused to bind - host not accepted") } + } + } + binding = connection + + LocationKeepAlive.start(context) + val bound = runCatching { + context.bindService(intent, connection, Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES) + }.getOrElse { + GearslipLog.e("bindService threw for ${app.component}", it) + false + } + if (!bound) { + binding = null + fail(Phase.FAILED, "bindService refused") + } else { + GearslipLog.i("host: binding to ${app.component.flattenToShortString()}") + } + } + + fun disconnect() { + lent?.let { sendSurfaceDestroyed(it) } + lent = null + val carApp = this.carApp + if (carApp != null) { + runCatching { carApp.onAppPause(noop("onAppPause")) } + runCatching { carApp.onAppStop(noop("onAppStop")) } + } + LocationKeepAlive.stop(context) + binding?.let { runCatching { context.unbindService(it) } } + binding = null + this.carApp = null + appManager = null + surfaceCallback = null + component = null + _template.value = null + update { Status() } + } + + // --- handshake --------------------------------------------------------------------- + + private fun onBound(service: IBinder) { + val app = ICarApp.Stub.asInterface(service) + carApp = app + update { it.copy(phase = Phase.HANDSHAKE, detail = "bound, asking for app info") } + GearslipLog.i("host: bound; requesting app info") + + call("getAppInfo", onValue = { value -> + val info = value as? AppInfo ?: return@call fail(Phase.FAILED, "app info was ${value?.javaClass?.simpleName}") + onAppInfo(info) + }) { app.getAppInfo(it) } + } + + /** + * Picks the newest API level both sides understand. An app that needs more than this host + * offers cannot be rendered correctly, so it is refused here rather than half-drawn. + */ + private fun onAppInfo(info: AppInfo) { + val negotiated = minOf(HOST_API_LEVEL, info.latestCarAppApiLevel) + GearslipLog.i( + "host: app api ${info.minCarAppApiLevel}..${info.latestCarAppApiLevel} " + + "(library ${info.libraryDisplayVersion}), host $HOST_API_LEVEL -> $negotiated" + ) + if (negotiated < info.minCarAppApiLevel) { + fail(Phase.FAILED, "app needs car api ${info.minCarAppApiLevel}, host offers $HOST_API_LEVEL") + return + } + val app = carApp ?: return + update { it.copy(apiLevel = negotiated, detail = "handshake") } + + val handshake = runCatching { Bundleable.create(HandshakeInfo(context.packageName, negotiated)) } + .getOrElse { return fail(Phase.FAILED, "could not bundle the handshake: ${it.message}") } + + call("onHandshakeCompleted", onValue = { onHandshakeAccepted() }) { + app.onHandshakeCompleted(handshake, it) + } + } + + private fun onHandshakeAccepted() { + val app = carApp ?: return + GearslipLog.i("host: handshake accepted; creating the app") + val intent = Intent(Intent.ACTION_MAIN).setComponent(component) + call("onAppCreate", onValue = { onCreated() }) { + app.onAppCreate(carHost, intent, carConfiguration(), it) + } + } + + private fun onCreated() { + val app = carApp ?: return + GearslipLog.i("host: created; starting") + call("onAppStart", onValue = { + call("onAppResume", onValue = { onRunning() }) { app.onAppResume(it) } + }) { app.onAppStart(it) } + } + + private fun onRunning() { + val app = carApp ?: return + update { it.copy(phase = Phase.RUNNING, detail = "connected") } + GearslipLog.verdict( + "${component?.packageName} accepted Gearslip as a template host", + "Handshake completed at car API level ${_status.value.apiLevel}.", + ) + + call("getManager(app)", onValue = { value -> + appManager = when (value) { + is IAppManager -> value + is IBinder -> IAppManager.Stub.asInterface(value) + else -> null + } + if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}") + requestTemplate() + }) { app.getManager(CarContext.APP_SERVICE, it) } + } + + /** The app describes its current screen; this is what the host draws. */ + private fun requestTemplate() { + val manager = appManager ?: return + call("getTemplate", onValue = { value -> + // TemplateWrapper carries the template plus the id the host echoes back on refresh. + val template = (value as? TemplateWrapper)?.template ?: value as? Template + if (template == null) { + GearslipLog.w("host: getTemplate returned ${value?.javaClass?.name}") + return@call + } + GearslipLog.i("host: template = ${template.javaClass.simpleName}") + _template.value = template + update { it.copy(template = template.javaClass.simpleName) } + }) { manager.getTemplate(it) } + } + + // --- the surface the app draws its map into ------------------------------------------- + + /** + * Lends the app a Surface to draw into. + * + * Navigation apps render their own map rather than describing it as a template, so the host + * has to give them somewhere to draw. Gearslip's car UI already lives in a Presentation on a + * VirtualDisplay backed by the encoder, so the Surface handed over here belongs to a + * SurfaceView in that Presentation: the app draws straight into the car's picture and + * SurfaceFlinger composites it under our chrome, with no readback or GL on our side. + * + * Safe to call before the app has asked for a surface - whichever of the two arrives second + * completes the handoff. + */ + fun attachSurface(surface: Surface, width: Int, height: Int, dpi: Int) { + main.post { + val current = lent + if (current != null && + current.surface == surface && + current.width == width && + current.height == height + ) { + return@post + } + current?.let { sendSurfaceDestroyed(it) } + lent = Lent(surface, width, height, dpi) + sendSurfaceAvailable() + } + } + + /** Takes the Surface back, e.g. when the SurfaceView goes away. */ + fun detachSurface() { + main.post { + val current = lent ?: return@post + lent = null + sendSurfaceDestroyed(current) + update { it.copy(surfaceAttached = false) } + } + } + + private fun sendSurfaceAvailable() { + val callback = surfaceCallback ?: return + val surface = lent ?: return + if (!surface.surface.isValid) { + GearslipLog.w("host: not lending an invalid surface") + return + } + + val container = runCatching { + Bundleable.create( + SurfaceContainer(surface.surface, surface.width, surface.height, surface.dpi) + ) + }.getOrElse { + GearslipLog.e("host: could not bundle the surface", it) + return + } + + val sent = runCatching { callback.onSurfaceAvailable(container, noop("onSurfaceAvailable")) } + if (sent.isFailure) { + GearslipLog.e("host: onSurfaceAvailable threw", sent.exceptionOrNull()) + return + } + + // The app draws for the whole surface; the visible area is what isn't covered by host + // chrome, and the stable area is the part that never gets covered. We keep our chrome + // to a top bar, so both are the surface minus that bar - reported by the UI via + // [reportAreas] once it has measured. Until then the whole surface is fair game. + val whole = Rect(0, 0, surface.width, surface.height) + reportAreas(whole, whole) + + GearslipLog.i( + "host: lent the app a ${surface.width}x${surface.height} @${surface.dpi}dpi surface" + ) + update { it.copy(surfaceAttached = true) } + } + + private fun sendSurfaceDestroyed(surface: Lent) { + val callback = surfaceCallback ?: return + val container = runCatching { + Bundleable.create( + SurfaceContainer(surface.surface, surface.width, surface.height, surface.dpi) + ) + }.getOrNull() ?: return + runCatching { callback.onSurfaceDestroyed(container, noop("onSurfaceDestroyed")) } + GearslipLog.i("host: took the surface back") + } + + /** Tells the app which part of its surface the driver can actually see. */ + fun reportAreas(visible: Rect, stable: Rect) { + val callback = surfaceCallback ?: return + runCatching { callback.onStableAreaChanged(stable, noop("onStableAreaChanged")) } + runCatching { callback.onVisibleAreaChanged(visible, noop("onVisibleAreaChanged")) } + } + + // --- gestures on the app's surface ----------------------------------------------------- + // + // These are the map interactions a host forwards: the app never sees raw touches, only + // these four. Coordinates are in surface pixels. Scroll distances follow GestureDetector's + // convention (previous minus current), which is the opposite sign to a Compose drag. + + fun surfaceClick(x: Float, y: Float) = onSurface("onClick") { it.onClick(x, y) } + + fun surfaceScroll(distanceX: Float, distanceY: Float) = + onSurface("onScroll") { it.onScroll(distanceX, distanceY) } + + fun surfaceFling(velocityX: Float, velocityY: Float) = + onSurface("onFling") { it.onFling(velocityX, velocityY) } + + fun surfaceScale(focusX: Float, focusY: Float, scaleFactor: Float) = + onSurface("onScale") { it.onScale(focusX, focusY, scaleFactor) } + + private inline fun onSurface(name: String, send: (ISurfaceCallback) -> Unit) { + val callback = surfaceCallback ?: return + runCatching { send(callback) }.onFailure { GearslipLog.w("host: $name failed: ${it.message}") } + } + + // --- host interfaces the app calls back into ----------------------------------------- + + private val appHost = object : IAppHost.Stub() { + override fun invalidate() { + GearslipLog.i("host: app invalidated its template") + main.post { requestTemplate() } + } + + override fun showToast(text: CharSequence?, duration: Int) { + GearslipLog.i("host: toast \"$text\"") + } + + /** + * Navigation apps draw their own map, so the host lends them a Surface. Receiving this + * is the signal that the app is ready to render into the car's picture. + */ + override fun setSurfaceCallback(callback: ISurfaceCallback?) { + GearslipLog.i("host: app asked for a surface (callback=${callback != null})") + main.post { + surfaceCallback = callback + update { it.copy(surfaceRequested = callback != null) } + if (callback != null) sendSurfaceAvailable() + } + } + + override fun sendLocation(location: Location?) = Unit + override fun showAlert(alert: Bundleable?) = Unit + override fun dismissAlert(alertId: Int) = Unit + override fun openMicrophone(request: Bundleable?): Bundleable? = null + } + + private val navigationHost = object : INavigationHost.Stub() { + override fun navigationStarted() = GearslipLog.i("host: navigation started") + override fun navigationEnded() = GearslipLog.i("host: navigation ended") + override fun updateTrip(trip: Bundleable?) = Unit + } + + /** + * Answers car-hardware questions with "this car doesn't have that". + * + * A phone knows nothing about a car's sensors, so every answer here is "unsupported" - but + * it has to be an actual answer. Returning no binder for this service at all is what made + * Organic Maps crash in onAppStart: its CarSensorsManager assumes a host that offers the + * service, and the library's own "unsupported" path only runs once a reply arrives. + * + * The reply's Bundleable is ignored by the library whenever isSupported is false, but the + * IBinder must be a real IOnDoneCallback - the app dispatches the reply through it. + */ + private val hardwareHost = object : ICarHardwareHost.Stub() { + override fun getCarHardwareResult( + type: Int, + params: Bundleable?, + result: ICarHardwareResult?, + ) = unsupported(type, result) + + override fun subscribeCarHardwareResult( + type: Int, + params: Bundleable?, + result: ICarHardwareResult?, + ) = unsupported(type, result) + + override fun unsubscribeCarHardwareResult(type: Int, params: Bundleable?) = Unit + + private fun unsupported(type: Int, result: ICarHardwareResult?) { + val target = result ?: return + runCatching { + target.onCarHardwareResult(type, false, null, noop("carHardwareResult").asBinder()) + }.onFailure { GearslipLog.w("host: could not answer hardware result $type") } + } + } + + /** + * Tells apps how much content this screen can take. + * + * These are safety limits in a real car - how many rows a driver may be shown at once - + * and the app trims its own lists to them. Gearslip's numbers match what the Car App + * Library documents for a projected head unit; they are about the screen, not about us. + */ + private val constraintHost = object : IConstraintHost.Stub() { + override fun getContentLimit(type: Int): Int = when (type) { + ConstraintManager.CONTENT_LIMIT_TYPE_GRID -> 12 + ConstraintManager.CONTENT_LIMIT_TYPE_PANE -> 4 + ConstraintManager.CONTENT_LIMIT_TYPE_ROUTE_LIST -> 3 + else -> 12 + } + + /** The app may refresh its own template without the driver touching anything. */ + override fun isAppDrivenRefreshEnabled(): Boolean = true + } + + /** + * Accepts the shortcuts an app offers for a car launcher. + * + * Gearslip has nowhere to show these yet, but accepting and dropping them is right: an app + * that pushes suggestions shouldn't fail because its host doesn't surface them. + */ + private val suggestionHost = object : ISuggestionHost.Stub() { + override fun updateSuggestions(suggestions: Bundleable?) = Unit + } + + private val carHost = object : ICarHost.Stub() { + override fun getHost(type: String?): IBinder? = when (type) { + CarContext.APP_SERVICE -> appHost.asBinder() + CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder() + CarContext.HARDWARE_SERVICE -> hardwareHost.asBinder() + CarContext.CONSTRAINT_SERVICE -> constraintHost.asBinder() + CarContext.SUGGESTION_SERVICE -> suggestionHost.asBinder() + else -> { + GearslipLog.i("host: app asked for an unsupported service \"$type\"") + null + } + } + + override fun startCarApp(intent: Intent?) { + GearslipLog.i("host: app wants to start $intent") + } + + override fun finish() { + GearslipLog.i("host: app finished") + main.post { disconnect() } + } + } + + // --- plumbing ------------------------------------------------------------------------ + + /** + * Every call in this protocol is one-way plus an IOnDoneCallback, so this pairs them back + * up and funnels a remote failure into the visible status rather than losing it. + */ + private fun call(name: String, onValue: (Any?) -> Unit, send: (IOnDoneCallback) -> Unit) { + val callback = object : IOnDoneCallback.Stub() { + override fun onSuccess(response: Bundleable?) { + val value = response?.let { runCatching { it.get() }.getOrNull() } + main.post { onValue(value) } + } + + override fun onFailure(response: Bundleable?) { + val value = response?.let { runCatching { it.get() }.getOrNull() } + main.post { fail(Phase.FAILED, "$name failed: $value") } + } + } + runCatching { send(callback) } + .onFailure { fail(Phase.REJECTED, "$name threw: ${it.javaClass.simpleName} ${it.message}") } + } + + private fun noop(name: String) = object : IOnDoneCallback.Stub() { + override fun onSuccess(response: Bundleable?) = Unit + override fun onFailure(response: Bundleable?) = GearslipLog.w("host: $name failed") + } + + /** + * The app lays itself out for this, so it describes the car screen rather than the phone, + * including UI_MODE_TYPE_CAR - templated apps branch on that. + */ + /** The day/night value last handed to the app, so a change is only pushed when it is one. */ + private var sentNight: Boolean? = null + + /** + * Tells a running app that the car's light changed. Templated apps take their map style from + * the configuration the host reports, so this is what turns the map to day or night. + */ + fun pushConfiguration() { + val app = carApp ?: return + if (_status.value.phase != Phase.RUNNING) return + if (CarEnvironment.darkOutside.value == sentNight) return + val configuration = carConfiguration() + call("onConfigurationChanged", onValue = { }) { app.onConfigurationChanged(configuration, it) } + } + + private fun carConfiguration(): Configuration { + val configuration = Configuration(context.resources.configuration) + val night = CarEnvironment.darkOutside.value + sentNight = night + configuration.uiMode = (configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or + if (night) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + configuration.densityDpi = frameDensity + configuration.screenWidthDp = frameWidth * 160 / frameDensity + configuration.screenHeightDp = frameHeight * 160 / frameDensity + configuration.smallestScreenWidthDp = + minOf(configuration.screenWidthDp, configuration.screenHeightDp) + configuration.orientation = + if (frameWidth >= frameHeight) Configuration.ORIENTATION_LANDSCAPE + else Configuration.ORIENTATION_PORTRAIT + configuration.uiMode = (configuration.uiMode and Configuration.UI_MODE_TYPE_MASK.inv()) or + Configuration.UI_MODE_TYPE_CAR + return configuration + } + + private fun fail(phase: Phase, detail: String) { + GearslipLog.e("host: $detail") + update { it.copy(phase = phase, detail = detail) } + } + + private fun update(block: (Status) -> Status) { + _status.value = block(_status.value) + } + + private companion object { + /** The newest template API this host claims to understand. */ + const val HOST_API_LEVEL = CarAppApiLevels.LEVEL_8 + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt new file mode 100644 index 0000000..d62a638 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarModels.kt @@ -0,0 +1,160 @@ +package app.seb3thehacker.gearslip.host + +import android.content.Context +import android.graphics.drawable.Drawable +import androidx.car.app.model.Action +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.CarText +import androidx.car.app.model.Distance +import androidx.car.app.model.InputCallbackDelegate +import androidx.car.app.model.OnClickDelegate +import androidx.car.app.model.OnContentRefreshDelegate +import androidx.car.app.model.SearchCallbackDelegate +import androidx.car.app.model.TabCallbackDelegate +import androidx.car.app.OnDoneCallback +import androidx.car.app.serialization.Bundleable +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.drawable.toBitmap +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import app.seb3thehacker.gearslip.GearslipLog +import java.util.concurrent.TimeUnit + +/** + * Turns the Car App Library's model objects into things Compose can draw. + * + * A templated app describes its screen in these types and says nothing about how they should + * look - that is the host's job, and every host looks different. These helpers are the seam + * between the app's description and Gearslip's rendering of it. + */ + +/** The app's text, preferring the longest variant that will fit; variants are ordered longest first. */ +fun CarText?.text(): String = this?.takeIf { !it.isEmpty }?.toCharSequence()?.toString().orEmpty() + +/** + * Loads an icon the app described. + * + * A [CarIcon] is either a standard type the host is expected to draw itself, or an IconCompat + * pointing into the app's own resources - which is why this needs a Context that can see the + * app, and why a failure here is a missing glyph rather than an error worth stopping for. + */ +fun CarIcon?.image(context: Context): ImageBitmap? { + val icon = this?.icon ?: return null + val drawable: Drawable = runCatching { icon.loadDrawable(context) }.getOrNull() ?: return null + val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: ICON_FALLBACK_PX + val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: ICON_FALLBACK_PX + return runCatching { drawable.toBitmap(width, height).asImageBitmap() }.getOrNull() +} + +/** Standard icons carry no bitmap - the host draws its own, so callers need to know which. */ +val CarIcon?.standardType: Int? get() = this?.takeIf { it.icon == null }?.type + +/** + * Resolves a colour the app asked for. The named colours deliberately stay close to what a + * driver expects them to mean rather than matching the app's brand. + */ +fun CarColor?.color(dark: Boolean, default: Color): Color = when (this?.type) { + null, CarColor.TYPE_DEFAULT -> default + CarColor.TYPE_CUSTOM -> Color(if (dark) colorDark else color) + CarColor.TYPE_RED -> Color(0xFFE53935) + CarColor.TYPE_GREEN -> Color(0xFF43A047) + CarColor.TYPE_BLUE -> Color(0xFF1E88E5) + CarColor.TYPE_YELLOW -> Color(0xFFFDD835) + else -> default +} + +/** "500 ft", "2.4 mi" - the app picks the unit, the host formats it. */ +fun Distance?.display(): String { + val distance = this ?: return "" + val unit = when (displayUnit) { + Distance.UNIT_METERS -> "m" + Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> "km" + Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> "mi" + Distance.UNIT_FEET -> "ft" + Distance.UNIT_YARDS -> "yd" + else -> "" + } + val decimals = when (displayUnit) { + Distance.UNIT_KILOMETERS_P1, Distance.UNIT_MILES_P1 -> 1 + Distance.UNIT_KILOMETERS, Distance.UNIT_MILES -> if (displayDistance < 10) 1 else 0 + else -> 0 + } + return "%.${decimals}f $unit".format(distance.displayDistance) +} + +/** "18 min" from the app's remaining-time estimate. */ +fun remainingTime(seconds: Long): String { + if (seconds <= 0) return "" + val hours = TimeUnit.SECONDS.toHours(seconds) + val minutes = TimeUnit.SECONDS.toMinutes(seconds) % 60 + return if (hours > 0) "$hours hr $minutes min" else "$minutes min" +} + +/** + * Tells the app something was tapped. + * + * [OnClickDelegate.isParkedOnly] marks an action the app itself considers unsafe while moving. + * Gearslip has no speed signal yet, so these are allowed through and the decision is recorded + * here as the place to gate them once the sensor channel exists. + */ +fun OnClickDelegate?.click(label: String = "action") { + val delegate = this ?: return + runCatching { delegate.sendClick(doneCallback("a $label tap")) } + .onFailure { GearslipLog.w("host: could not send a $label tap: ${it.message}") } +} + +/** + * Search is a conversation: the host owns the text and the keyboard, and tells the app about + * every keystroke so it can update its results as you type. [submitted] is the explicit "go". + */ +fun SearchCallbackDelegate?.textChanged(text: String) { + val delegate = this ?: return + runCatching { delegate.sendSearchTextChanged(text, doneCallback("search text")) } + .onFailure { GearslipLog.w("host: could not send search text: ${it.message}") } +} + +fun SearchCallbackDelegate?.submitted(text: String) { + val delegate = this ?: return + runCatching { delegate.sendSearchSubmitted(text, doneCallback("search submit")) } + .onFailure { GearslipLog.w("host: could not submit a search: ${it.message}") } +} + +/** Tells the app which tab the driver picked; the app answers with a new template. */ +fun TabCallbackDelegate?.tabSelected(contentId: String) { + val delegate = this ?: return + runCatching { delegate.sendTabSelected(contentId, doneCallback("tab")) } + .onFailure { GearslipLog.w("host: could not select a tab: ${it.message}") } +} + +/** Free text typed into a sign-in field, reported the same way search text is. */ +fun InputCallbackDelegate?.inputChanged(text: String) { + val delegate = this ?: return + runCatching { delegate.sendInputTextChanged(text, doneCallback("input text")) } + .onFailure { GearslipLog.w("host: could not send input text: ${it.message}") } +} + +fun InputCallbackDelegate?.inputSubmitted(text: String) { + val delegate = this ?: return + runCatching { delegate.sendInputSubmitted(text, doneCallback("input submit")) } + .onFailure { GearslipLog.w("host: could not submit input: ${it.message}") } +} + +/** The driver asked for fresher content, e.g. by pulling a place list. */ +fun OnContentRefreshDelegate?.refresh() { + val delegate = this ?: return + runCatching { delegate.sendContentRefreshRequested(doneCallback("refresh")) } + .onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") } +} + +private fun doneCallback(label: String) = object : OnDoneCallback { + // onSuccess is annotated nullable and onFailure isn't - the asymmetry is real. + override fun onSuccess(response: Bundleable?) = Unit + override fun onFailure(response: Bundleable) = GearslipLog.w("host: the app rejected $label") +} + +/** An action with nothing to show is one the host should skip rather than draw as a blank. */ +fun Action.isDrawable(): Boolean = + icon != null || title.text().isNotEmpty() || type != Action.TYPE_CUSTOM + +private const val ICON_FALLBACK_PX = 48 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt new file mode 100644 index 0000000..b86f0ab --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/LocationKeepAlive.kt @@ -0,0 +1,58 @@ +package app.seb3thehacker.gearslip.host + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.IBinder + +/** + * Lets a hosted navigation app keep receiving the phone's location while Gearslip is not on screen. + * + * Android only gives an app location in the background if something visible to the user is + * running for it. A nav app bound as a car app has nothing of its own showing, so it goes + * without a fix as soon as the phone screen leaves it. Binding with BIND_INCLUDE_CAPABILITIES + * lends it this service's location capability, which is why this foreground service exists. + * It needs the ordinary location permission, granted on the phone. + */ +class LocationKeepAlive : Service() { + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL, "Navigation location", NotificationManager.IMPORTANCE_LOW), + ) + val notification = Notification.Builder(this, CHANNEL) + .setSmallIcon(android.R.drawable.ic_menu_mylocation) + .setContentTitle("Gearslip is sharing location with the car map") + .setOngoing(true) + .build() + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION) + return START_NOT_STICKY + } + + companion object { + private const val CHANNEL = "gearslip_location" + private const val NOTIFICATION_ID = 44 + + /** Public flag value; the constant itself is hidden from the SDK. */ + const val BIND_INCLUDE_CAPABILITIES = 0x1000 + + fun start(context: Context) { + val granted = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (granted) runCatching { context.startForegroundService(Intent(context, LocationKeepAlive::class.java)) } + } + + fun stop(context: Context) { + context.stopService(Intent(context, LocationKeepAlive::class.java)) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt new file mode 100644 index 0000000..3a51be4 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -0,0 +1,234 @@ +package app.seb3thehacker.gearslip.media + +import android.content.Context +import android.graphics.Bitmap +import android.media.MediaMetadata +import android.media.browse.MediaBrowser +import android.media.session.MediaController +import android.media.session.PlaybackState +import android.net.Uri +import android.os.Bundle +import android.os.SystemClock +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** One row of an app's browse tree. */ +class MediaEntry( + val id: String, + val title: String, + val subtitle: String, + val iconUri: Uri?, + val iconBitmap: Bitmap?, + val browsable: Boolean, + val playable: Boolean, +) + +class CustomAction(val id: String, val name: String, val extras: Bundle?) + +/** What is playing, as the car's now-playing panel needs it. */ +class NowPlaying( + val title: String = "", + val artist: String = "", + val album: String = "", + val art: Bitmap? = null, + val artUri: Uri? = null, + val state: Int = PlaybackState.STATE_NONE, + val positionMs: Long = 0, + val durationMs: Long = 0, + val speed: Float = 1f, + val updatedAt: Long = 0, + val actions: Long = 0, + val custom: List = emptyList(), + val error: String? = null, +) { + val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING + val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE + + fun canDo(action: Long) = actions and action != 0L + + /** The position now, advanced from the last report the way the platform's own UIs do. */ + fun currentPosition(): Long { + if (!playing || state != PlaybackState.STATE_PLAYING) return positionMs + val elapsed = SystemClock.elapsedRealtime() - updatedAt + val moved = positionMs + (elapsed * speed).toLong() + return if (durationMs > 0) moved.coerceAtMost(durationMs) else moved + } +} + +class BrowseState( + /** Titles of the folders entered so far; empty at the app's root. */ + val trail: List = emptyList(), + val entries: List = emptyList(), + val loading: Boolean = true, + val failed: Boolean = false, +) + +/** + * Talks to one media app the way Android Auto does: connect to its MediaBrowserService, browse + * its tree, and drive playback through the session it publishes. The app decides what its + * tree contains and what the buttons do; this only presents it. + */ +class CarMedia(private val context: Context) { + + enum class Phase { IDLE, CONNECTING, READY, REJECTED } + + private val _phase = MutableStateFlow(Phase.IDLE) + val phase: StateFlow = _phase.asStateFlow() + + private val _browse = MutableStateFlow(BrowseState()) + val browse: StateFlow = _browse.asStateFlow() + + private val _now = MutableStateFlow(NowPlaying()) + val now: StateFlow = _now.asStateFlow() + + private var browser: MediaBrowser? = null + private var controller: MediaController? = null + private var subscribed: String? = null + private var rootId: String? = null + private val path = ArrayDeque>() // id to title + + fun connect(app: MediaApp) { + disconnect() + _phase.value = Phase.CONNECTING + _browse.value = BrowseState() + val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() { + override fun onConnected() = this@CarMedia.onConnected(app) + override fun onConnectionFailed() { + GearslipLog.w("media: ${app.label} refused the connection") + _phase.value = Phase.REJECTED + } + override fun onConnectionSuspended() { + GearslipLog.w("media: ${app.label} went away") + _phase.value = Phase.REJECTED + } + }, null) + browser = mb + runCatching { mb.connect() }.onFailure { + GearslipLog.e("media: connect threw", it) + _phase.value = Phase.REJECTED + } + } + + private fun onConnected(app: MediaApp) { + val mb = browser ?: return + GearslipLog.i("media: connected to ${app.label}, root=${mb.root}") + controller = MediaController(context, mb.sessionToken).also { + it.registerCallback(callback) + update(it.metadata, it.playbackState) + } + rootId = mb.root + path.clear() + _phase.value = Phase.READY + open(mb.root) + } + + private val callback = object : MediaController.Callback() { + override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState) + override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state) + } + + private fun update(metadata: MediaMetadata?, state: PlaybackState?) { + fun text(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }.orEmpty() + fun art(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getBitmap(it) } + fun uri(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }?.let(Uri::parse) + _now.value = NowPlaying( + title = text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, MediaMetadata.METADATA_KEY_TITLE), + artist = text(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, MediaMetadata.METADATA_KEY_ARTIST), + album = text(MediaMetadata.METADATA_KEY_ALBUM), + art = art(MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART, MediaMetadata.METADATA_KEY_DISPLAY_ICON), + artUri = uri(MediaMetadata.METADATA_KEY_ALBUM_ART_URI, MediaMetadata.METADATA_KEY_ART_URI, MediaMetadata.METADATA_KEY_DISPLAY_ICON_URI), + state = state?.state ?: PlaybackState.STATE_NONE, + positionMs = state?.position ?: 0, + durationMs = metadata?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0, + speed = state?.playbackSpeed ?: 1f, + updatedAt = state?.lastPositionUpdateTime ?: 0, + actions = state?.actions ?: 0, + custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) }, + error = state?.errorMessage?.toString(), + ) + } + + // --- browsing ------------------------------------------------------------------------ + + private fun open(parentId: String) { + val mb = browser ?: return + subscribed?.let { runCatching { mb.unsubscribe(it) } } + subscribed = parentId + _browse.update { BrowseState(trail = path.map { it.second }, loading = true) } + mb.subscribe(parentId, object : MediaBrowser.SubscriptionCallback() { + override fun onChildrenLoaded(parentId: String, children: MutableList) { + if (parentId != subscribed) return + _browse.value = BrowseState( + trail = path.map { it.second }, + entries = children.map(::entryOf), + loading = false, + ) + } + override fun onError(parentId: String) { + if (parentId != subscribed) return + GearslipLog.w("media: could not load $parentId") + _browse.value = BrowseState(trail = path.map { it.second }, loading = false, failed = true) + } + }) + } + + private fun entryOf(item: MediaBrowser.MediaItem): MediaEntry { + val d = item.description + return MediaEntry( + id = item.mediaId.orEmpty(), + title = d.title?.toString().orEmpty(), + subtitle = d.subtitle?.toString().orEmpty(), + iconUri = d.iconUri, + iconBitmap = d.iconBitmap, + browsable = item.isBrowsable, + playable = item.isPlayable, + ) + } + + /** Folders open; tracks play. Some entries are both (an album you can browse or play whole). */ + fun select(entry: MediaEntry) { + if (entry.browsable) { + path.addLast(entry.id to entry.title) + open(entry.id) + } else if (entry.playable) { + controller?.transportControls?.playFromMediaId(entry.id, null) + } + } + + fun playAll(entry: MediaEntry) { + controller?.transportControls?.playFromMediaId(entry.id, null) + } + + val canGoUp get() = path.isNotEmpty() + + fun up() { + if (path.isEmpty()) return + path.removeLast() + open(path.lastOrNull()?.first ?: rootId ?: return) + } + + // --- transport ----------------------------------------------------------------------- + + fun togglePlay() { + val controls = controller?.transportControls ?: return + if (_now.value.playing) controls.pause() else controls.play() + } + fun next() = controller?.transportControls?.skipToNext() + fun previous() = controller?.transportControls?.skipToPrevious() + fun seek(ms: Long) = controller?.transportControls?.seekTo(ms) + fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) + + fun disconnect() { + runCatching { controller?.unregisterCallback(callback) } + runCatching { subscribed?.let { browser?.unsubscribe(it) } } + runCatching { browser?.disconnect() } + controller = null + browser = null + subscribed = null + _phase.value = Phase.IDLE + _now.value = NowPlaying() + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt new file mode 100644 index 0000000..d52dcc5 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/Lyrics.kt @@ -0,0 +1,95 @@ +package app.seb3thehacker.gearslip.media + +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +class LyricLine(val timeMs: Long, val text: String) + +/** [synced] means every line carries a time, so the current one can be followed along. */ +class LyricsResult(val lines: List, val synced: Boolean) + +/** + * Looks lyrics up on LRCLIB (lrclib.net), a free community database that needs no key. + * + * Media apps almost never publish lyrics in their session metadata, so this is the only general + * source. It sends the track's title, artist, album and length to lrclib.net, which is why the + * lookup happens only when the driver opens the lyrics view and never in the background. + */ +object Lyrics { + + private val cache = HashMap() + + suspend fun find(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { + if (title.isBlank()) return null + val key = "$title|$artist|$album|$durationMs" + synchronized(cache) { if (cache.containsKey(key)) return cache[key] } + val result = withContext(Dispatchers.IO) { + runCatching { + exact(title, artist, album, durationMs) ?: search(title, artist) + }.onFailure { GearslipLog.w("lyrics: lookup failed: ${it.message}") }.getOrNull() + } + synchronized(cache) { cache[key] = result } + return result + } + + private fun exact(title: String, artist: String, album: String, durationMs: Long): LyricsResult? { + if (artist.isBlank()) return null + val query = buildString { + append("track_name=").append(enc(title)).append("&artist_name=").append(enc(artist)) + if (album.isNotBlank()) append("&album_name=").append(enc(album)) + if (durationMs > 0) append("&duration=").append(durationMs / 1000) + } + return get("https://lrclib.net/api/get?$query")?.let { parse(JSONObject(it)) } + } + + private fun search(title: String, artist: String): LyricsResult? { + val body = get("https://lrclib.net/api/search?q=" + enc("$artist $title".trim())) ?: return null + val hits = JSONArray(body) + for (i in 0 until hits.length()) parse(hits.getJSONObject(i))?.let { return it } + return null + } + + private fun parse(json: JSONObject): LyricsResult? { + val synced = json.optString("syncedLyrics").takeIf { it.isNotBlank() && it != "null" } + if (synced != null) { + val lines = parseLrc(synced) + if (lines.isNotEmpty()) return LyricsResult(lines, synced = true) + } + val plain = json.optString("plainLyrics").takeIf { it.isNotBlank() && it != "null" } ?: return null + return LyricsResult(plain.lines().map { LyricLine(0, it) }, synced = false) + } + + /** `[01:23.45] words` - a line may carry several stamps when a chorus repeats. */ + private fun parseLrc(text: String): List { + val stamp = Regex("""\[(\d+):(\d+(?:\.\d+)?)]""") + return text.lines().flatMap { raw -> + val stamps = stamp.findAll(raw).toList() + val words = raw.replace(stamp, "").trim() + stamps.map { + val ms = ((it.groupValues[1].toLong() * 60 + it.groupValues[2].toDouble()) * 1000).toLong() + LyricLine(ms, words) + } + }.sortedBy { it.timeMs } + } + + private fun get(url: String): String? { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 5_000 + readTimeout = 8_000 + setRequestProperty("User-Agent", "Gearslip (car media host)") + } + return try { + if (connection.responseCode != 200) null else connection.inputStream.bufferedReader().readText() + } finally { + connection.disconnect() + } + } + + private fun enc(value: String) = URLEncoder.encode(value, "UTF-8") +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaArt.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaArt.kt new file mode 100644 index 0000000..9540f21 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaArt.kt @@ -0,0 +1,34 @@ +package app.seb3thehacker.gearslip.media + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.LruCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.URL + +/** Cover art arrives as a content:// or https:// URI; this fetches and remembers it. */ +object MediaArt { + + private val cache = LruCache(48) + + suspend fun load(context: Context, uri: Uri?): Bitmap? { + if (uri == null) return null + val key = uri.toString() + cache.get(key)?.let { return it } + return withContext(Dispatchers.IO) { + runCatching { + val stream = when (uri.scheme) { + "http", "https" -> URL(key).openConnection().apply { + connectTimeout = 5_000 + readTimeout = 8_000 + }.getInputStream() + else -> context.contentResolver.openInputStream(uri) + } ?: return@runCatching null + stream.use { BitmapFactory.decodeStream(it) } + }.getOrNull()?.also { cache.put(key, it) } + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt new file mode 100644 index 0000000..97ff21f --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt @@ -0,0 +1,57 @@ +package app.seb3thehacker.gearslip.media + +import app.seb3thehacker.gearslip.host.AndroidAuto +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.service.media.MediaBrowserService + +/** A media app that has told the system it belongs in a car. */ +class MediaApp( + val component: ComponentName, + val label: String, + /** Whose audio to capture: playback capture is scoped to one uid so nothing else leaks in. */ + val uid: Int, +) + +/** + * Finds media apps that opted in to Android Auto. + * + * An app is a car media app when it exports a MediaBrowserService *and* declares the + * `com.google.android.gms.car.application` meta-data pointing at a descriptor with + * ``. The second half is what separates Deezer from a phone-only player + * that merely exposes a browser service to Bluetooth and Wear. + */ +object MediaCatalog { + + private const val CAR_META = "com.google.android.gms.car.application" + + fun installed(context: Context): List { + val manager = context.packageManager + return manager.queryIntentServices(Intent(MediaBrowserService.SERVICE_INTERFACE), PackageManager.GET_META_DATA) + .asSequence() + .filter { it.serviceInfo.packageName != context.packageName } + .filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) } + .filter { declaresCar(manager, it.serviceInfo) } + .map { + val info = it.serviceInfo + MediaApp( + component = ComponentName(info.packageName, info.name), + label = info.applicationInfo?.loadLabel(manager)?.toString() ?: info.packageName, + uid = info.applicationInfo?.uid ?: -1, + ) + } + .distinctBy { it.component.packageName } + .sortedBy { it.label.lowercase() } + .toList() + } + + private fun declaresCar(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean { + if (service.metaData?.containsKey(CAR_META) == true) return true + return runCatching { + manager.getApplicationInfo(service.packageName, PackageManager.GET_META_DATA) + .metaData?.containsKey(CAR_META) == true + }.getOrDefault(false) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/MirrorService.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/MirrorService.kt new file mode 100644 index 0000000..5a8a82e --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/MirrorService.kt @@ -0,0 +1,117 @@ +package app.seb3thehacker.gearslip.mirror + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.media.projection.MediaProjection +import android.media.projection.MediaProjectionManager +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import app.seb3thehacker.gearslip.GearslipLog + +/** + * Holds the screen-capture session for [PhoneMirror]. + * + * This exists because of an ordering rule: since Android 10 a MediaProjection may only be + * obtained while a foreground service declaring `mediaProjection` is already running, so the + * consent result has to be carried into a service before it can be redeemed for a token. + * Its notification doubles as the driver's way out - stopping capture from the shade works + * even when the car screen is showing a mirrored app rather than anything of ours. + */ +class MirrorService : Service() { + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + stopSelf() + return START_NOT_STICKY + } + + val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, 0) ?: 0 + val data = intent?.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java) + if (data == null) { + GearslipLog.e("mirror service started without a consent result") + stopSelf() + return START_NOT_STICKY + } + + startForeground( + NOTIFICATION_ID, notification(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION, + ) + + val media: MediaProjection? = try { + getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data) + } catch (t: Throwable) { + GearslipLog.e("getMediaProjection failed", t) + null + } + if (media == null) { + stopSelf() + return START_NOT_STICKY + } + + // The session owns the encoder Surface, so it performs the swap once a token exists. + Handler(Looper.getMainLooper()).post { PhoneMirror.onTokenReady?.invoke(media) } + return START_NOT_STICKY + } + + override fun onDestroy() { + super.onDestroy() + Handler(Looper.getMainLooper()).post { + PhoneMirror.end() + PhoneMirror.onEnded?.invoke() + } + } + + private fun notification(): Notification { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL, "Phone mirroring", NotificationManager.IMPORTANCE_LOW) + ) + val stop = PendingIntent.getService( + this, 0, + Intent(this, MirrorService::class.java).setAction(ACTION_STOP), + PendingIntent.FLAG_IMMUTABLE, + ) + return Notification.Builder(this, CHANNEL) + .setContentTitle("Mirroring to the car") + .setContentText("The car screen is showing this phone.") + .setSmallIcon(android.R.drawable.ic_menu_view) + .setOngoing(true) + .addAction(Notification.Action.Builder(null, "Stop", stop).build()) + .build() + } + + companion object { + private const val CHANNEL = "gearslip_mirror" + private const val NOTIFICATION_ID = 42 + private const val ACTION_STOP = "app.seb3thehacker.gearslip.STOP_MIRROR" + private const val EXTRA_RESULT_CODE = "resultCode" + private const val EXTRA_RESULT_DATA = "resultData" + + private var appContext: Context? = null + + /** Redeems a screen-capture consent result for a running capture session. */ + fun start(context: Context, resultCode: Int, data: Intent) { + appContext = context.applicationContext + context.startForegroundService( + Intent(context, MirrorService::class.java) + .putExtra(EXTRA_RESULT_CODE, resultCode) + .putExtra(EXTRA_RESULT_DATA, data) + ) + } + + fun stop() { + val context = appContext ?: return + context.startService(Intent(context, MirrorService::class.java).setAction(ACTION_STOP)) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneApps.kt new file mode 100644 index 0000000..bda9efe --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneApps.kt @@ -0,0 +1,85 @@ +package app.seb3thehacker.gearslip.mirror + +import app.seb3thehacker.gearslip.host.AndroidAuto +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.provider.Settings +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import app.seb3thehacker.gearslip.GearslipLog + +/** An app installed on the phone, offered on the car screen as something to mirror. */ +class PhoneApp(val packageName: String, val label: String, val icon: ImageBitmap?) + +/** + * The launcher's view of the phone, shown in the car so an app can be started without picking + * the phone up. Needs the `` entry in the manifest - package visibility hides other + * apps from us otherwise, and the list comes back empty rather than failing. + */ +object PhoneApps { + + private const val ICON_PX = 128 + + fun installed(context: Context): List { + val manager = context.packageManager + val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER) + return manager.queryIntentActivities(intent, 0) + .asSequence() + .filter { it.activityInfo.packageName != context.packageName } + .filterNot { AndroidAuto.matches(it.activityInfo.packageName, it.loadLabel(manager).toString()) } + .distinctBy { it.activityInfo.packageName } + .map { + PhoneApp( + packageName = it.activityInfo.packageName, + label = it.loadLabel(manager).toString(), + icon = runCatching { it.loadIcon(manager).toImageBitmap() }.getOrNull(), + ) + } + .sortedBy { it.label.lowercase() } + .toList() + } + + /** + * Starts an app on the phone's own screen, which is what the mirror is pointed at. + * + * Deliberately uses the application context: a composable in the car UI sees a context + * bound to the projected display, and launching from that would try to place the activity + * there - the exact thing the platform refuses. + */ + fun launch(context: Context, packageName: String) { + val app = context.applicationContext + val intent = app.packageManager.getLaunchIntentForPackage(packageName) + if (intent == null) { + GearslipLog.e("no launch intent for $packageName") + return + } + runCatching { app.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } + .onSuccess { GearslipLog.i("launched $packageName on the phone") } + .onFailure { GearslipLog.e("launching $packageName failed", it) } + } + + /** Opens the phone's accessibility settings, where the touch relay is switched on. */ + fun openAccessibilitySettings(context: Context) { + val app = context.applicationContext + runCatching { + app.startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + }.onFailure { GearslipLog.e("could not open accessibility settings", it) } + } + + private fun Drawable.toImageBitmap(): ImageBitmap { + if (this is BitmapDrawable && bitmap != null) { + return bitmap.copy(Bitmap.Config.ARGB_8888, false).asImageBitmap() + } + val bitmap = Bitmap.createBitmap(ICON_PX, ICON_PX, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap.asImageBitmap() + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneMirror.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneMirror.kt new file mode 100644 index 0000000..bd20a48 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/PhoneMirror.kt @@ -0,0 +1,146 @@ +package app.seb3thehacker.gearslip.mirror + +import android.content.Context +import android.graphics.PointF +import android.hardware.display.DisplayManager +import android.hardware.display.VirtualDisplay +import android.media.projection.MediaProjection +import android.view.Surface +import android.view.WindowManager +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Mirrors the phone's screen into the head unit's video stream. + * + * The platform will not let a normal app render another app onto a display it owns (an + * activity may only be placed on a virtual display whose owner shares its uid, and doing it + * for someone else's app needs INTERNAL_SYSTEM_WINDOW - see ScreenProjector.launchApp). + * Mirroring sidesteps that: the app runs where it always does, on the phone's own display, + * and MediaProjection copies that display into the encoder Surface we are already feeding to + * the car. Touch makes the return trip through [TouchRelayService]. + * + * The MediaProjection token is produced by [MirrorService], because Android only hands one + * over while a foreground service of type mediaProjection is running. + */ +object PhoneMirror { + + private val _active = MutableStateFlow(false) + + /** True while the car is showing the phone rather than Gearslip's own car UI. */ + val active: StateFlow = _active.asStateFlow() + + private var display: VirtualDisplay? = null + private var media: MediaProjection? = null + private var frameWidth = 0 + private var frameHeight = 0 + + /** + * Wired up by the session, which owns the encoder Surface and so is the only thing that + * can swap what draws into it. All three are invoked on the main thread. + */ + internal var onStartRequested: (() -> Unit)? = null + internal var onTokenReady: ((MediaProjection) -> Unit)? = null + internal var onEnded: (() -> Unit)? = null + + /** Asks the phone to put up the screen-capture consent dialog. Safe to call from the car. */ + fun requestStart() { + onStartRequested?.invoke() + } + + fun requestStop() = MirrorService.stop() + + private var pendingLaunch: String? = null + + /** + * Shows [packageName] on the car screen. + * + * When mirroring has not started yet the app is remembered rather than launched, because + * consent has to be granted while Gearslip is still the foreground app - starting the + * other app first would push us into the background, where no dialog can be shown. + */ + fun open(context: Context, packageName: String) { + if (_active.value) { + PhoneApps.launch(context, packageName) + } else { + pendingLaunch = packageName + requestStart() + } + } + + /** Called by the session once the mirror is live. */ + fun launchPending(context: Context) { + val packageName = pendingLaunch ?: return + pendingLaunch = null + PhoneApps.launch(context, packageName) + } + + /** + * Points [surface] at the phone's screen. The caller must have released whatever else was + * drawing into that Surface first - a Surface has one producer. + */ + fun begin(token: MediaProjection, surface: Surface, width: Int, height: Int, densityDpi: Int) { + end() + media = token + frameWidth = width + frameHeight = height + + token.registerCallback(object : MediaProjection.Callback() { + override fun onStop() { + GearslipLog.w("media projection stopped (revoked or replaced)") + MirrorService.stop() + } + }, null) + + display = try { + token.createVirtualDisplay( + "gearslip-mirror", width, height, densityDpi, + DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, surface, null, null, + ) + } catch (t: Throwable) { + GearslipLog.e("mirror display failed", t) + null + } + + _active.value = display != null + if (display != null) GearslipLog.i("mirroring the phone into ${width}x$height") + } + + fun end() { + runCatching { display?.release() } + runCatching { media?.stop() } + display = null + media = null + _active.value = false + } + + /** + * Maps a head-unit touch in projected coordinates onto the phone's screen. + * + * Mirroring letterboxes: the phone's display is scaled to fit the car frame with its + * aspect kept, so the live content occupies a centred sub-rectangle and everything outside + * it is padding that no window can receive. Metrics are read per call rather than cached + * because rotating the phone changes the shape of the mirror underneath us. + * + * Returns null for a touch that landed on the padding. + */ + fun mapToPhone(context: Context, x: Float, y: Float): PointF? { + if (frameWidth == 0 || frameHeight == 0) return null + val bounds = context.getSystemService(WindowManager::class.java) + ?.currentWindowMetrics?.bounds ?: return null + val phoneW = bounds.width().toFloat() + val phoneH = bounds.height().toFloat() + if (phoneW <= 0f || phoneH <= 0f) return null + + val scale = minOf(frameWidth / phoneW, frameHeight / phoneH) + val offsetX = (frameWidth - phoneW * scale) / 2f + val offsetY = (frameHeight - phoneH * scale) / 2f + + val px = (x - offsetX) / scale + val py = (y - offsetY) / scale + if (px < 0f || py < 0f || px > phoneW || py > phoneH) return null + return PointF(px, py) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/TouchRelayService.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/TouchRelayService.kt new file mode 100644 index 0000000..d7c8662 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/mirror/TouchRelayService.kt @@ -0,0 +1,139 @@ +package app.seb3thehacker.gearslip.mirror + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.GestureDescription +import android.graphics.Path +import android.graphics.PointF +import android.view.accessibility.AccessibilityEvent +import app.seb3thehacker.gearslip.GearslipLog + +/** + * Replays head-unit touches onto the phone while [PhoneMirror] is running. + * + * dispatchGesture is the only public API that lets one app inject input into another, which + * is why mirroring needs an accessibility service at all. Gearslip subscribes to no event + * types and never inspects a window, so nothing about the phone's screen is read here - the + * service is a one-way output. + * + * A head unit sends a stream of DOWN/MOVE/UP, but a gesture is dispatched as a whole path, so + * the two are bridged with continued strokes: each dispatch carries the movement since the + * last one, and the next is only queued once the previous completes. Moves that arrive while + * a segment is in flight collapse into the most recent point, which keeps the replayed path + * tracking the finger instead of falling behind it. + */ +class TouchRelayService : AccessibilityService() { + + private var stroke: GestureDescription.StrokeDescription? = null + private var last: PointF? = null + + /** A segment is in flight; further movement waits so strokes stay properly ordered. */ + private var dispatching = false + + /** Most recent unsent position, and whether the finger has already lifted. */ + private var pending: PointF? = null + private var lifted = false + + override fun onServiceConnected() { + instance = this + GearslipLog.i("touch relay connected") + } + + override fun onDestroy() { + super.onDestroy() + instance = null + reset() + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit + + override fun onInterrupt() = reset() + + fun down(x: Float, y: Float) { + reset() + val path = Path().apply { moveTo(x, y); lineTo(x, y) } + val start = GestureDescription.StrokeDescription(path, 0, SEGMENT_MS, true) + stroke = start + last = PointF(x, y) + send(start) + } + + fun move(x: Float, y: Float) { + if (stroke == null) return + pending = PointF(x, y) + pump() + } + + fun up(x: Float, y: Float) { + if (stroke == null) return + pending = PointF(x, y) + lifted = true + pump() + } + + private fun pump() { + if (dispatching) return + val previous = stroke ?: return + val from = last ?: return + val to = pending + // Nothing new to send yet: hold the stroke open until movement or a lift arrives. + if (to == null && !lifted) return + val target = to ?: from + + val path = Path().apply { moveTo(from.x, from.y); lineTo(target.x, target.y) } + val next = try { + previous.continueStroke(path, 0, SEGMENT_MS, !lifted) + } catch (t: Throwable) { + GearslipLog.e("continueStroke rejected", t) + reset() + return + } + stroke = next + last = target + pending = null + send(next) + } + + private fun send(description: GestureDescription.StrokeDescription) { + val gesture = GestureDescription.Builder().addStroke(description).build() + dispatching = true + val ok = dispatchGesture(gesture, object : GestureResultCallback() { + override fun onCompleted(g: GestureDescription?) { + dispatching = false + if (lifted && pending == null) reset() else pump() + } + + override fun onCancelled(g: GestureDescription?) { + dispatching = false + reset() + } + }, null) + if (!ok) { + GearslipLog.w("dispatchGesture refused - is another gesture in flight?") + dispatching = false + reset() + } + } + + private fun reset() { + stroke = null + last = null + pending = null + lifted = false + dispatching = false + } + + companion object { + /** + * How long each replayed segment is told to take. Short enough that the pointer keeps + * up with the finger, long enough that the framework does not treat a drag as a fling. + */ + private const val SEGMENT_MS = 32L + + @Volatile + var instance: TouchRelayService? = null + private set + + /** False until the user enables Gearslip under Settings > Accessibility. */ + val enabled: Boolean get() = instance != null + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt new file mode 100644 index 0000000..fc87fbc --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarMotion.kt @@ -0,0 +1,47 @@ +package app.seb3thehacker.gearslip.notify + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.SystemClock + +/** + * Whether the car is moving, judged from the phone's GPS speed. + * + * Used only to keep notification popups off the screen while driving. With no permission or no + * recent fix it answers "not moving", so a missing signal never hides anything for good. + */ +object CarMotion { + + private const val MOVING_MS = 2.0f // about 7 km/h + private const val STALE_MS = 15_000L + + private var listener: LocationListener? = null + private var speed = 0f + private var fixAt = 0L + + val moving: Boolean + get() = SystemClock.elapsedRealtime() - fixAt < STALE_MS && speed > MOVING_MS + + fun start(context: Context) { + if (listener != null) return + if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) return + val manager = context.getSystemService(LocationManager::class.java) ?: return + val l = object : LocationListener { + override fun onLocationChanged(location: Location) { + speed = if (location.hasSpeed()) location.speed else 0f + fixAt = SystemClock.elapsedRealtime() + } + } + listener = l + runCatching { manager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 2_000L, 0f, l) } + } + + fun stop(context: Context) { + listener?.let { l -> context.getSystemService(LocationManager::class.java)?.removeUpdates(l) } + listener = null + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt new file mode 100644 index 0000000..fba7a75 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/CarNotifications.kt @@ -0,0 +1,124 @@ +package app.seb3thehacker.gearslip.notify + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** How a notification can be answered: the action the app attached, and the field it fills. */ +class ReplyTarget(val action: Notification.Action, val input: android.app.RemoteInput) + +/** + * One notification as the car shows it. Only what the car UI needs is kept - the phone's + * Notification object stays with the listener, apart from the reply action. + */ +data class CarNotification( + /** Stable per conversation: an app re-posting an update reuses the key. */ + val key: String, + /** Unique per post, so an update to an already-shown notification still counts as new. */ + val id: Long, + val appLabel: String, + val title: String, + val text: String, + val postedAt: Long, + val icon: Bitmap?, + /** Null when the app offered no reply, or once the notification has left the phone. */ + val reply: ReplyTarget?, + val replied: Boolean = false, +) + +/** + * The car's notification centre: a short history, the one popup on screen, and an unread count. + * + * Held in memory only. Message contents are never written to disk, so a process restart clears + * the history - which is the safer failure for something shown on a car screen. + */ +object CarNotifications { + + private const val MAX_HISTORY = 50 + + /** A reply's own echo (apps re-post the thread with "You: ...") shouldn't pop up again. */ + private const val ECHO_WINDOW_MS = 10_000L + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + private val _popup = MutableStateFlow(null) + val popup: StateFlow = _popup.asStateFlow() + + private val _unread = MutableStateFlow(0) + val unread: StateFlow = _unread.asStateFlow() + + /** True while the system has bound the listener - i.e. the user has granted access. */ + private val _listening = MutableStateFlow(false) + val listening: StateFlow = _listening.asStateFlow() + + private var nextId = 0L + private var lastReplyKey: String? = null + private var lastReplyAt = 0L + + fun setListening(on: Boolean) { _listening.value = on } + + fun nextId(): Long = nextId++ + + /** [quiet] adds to the history without a popup or unread badge (used to seed on connect). */ + fun post(notification: CarNotification, quiet: Boolean) { + _history.update { list -> + (listOf(notification) + list.filterNot { it.key == notification.key }).take(MAX_HISTORY) + } + val echo = notification.key == lastReplyKey && + System.currentTimeMillis() - lastReplyAt < ECHO_WINDOW_MS + if (quiet || echo) return + // History still keeps it; only the popup is held back while driving. + if (CarMotion.moving) return + _popup.value = notification + } + + /** The phone dismissed it. It stays in history, but its reply intent is no longer trustworthy. */ + fun gone(key: String) { + _history.update { list -> list.map { if (it.key == key) it.copy(reply = null) else it } } + } + + fun dismissPopup(id: Long) { + _popup.update { if (it?.id == id) null else it } + } + + /** Called while the notification screen is showing: nothing is unread there. */ + fun markRead() { + _unread.value = 0 + _popup.value = null + } + + fun clear() { + _history.value = emptyList() + _popup.value = null + _unread.value = 0 + } + + /** Fills the app's reply field and fires its action. Returns false if the app has withdrawn it. */ + fun reply(context: Context, notification: CarNotification, text: String): Boolean { + val target = notification.reply ?: return false + val fill = Intent() + val results = Bundle().apply { putCharSequence(target.input.resultKey, text) } + android.app.RemoteInput.addResultsToIntent(arrayOf(target.input), fill, results) + return try { + target.action.actionIntent.send(context, 0, fill) + lastReplyKey = notification.key + lastReplyAt = System.currentTimeMillis() + _history.update { list -> + list.map { if (it.key == notification.key) it.copy(replied = true) else it } + } + true + } catch (e: PendingIntent.CanceledException) { + GearslipLog.w("notify: the reply to ${notification.appLabel} was withdrawn by the app") + false + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt new file mode 100644 index 0000000..49bd63a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/notify/GearslipNotificationListener.kt @@ -0,0 +1,89 @@ +package app.seb3thehacker.gearslip.notify + +import android.app.Notification +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import androidx.core.graphics.drawable.toBitmap + +/** + * Feeds the phone's notifications to the car. + * + * Reading other apps' notifications is gated by the user granting "Notification access" to + * Gearslip in the phone's settings - the same on-device consent as any notification mirror, + * and the only one needed. Until then the car UI says so and offers to open that page. + */ +class GearslipNotificationListener : NotificationListenerService() { + + override fun onListenerConnected() { + CarNotifications.setListening(true) + // Seed the history with what is already showing, oldest first so the newest ends up on top. + runCatching { activeNotifications.orEmpty().sortedBy { it.postTime } } + .getOrDefault(emptyList()) + .forEach { ingest(it, quiet = true) } + } + + override fun onListenerDisconnected() = CarNotifications.setListening(false) + + override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false) + + override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key) + + private fun ingest(sbn: StatusBarNotification, quiet: Boolean) { + if (sbn.packageName == packageName) return + val n = sbn.notification + // Ongoing entries (media, navigation, downloads) and group headers are status, not news. + if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return + if (n.category in QUIET_CATEGORIES) return + + val extras = n.extras + val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty() + val text = (extras.getCharSequence(Notification.EXTRA_BIG_TEXT) + ?: extras.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty() + if (title.isBlank() && text.isBlank()) return + + CarNotifications.post( + CarNotification( + key = sbn.key, + id = CarNotifications.nextId(), + appLabel = appLabel(sbn.packageName), + title = title, + text = text, + postedAt = sbn.postTime, + icon = icon(sbn), + reply = replyOf(n), + ), + quiet, + ) + } + + private fun appLabel(packageName: String): String = runCatching { + packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageName, 0)).toString() + }.getOrDefault(packageName.substringAfterLast('.')) + + private fun icon(sbn: StatusBarNotification) = runCatching { + val drawable = sbn.notification.getLargeIcon()?.loadDrawable(this) + ?: packageManager.getApplicationIcon(sbn.packageName) + drawable.toBitmap(ICON_PX, ICON_PX) + }.getOrNull() + + /** Messaging apps often attach the reply only for wearables, so both places are searched. */ + private fun replyOf(n: Notification): ReplyTarget? { + val actions = n.actions.orEmpty().toList() + Notification.WearableExtender(n).actions + for (action in actions) { + if (action.actionIntent == null) continue + val input = action.remoteInputs?.firstOrNull { it.allowFreeFormInput } ?: continue + return ReplyTarget(action, input) + } + return null + } + + private companion object { + const val ICON_PX = 96 + val QUIET_CATEGORIES = setOf( + Notification.CATEGORY_TRANSPORT, + Notification.CATEGORY_PROGRESS, + Notification.CATEGORY_SERVICE, + Notification.CATEGORY_SYSTEM, + ) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt new file mode 100644 index 0000000..8f198d0 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt @@ -0,0 +1,255 @@ +package app.seb3thehacker.gearslip.ui + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ActivityInfo +import android.graphics.Bitmap +import android.graphics.PixelFormat +import android.hardware.HardwareBuffer +import android.media.ImageReader +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.ScreenProjector +import app.seb3thehacker.gearslip.car.CarEnvironment +import app.seb3thehacker.gearslip.car.CarUi + +/** + * Runs the real car UI on the bench and shows it on the phone. + * + * This is the same pipeline the car gets - a VirtualDisplay hosting a Presentation, driven by + * the same [ScreenProjector] and fed the same touches - with an ImageReader standing in for the + * H.264 encoder. That matters for anything composited rather than drawn by Compose, such as the + * SurfaceView a templated app draws its map into: if it appears here, it will reach the car. + * + * Debug affordance only. Nothing in the car session path goes through this screen. + */ +@Composable +fun CarPreviewScreen(onBack: () -> Unit) { + val context = LocalContext.current + val frame by CarEnvironment.frame.collectAsStateWithLifecycle() + var bitmap by remember { mutableStateOf(null) } + var viewSize by remember { mutableStateOf(0 to 0) } + var fullscreen by remember { mutableStateOf(false) } + + val width = frame.width + val height = frame.height + val density = frame.densityDpi + + val projector = remember { ScreenProjector(context) } + + DisposableEffect(width, height, density) { + CarEnvironment.start(context) + + // COMPOSER_OVERLAY is what makes SurfaceFlinger willing to composite into this reader; + // CPU_READ_OFTEN is what makes the resulting buffer lockable, which is how the frame + // gets copied into a Bitmap. Without both, frames arrive but every copy fails. + val reader = ImageReader.newInstance( + width, height, PixelFormat.RGBA_8888, 3, + HardwareBuffer.USAGE_COMPOSER_OVERLAY or + HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or + HardwareBuffer.USAGE_CPU_READ_OFTEN, + ) + val handler = Handler(Looper.getMainLooper()) + reader.setOnImageAvailableListener({ source -> + val image = runCatching { source.acquireLatestImage() }.getOrNull() ?: return@setOnImageAvailableListener + try { + bitmap = image.toBitmap(width, height) + } catch (t: Throwable) { + GearslipLog.e("preview: could not copy a frame", t) + } finally { + image.close() + } + }, handler) + + projector.start(reader.surface, width, height, density) { CarUi() } + + onDispose { + projector.stop() + reader.close() + bitmap = null + } + } + + // The frame is drawn the same way in both layouts; only what surrounds it differs. + val frameContent: @Composable (Modifier) -> Unit = { modifier -> + Box( + modifier + .aspectRatio(width.toFloat() / height) + .background(Color.Black) + .onSizeChanged { viewSize = it.width to it.height } + .pointerInteropFilter { event -> + val (viewWidth, viewHeight) = viewSize + if (viewWidth > 0 && viewHeight > 0) { + projector.dispatchTouch( + event.actionMasked.toCarAction(), + event.x * width / viewWidth, + event.y * height / viewHeight, + ) + } + true + }, + contentAlignment = Alignment.Center, + ) { + bitmap?.let { + Image( + it.asImageBitmap(), + contentDescription = "Car screen", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } ?: Text("Waiting for a frame…", color = Color.White) + } + } + + if (fullscreen) { + Fullscreen(landscape = width > height, onExit = { fullscreen = false }) { + // No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the + // whole height, whichever the car's shape allows, and the rest stays black. + frameContent(Modifier) + } + return + } + + Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { + Text( + "Car preview", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(vertical = 12.dp), + ) + Text( + "${width}x$height @ ${density}dpi - touches go to the car UI", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth()) + + Row { + TextButton(onClick = onBack) { Text("Back") } + TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") } + } + } + } +} + +/** + * Fills the phone screen with [content], centred and letterboxed. + * + * A car screen is wider than it is tall, so a portrait phone would waste most of its height on + * one. While this is showing, the activity is turned to match and the system bars go away - both + * are undone on the way out, so the rest of the phone UI is unaffected. + */ +@Composable +private fun Fullscreen( + landscape: Boolean, + onExit: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + val activity = LocalContext.current.activity() + + DisposableEffect(activity, landscape) { + if (activity == null) return@DisposableEffect onDispose { } + + val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.systemBars()) + } + val previousOrientation = activity.requestedOrientation + if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + + onDispose { + insets.show(WindowInsetsCompat.Type.systemBars()) + activity.requestedOrientation = previousOrientation + } + } + + BackHandler(onBack = onExit) + + Box( + Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center, + ) { + content() + + // The frame swallows every touch inside it, so leaving needs either the back gesture or + // a button of its own. This one sits in the corner the car UI leaves emptiest. + FilledTonalIconButton( + onClick = onExit, + modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), + ) { + Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen") + } + } +} + +/** Compose hands out a themed wrapper, so the activity is a few unwraps down. */ +private tailrec fun Context.activity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.activity() + else -> null +} + +/** Only the four actions [ScreenProjector.dispatchTouch] understands reach the car UI. */ +private fun Int.toCarAction(): Int = when (this) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, + -> this + else -> MotionEvent.ACTION_CANCEL +} + +/** + * Copies one frame out of the reader. The reader's rows are padded to a hardware-friendly + * stride, so the copy is made at the padded width and then cropped back. + */ +private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap { + val plane = planes[0] + val pixelStride = plane.pixelStride + val rowPadding = plane.rowStride - pixelStride * width + val padded = Bitmap.createBitmap( + width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888, + ) + padded.copyPixelsFromBuffer(plane.buffer) + return if (padded.width == width) padded + else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt new file mode 100644 index 0000000..f529e8f --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt @@ -0,0 +1,44 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Context +import app.seb3thehacker.gearslip.CertProvider +import java.text.DateFormat +import java.util.Date + +/** What the UI says about the certificate a connection would present. */ +data class CertSummary( + val kind: CertProvider.Kind, + val headline: String, + val subject: String?, + val issuer: String?, + val validUntil: String?, +) { + companion object { + /** Blocking (parses a key store): call from a background dispatcher. */ + fun read(context: Context): CertSummary { + val identity = CertProvider.loadSupplied(context) + ?: return CertSummary( + CertProvider.Kind.SELF_SIGNED, + "None loaded - a self-signed one is generated, which real head units reject", + null, null, null, + ) + val cert = identity.certificate + val name = friendlyName(cert.subjectX500Principal.name) + val prefix = if (identity.kind == CertProvider.Kind.IMPORTED) "Imported" else "Staged over adb" + return CertSummary( + identity.kind, + "$prefix: $name", + cert.subjectX500Principal.name, + cert.issuerX500Principal.name, + DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)), + ) + } + + private fun friendlyName(distinguishedName: String): String { + for (key in listOf("CN", "O")) { + Regex("(?:^|,)$key=([^,]+)").find(distinguishedName)?.let { return it.groupValues[1] } + } + return distinguishedName + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt new file mode 100644 index 0000000..86e394d --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -0,0 +1,29 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW } + +/** Three screens and a back stack of depth one: no navigation library needed. */ +@Composable +fun GearslipApp(onDisconnect: () -> Unit) { + var screen by rememberSaveable { mutableStateOf(Screen.HOME) } + BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME } + + when (screen) { + Screen.HOME -> HomeScreen( + onOpenLogs = { screen = Screen.LOGS }, + onOpenSettings = { screen = Screen.SETTINGS }, + onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, + onDisconnect = onDisconnect, + ) + Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) + Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME }) + Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipTheme.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipTheme.kt new file mode 100644 index 0000000..9b69689 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipTheme.kt @@ -0,0 +1,16 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +/** Material You: the palette comes from the wallpaper (minSdk 35, so dynamic color always exists). */ +@Composable +fun GearslipTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val scheme = if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + MaterialTheme(colorScheme = scheme, content = content) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt new file mode 100644 index 0000000..6037027 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -0,0 +1,172 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.SessionStatus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +fun HomeScreen( + onOpenLogs: () -> Unit, + onOpenSettings: () -> Unit, + onOpenCarPreview: () -> Unit, + onDisconnect: () -> Unit, +) { + val status by SessionStatus.state.collectAsStateWithLifecycle() + + Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp), + ) { + Column( + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(24.dp)) + Text( + "Gearslip", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(16.dp)) + StatusCard(status) + Spacer(Modifier.height(24.dp)) + SetupSummary() + Spacer(Modifier.height(16.dp)) + } + + // Actions sit at the bottom, where a thumb reaches them. + Column( + Modifier.padding(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val live = status.phase == SessionStatus.Phase.CONNECTING || + status.phase == SessionStatus.Phase.PROJECTING + if (live) { + Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { + Text("Disconnect") + } + } + if (!live) { + FilledTonalButton( + onClick = onOpenCarPreview, + modifier = Modifier.fillMaxWidth().height(56.dp), + ) { + Text("Car preview") + } + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + FilledTonalButton(onClick = onOpenLogs, modifier = Modifier.weight(1f).height(56.dp)) { + Text("Live logs") + } + OutlinedButton(onClick = onOpenSettings, modifier = Modifier.weight(1f).height(56.dp)) { + Text("Settings") + } + } + } + } + } +} + +@Composable +private fun StatusCard(status: SessionStatus.Snapshot) { + val scheme = MaterialTheme.colorScheme + val (container, content) = when (status.phase) { + SessionStatus.Phase.PROJECTING -> scheme.primaryContainer to scheme.onPrimaryContainer + SessionStatus.Phase.CONNECTING -> scheme.secondaryContainer to scheme.onSecondaryContainer + SessionStatus.Phase.FAILED -> scheme.errorContainer to scheme.onErrorContainer + SessionStatus.Phase.IDLE, SessionStatus.Phase.DISCONNECTED -> + scheme.surfaceContainerHigh to scheme.onSurface + } + + Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) { + Row( + Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (status.phase == SessionStatus.Phase.CONNECTING) { + CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp, color = content) + } else { + Surface( + modifier = Modifier.size(14.dp), + shape = MaterialTheme.shapes.extraLarge, + color = if (status.phase == SessionStatus.Phase.IDLE || + status.phase == SessionStatus.Phase.DISCONNECTED + ) content.copy(alpha = 0.4f) else content, + ) {} + } + Column { + Text(status.headline, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Text(status.detail, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +/** A read-only recap of what a connection will use, so setup problems are visible before the car. */ +@Composable +private fun SetupSummary() { + val context = LocalContext.current + val cert by produceState(null) { + value = withContext(Dispatchers.Default) { CertSummary.read(context) } + } + val startup = AppSettings.startupUrl(context) + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + SummaryRow("Certificate", cert?.headline ?: "Checking…") + SummaryRow("Car screen shows", startup.ifEmpty { "Built-in test page" }) + } +} + +@Composable +private fun SummaryRow(label: String, value: String) { + Column { + Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt new file mode 100644 index 0000000..ce94cab --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/LogsScreen.kt @@ -0,0 +1,141 @@ +package app.seb3thehacker.gearslip.ui + +import android.content.Intent +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import app.seb3thehacker.gearslip.GearslipLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogsScreen(onBack: () -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val version by GearslipLog.version.collectAsStateWithLifecycle() + val lines = remember(version) { GearslipLog.snapshot() } + + val listState = rememberLazyListState() + val atEnd by remember { + derivedStateOf { + val info = listState.layoutInfo + val last = info.visibleItemsInfo.lastOrNull() + last == null || last.index >= info.totalItemsCount - 1 + } + } + // Live view: stay pinned to the newest line, but let go the moment the user scrolls up to + // read something, and pick back up when they return to the bottom. + var follow by remember { mutableStateOf(true) } + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress to atEnd }.collect { (scrolling, end) -> + if (scrolling) follow = end + } + } + LaunchedEffect(lines.size, version) { + if (follow && lines.isNotEmpty()) listState.scrollToItem(lines.size - 1) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { Text("Live logs") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton(onClick = { + scope.launch { + val text = withContext(Dispatchers.IO) { GearslipLog.fullText() } + val send = Intent(Intent.ACTION_SEND) + .setType("text/plain") + .putExtra(Intent.EXTRA_SUBJECT, "Gearslip log") + .putExtra(Intent.EXTRA_TEXT, text) + context.startActivity(Intent.createChooser(send, "Share log")) + } + }) { Text("Share") } + TextButton(onClick = { GearslipLog.clearLive() }) { Text("Clear") } + }, + ) + }, + floatingActionButton = { + if (!follow && lines.isNotEmpty()) { + ExtendedFloatingActionButton( + onClick = { + follow = true + scope.launch { listState.scrollToItem(lines.size - 1) } + }, + ) { Text("Jump to latest") } + } + }, + ) { padding -> + if (lines.isEmpty()) { + Text( + "Nothing logged yet. Lines appear here live while a session runs.", + modifier = Modifier.padding(padding).padding(24.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + SelectionContainer { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + itemsIndexed(lines) { _, line -> + Text( + line, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 15.sp, + color = colorFor(line), + ) + } + } + } + } + } +} + +/** Lines look like "HH:mm:ss.SSS L message"; the level letter sits at index 13. */ +@Composable +private fun colorFor(line: String): Color = when (line.getOrNull(13)) { + 'E' -> MaterialTheme.colorScheme.error + 'W' -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurface +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt new file mode 100644 index 0000000..8aff7d7 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -0,0 +1,224 @@ +package app.seb3thehacker.gearslip.ui + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.CertProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(onBack: () -> Unit) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { Text("Settings") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + Spacer(Modifier.height(0.dp)) + StartupSection() + CertificateSection() + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +private fun SectionTitle(text: String) { + Text( + text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) +} + +@Composable +private fun StartupSection() { + val context = LocalContext.current + var url by remember { mutableStateOf(AppSettings.startupUrl(context)) } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + SectionTitle("Startup URL") + OutlinedTextField( + value = url, + onValueChange = { + url = it + AppSettings.setStartupUrl(context, it) + }, + modifier = Modifier.fillMaxWidth(), + label = { Text("URL") }, + placeholder = { Text("https://example.com") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + supportingText = { + Text("Shown on the car screen. Leave empty for the built-in test page. Applies on the next connection.") + }, + ) + } +} + +@Composable +private fun CertificateSection() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var refresh by remember { mutableIntStateOf(0) } + val summary by produceState(null, refresh) { + value = withContext(Dispatchers.Default) { CertSummary.read(context) } + } + + var pendingUri by remember { mutableStateOf(null) } + var notice by remember { mutableStateOf(null) } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri != null) pendingUri = uri + } + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + SectionTitle("Certificate") + + Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.large) { + Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + val s = summary + if (s == null) { + Text("Checking…", style = MaterialTheme.typography.bodyMedium) + } else { + Text(s.headline, style = MaterialTheme.typography.bodyLarge) + s.subject?.let { Detail("Subject", it) } + s.issuer?.let { Detail("Issuer", it) } + s.validUntil?.let { Detail("Valid until", it) } + } + } + } + + Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) { + Text("Import certificate (.p12)") + } + if (summary?.kind == CertProvider.Kind.IMPORTED) { + OutlinedButton( + onClick = { + CertProvider.removeImported(context) + notice = "Removed the imported certificate." + refresh++ + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + ) { Text("Remove imported certificate") } + } + notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + Text( + "An imported certificate takes priority over one staged over adb. Takes effect on the next connection.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + pendingUri?.let { uri -> + PasswordDialog( + onDismiss = { pendingUri = null }, + onConfirm = { password, reportError -> + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { CertProvider.importFrom(context, uri, password) } + } + result.onSuccess { + pendingUri = null + notice = "Certificate imported." + refresh++ + }.onFailure { + reportError("Could not import: wrong password, or not a PKCS#12 file with a private key.") + } + } + }, + ) + } +} + +@Composable +private fun Detail(label: String, value: String) { + Column { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -> Unit) -> Unit) { + var password by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Certificate password") }, + text = { + OutlinedTextField( + value = password, + onValueChange = { password = it; error = null }, + singleLine = true, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + isError = error != null, + supportingText = { Text(error ?: "Leave empty if the file has none.") }, + ) + }, + confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} diff --git a/gearslip/src/main/res/drawable/ic_launcher_background.xml b/gearslip/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..29c3258 --- /dev/null +++ b/gearslip/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/gearslip/src/main/res/drawable/ic_launcher_foreground.xml b/gearslip/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..d147af0 --- /dev/null +++ b/gearslip/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + diff --git a/gearslip/src/main/res/drawable/ic_launcher_monochrome.xml b/gearslip/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..e173db8 --- /dev/null +++ b/gearslip/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/gearslip/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/gearslip/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80faec8 --- /dev/null +++ b/gearslip/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml new file mode 100644 index 0000000..c7c843a --- /dev/null +++ b/gearslip/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Lets Gearslip replay touches from the car screen onto the phone while mirroring. Gearslip does not read screen content. + diff --git a/gearslip/src/main/res/values/themes.xml b/gearslip/src/main/res/values/themes.xml new file mode 100644 index 0000000..fa51d18 --- /dev/null +++ b/gearslip/src/main/res/values/themes.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/gearslip/src/main/res/xml/accessory_filter.xml b/gearslip/src/main/res/xml/accessory_filter.xml new file mode 100644 index 0000000..80a2d38 --- /dev/null +++ b/gearslip/src/main/res/xml/accessory_filter.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/gearslip/src/main/res/xml/touch_relay.xml b/gearslip/src/main/res/xml/touch_relay.xml new file mode 100644 index 0000000..8c24afe --- /dev/null +++ b/gearslip/src/main/res/xml/touch_relay.xml @@ -0,0 +1,11 @@ + + + diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt new file mode 100644 index 0000000..0ace3ef --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/CryptoTest.kt @@ -0,0 +1,306 @@ +package app.seb3thehacker.gearslip + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Runs the two riskiest pieces - the hand-written X.509 encoder and the SSLEngine wiring - + * on the local JVM, so a defect surfaces here rather than as a mystery failure in a car. + * + * The JVM uses SunJSSE where the phone uses Conscrypt, so this validates the DER and the + * engine logic, not the exact provider behaviour. The on-device "Self-test" button remains + * the authoritative bench check. + */ +class CryptoTest { + + @Test + fun `generates a parseable self-signed certificate`() { + val identity = SelfSignedCert.generate(commonName = "TestCN", organisation = "TestO") + val cert = identity.certificate + + // X500Principal.getName() is RFC 2253, which lists RDNs in reverse order. + assertEquals("O=TestO,CN=TestCN", cert.subjectX500Principal.name) + assertEquals(cert.subjectX500Principal, cert.issuerX500Principal) + assertEquals(3, cert.version) + assertEquals("SHA256withRSA", cert.sigAlgName) + assertTrue("serial must be positive", cert.serialNumber.signum() > 0) + + cert.checkValidity() + cert.verify(cert.publicKey) + + val key = identity.keyStore.getKey(SelfSignedCert.ALIAS, SelfSignedCert.PASSWORD) + assertEquals("RSA", key.algorithm) + } + + @Test + fun `completes a TLS handshake as the server and round-trips data`() { + val ss = SelfSignedCert.generate() + val identity = CertProvider.Identity( + ss.keyStore, ss.certificate, SelfSignedCert.PASSWORD, "test", + ) + assertTrue(TlsSelfTest.run(identity)) + } + + @Test + fun `frames round-trip through the parser`() { + val payload = ByteArray(300) { it.toByte() } + val parser = Frames.Parser() + val frame = Frames.build(Frames.CHANNEL_CONTROL, encrypted = false, payload = payload) + + // Split mid-frame: a bulk transfer boundary must not break parsing. + parser.append(frame, 7) + assertEquals(null, parser.next()) + parser.append(frame.copyOfRange(7, frame.size), frame.size - 7) + + val parsed = parser.next()!! + assertEquals(Frames.CHANNEL_CONTROL, parsed.channel) + assertEquals(Frames.TYPE_BULK, parsed.frameType) + assertEquals(false, parsed.encrypted) + assertTrue(payload.contentEquals(parsed.payload)) + } + + @Test + fun `reads negative AuthResponse status values`() { + // STATUS_CERTIFICATE_ERROR = -2 encodes as a 10-byte varint. + val encoded = Protobuf.varintField(1, -2L) + assertEquals(11, encoded.size) + assertEquals(-2, Protobuf.readInt32Field(encoded, 1)) + assertEquals(0, Protobuf.readInt32Field(Protobuf.varintField(1, 0L), 1)) + assertEquals(-3, Protobuf.readInt32Field(Protobuf.varintField(1, -3L), 1)) + } + + // --- Phase A: service discovery parsing --------------------------------------------- + + private fun lenField(number: Int, body: ByteArray): ByteArray = + Protobuf.varint(((number shl 3) or 2).toLong()) + + Protobuf.varint(body.size.toLong()) + body + + /** + * Rebuilds the exact shape the 2018 Uconnect sent (docs/spike/car-run-2026-09-16-run2.log) + * and checks we read it back correctly: video on channel 1, H264_BP, 1280x720 and 800x480 + * both at 30fps. + */ + @Test + fun `extracts the video service as the Uconnect described it`() { + val config720 = Protobuf.varintField(1, 2) + Protobuf.varintField(2, 2) + + Protobuf.varintField(3, 0) + Protobuf.varintField(4, 12) + + Protobuf.varintField(5, 240) + Protobuf.varintField(6, 0) + + Protobuf.varintField(8, 10000) + val config480 = Protobuf.varintField(1, 1) + Protobuf.varintField(2, 2) + + Protobuf.varintField(3, 0) + Protobuf.varintField(4, 30) + + Protobuf.varintField(5, 160) + Protobuf.varintField(6, 0) + + Protobuf.varintField(8, 10000) + + val mediaSink = Protobuf.varintField(1, 3) + // available_type = H264_BP + lenField(4, config720) + lenField(4, config480) // video_configs + val videoService = Protobuf.varintField(1, 1) + lenField(3, mediaSink) + + // An audio sink (no video_configs) must not be mistaken for the video service. + val audioSink = Protobuf.varintField(1, 1) + lenField(3, Protobuf.varintField(1, 48000)) + val audioService = Protobuf.varintField(1, 5) + lenField(3, audioSink) + + val response = lenField(1, audioService) + lenField(1, videoService) + + val video = ServiceDiscovery.findVideoService(response)!! + assertEquals(1, video.serviceId) + assertEquals("VIDEO_H264_BP", video.codecName) + assertEquals(2, video.configs.size) + assertEquals("1280x720", video.configs[0].resolutionName) + assertEquals("30fps", video.configs[0].frameRateName) + assertEquals(240, video.configs[0].density) + assertEquals("800x480", video.configs[1].resolutionName) + assertEquals("30fps", video.configs[1].frameRateName) + } + + @Test + fun `media message ids above 32767 survive the 2-byte encoding`() { + // SETUP = 32768 sets the high bit; a signed-byte slip here would corrupt every + // video-channel message. + val id = 32768 + val hi = ((id shr 8) and 0xFF).toByte() + val lo = (id and 0xFF).toByte() + val decoded = ((hi.toInt() and 0xFF) shl 8) or (lo.toInt() and 0xFF) + assertEquals(32768, decoded) + } + + /** + * Regression: channel-open messages must carry MessageType::CONTROL even on a service + * channel. Sending them as SPECIFIC made the 2018 Uconnect drop the USB link ~160ms + * later with no protocol-level error at all - invisible in code, fatal on the wire. + */ + @Test + fun `channel-open frames carry the CONTROL message type bit`() { + val control = Frames.build(1, encrypted = false, payload = byteArrayOf(0, 7), + messageType = Frames.MESSAGE_CONTROL) + assertEquals(Frames.MESSAGE_CONTROL, control[1].toInt() and Frames.MESSAGE_CONTROL) + + val specific = Frames.build(1, encrypted = false, payload = byteArrayOf(0x80.toByte(), 0)) + assertEquals(0, specific[1].toInt() and Frames.MESSAGE_CONTROL) + + val parser = Frames.Parser() + parser.append(control, control.size) + assertEquals(Frames.MESSAGE_CONTROL, parser.next()!!.messageType) + parser.append(specific, specific.size) + assertEquals(0, parser.next()!!.messageType) + } + + @Test + fun `maps resolution enums to pixel sizes and prefers the smallest`() { + val small = ServiceDiscovery.VideoConfig(resolution = 1, frameRate = 2, density = 160) + val large = ServiceDiscovery.VideoConfig(resolution = 2, frameRate = 2, density = 240) + assertEquals(800 to 480, small.pixelSize()) + assertEquals(1280 to 720, large.pixelSize()) + assertTrue(small.pixelCount() < large.pixelCount()) + } + + /** The Data payload is an 8-byte big-endian timestamp followed by the access unit. */ + @Test + fun `video frame timestamp is 8 bytes big-endian`() { + val pts = 0x0102030405060708L + val stamp = ByteArray(8) + for (i in 0 until 8) stamp[i] = ((pts shr ((7 - i) * 8)) and 0xFF).toByte() + assertEquals(0x01.toByte(), stamp[0]) + assertEquals(0x08.toByte(), stamp[7]) + + var rebuilt = 0L + for (b in stamp) rebuilt = (rebuilt shl 8) or (b.toLong() and 0xFF) + assertEquals(pts, rebuilt) + } + + /** Matches the Uconnect's input service: id 2, touchscreen 1258x708. */ + @Test + fun `extracts the input service and touchscreen size`() { + val touchScreen = Protobuf.varintField(1, 1258) + Protobuf.varintField(2, 708) + + Protobuf.varintField(3, 1) + val inputSource = lenField(1, Protobuf.varintField(1, 42)) + // keycodes, ignored + lenField(2, touchScreen) + val inputService = Protobuf.varintField(1, 2) + lenField(4, inputSource) + + // A video service must not be mistaken for the input service. + val videoService = Protobuf.varintField(1, 1) + + lenField(3, Protobuf.varintField(1, 3) + lenField(4, Protobuf.varintField(1, 1))) + + val response = lenField(1, videoService) + lenField(1, inputService) + + val input = ServiceDiscovery.findInputService(response)!! + assertEquals(2, input.serviceId) + assertEquals(1258, input.touchWidth) + assertEquals(708, input.touchHeight) + } + + /** + * Regression: touch arrives already in projected video coordinates, NOT in the head + * unit's advertised touchscreen space. An earlier version scaled by video/touchscreen, + * which compressed the right-hand third of the screen away - a tap on a right-edge + * button landed on the middle one (confirmed on the Uconnect: 800x480 projected, panel + * advertised 1258x708, raw x never exceeded 791). + * + * Also pins the action mapping: Android Auto's PointerAction values equal Android's + * MotionEvent actions, so no translation table is needed. + */ + @Test + fun `touch coordinates are used as-is and clamped to the video bounds`() { + fun clamp(value: Int, limit: Int?): Float = when { + limit == null -> value.toFloat() + value < 0 -> 0f + value >= limit -> (limit - 1).toFloat() + else -> value.toFloat() + } + + // A right-edge tap must stay at the right edge, not be scaled inward. + assertEquals(791f, clamp(791, 800)) + assertEquals(216f, clamp(216, 800)) + assertEquals(0f, clamp(-5, 800)) + assertEquals(799f, clamp(1258, 800)) + assertEquals(479f, clamp(708, 480)) + assertEquals(351f, clamp(351, 480)) + + assertEquals(0, android.view.MotionEvent.ACTION_DOWN) + assertEquals(1, android.view.MotionEvent.ACTION_UP) + assertEquals(2, android.view.MotionEvent.ACTION_MOVE) + } + + /** + * Regression: a video frame over 16 KB used to throw out of Frames.build and kill the + * encoder loop, freezing the head unit picture. Anything busier than a test card exceeds + * that routinely, so large messages must split into FIRST/MIDDLE/LAST and reassemble. + */ + @Test + fun `oversized messages split and reassemble`() { + val payload = ByteArray(Frames.MAX_FRAME_PAYLOAD * 2 + 500) { (it % 251).toByte() } + + val frames = mutableListOf() + var offset = 0 + while (offset < payload.size) { + val size = minOf(Frames.MAX_FRAME_PAYLOAD, payload.size - offset) + val chunk = payload.copyOfRange(offset, offset + size) + val frameType = when { + offset == 0 -> Frames.TYPE_FIRST + offset + size >= payload.size -> Frames.TYPE_LAST + else -> Frames.TYPE_MIDDLE + } + frames += Frames.build( + 7, encrypted = false, payload = chunk, messageType = Frames.MESSAGE_SPECIFIC, + frameType = frameType, + totalSize = if (frameType == Frames.TYPE_FIRST) payload.size else null, + ) + offset += size + } + assertEquals(3, frames.size) + + // The FIRST frame carries the 4-byte total length; the others use the short header. + assertEquals(4 + 4 + Frames.MAX_FRAME_PAYLOAD, frames[0].size) + assertEquals(4 + Frames.MAX_FRAME_PAYLOAD, frames[1].size) + + val parser = Frames.Parser() + val assembler = Frames.Assembler() + var rebuilt: ByteArray? = null + for (frame in frames) { + parser.append(frame, frame.size) + val parsed = parser.next()!! + assembler.offer(parsed, parsed.payload)?.let { rebuilt = it } + } + assertTrue(payload.contentEquals(rebuilt!!)) + } + + /** + * Regression: a single resync request was not enough. The requested keyframe is itself + * just another frame, so it can be dropped by the same flow-control check that provoked + * the request, and the old logic (request once, wait for a keyframe to clear the flag) + * never asked again - leaving the picture permanently corrupted for the rest of the + * session. Confirmed on the road: docs/spike/phase-c-stuck-resync-2026-09-17.log shows + * dropped=27 in one burst, then 0.0 fps for over 100 seconds with no recovery. + * + * Model of the fixed decision: ask again on every drop, not just the first, and only + * stop asking once a keyframe actually goes out (not merely once requested). + */ + @Test + fun `resync keeps retrying if the requested keyframe is itself dropped`() { + var requests = 0 + var resyncRequested = false + + fun onDrop() { + resyncRequested = true + requests++ // old, buggy behaviour would gate this behind `if (!resyncRequested)` + } + fun onSent(keyFrame: Boolean) { + if (keyFrame) resyncRequested = false + } + + // Burst: drop, drop (this "keyframe attempt" also dropped), drop again, then finally + // a keyframe gets through once the window clears. + onDrop() + assertEquals(1, requests) + onDrop() // the resync keyframe we asked for got dropped too + assertEquals(2, requests) // must ask again - this is what the old code failed to do + onDrop() + assertEquals(3, requests) + onSent(keyFrame = true) // window finally clears, a keyframe gets out + assertTrue(!resyncRequested) + + // A later drop must be able to trigger a fresh request cycle. + onDrop() + assertEquals(4, requests) + } +} diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/VehicleProfileTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/VehicleProfileTest.kt new file mode 100644 index 0000000..73f94bd --- /dev/null +++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/VehicleProfileTest.kt @@ -0,0 +1,41 @@ +package app.seb3thehacker.gearslip + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VehicleProfileTest { + + private val challenger = ServiceDiscovery.HeadUnitInfo( + headUnitName = "Uconnect", carModel = "Dodge Challenger", carYear = "18", + headUnitMake = "Delphi", headUnitModel = "VP2_R Uconnect 7", + ) + + private fun profile(name: String, vararg match: Pair) = + VehicleProfile(name, match.toMap(), resolution = null, insets = Insets.NONE) + + @Test + fun matchesWhenEveryKeyEqualsIgnoringCase() { + val p = profile("challenger", "carModel" to "dodge challenger", "headUnitName" to "UCONNECT") + assertEquals(p, VehicleProfiles.find(listOf(p), challenger)) + } + + @Test + fun aDifferentCarDoesNotMatch() { + val p = profile("challenger", "carModel" to "Dodge Challenger") + val honda = ServiceDiscovery.HeadUnitInfo("Honda", "Accord", "25", "", "") + assertNull(VehicleProfiles.find(listOf(p), honda)) + } + + @Test + fun aProfileWithNoMatchKeysNeverMatchesEverything() { + assertNull(VehicleProfiles.find(listOf(profile("wildcard")), challenger)) + } + + @Test + fun firstMatchingProfileWins() { + val specific = profile("specific", "carYear" to "18") + val loose = profile("loose", "headUnitName" to "Uconnect") + assertEquals(specific, VehicleProfiles.find(listOf(specific, loose), challenger)) + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ad5fff4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,24 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +android.dependency.useConstraints=false \ No newline at end of file diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 0000000..5c34300 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..c43d235 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,58 @@ +[versions] +agp = "9.1.1" +kotlin = "2.4.0" +coreKtx = "1.19.0" +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +lifecycleRuntimeKtx = "2.10.0" +activityCompose = "1.13.0" +composeBom = "2026.05.01" +carApp = "1.7.0" +appcompat = "1.7.1" +material = "1.14.0" +constraintlayout = "2.2.1" +zxing = "3.5.4" +okhttp = "5.4.0" +kotlinxSerializationJson = "1.11.0" +ossLicenses = "17.5.1" +webkit = "1.16.0" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-car-app = { group = "androidx.car.app", name = "app", version.ref = "carApp" } +androidx-car-app-testing = { group = "androidx.car.app", name = "app-testing", version.ref = "carApp" } +# Only for the ICarHardwareHost AIDL stubs, which the host half has to implement but which +# live in the projected artifact rather than the core one. +androidx-car-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "carApp" } +androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityCompose" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +google-material = { group = "com.google.android.material", name = "material", version.ref = "material" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aad3e1f --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Sep 28 18:17:20 JST 2025 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e67dba8 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,28 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven("https://jitpack.io") + } +} + +rootProject.name = "Gearslip" +include(":gearslip") + \ No newline at end of file