commit 24cfbee0c4b6d120dc2b83a039d3390703099208 Author: seb Date: Sun Sep 20 02:06:21 2026 -0700 Gearslip: phone-side Android Auto client 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 0000000..ad868c9 Binary files /dev/null and b/docs/icon/archive/gearslip-icon-rounded.png differ diff --git a/docs/icon/archive/gearslip-icon-rounded.svg b/docs/icon/archive/gearslip-icon-rounded.svg new file mode 100644 index 0000000..95c7437 --- /dev/null +++ b/docs/icon/archive/gearslip-icon-rounded.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ 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 0000000..3a632e6 Binary files /dev/null and b/docs/icon/gearslip-icon.png differ diff --git a/docs/icon/gearslip-icon.svg b/docs/icon/gearslip-icon.svg new file mode 100644 index 0000000..8b17565 --- /dev/null +++ b/docs/icon/gearslip-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ 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 0000000..fcc2b87 Binary files /dev/null and b/docs/icon/on-device-verification.png differ 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 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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