commit 3b67c80b0acf663dec01dba5c9e1a8cc8d516c47 Author: shihabal3amri <42039842+shihabal3amri@users.noreply.github.com> Date: Fri Sep 25 13:38:35 2026 +0400 Reset public source after withdrawing credential-bearing release diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..47a9a7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,36 @@ +name: Connection, display or audio problem +description: Help us reproduce an issue on your head unit. +title: '[Bug] ' +body: + - type: markdown + attributes: + value: 'Test while parked. Reports are public: review diagnostic files and remove personal information before attaching them. Never post credentials.' + - type: input + id: version + attributes: {label: DiPlay version, placeholder: '0.1.0'} + validations: {required: true} + - type: input + id: car + attributes: {label: Car and head-unit model, placeholder: 'Car model/year, DiLink version, Android version'} + validations: {required: true} + - type: input + id: phone + attributes: {label: iPhone model and exact iOS version} + validations: {required: true} + - type: dropdown + id: transport + attributes: + label: Connection + options: [Wireless, USB, Both] + validations: {required: true} + - type: textarea + id: reproduce + attributes: + label: What happened and how to reproduce it + description: 'Include expected behavior, steps, display settings and whether the car is also connected to a Wi-Fi network. For wireless, include the band/channel if the report shows it.' + validations: {required: true} + - type: textarea + id: diagnostics + attributes: + label: Diagnostic report or recording + description: 'Settings → Save diagnostic report; Android 10+ saves to Downloads/DiPlay. Attach the reviewed file here. If export fails, describe exactly when.' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..67f86f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Installation and connection help + url: https://github.com/shihabal3amri/DiPlay/blob/main/docs/INSTALL.md + about: Read setup and troubleshooting steps first. diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..dedbff2 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,35 @@ +name: Android checks +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Reject credential files in public source + run: python3 scripts/check_public_tree.py + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + - uses: android-actions/setup-android@v3 + with: + packages: platform-tools platforms;android-37.0 build-tools;36.0.0 ndk;28.2.13676358 + - uses: gradle/actions/setup-gradle@v4 + - name: Unit tests, lint and source-only debug build + run: ./gradlew :shared:testDebugUnitTest :common:testDebugUnitTest :mobile:lintDebug :mobile:assembleDebug --stacktrace + - name: Test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + shared/build/reports/tests/ + common/build/reports/tests/ + mobile/build/reports/lint-results-debug.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..e2ae430 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,28 @@ +name: Publish download site +on: + push: + branches: [main] + paths: ['site/**', 'scripts/build_site.py', '.github/workflows/pages.yml'] + workflow_dispatch: +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: pages + cancel-in-progress: true +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - run: python3 scripts/build_site.py + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75d716a --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Android Studio and local IDE state +.idea/ +*.iml +.vscode/ + +# Gradle and local tooling +.gradle/ +.kotlin/ +build/ +local.properties + +# Native build output +.cxx/ +.externalNativeBuild/ +captures/ + +# Local agent working notes +/agent_docs/ + +# OS files +.DS_Store +Thumbs.db +*.log +/.idea + +# Local beta credentials and release signing (never commit) +.private/ +*.jks +*.keystore +release-signing.properties + +# Never commit credential containers or installable distribution artifacts. +*.pk8 +*.p7b +*.p12 +*.pfx +*.pem +*.key +*.apk +*.aab diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5624149 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Source reset — 2026-09-25 + +- Withdrew the 0.1.0 APK and removed its release tag. +- Reset the public branch after preserving restricted local incident records. +- Removed static synthetic test private keys; generate test identities at runtime. +- Removed automatic private-asset packaging and disabled the old release build script. +- Added a build guard rejecting credential asset files. +- Replaced the download site with a five-language suspension notice. + +No replacement APK has been released. Existing copies cannot be recalled by a Git history reset. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 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 General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + 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 GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..02be52c --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# DiPlay + +Independent CarPlay receiver research for Android head units, based on xcertplay with a DiAuto-style interface. + +## Downloads suspended + +The 0.1.0 APK was withdrawn because it bundled a recovered third-party accessory identity. Do not redistribute that APK or extract its identity for new builds. No replacement APK is available. + +Public branch history has been reset to this reviewed source snapshot. The previous release and tag are removed; restricted local incident records are retained. Rewriting history does not recall downloaded copies or invalidate an exposed credential. + +This source snapshot contains no accessory private-key files. Synthetic test identities are generated at test runtime. Automatic private-asset packaging has been removed and the build rejects credential asset files. A source-only build does not provide working standalone CarPlay without an authorized authentication design. + +[Project status](https://shihabal3amri.github.io/DiPlay/) · [Build instructions](docs/BUILD.md) · [Security](SECURITY.md) · [Privacy](docs/PRIVACY.md) + +## Credits + +Based on [xcertplay](https://github.com/shilapi/xcertplay), GPL-3.0. Home/settings UI and website adapt [DiAuto](https://github.com/shihabal3amri/DiAuto), AGPL-3.0. License texts and attribution remain in this repository. CarPlay and its icon belong to Apple Inc. No affiliation with Apple or BYD is implied. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..7d177ba --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,7 @@ +# DiPlay + +APK 下载已暂停。0.1.0 版本包含提取自第三方固件的配件身份,现已撤下。请勿继续分发该 APK 或提取其中的身份用于新构建。目前没有替代 APK。 + +公开分支已重置为经过审查的源代码快照。删除历史记录不会撤回已下载的副本,也不会使已泄露的凭据失效。测试密钥现在在测试运行时生成,不再存储于仓库。 + +[项目状态](https://shihabal3amri.github.io/DiPlay/zh-Hans/) · [完整说明](README.md) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..396ff2f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Distribution suspended + +The 0.1.0 APK was withdrawn after identifying exposure of a recovered third-party accessory private key bundled in the APK. Do not redistribute the APK or reuse that identity in new packages. + +The public branch has been reset and the old release/tag removed. Restricted local release records and the original repository bundle are preserved for investigation. These steps reduce exposure through project-controlled links; they do not recall existing copies or revoke the credential. + +A review of the original published Git objects found no copy of the exposed key in binary or common text encodings, including the private scalar. The original Git tree did contain a different synthetic test key; that file has also been removed. Tests now generate self-signed identities at runtime. + +The Android APK-signing key is separate. No evidence of its publication was found in the audited source or release contents. Changing that signing key would not remediate the accessory-key exposure. + +Do not include credentials, pairing records or personal information in public issues. Use GitHub private vulnerability reporting for sensitive findings. Credential revocation or replacement must be coordinated with the verified credential owner/provider; this project cannot declare those actions completed. + +A future release requires authorized authentication provisioning. Obfuscating an already exposed shared key, encrypting it with a key shipped in the app, or rewriting Git history does not restore confidentiality. + +GitHub guidance: https://docs.github.com/en/code-security/tutorials/remediate-leaked-secrets/remediating-a-leaked-secret diff --git a/asset/xcertplay.png b/asset/xcertplay.png new file mode 100644 index 0000000..5e43259 Binary files /dev/null and b/asset/xcertplay.png differ diff --git a/asset/xcertplay_small.png b/asset/xcertplay_small.png new file mode 100644 index 0000000..a62b81b Binary files /dev/null and b/asset/xcertplay_small.png differ diff --git a/automotive/.gitignore b/automotive/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/automotive/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts new file mode 100644 index 0000000..0da638c --- /dev/null +++ b/automotive/build.gradle.kts @@ -0,0 +1,64 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.shilapi.xcertplay" + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = "com.shilapi.xcertplay" + minSdk = 28 + targetSdk = 37 + versionCode = 1201 + versionName = "1.2.1" + + } + + signingConfigs { + create("release") { + storeFile = file( + providers.environmentVariable("ANDROID_KEYSTORE_PATH") + .getOrElse("missing-release-keystore.jks"), + ) + storePassword = providers.environmentVariable("ANDROID_KEYSTORE_PASSWORD").getOrElse("") + keyAlias = providers.environmentVariable("ANDROID_KEY_ALIAS").getOrElse("") + keyPassword = providers.environmentVariable("ANDROID_KEY_PASSWORD").getOrElse("") + } + } + + buildTypes { + release { + optimization { + enable = false + } + signingConfig = signingConfigs.getByName("release") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(project(":common")) + implementation(project(":shared")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.app.automotive) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + debugImplementation(libs.androidx.compose.ui.tooling) +} diff --git a/automotive/src/main/AndroidManifest.xml b/automotive/src/main/AndroidManifest.xml new file mode 100644 index 0000000..61f5c73 --- /dev/null +++ b/automotive/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/automotive/src/main/keepRules/rules.keep b/automotive/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/automotive/src/main/keepRules/rules.keep @@ -0,0 +1,12 @@ +# Add project specific R8 rules here. +# AGP will combine all keep rule files in src/main/keepRules to pass to R8 +# +# For more details, see +# https://d.android.com/r/tools/r8/keep-rules + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} \ No newline at end of file diff --git a/automotive/src/main/res/values/config.xml b/automotive/src/main/res/values/config.xml new file mode 100644 index 0000000..e2c7cde --- /dev/null +++ b/automotive/src/main/res/values/config.xml @@ -0,0 +1,3 @@ + + true + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..18318be --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +// 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.compose) apply false +} \ No newline at end of file diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..ca40419 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.shilapi.xcertplay.host" + compileSdk { + version = release(37) + } + + defaultConfig { + minSdk = 28 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + buildFeatures { + compose = true + } +} + +dependencies { + api(project(":shared")) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + testImplementation(libs.junit) + testImplementation("org.robolectric:robolectric:4.17") +} diff --git a/common/src/main/AndroidManifest.xml b/common/src/main/AndroidManifest.xml new file mode 100644 index 0000000..545fa78 --- /dev/null +++ b/common/src/main/AndroidManifest.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt new file mode 100644 index 0000000..345868a --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/AirPlayPersistence.kt @@ -0,0 +1,587 @@ +package com.shilapi.xcertplay + +import android.content.Context +import android.os.Build +import com.shilapi.xcertplay.airplay.AirPlayDisplaySettings +import com.shilapi.xcertplay.airplay.AirPlayPhysicalSizeBasis +import com.shilapi.xcertplay.airplay.CarPlayDisplayScale +import com.shilapi.xcertplay.airplay.CarPlayUiScale +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.PairingStore +import com.shilapi.xcertplay.airplay.SafeAreaCodec +import com.shilapi.xcertplay.airplay.SafeAreaRect +import com.shilapi.xcertplay.orchestration.ManualHotspotBand +import com.shilapi.xcertplay.orchestration.ManualHotspotSecurity +import com.shilapi.xcertplay.orchestration.MfiTarget +import com.shilapi.xcertplay.orchestration.WirelessHotspotMode +import com.shilapi.xcertplay.transport.LockdownPairRecord +import java.io.File + +/** SharedPreferences persistence for the accessory identity and paired controllers. */ +object AirPlayPersistence { + private const val PREFS = "xcertplay_airplay" + private const val KEY_IDENT_PRIVATE = "identity_private" + private const val KEY_IDENT_PUBLIC = "identity_public" + private const val KEY_PAIRING_ID = "pairing_id" + private const val KEY_PAIRING_IDS = "pairing_ids" + private const val KEY_LOCKDOWN_HOST_ID = "lockdown_host_id" + private const val KEY_LOCKDOWN_SYSTEM_BUID = "lockdown_system_buid" + private const val KEY_LOCKDOWN_WIFI_MAC = "lockdown_wifi_mac" + private const val KEY_LOCKDOWN_DEVICE_PUBLIC = "lockdown_device_public" + private const val KEY_LOCKDOWN_DEVICE_CERT = "lockdown_device_cert" + private const val KEY_LOCKDOWN_HOST_PRIVATE = "lockdown_host_private" + private const val KEY_LOCKDOWN_HOST_CERT = "lockdown_host_cert" + private const val KEY_LOCKDOWN_ROOT_PRIVATE = "lockdown_root_private" + private const val KEY_LOCKDOWN_ROOT_CERT = "lockdown_root_cert" + private const val KEY_DISPLAY_SCALE_TENTHS = "display_scale_tenths" + private const val KEY_UI_SCALE_PERCENT = "ui_scale_percent" + private const val KEY_HEVC_ENABLED = "hevc_enabled" + private const val KEY_HEVC_SOFTWARE_DECODER = "hevc_software_decoder" + private const val KEY_ADVANCED_AUDIO_CHANNEL_MAPPING = "advanced_audio_channel_mapping" + private const val KEY_WIRELESS_ENABLED = "wireless_enabled" + private const val KEY_WIRELESS_HOTSPOT_MODE = "wireless_hotspot_mode" + private const val KEY_MANUAL_HOTSPOT_SSID = "manual_hotspot_ssid" + private const val KEY_MANUAL_HOTSPOT_PASSPHRASE = "manual_hotspot_passphrase" + private const val KEY_MANUAL_HOTSPOT_BAND = "manual_hotspot_band" + private const val KEY_MANUAL_HOTSPOT_CHANNEL = "manual_hotspot_channel" + private const val KEY_MANUAL_HOTSPOT_SECURITY = "manual_hotspot_security" + private const val KEY_DEBUG_LOGS_ENABLED = "debug_logs_enabled" + private const val KEY_MANUFACTURER = "manufacturer" + private const val KEY_MODEL = "model" + private const val KEY_OEM_LABEL = "oem_label" + private const val KEY_FPS = "display_fps" + private const val KEY_WIDTH_PHYSICAL_MM = "display_width_physical_mm" + private const val KEY_PHYSICAL_SIZE_BASIS = "display_physical_size_basis" + private const val KEY_MAX_DETECTED_WIDTH = "display_max_detected_width" + private const val KEY_MAX_DETECTED_HEIGHT = "display_max_detected_height" + private const val KEY_RIGHT_HAND_DRIVE = "right_hand_drive" + private const val KEY_HIDE_TOP_BAR = "hide_top_bar" + private const val KEY_HIDE_BOTTOM_BAR = "hide_bottom_bar" + private const val KEY_SAFE_AREA_DRAW_OUTSIDE = "safe_area_draw_outside" + private const val KEY_AUTO_START_ON_BOOT = "auto_start_on_boot" + private const val KEY_LOCATION_REPORTING_ENABLED = "location_reporting_enabled" + private const val KEY_MFI_TARGET = "mfi_target" + private const val KEY_MFI_I2C_PATH = "mfi_i2c_path" + private const val KEY_REMOTE_MFI_SERVER = "remote_mfi_server" + private const val KEY_REMOTE_MFI_TOKEN = "remote_mfi_token" + private const val SAFE_AREA_KEY_PREFIX = "safe_area_" + private const val CUSTOM_ICON_FILE = "airplay-icon.png" + + const val DEFAULT_MANUFACTURER = "DiPlay" + const val DEFAULT_MODEL = "DiPlay" + const val DEFAULT_OEM_LABEL = "" + const val DEFAULT_MFI_I2C_PATH = "/dev/i2c-1" + + fun loadDisplayScaleTenths(context: Context): Int { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return CarPlayDisplayScale.sanitize( + prefs.getInt(KEY_DISPLAY_SCALE_TENTHS, CarPlayDisplayScale.DEFAULT_TENTHS), + ) + } + + fun saveDisplayScaleTenths(context: Context, tenths: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_DISPLAY_SCALE_TENTHS, CarPlayDisplayScale.sanitize(tenths)) + .apply() + } + + fun loadHevcEnabled(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_HEVC_ENABLED, false) + + fun loadUiScalePercent(context: Context): Int = CarPlayUiScale.sanitize( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_UI_SCALE_PERCENT, CarPlayUiScale.DEFAULT), + ) + + fun saveUiScalePercent(context: Context, percent: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_UI_SCALE_PERCENT, CarPlayUiScale.sanitize(percent)).apply() + } + + fun saveHevcEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_HEVC_ENABLED, enabled) + .apply() + } + + fun loadHevcSoftwareDecoderEnabled(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_HEVC_SOFTWARE_DECODER, false) + + fun saveHevcSoftwareDecoderEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_HEVC_SOFTWARE_DECODER, enabled) + .apply() + } + + fun loadAdvancedAudioChannelMapping(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_ADVANCED_AUDIO_CHANNEL_MAPPING, false) + + fun saveAdvancedAudioChannelMapping(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_ADVANCED_AUDIO_CHANNEL_MAPPING, enabled) + .apply() + } + + fun loadWirelessEnabled(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_WIRELESS_ENABLED, true) + + fun saveWirelessEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_WIRELESS_ENABLED, enabled) + .apply() + } + + fun loadMfiTarget(context: Context): MfiTarget { + val stored = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MFI_TARGET, null) + return MfiTarget.entries.firstOrNull { it.name == stored } ?: MfiTarget.LOCAL + } + + fun saveMfiTarget(context: Context, target: MfiTarget) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MFI_TARGET, target.name) + .apply() + } + + fun loadMfiI2cPath(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MFI_I2C_PATH, null) + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_MFI_I2C_PATH + + fun saveMfiI2cPath(context: Context, path: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MFI_I2C_PATH, path.trim()) + .apply() + } + + fun loadRemoteMfiServer(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_REMOTE_MFI_SERVER, null) + .orEmpty() + + fun saveRemoteMfiServer(context: Context, server: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_REMOTE_MFI_SERVER, server) + .apply() + } + + fun loadRemoteMfiToken(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_REMOTE_MFI_TOKEN, null) + .orEmpty() + + fun saveRemoteMfiToken(context: Context, token: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_REMOTE_MFI_TOKEN, token) + .apply() + } + + fun loadWirelessHotspotMode(context: Context): WirelessHotspotMode { + val stored = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_WIRELESS_HOTSPOT_MODE, null) + val mode = WirelessHotspotMode.entries.firstOrNull { it.name == stored } + ?: WirelessHotspotMode.WIFI_P2P + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + mode == WirelessHotspotMode.WIFI_P2P + ) { + WirelessHotspotMode.LOCAL_ONLY_HOTSPOT + } else { + mode + } + } + + fun saveWirelessHotspotMode(context: Context, mode: WirelessHotspotMode) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_WIRELESS_HOTSPOT_MODE, mode.name) + .apply() + } + + fun loadManualHotspotSsid(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MANUAL_HOTSPOT_SSID, null) + .orEmpty() + + fun saveManualHotspotSsid(context: Context, ssid: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MANUAL_HOTSPOT_SSID, ssid) + .apply() + } + + fun loadManualHotspotPassphrase(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MANUAL_HOTSPOT_PASSPHRASE, null) + .orEmpty() + + fun saveManualHotspotPassphrase(context: Context, passphrase: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MANUAL_HOTSPOT_PASSPHRASE, passphrase) + .apply() + } + + fun loadManualHotspotBand(context: Context): ManualHotspotBand { + val stored = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MANUAL_HOTSPOT_BAND, null) + return ManualHotspotBand.entries.firstOrNull { it.name == stored } + ?: ManualHotspotBand.AUTO + } + + fun saveManualHotspotBand(context: Context, band: ManualHotspotBand) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MANUAL_HOTSPOT_BAND, band.name) + .apply() + } + + fun loadManualHotspotChannel(context: Context): Int = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_MANUAL_HOTSPOT_CHANNEL, 0) + .coerceIn(0, 196) + + fun saveManualHotspotChannel(context: Context, channel: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_MANUAL_HOTSPOT_CHANNEL, channel.coerceIn(0, 196)) + .apply() + } + + fun loadManualHotspotSecurity(context: Context): ManualHotspotSecurity { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val stored = prefs.getString(KEY_MANUAL_HOTSPOT_SECURITY, null) + return ManualHotspotSecurity.entries.firstOrNull { it.name == stored } + ?: if (loadManualHotspotPassphrase(context).isEmpty()) { + ManualHotspotSecurity.OPEN + } else { + ManualHotspotSecurity.WPA2 + } + } + + fun saveManualHotspotSecurity(context: Context, security: ManualHotspotSecurity) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MANUAL_HOTSPOT_SECURITY, security.name) + .apply() + } + + fun loadDebugLogsEnabled(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_DEBUG_LOGS_ENABLED, false) + + fun saveDebugLogsEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_DEBUG_LOGS_ENABLED, enabled) + .apply() + } + + fun loadAutoStartOnBoot(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_AUTO_START_ON_BOOT, false) + + fun saveAutoStartOnBoot(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_AUTO_START_ON_BOOT, enabled) + .apply() + } + + fun loadLocationReportingEnabled(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_LOCATION_REPORTING_ENABLED, false) + + fun saveLocationReportingEnabled(context: Context, enabled: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_LOCATION_REPORTING_ENABLED, enabled) + .apply() + } + + fun loadManufacturer(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MANUFACTURER, null) + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_MANUFACTURER + + fun saveManufacturer(context: Context, manufacturer: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MANUFACTURER, manufacturer) + .apply() + } + + fun loadModel(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_MODEL, null) + ?.takeIf { it.isNotBlank() } + ?: DEFAULT_MODEL + + fun saveModel(context: Context, model: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_MODEL, model) + .apply() + } + + fun loadOemLabel(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_OEM_LABEL, DEFAULT_OEM_LABEL) + .orEmpty() + + fun saveOemLabel(context: Context, oemLabel: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_OEM_LABEL, oemLabel) + .apply() + } + + fun loadFps(context: Context): Int = AirPlayDisplaySettings.sanitizeFps( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getInt(KEY_FPS, 30), + ) + + fun saveFps(context: Context, fps: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_FPS, AirPlayDisplaySettings.sanitizeFps(fps)) + .apply() + } + + fun loadWidthPhysicalMm(context: Context): Int = + AirPlayDisplaySettings.sanitizeWidthPhysicalMm( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt( + KEY_WIDTH_PHYSICAL_MM, + AirPlayDisplaySettings.DEFAULT_WIDTH_PHYSICAL_MM, + ), + ) + + fun saveWidthPhysicalMm(context: Context, widthPhysicalMm: Int) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt( + KEY_WIDTH_PHYSICAL_MM, + AirPlayDisplaySettings.sanitizeWidthPhysicalMm(widthPhysicalMm), + ) + .apply() + } + + fun loadPhysicalSizeBasis(context: Context): AirPlayPhysicalSizeBasis { + val stored = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_PHYSICAL_SIZE_BASIS, null) + return AirPlayPhysicalSizeBasis.entries.firstOrNull { it.name == stored } + ?: AirPlayDisplaySettings.DEFAULT_PHYSICAL_SIZE_BASIS + } + + fun savePhysicalSizeBasis(context: Context, basis: AirPlayPhysicalSizeBasis) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_PHYSICAL_SIZE_BASIS, basis.name) + .apply() + } + + fun loadMaximumDetectedDisplay(context: Context): Pair { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return prefs.getInt(KEY_MAX_DETECTED_WIDTH, 0) to + prefs.getInt(KEY_MAX_DETECTED_HEIGHT, 0) + } + + fun saveMaximumDetectedDisplay( + context: Context, + widthPixels: Int, + heightPixels: Int, + ) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putInt(KEY_MAX_DETECTED_WIDTH, widthPixels.coerceAtLeast(0)) + .putInt(KEY_MAX_DETECTED_HEIGHT, heightPixels.coerceAtLeast(0)) + .apply() + } + + fun loadRightHandDrive(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_RIGHT_HAND_DRIVE, false) + + fun saveRightHandDrive(context: Context, rightHandDrive: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_RIGHT_HAND_DRIVE, rightHandDrive) + .apply() + } + + fun loadHideTopBar(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_HIDE_TOP_BAR, true) + + fun saveHideTopBar(context: Context, hide: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_HIDE_TOP_BAR, hide) + .apply() + } + + fun loadHideBottomBar(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_HIDE_BOTTOM_BAR, true) + + fun saveHideBottomBar(context: Context, hide: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_HIDE_BOTTOM_BAR, hide) + .apply() + } + + fun loadSafeAreaDrawOutside(context: Context): Boolean = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_SAFE_AREA_DRAW_OUTSIDE, true) + + fun saveSafeAreaDrawOutside(context: Context, drawOutside: Boolean) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putBoolean(KEY_SAFE_AREA_DRAW_OUTSIDE, drawOutside) + .apply() + } + + fun loadSafeAreaRect(context: Context, widthPixels: Int, heightPixels: Int): SafeAreaRect? { + require(widthPixels > 0 && heightPixels > 0) { "Activity dimensions must be positive" } + return SafeAreaCodec.decode( + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(safeAreaKey(widthPixels, heightPixels), null), + ) + } + + fun saveSafeAreaRect( + context: Context, + activityWidthPixels: Int, + activityHeightPixels: Int, + rect: SafeAreaRect, + commit: Boolean = false, + ) { + require(activityWidthPixels > 0 && activityHeightPixels > 0) { + "Activity dimensions must be positive" + } + val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString( + safeAreaKey(activityWidthPixels, activityHeightPixels), + SafeAreaCodec.encode(rect.clampTo(activityWidthPixels, activityHeightPixels)), + ) + if (commit) editor.commit() else editor.apply() + } + + fun clearSafeAreaRect( + context: Context, + activityWidthPixels: Int, + activityHeightPixels: Int, + commit: Boolean = false, + ) { + require(activityWidthPixels > 0 && activityHeightPixels > 0) { + "Activity dimensions must be positive" + } + val editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .remove(safeAreaKey(activityWidthPixels, activityHeightPixels)) + if (commit) editor.commit() else editor.apply() + } + + fun loadCustomAirPlayIconFile(context: Context): File? = + File(context.filesDir, CUSTOM_ICON_FILE).takeIf { it.isFile } + + fun saveCustomAirPlayIcon(context: Context, encodedImage: ByteArray) { + require(encodedImage.isNotEmpty()) { "AirPlay icon data must not be empty" } + File(context.filesDir, CUSTOM_ICON_FILE).outputStream().use { output -> + output.write(encodedImage) + } + } + + fun clearCustomAirPlayIcon(context: Context) { + File(context.filesDir, CUSTOM_ICON_FILE).delete() + } + + fun loadIdentity(context: Context): AirPlayIdentity { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val privateKey = prefs.getString(KEY_IDENT_PRIVATE, null) + val publicKey = prefs.getString(KEY_IDENT_PUBLIC, null) + val pairingId = prefs.getString(KEY_PAIRING_ID, null) + if (privateKey != null && publicKey != null && pairingId != null) { + return AirPlayIdentity(privateKey.decodeHex(), publicKey.decodeHex(), pairingId) + } + return AirPlayIdentity.generate().also { identity -> + prefs.edit() + .putString(KEY_IDENT_PRIVATE, identity.privateKey.toHex()) + .putString(KEY_IDENT_PUBLIC, identity.publicKey.toHex()) + .putString(KEY_PAIRING_ID, identity.pairingId) + .apply() + } + } + + fun loadPairings(context: Context, onSave: (String, ByteArray) -> Unit): PairingStore { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val store = PairingStore(onSave) + for (identifier in prefs.getStringSet(KEY_PAIRING_IDS, emptySet()).orEmpty()) { + prefs.getString("pairing.$identifier", null)?.let { store.save(identifier, it.decodeHex()) } + } + return store + } + + fun savePairing(context: Context, identifier: String, longTermPublicKey: ByteArray) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val identifiers = prefs.getStringSet(KEY_PAIRING_IDS, emptySet()).orEmpty().toMutableSet() + identifiers.add(identifier) + prefs.edit() + .putString("pairing.$identifier", longTermPublicKey.toHex()) + .putStringSet(KEY_PAIRING_IDS, identifiers) + .apply() + } + + fun loadLockdownRecord(context: Context): LockdownPairRecord? { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val hostId = prefs.getString(KEY_LOCKDOWN_HOST_ID, null) ?: return null + val systemBuid = prefs.getString(KEY_LOCKDOWN_SYSTEM_BUID, null) ?: return null + val wifiMac = prefs.getString(KEY_LOCKDOWN_WIFI_MAC, null) ?: return null + val devicePublic = prefs.getString(KEY_LOCKDOWN_DEVICE_PUBLIC, null) ?: return null + val deviceCert = prefs.getString(KEY_LOCKDOWN_DEVICE_CERT, null) ?: return null + val hostPrivate = prefs.getString(KEY_LOCKDOWN_HOST_PRIVATE, null) ?: return null + val hostCert = prefs.getString(KEY_LOCKDOWN_HOST_CERT, null) ?: return null + val rootPrivate = prefs.getString(KEY_LOCKDOWN_ROOT_PRIVATE, null) ?: return null + val rootCert = prefs.getString(KEY_LOCKDOWN_ROOT_CERT, null) ?: return null + return try { + LockdownPairRecord.restore( + hostId = hostId, + systemBuid = systemBuid, + wifiMacAddress = wifiMac, + devicePublicKeyPem = devicePublic.decodeHex(), + deviceCertificatePem = deviceCert.decodeHex(), + hostPrivateKeyPem = hostPrivate.decodeHex(), + hostCertificatePem = hostCert.decodeHex(), + rootPrivateKeyPem = rootPrivate.decodeHex(), + rootCertificatePem = rootCert.decodeHex(), + ) + } catch (_: Exception) { + null + } + } + + fun saveLockdownRecord(context: Context, record: LockdownPairRecord) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .putString(KEY_LOCKDOWN_HOST_ID, record.hostId) + .putString(KEY_LOCKDOWN_SYSTEM_BUID, record.systemBuid) + .putString(KEY_LOCKDOWN_WIFI_MAC, record.wifiMacAddress) + .putString(KEY_LOCKDOWN_DEVICE_PUBLIC, record.devicePublicKeyPem.toHex()) + .putString(KEY_LOCKDOWN_DEVICE_CERT, record.deviceCertificatePem.toHex()) + .putString(KEY_LOCKDOWN_HOST_PRIVATE, record.hostPrivateKeyPem.toHex()) + .putString(KEY_LOCKDOWN_HOST_CERT, record.hostCertificatePem.toHex()) + .putString(KEY_LOCKDOWN_ROOT_PRIVATE, record.rootPrivateKeyPem.toHex()) + .putString(KEY_LOCKDOWN_ROOT_CERT, record.rootCertificatePem.toHex()) + .apply() + } + + fun clearLockdownRecord(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() + .remove(KEY_LOCKDOWN_HOST_ID) + .remove(KEY_LOCKDOWN_SYSTEM_BUID) + .remove(KEY_LOCKDOWN_WIFI_MAC) + .remove(KEY_LOCKDOWN_DEVICE_PUBLIC) + .remove(KEY_LOCKDOWN_DEVICE_CERT) + .remove(KEY_LOCKDOWN_HOST_PRIVATE) + .remove(KEY_LOCKDOWN_HOST_CERT) + .remove(KEY_LOCKDOWN_ROOT_PRIVATE) + .remove(KEY_LOCKDOWN_ROOT_CERT) + .apply() + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun String.decodeHex(): ByteArray { + require(length % 2 == 0) { "hex string must have even length" } + return ByteArray(length / 2) { index -> + substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + + private fun safeAreaKey(widthPixels: Int, heightPixels: Int): String = + "$SAFE_AREA_KEY_PREFIX${widthPixels}x$heightPixels" +} diff --git a/common/src/main/java/com/shilapi/xcertplay/BootReceiver.kt b/common/src/main/java/com/shilapi/xcertplay/BootReceiver.kt new file mode 100644 index 0000000..7e96d1e --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/BootReceiver.kt @@ -0,0 +1,31 @@ +package com.shilapi.xcertplay + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** Starts the CarPlay host after boot when the user has enabled the startup option. */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + if (!AirPlayPersistence.loadAutoStartOnBoot(context)) return + + val launch = Intent(context, DiPlayActivity::class.java).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + } + try { + context.startActivity(launch) + } catch (error: RuntimeException) { + Log.w(TAG, "Boot auto-start could not launch CarPlayHostActivity", error) + } + } + + private companion object { + const val TAG = "xcertplay-boot" + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt new file mode 100644 index 0000000..54b1025 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt @@ -0,0 +1,3530 @@ +package com.shilapi.xcertplay + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.SurfaceTexture +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.Process +import android.text.Editable +import android.text.InputType +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.view.Gravity +import android.view.MotionEvent +import android.view.Surface +import android.view.TextureView +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.ScrollView +import android.widget.SeekBar +import android.widget.Switch +import android.widget.TextView +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayDisplaySettings +import com.shilapi.xcertplay.airplay.AirPlayPhysicalSizeBasis +import com.shilapi.xcertplay.airplay.AirPlayPhysicalSizeMm +import com.shilapi.xcertplay.airplay.CarPlayDisplayScale +import com.shilapi.xcertplay.airplay.CarPlayUiScale +import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.AirPlayIcon +import com.shilapi.xcertplay.airplay.AirPlaySafeArea +import com.shilapi.xcertplay.airplay.AirPlaySession +import com.shilapi.xcertplay.airplay.AirPlaySessionListener +import com.shilapi.xcertplay.airplay.CarPlayMediaEngine +import com.shilapi.xcertplay.airplay.SafeAreaRect +import com.shilapi.xcertplay.host.R +import com.shilapi.xcertplay.location.AndroidCarPlayLocationProvider +import com.shilapi.xcertplay.media.AndroidMediaSink +import com.shilapi.xcertplay.media.CarPlayTouchMapper +import com.shilapi.xcertplay.network.CarPlayVpnService +import com.shilapi.xcertplay.orchestration.CarPlayController +import com.shilapi.xcertplay.orchestration.CarPlayRuntimeConfig +import com.shilapi.xcertplay.orchestration.CarPlayStatus +import com.shilapi.xcertplay.orchestration.CarPlayTransport +import com.shilapi.xcertplay.orchestration.ManualHotspotBand +import com.shilapi.xcertplay.orchestration.ManualHotspotSecurity +import com.shilapi.xcertplay.orchestration.MfiTarget +import com.shilapi.xcertplay.orchestration.WirelessHotspotMode +import com.shilapi.xcertplay.orchestration.isManualHotspotChannelCompatible +import com.shilapi.xcertplay.transport.Iap2IdentificationConfig +import com.shilapi.xcertplay.transport.Iap2LocationProvider +import com.shilapi.xcertplay.transport.UsbDeviceId +import java.io.File +import java.text.SimpleDateFormat +import java.util.ArrayDeque +import java.util.Date +import java.util.Locale +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Full-screen CarPlay host. It renders decoded video through a [TextureView], forwards touch to + * the active AirPlay session, and drives the complete wired or wireless bring-up through + * [CarPlayController]. + * + * Apple devices are discovered by vendor ID; CH341 uses the configured VID/PID below. + */ +class CarPlayHostActivity : ComponentActivity() { + private data class SettingsBaseline( + val safeAreaSize: DisplaySize?, + val safeAreaRect: SafeAreaRect?, + val customIconBytes: ByteArray?, + ) + + private var connectionPanel: View? = null + private var wifiRecoveryButton: View? = null + private var reconnectAttempts = 0 + private lateinit var airPlayIdentity: AirPlayIdentity + + // CH341 USB\VID_1A86&PID_5512&REV_0304 is the deployment-supplied bridge identity. + private fun createRuntimeConfig(): CarPlayRuntimeConfig = CarPlayRuntimeConfig( + mfiTarget = MfiTarget.LOCAL, + ch341Devices = if (mfiTarget == MfiTarget.USB_CH341) { + listOf(UsbDeviceId(0x1a86, 0x5512)) + } else { + emptyList() + }, + // The CP latches its I2C address from the RST level at its own power-up, so the host must + // not pulse RST before discovery. Driving D0 re-latches the part onto the alternate + // address (0x10), where the accessory certificate is not readable. Leave RST at its + // hardware pull (VCC -> 0x11) and let the scanner find the part with its certificate. + // Set this back to 0 to restore the D0 pulse. + ch341MfiResetGpio = null, + linuxI2cPath = if (mfiTarget == MfiTarget.I2C) mfiI2cPath.trim() else null, + remoteMfiServer = remoteMfiServer.trim().takeIf { it.isNotEmpty() }, + remoteMfiToken = remoteMfiToken.takeIf { it.isNotEmpty() }, + identification = Iap2IdentificationConfig( + name = "DiPlay", + modelIdentifier = normalizedModel(), + manufacturer = normalizedManufacturer(), + serialNumber = "DIPLAY-" + DiPlayBootstrap.deviceId(airPlayIdentity).replace(":", ""), + firmwareVersion = "0.1.0", + hardwareVersion = "1.0", + carPlayUsbInterfaceNumber = 3, + locationInformationEnabled = locationReportingEnabled, + ), + label = "DiPlay", + hostName = "diplay-" + DiPlayBootstrap.deviceId(airPlayIdentity).replace(":", "").lowercase(), + hostMac = DiPlayBootstrap.deviceId(airPlayIdentity).split(":").map { it.toInt(16).toByte() }.toByteArray(), + wirelessBluetoothDeviceAddress = DiPlayPreferences.phoneAddress(this), + transport = if (wirelessEnabled) CarPlayTransport.WIRELESS else CarPlayTransport.WIRED, + wirelessHotspotMode = wirelessHotspotMode, + manualHotspotSsid = manualHotspotSsid, + manualHotspotPassphrase = manualHotspotPassphrase, + manualHotspotBand = manualHotspotBand, + manualHotspotChannel = manualHotspotChannel, + manualHotspotSecurity = manualHotspotSecurity, + locationReportingEnabled = locationReportingEnabled, + ) + + private val vpnConsent = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + awaitingVpnConsent = false + if (result.resultCode == RESULT_OK) { + vpnReady = true + maybeStartCarPlay() + } else { + setStatus("VPN consent was denied") + } + } + private val wirelessPermissions = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + awaitingWirelessPermissions = false + wirelessPermissionsReady = hasRequiredWirelessPermissions() + appendLog( + if (wirelessPermissionsReady) { + "Wireless startup permissions granted" + } else { + "Wireless startup permissions denied" + }, + ) + updateHotspotStatusBlock() + maybeStartCarPlay() + } + private val microphonePermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + microphoneAvailable = granted + microphonePermissionResolved = true + appendLog(if (granted) "Microphone permission granted" else "Microphone permission denied") + requestStartupPrerequisites() + } + private val locationPermission = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> + awaitingLocationPermission = false + locationPermissionAvailable = hasFineLocationPermission() + if (locationPermissionAvailable) { + appendLog("Location permission granted") + } else if (locationReportingEnabled) { + locationReportingEnabled = false + if (!menuOpen) { + AirPlayPersistence.saveLocationReportingEnabled( + this@CarPlayHostActivity, + false, + ) + } + locationReportingSwitch?.isChecked = false + val approximateOnly = + grants[Manifest.permission.ACCESS_COARSE_LOCATION] == true + appendLog( + if (approximateOnly) { + "Precise location permission denied; location reporting disabled" + } else { + "Location permission denied; location reporting disabled" + }, + ) + } + updateResolutionMenu() + if (!menuOpen) requestStartupPrerequisites() + } + + private val imagePicker = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri == null) { + externalActivityInProgress = false + return@registerForActivityResult + } + imageCrop.launch( + Intent(this, ImageCropActivity::class.java) + .setData(uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION), + ) + } + private val imageCrop = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + externalActivityInProgress = false + if (result.resultCode == RESULT_OK) { + updateAirPlayIconPreview() + appendLog("Custom AirPlay icon updated") + } + } + + private var videoView: TextureView? = null + private var gestureOverlay: View? = null + private var settingsMenu: View? = null + private var mfiTargetGroup: RadioGroup? = null + private var mfiI2cFields: View? = null + private var mfiRemoteFields: View? = null + private var mfiErrorView: TextView? = null + private var mfiI2cPathInput: EditText? = null + private var remoteMfiServerInput: EditText? = null + private var remoteMfiTokenInput: EditText? = null + private var settingsBaseline: SettingsBaseline? = null + private var locationReportingSwitch: Switch? = null + private var statusView: TextView? = null + private var statusScrollView: ScrollView? = null + private var stageStatusView: TextView? = null + private var resolutionValueView: TextView? = null + private var resolutionPreviewView: TextView? = null + private var hotspotStatusView: TextView? = null + private var manualHotspotFields: View? = null + private var manualHotspotErrorView: TextView? = null + private var iconPreviewView: ImageView? = null + private var iconStatusView: TextView? = null + private var safeAreaSummaryView: TextView? = null + private var safeAreaEditor: View? = null + private var safeAreaEditorView: SafeAreaEditorView? = null + private var safeAreaEditSize: DisplaySize? = null + private var safeAreaEditorActive = false + private var externalActivityInProgress = false + private var sink: AndroidMediaSink? = null + private var controller: CarPlayController? = null + private var currentSurface: Surface? = null + private var currentSurfaceTexture: SurfaceTexture? = null + private var activeDisplaySize: DisplaySize? = null + private var pendingDisplaySize: DisplaySize? = null + private var displayScaleTenths = CarPlayDisplayScale.DEFAULT_TENTHS + private var uiScalePercent = CarPlayUiScale.DEFAULT + private var displayDiagnosticAttempt: String? = null + private var hevcEnabled = true + private var hevcSoftwareDecoderEnabled = false + private var advancedAudioChannelMappingSupported = false + private var advancedAudioChannelMapping = false + private var debugLogsEnabled = false + private var autoStartOnBoot = false + private var manufacturer = AirPlayPersistence.DEFAULT_MANUFACTURER + private var model = AirPlayPersistence.DEFAULT_MODEL + private var oemLabel = AirPlayPersistence.DEFAULT_OEM_LABEL + private var fps = AirPlayDisplaySettings.DEFAULT_FPS + private var widthPhysicalMm = AirPlayDisplaySettings.DEFAULT_WIDTH_PHYSICAL_MM + private var physicalSizeBasis = AirPlayDisplaySettings.DEFAULT_PHYSICAL_SIZE_BASIS + private var maximumDetectedWidthPixels = 0 + private var maximumDetectedHeightPixels = 0 + private var rightHandDrive = false + private var hideTopBar = true + private var hideBottomBar = true + private var safeAreaDrawOutside = true + private var locationReportingEnabled = false + private var locationPermissionAvailable = false + private var microphoneAvailable = false + private var microphonePermissionResolved = false + private var wirelessEnabled = false + private var mfiTarget = MfiTarget.USB_CH341 + private var mfiI2cPath = AirPlayPersistence.DEFAULT_MFI_I2C_PATH + private var remoteMfiServer = "" + private var remoteMfiToken = "" + private var wirelessPermissionsReady = false + private var wirelessHotspotMode = WirelessHotspotMode.WIFI_P2P + private var manualHotspotSsid = "" + private var manualHotspotPassphrase = "" + private var manualHotspotBand = ManualHotspotBand.AUTO + private var manualHotspotChannel = 0 + private var manualHotspotSecurity = ManualHotspotSecurity.OPEN + private var awaitingVpnConsent = false + private var awaitingWirelessPermissions = false + private var awaitingLocationPermission = false + private var vpnReady = false + private var hotspotStatus = HotspotStatus(state = "off") + private var menuOpen = false + private var latestStage = "Preparing CarPlay" + private var darkMode = false + private var activeAirPlaySession: AirPlaySession? = null + private val activeScreenStreamTypes = mutableSetOf() + private var handshakeResetInProgress = false + private var startAfterHandshakeReset = false + private var restartGeneration = 0 + private var reconnectScheduled = false + private var sessionLog: SessionLogFile? = null + private var gestureSequenceActive = false + private var gestureTracking = false + private var gestureStartX = 0f + private var gestureStartY = 0f + private val shuttingDown = AtomicBoolean(false) + private val mainHandler = Handler(Looper.getMainLooper()) + private val teardownExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val airPlayCommandExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val logLines = ArrayDeque() + private val expireOldLogLines = Runnable { refreshLogView(System.currentTimeMillis()) } + private val applyDisplaySize = Runnable { + val size = pendingDisplaySize ?: return@Runnable + pendingDisplaySize = null + applyDisplaySize(size) + } + + private val textureListener = object : TextureView.SurfaceTextureListener { + override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) { + val existing = currentSurface + val surface = if ( + existing != null && + currentSurfaceTexture === texture && + existing.isValid + ) { + existing + } else { + Surface(texture).also { + existing?.release() + currentSurface = it + currentSurfaceTexture = texture + } + } + appendLog(if (existing === surface) "Texture surface reused" else "Texture surface created") + attachSurface(surface) + scheduleDisplaySize(width, height) + } + + override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) { + scheduleDisplaySize(width, height) + } + + override fun onSurfaceTextureDestroyed(texture: SurfaceTexture): Boolean { + if (currentSurfaceTexture !== texture) return true + currentSurface?.let { surface -> + sink?.clearSurface(SCREEN_TYPE_MAIN, surface) + sink?.clearSurface(SCREEN_TYPE_ALT, surface) + surface.release() + } + currentSurface = null + currentSurfaceTexture = null + appendLog("Texture surface destroyed") + return true + } + + override fun onSurfaceTextureUpdated(texture: SurfaceTexture) = Unit + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED") { + AirPlayPersistence.saveWirelessEnabled(this, false) + } + if (runCatching { DiPlayBootstrap.ensure(this) }.isFailure) { + startActivity(Intent(this, DiPlayActivity::class.java)) + finish(); return + } + window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + initializeSessionLog() + darkMode = isDarkMode(resources.configuration.uiMode) + advancedAudioChannelMappingSupported = + resources.getBoolean(R.bool.config_advanced_audio_channel_mapping) + airPlayIdentity = AirPlayPersistence.loadIdentity(this) + loadPersistedSettings() + locationPermissionAvailable = hasFineLocationPermission() + setContentView(buildContentView()) + applyFullscreenMode() + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (menuOpen) { + if (safeAreaEditorActive) closeSafeAreaEditor() else cancelSettingsEdits() + } else { + showDiPlayHome() + } + } + }, + ) + + appendLog( + "Host started; MFI target=${mfiTargetLabel(mfiTarget)}; " + + "transport=${if (wirelessEnabled) "wireless" else "wired"}", + ) + val reusedBackgroundSession = adoptBackgroundSession() + microphoneAvailable = + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + microphonePermissionResolved = microphoneAvailable + if (reusedBackgroundSession) { + updateDebugOverlays() + } else if (microphonePermissionResolved) { + requestStartupPrerequisites() + } else { + microphonePermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + + private fun loadPersistedSettings() { + displayScaleTenths = AirPlayPersistence.loadDisplayScaleTenths(this) + uiScalePercent = AirPlayPersistence.loadUiScalePercent(this) + hevcEnabled = AirPlayPersistence.loadHevcEnabled(this) + hevcSoftwareDecoderEnabled = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + AirPlayPersistence.loadHevcSoftwareDecoderEnabled(this) + advancedAudioChannelMapping = + advancedAudioChannelMappingSupported && + AirPlayPersistence.loadAdvancedAudioChannelMapping(this) + debugLogsEnabled = AirPlayPersistence.loadDebugLogsEnabled(this) + autoStartOnBoot = AirPlayPersistence.loadAutoStartOnBoot(this) + manufacturer = AirPlayPersistence.loadManufacturer(this) + model = AirPlayPersistence.loadModel(this) + oemLabel = AirPlayPersistence.loadOemLabel(this) + fps = AirPlayPersistence.loadFps(this) + widthPhysicalMm = AirPlayPersistence.loadWidthPhysicalMm(this) + physicalSizeBasis = AirPlayPersistence.loadPhysicalSizeBasis(this) + AirPlayPersistence.loadMaximumDetectedDisplay(this).let { (width, height) -> + maximumDetectedWidthPixels = width + maximumDetectedHeightPixels = height + } + rightHandDrive = AirPlayPersistence.loadRightHandDrive(this) + hideTopBar = AirPlayPersistence.loadHideTopBar(this) + hideBottomBar = AirPlayPersistence.loadHideBottomBar(this) + safeAreaDrawOutside = AirPlayPersistence.loadSafeAreaDrawOutside(this) + locationReportingEnabled = AirPlayPersistence.loadLocationReportingEnabled(this) + locationPermissionAvailable = hasFineLocationPermission() + wirelessEnabled = AirPlayPersistence.loadWirelessEnabled(this) + mfiTarget = AirPlayPersistence.loadMfiTarget(this) + mfiI2cPath = AirPlayPersistence.loadMfiI2cPath(this) + remoteMfiServer = AirPlayPersistence.loadRemoteMfiServer(this) + remoteMfiToken = AirPlayPersistence.loadRemoteMfiToken(this) + wirelessHotspotMode = AirPlayPersistence.loadWirelessHotspotMode(this) + manualHotspotSsid = AirPlayPersistence.loadManualHotspotSsid(this) + manualHotspotPassphrase = AirPlayPersistence.loadManualHotspotPassphrase(this) + manualHotspotBand = AirPlayPersistence.loadManualHotspotBand(this) + manualHotspotChannel = AirPlayPersistence.loadManualHotspotChannel(this) + manualHotspotSecurity = AirPlayPersistence.loadManualHotspotSecurity(this) + wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() + } + + private fun requestStartupPrerequisites() { + if (locationReportingEnabled && !locationPermissionAvailable) { + requestLocationPermission() + return + } + if (wirelessEnabled) { + requestWirelessPermissions() + } else { + requestVpnConsent() + } + } + + private fun requestLocationPermission() { + if (locationPermissionAvailable || awaitingLocationPermission) return + awaitingLocationPermission = true + locationPermission.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ), + ) + } + + private fun hasFineLocationPermission(): Boolean = + checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + private fun requestVpnConsent() { + val consent = CarPlayVpnService.prepare(this) + if (consent == null) { + vpnReady = true + maybeStartCarPlay() + } else { + awaitingVpnConsent = true + vpnConsent.launch(consent) + } + } + + private fun requestWirelessPermissions() { + val permissions = requiredWirelessPermissions() + if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) { + wirelessPermissionsReady = true + updateHotspotStatusBlock() + maybeStartCarPlay() + return + } + wirelessPermissionsReady = false + updateHotspotStatusBlock() + awaitingWirelessPermissions = true + wirelessPermissions.launch(permissions.toTypedArray()) + } + + private fun hasRequiredWirelessPermissions(): Boolean = + requiredWirelessPermissions().all { + checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED + } + + private fun requiredWirelessPermissions(): List = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> listOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.NEARBY_WIFI_DEVICES, + ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> listOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + else -> listOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION, + ) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + if (intent.action == "android.hardware.usb.action.USB_DEVICE_ATTACHED" && wirelessEnabled) { + shutdown(false, "switching to USB") { + AirPlayPersistence.saveWirelessEnabled(this, false) + startActivity(Intent(this, CarPlayHostActivity::class.java)) + } + finish() + } + } + + override fun onResume() { + super.onResume() + locationPermissionAvailable = hasFineLocationPermission() + if (locationReportingEnabled && !locationPermissionAvailable && !menuOpen) { + requestLocationPermission() + } + wirelessPermissionsReady = !wirelessEnabled || hasRequiredWirelessPermissions() + maybeStartCarPlay() + applyFullscreenMode() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) applyFullscreenMode() + } + + override fun onStop() { + // The controller, USB/iAP2 link, and VPN attachment intentionally outlive the UI. + super.onStop() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + val nextDarkMode = isDarkMode(newConfig.uiMode) + if (nextDarkMode != darkMode) { + darkMode = nextDarkMode + syncAirPlayDarkMode() + } + applyFullscreenMode() + stageStatusView?.maxWidth = (resources.displayMetrics.widthPixels * 0.78f).toInt() + scrollLogsToBottom() + videoView?.post { + val view = videoView ?: return@post + scheduleDisplaySize(view.width, view.height) + } + } + + override fun onDestroy() { + mainHandler.removeCallbacks(applyDisplaySize) + mainHandler.removeCallbacks(expireOldLogLines) + currentSurface?.let { surface -> + sink?.clearSurface(SCREEN_TYPE_MAIN, surface) + sink?.clearSurface(SCREEN_TYPE_ALT, surface) + surface.release() + } + currentSurface = null + currentSurfaceTexture = null + sessionLog?.append("Activity destroyed") + sessionLog?.close() + sessionLog = null + super.onDestroy() + } + + private fun buildContentView(): View { + val root = FrameLayout(this).apply { setBackgroundColor(Color.rgb(12, 17, 27)) } + val video = TextureView(this).apply { + isOpaque = false + surfaceTextureListener = textureListener + } + val gestureLayer = View(this).apply { + isClickable = true + setOnTouchListener { view, event -> onHostTouch(view, event) } + } + root.addView(video, FrameLayout.LayoutParams(-1, -1)) + root.addView(gestureLayer, FrameLayout.LayoutParams(-1, -1)) + val panel = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setPadding(dp(32), dp(32), dp(32), dp(32)) + setBackgroundColor(Color.rgb(12, 17, 27)) + isClickable = true + } + panel.addView(ImageView(this).apply { + setImageResource(R.drawable.ic_carplay); contentDescription = "CarPlay" + }, LinearLayout.LayoutParams(dp(88), dp(88))) + panel.addView(TextView(this).apply { + text = "DiPlay"; textSize = 34f; setTextColor(Color.rgb(241, 245, 252)) + gravity = Gravity.CENTER; setPadding(0, dp(18), 0, dp(14)) + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + }) + val stage = TextView(this).apply { + text = "Getting CarPlay ready…"; textSize = 22f; gravity = Gravity.CENTER + setTextColor(Color.rgb(241, 245, 252)) + } + panel.addView(stage) + panel.addView(TextView(this).apply { + text = if (wirelessEnabled) "Keep your iPhone nearby with Bluetooth and Wi-Fi on.\nAllow CarPlay if your iPhone asks." + else "Use a USB data cable and unlock your iPhone.\nAllow Trust and CarPlay if your iPhone asks." + textSize = 17f; gravity = Gravity.CENTER; setTextColor(Color.rgb(168, 182, 202)) + setPadding(0, dp(14), 0, dp(24)) + }) + panel.addView(Button(this).apply { + text = "Reset CarPlay Wi-Fi"; isAllCaps = false; textSize = 18f + visibility = View.GONE + setOnClickListener { showDiPlayHome("wireless-recovery") } + wifiRecoveryButton = this + }, LinearLayout.LayoutParams(dp(300), dp(64)).apply { bottomMargin = dp(12) }) + panel.addView(Button(this).apply { + text = "Back to DiPlay"; isAllCaps = false; textSize = 18f + setTextColor(Color.rgb(12, 17, 27)) + background = GradientDrawable().apply { setColor(Color.rgb(166, 200, 255)); cornerRadius = dp(20).toFloat() } + setOnClickListener { showDiPlayHome() } + }, LinearLayout.LayoutParams(dp(300), dp(64))) + panel.addView(TextView(this).apply { + text = "In CarPlay, swipe down with three fingers to open DiPlay settings." + textSize = 13f; gravity = Gravity.CENTER; setTextColor(Color.rgb(168, 182, 202)); setPadding(0, dp(20), 0, 0) + }) + root.addView(panel, FrameLayout.LayoutParams(-1, -1)) + videoView = video + gestureOverlay = gestureLayer + stageStatusView = stage + connectionPanel = panel + updateDebugOverlays() + return root + } + + private fun buildSettingsMenu(): View { + val overlay = FrameLayout(this).apply { + setBackgroundColor(Color.BLACK) + isClickable = true + } + val panel = FrameLayout(this).apply { + setBackgroundColor(MENU_BACKGROUND) + } + val content = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(dp(48), dp(36), dp(48), dp(36)) + } + content.addView( + menuText("CarPlay settings", 32f, Color.WHITE, bold = true).apply { + setPadding(dp(56), 0, 0, 0) + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + content.addView( + settingsCategoryHeader("Connection"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(32) }, + ) + + content.addView( + buildMfiTargetSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(14) }, + ) + + val wirelessRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + wirelessRow.addView( + menuText("Wireless CarPlay", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + val wirelessSwitch = Switch(this).apply { + isChecked = wirelessEnabled + contentDescription = "Wireless CarPlay transport" + showText = false + thumbTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + trackTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), + ) + setOnCheckedChangeListener { _, checked -> + if (wirelessEnabled == checked) return@setOnCheckedChangeListener + wirelessEnabled = checked + hotspotStatus = HotspotStatus(state = if (wirelessEnabled) "stopped" else "off") + updateHotspotStatusBlock() + appendLog( + "Wireless CarPlay ${if (wirelessEnabled) "enabled" else "disabled"}; " + + "applies when settings close", + ) + requestStartupPrerequisites() + } + } + wirelessRow.addView( + wirelessSwitch, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + content.addView( + wirelessRow, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(30) }, + ) + + content.addView( + buildHotspotModeSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(30) }, + ) + + content.addView( + menuText("Hotspot status", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(18) }, + ) + val hotspotStatusView = menuText("", 16f, MENU_ACCENT) + content.addView( + hotspotStatusView, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(6) }, + ) + + content.addView( + settingsCategoryHeader("Location"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(36) }, + ) + content.addView( + buildLocationReportingSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + + content.addView( + settingsCategoryHeader("Startup"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(36) }, + ) + content.addView( + settingsSwitchRow( + label = "Auto-start on boot", + checked = autoStartOnBoot, + description = "Start CarPlay automatically after device boot", + ) { checked -> + autoStartOnBoot = checked + appendLog("Boot auto-start ${if (checked) "enabled" else "disabled"}") + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + + if (advancedAudioChannelMappingSupported) { + content.addView( + settingsCategoryHeader("Audio"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(36) }, + ) + content.addView( + settingsSwitchRow( + label = "Advanced audio channel mapping", + checked = advancedAudioChannelMapping, + description = "Route AAOS audio buses by CarPlay audio type", + ) { checked -> + advancedAudioChannelMapping = checked + appendLog( + "Advanced audio channel mapping ${if (checked) "enabled" else "disabled"}; " + + "applies when settings close", + ) + updateResolutionMenu() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + } + + content.addView( + settingsCategoryHeader("Identity & appearance"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(36) }, + ) + content.addView( + buildIdentitySettingsSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + content.addView( + buildAirPlayIconSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(26) }, + ) + content.addView( + buildDrivingSideSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(26) }, + ) + content.addView( + settingsCategoryHeader("Display & video"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(40) }, + ) + + val resolutionHeader = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + resolutionHeader.addView( + menuText("Resolution", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + val resolutionValue = menuText( + CarPlayDisplayScale.label(displayScaleTenths), + 28f, + MENU_ACCENT, + bold = true, + ) + resolutionHeader.addView( + resolutionValue, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + content.addView( + resolutionHeader, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(14) }, + ) + + val seekBar = SeekBar(this).apply { + max = CarPlayDisplayScale.MAX_TENTHS - CarPlayDisplayScale.MIN_TENTHS + progress = displayScaleTenths - CarPlayDisplayScale.MIN_TENTHS + splitTrack = false + progressTintList = ColorStateList.valueOf(MENU_ACCENT) + thumbTintList = ColorStateList.valueOf(MENU_ACCENT) + setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + displayScaleTenths = CarPlayDisplayScale.sanitize( + CarPlayDisplayScale.MIN_TENTHS + progress, + ) + updateResolutionMenu() + } + + override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar) = Unit + }, + ) + } + content.addView( + seekBar, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + + val range = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + } + range.addView( + menuText("0.3x", 15f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + range.addView( + menuText("1.0x", 15f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + content.addView( + range, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + content.addView( + buildStepSliderSection( + title = "Frame rate", + values = ( + AirPlayDisplaySettings.MIN_FPS..AirPlayDisplaySettings.MAX_FPS + step AirPlayDisplaySettings.FPS_STEP + ).toList(), + selectedValue = fps, + label = { "$it fps" }, + onValueChanged = { value -> + fps = value + updateResolutionMenu() + }, + ), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(24) }, + ) + + content.addView( + settingsChoiceRow( + label = "Physical size basis", + options = listOf( + AirPlayPhysicalSizeBasis.WIDTH to "Widest width", + AirPlayPhysicalSizeBasis.HEIGHT to "Longest height", + ), + selected = physicalSizeBasis, + ) { value -> + physicalSizeBasis = value + updateResolutionMenu() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(24) }, + ) + + content.addView( + buildStepSliderSection( + title = "Physical length", + values = ( + AirPlayDisplaySettings.MIN_WIDTH_PHYSICAL_MM.. + AirPlayDisplaySettings.MAX_WIDTH_PHYSICAL_MM + step AirPlayDisplaySettings.WIDTH_PHYSICAL_MM_STEP + ).toList(), + selectedValue = widthPhysicalMm, + label = { "$it mm" }, + onValueChanged = { value -> + widthPhysicalMm = value + updateResolutionMenu() + }, + ), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(16) }, + ) + + val hevcRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + hevcRow.addView( + menuText("HEVC (H.265)", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + val hevcSwitch = Switch(this).apply { + isChecked = hevcEnabled + contentDescription = "HEVC H.265 video transport" + showText = false + thumbTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + trackTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), + ) + setOnCheckedChangeListener { _, checked -> + if (hevcEnabled == checked) return@setOnCheckedChangeListener + hevcEnabled = checked + appendLog( + "HEVC (H.265) ${if (hevcEnabled) "enabled" else "disabled"}; " + + "applies when settings close", + ) + updateResolutionMenu() + } + } + hevcRow.addView( + hevcSwitch, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + content.addView( + hevcRow, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(30) }, + ) + + val softwareHevcRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + softwareHevcRow.addView( + menuText("HEVC software decoder", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + val softwareHevcSwitch = Switch(this).apply { + isChecked = hevcSoftwareDecoderEnabled + contentDescription = "Use software HEVC decoder" + showText = false + thumbTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + trackTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), + ) + setOnCheckedChangeListener { _, checked -> + if (hevcSoftwareDecoderEnabled == checked) return@setOnCheckedChangeListener + hevcSoftwareDecoderEnabled = checked + appendLog( + "HEVC software decoder ${if (hevcSoftwareDecoderEnabled) "enabled" else "disabled"}; " + + "applies when settings close", + ) + updateResolutionMenu() + } + } + softwareHevcRow.addView( + softwareHevcSwitch, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + content.addView( + softwareHevcRow, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(16) }, + ) + } + + content.addView( + buildSafeAreaSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(30) }, + ) + + content.addView( + settingsCategoryHeader("Window"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(40) }, + ) + content.addView( + buildFullscreenSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + + content.addView( + settingsCategoryHeader("Diagnostics"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(40) }, + ) + content.addView( + buildDebugLogsSection(), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + content.addView( + settingsCategoryHeader("Android 9 compatibility"), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(40) }, + ) + content.addView( + menuText( + "The following settings are unavailable and hidden on Android 9 " + + "(API 28):\n" + + "• Wi-Fi P2P (5 GHz) — LocalOnlyHotspot is used instead.\n" + + "• HEVC software decoder — hardware decoding is used instead.", + 16f, + MENU_SECONDARY, + ), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + } + + val preview = menuText("", 17f, MENU_SECONDARY) + content.addView( + preview, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(30) }, + ) + + val save = Button(this).apply { + text = "Save and reconnect" + isAllCaps = false + textSize = 17f + setTextColor(MENU_BUTTON_TEXT) + backgroundTintList = ColorStateList.valueOf(MENU_ACCENT) + minHeight = dp(52) + setOnClickListener { saveSettingsAndReconnect() } + } + content.addView( + save, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(46) }, + ) + + val exitApplicationButton = Button(this).apply { + text = "EXIT APPLICATION" + isAllCaps = false + textSize = 17f + setTextColor(Color.WHITE) + backgroundTintList = ColorStateList.valueOf(MENU_DANGER) + minHeight = dp(52) + setOnClickListener { exitApplication() } + } + content.addView( + exitApplicationButton, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + + val scroll = ScrollView(this).apply { + isFillViewport = true + addView( + content, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + panel.addView( + scroll, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + panel.addView( + Button(this).apply { + text = "X" + isAllCaps = false + textSize = 22f + setTextColor(Color.WHITE) + backgroundTintList = ColorStateList.valueOf(MENU_TRACK_OFF) + contentDescription = "Discard changes and exit settings" + minWidth = 0 + minHeight = 0 + setPadding(0, 0, 0, 0) + setOnClickListener { cancelSettingsEdits() } + }, + FrameLayout.LayoutParams(dp(48), dp(48), Gravity.TOP or Gravity.START).apply { + leftMargin = dp(16) + topMargin = dp(16) + }, + ) + overlay.addView( + panel, + FrameLayout.LayoutParams( + minOf(resources.displayMetrics.widthPixels, MAX_SETTINGS_MENU_WIDTH_PX), + FrameLayout.LayoutParams.MATCH_PARENT, + Gravity.CENTER, + ), + ) + overlay.addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> + val desiredWidth = minOf(view.width, MAX_SETTINGS_MENU_WIDTH_PX) + val params = panel.layoutParams + if (params.width != desiredWidth) { + params.width = desiredWidth + panel.layoutParams = params + } + } + + resolutionValueView = resolutionValue + resolutionPreviewView = preview + this.hotspotStatusView = hotspotStatusView + updateHotspotStatusBlock() + updateResolutionMenu() + return overlay + } + + private fun persistMenuSettings() { + AirPlayPersistence.saveWirelessEnabled(this, wirelessEnabled) + AirPlayPersistence.saveMfiTarget(this, mfiTarget) + AirPlayPersistence.saveMfiI2cPath(this, mfiI2cPath) + AirPlayPersistence.saveRemoteMfiServer(this, remoteMfiServer) + AirPlayPersistence.saveRemoteMfiToken(this, remoteMfiToken) + AirPlayPersistence.saveWirelessHotspotMode(this, wirelessHotspotMode) + AirPlayPersistence.saveManualHotspotSsid(this, manualHotspotSsid) + AirPlayPersistence.saveManualHotspotPassphrase(this, manualHotspotPassphrase) + AirPlayPersistence.saveManualHotspotBand(this, manualHotspotBand) + AirPlayPersistence.saveManualHotspotChannel(this, manualHotspotChannel) + AirPlayPersistence.saveManualHotspotSecurity(this, manualHotspotSecurity) + AirPlayPersistence.saveLocationReportingEnabled(this, locationReportingEnabled) + AirPlayPersistence.saveAutoStartOnBoot(this, autoStartOnBoot) + AirPlayPersistence.saveAdvancedAudioChannelMapping(this, advancedAudioChannelMapping) + AirPlayPersistence.saveDisplayScaleTenths(this, displayScaleTenths) + AirPlayPersistence.saveFps(this, fps) + AirPlayPersistence.saveWidthPhysicalMm(this, widthPhysicalMm) + AirPlayPersistence.savePhysicalSizeBasis(this, physicalSizeBasis) + AirPlayPersistence.saveHevcEnabled(this, hevcEnabled) + AirPlayPersistence.saveHevcSoftwareDecoderEnabled(this, hevcSoftwareDecoderEnabled) + AirPlayPersistence.saveManufacturer(this, manufacturer) + AirPlayPersistence.saveModel(this, model) + AirPlayPersistence.saveOemLabel(this, oemLabel) + AirPlayPersistence.saveDebugLogsEnabled(this, debugLogsEnabled) + AirPlayPersistence.saveRightHandDrive(this, rightHandDrive) + AirPlayPersistence.saveHideTopBar(this, hideTopBar) + AirPlayPersistence.saveHideBottomBar(this, hideBottomBar) + AirPlayPersistence.saveSafeAreaDrawOutside(this, safeAreaDrawOutside) + } + + private fun captureSettingsBaseline(): SettingsBaseline { + val safeAreaSize = currentActivitySize() + val customIconBytes = try { + AirPlayPersistence.loadCustomAirPlayIconFile(this)?.readBytes() + } catch (error: Exception) { + Log.w(TAG, "Could not read the current AirPlay icon for settings rollback", error) + null + } + return SettingsBaseline( + safeAreaSize = safeAreaSize, + safeAreaRect = safeAreaSize?.let { + AirPlayPersistence.loadSafeAreaRect(this, it.width, it.height) + }, + customIconBytes = customIconBytes, + ) + } + + private fun restoreSettingsBaseline() { + val baseline = settingsBaseline ?: return + loadPersistedSettings() + baseline.safeAreaSize?.let { size -> + baseline.safeAreaRect?.let { rect -> + AirPlayPersistence.saveSafeAreaRect( + this, + size.width, + size.height, + rect, + commit = true, + ) + } ?: AirPlayPersistence.clearSafeAreaRect( + this, + size.width, + size.height, + commit = true, + ) + } + try { + baseline.customIconBytes?.let { bytes -> + AirPlayPersistence.saveCustomAirPlayIcon(this, bytes) + } ?: AirPlayPersistence.clearCustomAirPlayIcon(this) + } catch (error: Exception) { + Log.w(TAG, "Could not restore the previous AirPlay icon", error) + } + settingsBaseline = null + locationPermissionAvailable = hasFineLocationPermission() + hotspotStatus = HotspotStatus(state = if (wirelessEnabled) "stopped" else "off") + syncMfiSettingsControls() + updateManualHotspotFields() + updateAirPlayIconPreview() + updateSafeAreaSummary() + updateHotspotStatusBlock() + updateResolutionMenu() + updateDebugOverlays() + applyFullscreenMode() + refreshDisplaySizeAfterLayout() + } + + private fun buildMfiTargetSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + val targetChoice = settingsChoiceRow( + label = "MFI certificate & signing target", + options = listOf( + MfiTarget.USB_CH341 to "USB/CH341", + MfiTarget.I2C to "I2C", + MfiTarget.REMOTE to "Remote", + ), + selected = mfiTarget, + ) { target -> + if (mfiTarget == target) return@settingsChoiceRow + mfiTarget = target + updateMfiTargetFields() + appendLog("MFI target: ${mfiTargetLabel(target)}; applies when settings close") + } + mfiTargetGroup = (targetChoice as ViewGroup).getChildAt(1) as RadioGroup + section.addView( + targetChoice, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + val i2cFields = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView( + settingsInputRow( + "I2C device", + mfiI2cPath, + onInputCreated = { mfiI2cPathInput = it }, + ) { value -> + mfiI2cPath = value + mfiErrorView?.visibility = View.GONE + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + addView( + menuText("Linux device path, for example /dev/i2c-1.", 14f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(4) }, + ) + } + section.addView( + i2cFields, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + mfiI2cFields = i2cFields + + val remoteFields = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView( + settingsInputRow( + "Server address", + remoteMfiServer, + onInputCreated = { remoteMfiServerInput = it }, + ) { value -> + remoteMfiServer = value + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + addView( + settingsInputRow( + "Token (optional)", + remoteMfiToken, + password = true, + onInputCreated = { remoteMfiTokenInput = it }, + ) { value -> + remoteMfiToken = value + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + addView( + menuText( + "Address must start with http:// or https://. Token is optional and sent " + + "as an Authorization bearer token.", + 14f, + MENU_SECONDARY, + ), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(4) }, + ) + } + section.addView( + remoteFields, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + mfiRemoteFields = remoteFields + val error = menuText("", 14f, MENU_DANGER).apply { + visibility = View.GONE + } + section.addView( + error, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(6) }, + ) + mfiErrorView = error + updateMfiTargetFields() + return section + } + + private fun updateMfiTargetFields() { + mfiI2cFields?.visibility = if (mfiTarget == MfiTarget.I2C) View.VISIBLE else View.GONE + mfiRemoteFields?.visibility = if (mfiTarget == MfiTarget.REMOTE) View.VISIBLE else View.GONE + mfiErrorView?.visibility = View.GONE + } + + private fun syncMfiSettingsControls() { + mfiTargetGroup?.let { group -> + val button = (0 until group.childCount) + .map { group.getChildAt(it) } + .filterIsInstance() + .firstOrNull { it.tag == mfiTarget } + button?.let { group.check(it.id) } + } + if (mfiI2cPathInput?.text?.toString() != mfiI2cPath) { + mfiI2cPathInput?.setText(mfiI2cPath) + } + if (remoteMfiServerInput?.text?.toString() != remoteMfiServer) { + remoteMfiServerInput?.setText(remoteMfiServer) + } + if (remoteMfiTokenInput?.text?.toString() != remoteMfiToken) { + remoteMfiTokenInput?.setText(remoteMfiToken) + } + updateMfiTargetFields() + } + + private fun mfiTargetLabel(target: MfiTarget): String = when (target) { + MfiTarget.LOCAL -> "Local offline" + MfiTarget.USB_CH341 -> "USB/CH341" + MfiTarget.I2C -> "I2C" + MfiTarget.REMOTE -> "Remote" + } + + private fun buildIdentitySettingsSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + section.addView( + settingsInputRow("Manufacturer", manufacturer) { value -> + manufacturer = value + updateResolutionMenu() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + section.addView( + settingsInputRow("Model", model) { value -> + model = value + updateResolutionMenu() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + section.addView( + settingsInputRow("OEM label", oemLabel) { value -> + oemLabel = value + updateResolutionMenu() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + return section + } + + private fun settingsCategoryHeader(title: String): TextView = + menuText(title, 16f, MENU_ACCENT, bold = true) + + private fun buildLocationReportingSection(): View = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + val row = LinearLayout(this@CarPlayHostActivity).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + row.addView( + menuText("Report location to iPhone", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + val switch = Switch(this@CarPlayHostActivity).apply { + isChecked = locationReportingEnabled + contentDescription = "Report Android location to the iPhone" + showText = false + thumbTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + trackTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), + ) + setOnCheckedChangeListener { _, checked -> + onLocationReportingChanged(checked) + } + } + locationReportingSwitch = switch + row.addView( + switch, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + addView( + row, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + addView( + menuText( + "Sends precise Android location as CarPlay GPS data when the iPhone requests it.", + 14f, + MENU_SECONDARY, + ), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(6) }, + ) + } + + private fun onLocationReportingChanged(checked: Boolean) { + if (locationReportingEnabled == checked) return + locationReportingEnabled = checked + appendLog( + "Location reporting ${if (locationReportingEnabled) "enabled" else "disabled"}; " + + "applies when settings close", + ) + updateResolutionMenu() + if (locationReportingEnabled && !locationPermissionAvailable) { + requestLocationPermission() + } + } + + private fun buildDebugLogsSection(): View = + settingsSwitchRow( + label = "Debug logs", + checked = debugLogsEnabled, + description = "Show on-screen debug logs", + ) { checked -> + debugLogsEnabled = checked + appendLog("Debug logs ${if (debugLogsEnabled) "enabled" else "disabled"}") + updateDebugOverlays() + } + + private fun buildStepSliderSection( + title: String, + values: List, + selectedValue: Int, + label: (Int) -> String, + onValueChanged: (Int) -> Unit, + ): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + val header = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + header.addView( + menuText(title, 20f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + val selectedIndex = values.indexOf(selectedValue) + .takeIf { it >= 0 } + ?: 0 + val valueView = menuText(label(values[selectedIndex]), 22f, MENU_ACCENT, bold = true) + header.addView( + valueView, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + section.addView( + header, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + val seekBar = SeekBar(this).apply { + max = (values.size - 1).coerceAtLeast(0) + progress = selectedIndex + splitTrack = false + progressTintList = ColorStateList.valueOf(MENU_ACCENT) + thumbTintList = ColorStateList.valueOf(MENU_ACCENT) + setOnSeekBarChangeListener( + object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val value = values.getOrNull(progress) ?: return + valueView.text = label(value) + if (fromUser) onValueChanged(value) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) = Unit + override fun onStopTrackingTouch(seekBar: SeekBar) = Unit + }, + ) + } + section.addView( + seekBar, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + return section + } + + private fun buildAirPlayIconSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + section.addView( + menuText("AirPlay icon", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + val row = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + } + val preview = ImageView(this).apply { + scaleType = ImageView.ScaleType.CENTER_CROP + background = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(8).toFloat() + setColor(MENU_TRACK_OFF) + } + } + row.addView( + preview, + LinearLayout.LayoutParams(dp(72), dp(72)), + ) + val actions = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + actions.addView( + Button(this).apply { + text = "Choose image" + isAllCaps = false + setOnClickListener { + externalActivityInProgress = true + imagePicker.launch("image/*") + } + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + actions.addView( + Button(this).apply { + text = "Default icon" + isAllCaps = false + setOnClickListener { + AirPlayPersistence.clearCustomAirPlayIcon(this@CarPlayHostActivity) + updateAirPlayIconPreview() + } + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + row.addView( + actions, + LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f, + ).apply { marginStart = dp(16) }, + ) + section.addView( + row, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + val status = menuText("", 14f, MENU_SECONDARY) + section.addView( + status, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + iconPreviewView = preview + iconStatusView = status + updateAirPlayIconPreview() + return section + } + + private fun buildDrivingSideSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + section.addView( + menuText("Driving side", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + val group = RadioGroup(this).apply { + orientation = RadioGroup.HORIZONTAL + } + val left = RadioButton(this).apply { + id = View.generateViewId() + text = "Left-hand drive" + setTextColor(Color.WHITE) + isChecked = !rightHandDrive + } + val right = RadioButton(this).apply { + id = View.generateViewId() + text = "Right-hand drive" + setTextColor(Color.WHITE) + isChecked = rightHandDrive + } + group.addView(left) + group.addView(right) + group.setOnCheckedChangeListener { _, checkedId -> + rightHandDrive = checkedId == right.id + updateResolutionMenu() + } + section.addView( + group, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + return section + } + + private fun buildFullscreenSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + section.addView( + menuText("Fullscreen", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + section.addView( + settingsSwitchRow( + label = "Hide top bar", + checked = hideTopBar, + description = "Hide the status bar", + ) { checked -> + hideTopBar = checked + applyFullscreenMode() + refreshDisplaySizeAfterLayout() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + section.addView( + settingsSwitchRow( + label = "Hide bottom bar", + checked = hideBottomBar, + description = "Hide the navigation bar", + ) { checked -> + hideBottomBar = checked + applyFullscreenMode() + refreshDisplaySizeAfterLayout() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + return section + } + + private fun buildSafeAreaSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + section.addView( + menuText("Safe area", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + val summary = menuText("", 15f, MENU_ACCENT) + section.addView( + summary, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(6) }, + ) + val buttons = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + } + buttons.addView( + Button(this).apply { + text = "Set" + isAllCaps = false + setOnClickListener { openSafeAreaEditor() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + buttons.addView( + Button(this).apply { + text = "Reset" + isAllCaps = false + setOnClickListener { resetSafeAreaForCurrentSize() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(12) + }, + ) + section.addView( + buttons, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + section.addView( + settingsSwitchRow( + label = "Draw outside safe area", + checked = safeAreaDrawOutside, + description = "Allow CarPlay UI outside the safe area", + ) { checked -> + safeAreaDrawOutside = checked + updateResolutionMenu() + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(12) }, + ) + safeAreaSummaryView = summary + updateSafeAreaSummary() + return section + } + + private fun buildSafeAreaEditor(): View { + val overlay = FrameLayout(this).apply { + setBackgroundColor(Color.BLACK) + isClickable = true + } + val editor = SafeAreaEditorView(this) + overlay.addView( + editor, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + overlay.addView( + menuText("Safe area", 24f, Color.WHITE, bold = true).apply { + setPadding(dp(16), dp(12), dp(16), dp(8)) + }, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.START, + ), + ) + val controls = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + setPadding(dp(16), dp(10), dp(16), dp(16)) + } + controls.addView( + Button(this).apply { + text = "Cancel" + isAllCaps = false + setOnClickListener { closeSafeAreaEditor() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + controls.addView( + Button(this).apply { + text = "Save" + isAllCaps = false + setOnClickListener { saveSafeAreaEditor() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(12) + }, + ) + overlay.addView( + controls, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + safeAreaEditorView = editor + return overlay + } + + private fun settingsInputRow( + label: String, + value: String, + password: Boolean = false, + numeric: Boolean = false, + onInputCreated: ((EditText) -> Unit)? = null, + onChanged: (String) -> Unit, + ): View = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView( + menuText(label, 18f, MENU_SECONDARY).apply { + gravity = Gravity.CENTER_VERTICAL + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + addView( + EditText(this@CarPlayHostActivity).apply { + setText(value) + textSize = 18f + setTextColor(Color.WHITE) + setHintTextColor(MENU_SECONDARY) + backgroundTintList = ColorStateList.valueOf(MENU_ACCENT) + minHeight = dp(48) + isSingleLine = true + inputType = when { + numeric -> InputType.TYPE_CLASS_NUMBER + password -> InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_PASSWORD or + InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + else -> InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + } + addTextChangedListener(afterTextChanged(onChanged)) + onInputCreated?.invoke(this) + }, + LinearLayout.LayoutParams( + 0, + ViewGroup.LayoutParams.WRAP_CONTENT, + 1f, + ).apply { marginStart = dp(12) }, + ) + } + + private fun settingsSwitchRow( + label: String, + checked: Boolean, + description: String, + onChanged: (Boolean) -> Unit, + ): View = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + addView( + menuText(label, 18f, MENU_SECONDARY), + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + addView( + Switch(this@CarPlayHostActivity).apply { + isChecked = checked + contentDescription = description + showText = false + thumbTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + trackTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT_TRACK, MENU_TRACK_OFF), + ) + setOnCheckedChangeListener { _, value -> onChanged(value) } + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + + private fun afterTextChanged(onChanged: (String) -> Unit): TextWatcher = + object : TextWatcher { + override fun beforeTextChanged( + text: CharSequence?, + start: Int, + count: Int, + after: Int, + ) = Unit + + override fun onTextChanged( + text: CharSequence?, + start: Int, + before: Int, + count: Int, + ) = Unit + + override fun afterTextChanged(text: Editable?) { + onChanged(text?.toString().orEmpty()) + } + } + + private fun buildHotspotModeSection(): View { + val section = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + section.addView( + menuText("Wi-Fi session", 20f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + val group = RadioGroup(this).apply { + orientation = LinearLayout.VERTICAL + setPadding(0, dp(8), 0, 0) + } + val modes = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + add(WirelessHotspotMode.WIFI_P2P to "Wi-Fi P2P (5 GHz)") + } + add(WirelessHotspotMode.LOCAL_ONLY_HOTSPOT to "LocalOnlyHotspot") + add(WirelessHotspotMode.MANUAL to "Manual hotspot") + } + var selectedId = View.NO_ID + for ((mode, label) in modes) { + val button = RadioButton(this).apply { + id = View.generateViewId() + text = label + textSize = 18f + setTextColor(MENU_SECONDARY) + buttonTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + tag = mode + isChecked = wirelessHotspotMode == mode + } + if (wirelessHotspotMode == mode) selectedId = button.id + group.addView( + button, + RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + if (selectedId != View.NO_ID) group.check(selectedId) + group.setOnCheckedChangeListener { radioGroup, checkedId -> + val selected = radioGroup.findViewById(checkedId) + ?.tag as? WirelessHotspotMode + ?: return@setOnCheckedChangeListener + if (wirelessHotspotMode == selected) return@setOnCheckedChangeListener + wirelessHotspotMode = selected + hotspotStatus = HotspotStatus(state = if (wirelessEnabled) "stopped" else "off") + updateHotspotStatusBlock() + updateManualHotspotFields() + appendLog( + "Wi-Fi session mode: ${hotspotModeLabel(wirelessHotspotMode)}; " + + "applies when settings close", + ) + } + section.addView( + group, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + val manualFields = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + } + manualFields.addView( + settingsInputRow("Hotspot SSID", manualHotspotSsid) { value -> + manualHotspotSsid = value + manualHotspotErrorView?.visibility = View.GONE + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + + manualFields.addView( + settingsChoiceRow( + label = "Band", + options = listOf( + ManualHotspotBand.AUTO to "Auto", + ManualHotspotBand.GHZ_2_4 to "2.4 GHz", + ManualHotspotBand.GHZ_5 to "5 GHz", + ), + selected = manualHotspotBand, + ) { value -> + manualHotspotBand = value + manualHotspotErrorView?.visibility = View.GONE + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + + manualFields.addView( + settingsInputRow( + label = "Channel (0 = auto)", + value = manualHotspotChannel.toString(), + numeric = true, + ) { value -> + manualHotspotChannel = value.toIntOrNull() ?: -1 + manualHotspotErrorView?.visibility = View.GONE + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + + manualFields.addView( + settingsInputRow( + label = "Hotspot password", + value = manualHotspotPassphrase, + password = true, + ) { value -> + manualHotspotPassphrase = value + manualHotspotErrorView?.visibility = View.GONE + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + + manualFields.addView( + settingsChoiceRow( + label = "Security", + options = listOf( + ManualHotspotSecurity.OPEN to "Open", + ManualHotspotSecurity.WPA2 to "WPA2", + ManualHotspotSecurity.WPA3_TRANSITION to "WPA3 transition", + ManualHotspotSecurity.WPA3 to "WPA3", + ), + selected = manualHotspotSecurity, + ) { value -> + manualHotspotSecurity = value + manualHotspotErrorView?.visibility = View.GONE + }, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(10) }, + ) + + val error = menuText("", 14f, Color.rgb(0xff, 0x7a, 0x7a)).apply { + visibility = View.GONE + } + manualFields.addView( + error, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + + section.addView( + manualFields, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ).apply { topMargin = dp(8) }, + ) + manualHotspotFields = manualFields + manualHotspotErrorView = error + updateManualHotspotFields() + return section + } + + private fun updateManualHotspotFields() { + val visible = wirelessHotspotMode == WirelessHotspotMode.MANUAL + manualHotspotFields?.visibility = if (visible) View.VISIBLE else View.GONE + if (!visible) manualHotspotErrorView?.visibility = View.GONE + } + + private fun validateMfiSettings(): Boolean { + val error = when { + mfiTarget == MfiTarget.I2C && mfiI2cPath.isBlank() -> + "I2C device path is required" + mfiTarget == MfiTarget.REMOTE && remoteMfiServer.isBlank() -> + "Remote server address is required" + mfiTarget == MfiTarget.REMOTE && + !remoteMfiServer.trim().startsWith("http://") && + !remoteMfiServer.trim().startsWith("https://") -> + "Remote server address must start with http:// or https://" + '\u0000' in mfiI2cPath -> "I2C device path contains U+0000" + '\u0000' in remoteMfiServer -> "Remote server address contains U+0000" + '\u0000' in remoteMfiToken -> "Remote token contains U+0000" + else -> null + } + mfiErrorView?.text = error.orEmpty() + mfiErrorView?.visibility = if (error == null) View.GONE else View.VISIBLE + return error == null + } + + private fun validateManualHotspotSettings(): Boolean { + if (wirelessHotspotMode != WirelessHotspotMode.MANUAL) return true + val error = when { + manualHotspotSsid.isBlank() -> "Hotspot SSID is required" + manualHotspotSsid.encodeToByteArray().size > 32 -> + "Hotspot SSID must be at most 32 UTF-8 bytes" + '\u0000' in manualHotspotSsid -> "Hotspot SSID contains U+0000" + manualHotspotChannel !in 0..196 -> "Channel must be 0 or 1-196" + manualHotspotChannel != 0 && + !isManualHotspotChannelCompatible(manualHotspotBand, manualHotspotChannel) -> + "Channel is not valid for the selected band" + '\u0000' in manualHotspotPassphrase -> "Hotspot password contains U+0000" + manualHotspotSecurity == ManualHotspotSecurity.OPEN && + manualHotspotPassphrase.isNotEmpty() -> + "Password must be empty when security is Open" + manualHotspotSecurity != ManualHotspotSecurity.OPEN && + manualHotspotPassphrase.length !in 8..63 -> + "WPA2/WPA3 password must be 8-63 characters" + else -> null + } + manualHotspotErrorView?.text = error.orEmpty() + manualHotspotErrorView?.visibility = if (error == null) View.GONE else View.VISIBLE + return error == null + } + + private fun hotspotModeLabel(mode: WirelessHotspotMode): String = when (mode) { + WirelessHotspotMode.WIFI_P2P -> "Wi-Fi P2P (5 GHz)" + WirelessHotspotMode.LOCAL_ONLY_HOTSPOT -> "LocalOnlyHotspot" + WirelessHotspotMode.MANUAL -> "Manual hotspot" + } + + private fun menuText( + text: String, + sizeSp: Float, + color: Int, + bold: Boolean = false, + ): TextView = TextView(this).apply { + this.text = text + textSize = sizeSp + setTextColor(color) + typeface = if (bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT + includeFontPadding = false + } + + private fun updateHotspotStatus(status: CarPlayStatus) { + if (!wirelessEnabled) return + hotspotStatus = when (status) { + CarPlayStatus.StartingHotspot -> HotspotStatus(state = "Starting") + is CarPlayStatus.HotspotReady -> HotspotStatus( + state = "Ready", + ssid = status.ssid, + band = status.band, + channel = status.channel, + backend = status.backend, + ) + CarPlayStatus.WaitingForPairedIphone -> + hotspotStatus.copy(state = "Waiting for paired iPhone") + CarPlayStatus.ConnectingBluetooth -> + hotspotStatus.copy(state = "Connecting Bluetooth") + CarPlayStatus.RunningWireless -> + hotspotStatus.copy(state = "Running") + CarPlayStatus.WirelessActive -> + hotspotStatus.copy(state = "Active") + CarPlayStatus.AttachingNetwork -> + hotspotStatus.copy(state = "Starting AirPlay service") + is CarPlayStatus.Failed -> hotspotStatus.copy(state = "Error") + else -> return + } + updateHotspotStatusBlock() + } + + private fun updateHotspotStatusBlock() { + if (!wirelessEnabled) { + hotspotStatusView?.text = "Wireless hotspot: off" + return + } + val status = hotspotStatus + hotspotStatusView?.text = buildString { + append("Wireless hotspot: ").append(status.state) + status.ssid?.let { append("\nSSID: ").append(it) } + status.backend?.let { append("\nBackend: ").append(it) } + status.band?.let { append("\nBand: ").append(it) } + status.channel?.let { + append("\nChannel: ").append(if (it == 0) "Auto" else it.toString()) + } + } + } + + @Suppress("UNCHECKED_CAST") + private fun settingsChoiceRow( + label: String, + options: List>, + selected: T, + onSelected: (T) -> Unit, + ): View = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + addView( + menuText(label, 18f, MENU_SECONDARY), + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + val group = RadioGroup(this@CarPlayHostActivity).apply { + orientation = RadioGroup.VERTICAL + setPadding(0, dp(4), 0, 0) + } + var selectedId = View.NO_ID + for ((value, text) in options) { + val button = RadioButton(this@CarPlayHostActivity).apply { + id = View.generateViewId() + this.text = text + textSize = 17f + setTextColor(MENU_SECONDARY) + buttonTintList = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(MENU_ACCENT, MENU_SECONDARY), + ) + tag = value + isChecked = value == selected + } + if (value == selected) selectedId = button.id + group.addView( + button, + RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + if (selectedId != View.NO_ID) group.check(selectedId) + group.setOnCheckedChangeListener { radioGroup, checkedId -> + val value = radioGroup.findViewById(checkedId)?.tag as? T ?: return@setOnCheckedChangeListener + onSelected(value) + } + addView( + group, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + ), + ) + } + + private fun updateResolutionMenu() { + resolutionValueView?.text = CarPlayDisplayScale.label(displayScaleTenths) + val native = activeDisplaySize ?: currentActivitySize() + val resolution = if (native == null) { + "Handshake resolution: waiting for display" + } else { + val negotiated = CarPlayDisplayScale.apply( + AirPlayDisplayConfig( + widthPixels = native.width, + heightPixels = native.height, + widthPhysicalMm = widthPhysicalMm, + fps = fps, + ), + displayScaleTenths, + ) + "Handshake resolution: ${native.width} x ${native.height} -> " + + "${negotiated.widthPixels} x ${negotiated.heightPixels}" + } + val transport = if (!hevcEnabled) { + "H.264" + } else { + "HEVC (H.265, ${if (hevcSoftwareDecoderEnabled) "software" else "hardware"})" + } + val fullscreen = buildString { + append(if (hideTopBar) "top hidden" else "top shown") + append(", ") + append(if (hideBottomBar) "bottom hidden" else "bottom shown") + } + resolutionPreviewView?.text = buildString { + append(resolution).append('\n') + append("Identity: ").append(normalizedManufacturer()).append(" / ") + .append(normalizedModel()).append('\n') + append("OEM label: ").append(oemLabel.ifBlank { "(empty)" }).append('\n') + append("Frame rate: ").append(fps).append(" fps\n") + append("Detected maximum: ") + .append(maximumDetectedWidthPixels).append(" x ") + .append(maximumDetectedHeightPixels).append(" px\n") + append("Physical reference: ") + .append( + when (physicalSizeBasis) { + AirPlayPhysicalSizeBasis.WIDTH -> "widest width" + AirPlayPhysicalSizeBasis.HEIGHT -> "longest height" + }, + ) + .append(" = ").append(widthPhysicalMm).append(" mm\n") + native?.let { size -> + val physical = resolvePhysicalSize(size) + append("CarPlay physical size: ") + .append(physical.widthMm).append(" x ") + .append(physical.heightMm).append(" mm\n") + } + append("Driving side: ").append(if (rightHandDrive) "right" else "left").append('\n') + append("Fullscreen: ").append(fullscreen).append('\n') + append("Video transport: ").append(transport).append('\n') + append("Location reporting: ") + .append(if (locationReportingEnabled) "enabled" else "disabled") + .append('\n') + if (advancedAudioChannelMappingSupported) { + append("Audio channel mapping: ") + .append(if (advancedAudioChannelMapping) "AAOS buses" else "Mobile compatible") + .append('\n') + } + append(safeAreaSummary()) + } + } + + private data class CanvasSupport(val supported: Boolean, val reason: String, val details: String) + + private fun largerCanvasSupport(display: AirPlayDisplayConfig): CanvasSupport = try { + val mime = if (hevcEnabled) MediaFormat.MIMETYPE_VIDEO_HEVC else MediaFormat.MIMETYPE_VIDEO_AVC + // Match MediaCodec.createDecoderByType's first suitable decoder; do not silently force + // an enlarged stream through a software decoder on a slower head unit. + val decoder = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { + !it.isEncoder && it.supportedTypes.any { type -> type.equals(mime, ignoreCase = true) } + } + if (decoder == null) { + CanvasSupport(false, "no_decoder", "Decoder capability mime=$mime result=no_decoder") + } else { + val hardware = if (Build.VERSION.SDK_INT >= 29) decoder.isHardwareAccelerated + else !decoder.name.startsWith("OMX.google.") && !decoder.name.startsWith("c2.android.") + val video = decoder.getCapabilitiesForType(mime).videoCapabilities + val sizeSupported = video?.isSizeSupported(display.widthPixels, display.heightPixels) == true + val rateSupported = sizeSupported && video?.areSizeAndRateSupported( + display.widthPixels, display.heightPixels, display.fps.toDouble()) == true + val reason = when { + !hardware -> "software_decoder" + hevcEnabled && hevcSoftwareDecoderEnabled -> "software_hevc_selected" + video == null -> "no_video_capabilities" + !sizeSupported -> "canvas_dimensions_unsupported" + !rateSupported -> "frame_rate_unsupported" + else -> "supported" + } + CanvasSupport(reason == "supported", reason, + "Decoder capability codec=${decoder.name} mime=$mime hardware=$hardware " + + "sizeSupported=$sizeSupported rateSupported=$rateSupported " + + "widths=${video?.supportedWidths} heights=${video?.supportedHeights} " + + "alignment=${video?.widthAlignment}x${video?.heightAlignment} " + + "fpsRange=${video?.supportedFrameRates} result=$reason") + } + } catch (error: Exception) { + CanvasSupport(false, "capability_query_${error.javaClass.simpleName}", + "Decoder capability query failed error=${error.javaClass.simpleName}") + } + + private fun createAirPlayConfig(size: DisplaySize): AirPlayConfig { + val physical = resolvePhysicalSize(size) + val baseDisplay = AirPlayDisplayConfig( + widthPixels = size.width, + heightPixels = size.height, + widthPhysicalMm = physical.widthMm, + heightPhysicalMm = physical.heightMm, + fps = fps, + ) + val resolutionDisplay = CarPlayDisplayScale.apply(baseDisplay, displayScaleTenths) + val requestedPercent = uiScalePercent + var scaledDisplay = CarPlayUiScale.apply(resolutionDisplay, uiScalePercent) + val candidate = scaledDisplay + val support = when { + uiScalePercent >= CarPlayUiScale.DEFAULT -> CanvasSupport(true, "not_enlarging", "Decoder capability enlargement check not required") + scaledDisplay === resolutionDisplay -> CanvasSupport(false, "canvas_4k_limit", "Decoder capability check skipped: canvas exceeds enlargement limit") + else -> largerCanvasSupport(scaledDisplay) + } + if (!support.supported) { + scaledDisplay = resolutionDisplay + uiScalePercent = CarPlayUiScale.DEFAULT + AirPlayPersistence.saveUiScalePercent(this, uiScalePercent) + appendLog("Larger CarPlay canvas unavailable reason=${support.reason}; using Default icon and text size") + runOnUiThread { + android.widget.Toast.makeText(this, + "This head unit cannot use the smaller size at this resolution. Using Default.", + android.widget.Toast.LENGTH_LONG).show() + } + } + appendLog("CarPlay size=${CarPlayUiScale.label(uiScalePercent)} canvas=${scaledDisplay.widthPixels}x${scaledDisplay.heightPixels}") + val display = scaledDisplay.copy( + safeArea = AirPlaySafeArea.toInsets( + mapping = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height), + activityWidthPixels = size.width, + activityHeightPixels = size.height, + displayWidthPixels = scaledDisplay.widthPixels, + displayHeightPixels = scaledDisplay.heightPixels, + ), + safeAreaDrawOutside = safeAreaDrawOutside, + ) + val requestSummary = "Display request selected=${CarPlayUiScale.label(requestedPercent)} percent=$requestedPercent " + + "surface=${size.width}x${size.height} resolution=${displayScaleTenths * 10}% " + + "base=${resolutionDisplay.widthPixels}x${resolutionDisplay.heightPixels} " + + "candidate=${candidate.widthPixels}x${candidate.heightPixels} fps=$fps " + + "codec=${if (hevcEnabled) "HEVC" else "H.264"} softwareHevc=$hevcSoftwareDecoderEnabled" + val effectiveSummary = "Display effective percent=$uiScalePercent " + + "canvas=${display.widthPixels}x${display.heightPixels} decision=${support.reason} " + + "physical=${physical.widthMm}x${physical.heightMm}mm safeArea=${display.safeArea} " + + "drawOutside=${display.safeAreaDrawOutside}" + displayDiagnosticAttempt = DisplayDiagnosticSnapshot.begin(this, requestSummary, support.details, effectiveSummary) + appendLog(requestSummary) + appendLog(support.details) + appendLog(effectiveSummary) + return AirPlayConfig( + deviceName = "DiPlay", + deviceId = DiPlayBootstrap.deviceId(airPlayIdentity), + btMac = DiPlayBluetooth.localAddress(this) ?: DiPlayBootstrap.deviceId(airPlayIdentity), + sourceVersion = "950.7.1", + main = display, + rightHandDrive = rightHandDrive, + hevc = hevcEnabled, + microphone = microphoneAvailable, + manufacturer = normalizedManufacturer(), + model = normalizedModel(), + oemLabel = oemLabel, + icons = listOf(loadAirPlayIcon()), + ) + } + + private fun loadAirPlayIcon(): AirPlayIcon { + val customBytes = try { + AirPlayPersistence.loadCustomAirPlayIconFile(this)?.readBytes() + } catch (_: Exception) { + null + } + if (customBytes != null) { + decodeAirPlayIcon(customBytes)?.let { return it } + AirPlayPersistence.clearCustomAirPlayIcon(this) + } + return decodeAirPlayIcon(defaultAirPlayIconBytes()) + ?: throw IllegalStateException("Packaged AirPlay icon is invalid") + } + + private fun decodeAirPlayIcon(encoded: ByteArray): AirPlayIcon? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(encoded, 0, encoded.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0 || + bounds.outWidth != bounds.outHeight + ) { + return null + } + return AirPlayIcon(bounds.outWidth, bounds.outHeight, encoded) + } + + private fun defaultAirPlayIconBytes(): ByteArray = + resources.openRawResource(R.drawable.ic_carplay).use { it.readBytes() } + + private fun updateAirPlayIconPreview() { + val preview = iconPreviewView ?: return + val custom = AirPlayPersistence.loadCustomAirPlayIconFile(this) + var customBitmap: Bitmap? = null + if (custom != null) { + customBitmap = BitmapFactory.decodeFile(custom.absolutePath) + if (customBitmap == null) { + AirPlayPersistence.clearCustomAirPlayIcon(this) + } + } + val bitmap = customBitmap ?: BitmapFactory.decodeResource(resources, R.raw.placeholder_icon) + preview.setImageBitmap(bitmap) + iconStatusView?.text = + if (customBitmap != null) "Custom 1:1 icon" else "Default placeholder icon" + } + + private fun currentActivitySize(): DisplaySize? { + val view = videoView + if (view != null && view.width > 0 && view.height > 0) { + return DisplaySize(view.width, view.height) + } + return activeDisplaySize + } + + private fun resolvePhysicalSize(size: DisplaySize): AirPlayPhysicalSizeMm = + AirPlayDisplaySettings.resolvePhysicalSizeMm( + currentWidthPixels = size.width, + currentHeightPixels = size.height, + maximumWidthPixels = maxOf(maximumDetectedWidthPixels, size.width), + maximumHeightPixels = maxOf(maximumDetectedHeightPixels, size.height), + referenceMillimeters = widthPhysicalMm, + basis = physicalSizeBasis, + ) + + private fun safeAreaSummary(): String { + val size = currentActivitySize() ?: return "Safe area: waiting for activity size" + val mapping = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height) + return if (mapping == null) { + "Safe area: full screen at ${size.width} x ${size.height}" + } else { + "Safe area: ${mapping.width} x ${mapping.height} at " + + "(${mapping.left}, ${mapping.top}) in ${size.width} x ${size.height}" + } + } + + private fun updateSafeAreaSummary() { + safeAreaSummaryView?.text = safeAreaSummary() + } + + private fun openSafeAreaEditor() { + val size = currentActivitySize() + if (size == null) { + appendLog("Safe area editor is unavailable before display layout") + return + } + val editorView = safeAreaEditorView ?: return + val initial = AirPlayPersistence.loadSafeAreaRect(this, size.width, size.height) + ?: AirPlaySafeArea.default(size.width, size.height) + safeAreaEditSize = size + safeAreaEditorActive = true + // Keep the current activity size; changing system bars here would remap the safe area. + settingsMenu?.visibility = View.GONE + safeAreaEditor?.visibility = View.VISIBLE + editorView.setRect(initial, size.width, size.height) + appendLog( + "Safe area editor opened for ${size.width}x${size.height}; " + + "drag the four boundaries", + ) + } + + private fun closeSafeAreaEditor() { + if (!safeAreaEditorActive) return + safeAreaEditorActive = false + safeAreaEditSize = null + safeAreaEditor?.visibility = View.GONE + settingsMenu?.visibility = View.VISIBLE + updateSafeAreaSummary() + updateResolutionMenu() + appendLog("Safe area editor closed") + } + + private fun saveSafeAreaEditor() { + val size = safeAreaEditSize ?: currentActivitySize() ?: return + val rect = safeAreaEditorView?.currentRectForSource() ?: return + AirPlayPersistence.saveSafeAreaRect(this, size.width, size.height, rect) + appendLog( + "Safe area saved for ${size.width}x${size.height}: " + + "${rect.width}x${rect.height} at (${rect.left}, ${rect.top})", + ) + closeSafeAreaEditor() + } + + private fun resetSafeAreaForCurrentSize() { + val size = currentActivitySize() + if (size == null) { + appendLog("Safe area reset is unavailable before display layout") + return + } + AirPlayPersistence.clearSafeAreaRect(this, size.width, size.height) + updateSafeAreaSummary() + updateResolutionMenu() + appendLog("Safe area reset to full screen for ${size.width}x${size.height}") + } + + private fun refreshDisplaySizeAfterLayout() { + videoView?.post { + val view = videoView ?: return@post + scheduleDisplaySize(view.width, view.height) + } + } + + private fun normalizedManufacturer(): String = + manufacturer.trim().ifBlank { AirPlayPersistence.DEFAULT_MANUFACTURER } + + private fun normalizedModel(): String = + model.trim().ifBlank { AirPlayPersistence.DEFAULT_MODEL } + + private fun createMediaSink( + videoWidth: Int, + videoHeight: Int, + controllerGeneration: Int, + ): AndroidMediaSink = AndroidMediaSink( + surface = null, + videoWidth = videoWidth, + videoHeight = videoHeight, + preferSoftwareHevcDecoder = hevcSoftwareDecoderEnabled, + advancedAudioChannelMapping = advancedAudioChannelMapping, + onScreenStreamActiveChanged = { type, active -> + onScreenStreamStateChanged(controllerGeneration, type, active) + }, + ) + + private fun createMediaEngine(sink: AndroidMediaSink): CarPlayMediaEngine = + CarPlayMediaEngine( + sink = sink, + microphoneEnabled = microphoneAvailable, + audioCaptureDirectory = audioCaptureDirectory(), + ) + + private fun createSessionListener(controllerGeneration: Int): AirPlaySessionListener = + object : AirPlaySessionListener { + override fun onSessionActive(session: AirPlaySession) { + runOnUiThread { + if (controllerGeneration != restartGeneration) { + return@runOnUiThread + } + activeAirPlaySession = session + CarPlayBackgroundSession.active = true + reconnectAttempts = 0 + syncAirPlayDarkMode() + if (menuOpen) return@runOnUiThread + appendLog("AirPlay session active") + } + } + + override fun onSessionEnded(session: AirPlaySession) { + runOnUiThread { + if (activeAirPlaySession === session) activeAirPlaySession = null + CarPlayBackgroundSession.active = false + if (menuOpen || controllerGeneration != restartGeneration) { + return@runOnUiThread + } + activeScreenStreamTypes.clear() + setConnectionStage("CarPlay session ended; reconnecting") + appendLog("AirPlay session ended; reconnecting from scratch") + reconnectAfterLoss("AirPlay session ended") + } + } + + override fun onTransportError(message: String) { + runOnUiThread { + if (menuOpen || controllerGeneration != restartGeneration) { + return@runOnUiThread + } + activeScreenStreamTypes.clear() + setConnectionStage("Transport error; reconnecting") + appendLog("CarPlay transport error: $message; reconnecting from scratch") + reconnectAfterLoss("CarPlay transport error: $message") + } + } + + override fun onDebugLog(message: String) { + if (DiagnosticRedactor.redact(message) == null) return + runOnUiThread { + if (controllerGeneration != restartGeneration) { + return@runOnUiThread + } + DisplayDiagnosticSnapshot.record(this@CarPlayHostActivity, displayDiagnosticAttempt, message) + if (menuOpen) return@runOnUiThread + if (message.startsWith(PROTOCOL_TRACE_PREFIX)) { + appendFileLog(message) + } else { + appendLog(message) + } + } + } + } + + private fun createStatusReporter( + controllerGeneration: Int, + ): (CarPlayStatus) -> Unit = { status -> + if (!menuOpen && controllerGeneration == restartGeneration) { + updateHotspotStatus(status) + val description = status.describe() + setConnectionStage(description) + when (status) { + is CarPlayStatus.Failed -> if (status.wifiResetRequired) { + wifiRecoveryButton?.visibility = View.VISIBLE + } else { + wifiRecoveryButton?.visibility = View.GONE + reconnectAfterLoss(description) + } + else -> Unit + } + } + } + + private fun adoptBackgroundSession(): Boolean { + val snapshot = CarPlayBackgroundSession.snapshot() ?: return false + if (snapshot.controller.isClosed()) { + CarPlayBackgroundSession.clear(snapshot.controller) + return false + } + displayDiagnosticAttempt = DisplayDiagnosticSnapshot.currentAttempt(this) + controller = snapshot.controller + sink = snapshot.sink + CarPlayBackgroundSession.store(snapshot.controller, snapshot.sink, snapshot.width, snapshot.height, this) { completion -> + runOnUiThread { + shutdown(false, "DiPlay disconnect", completion) + finish() + } + } + if (snapshot.width > 0 && snapshot.height > 0) { + activeDisplaySize = DisplaySize(snapshot.width, snapshot.height) + } + val generation = restartGeneration + snapshot.controller.attachUi( + createSessionListener(generation), + createStatusReporter(generation), + ) + snapshot.sink.setScreenStreamActiveChangedListener { type, active -> + onScreenStreamStateChanged(restartGeneration, type, active) + } + currentSurface?.let(::attachSurface) + val serviceReused = snapshot.controller.hasActiveAirPlayAttachment() + appendLog( + if (serviceReused) { + "Reusing existing background CarPlay service" + } else { + "Reusing existing background CarPlay session" + }, + ) + setConnectionStage( + if (serviceReused) { + "CarPlay service already running" + } else { + "CarPlay session already running" + }, + ) + updateDebugOverlays() + return true + } + + private fun startCarPlay(size: DisplaySize) { + if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) return + if (shuttingDown.get() || menuOpen || handshakeResetInProgress || controller != null) return + val controllerGeneration = restartGeneration + val config = createRuntimeConfig() + val airPlayConfig = createAirPlayConfig(size) + val locationProvider: Iap2LocationProvider? = + if (config.locationReportingEnabled) { + AndroidCarPlayLocationProvider(this) + } else { + null + } + appendLog( + "Starting CarPlay controller at ${size.width}x${size.height} -> " + + "${airPlayConfig.main.widthPixels}x${airPlayConfig.main.heightPixels} " + + "(${CarPlayDisplayScale.label(displayScaleTenths)}) " + + "physical=${airPlayConfig.main.widthPhysicalMm}x" + + "${airPlayConfig.main.heightPhysicalMm}mm " + + "video=${if (airPlayConfig.hevc) "HEVC" else "H.264"} " + + "decoder=${if (airPlayConfig.hevc && hevcSoftwareDecoderEnabled) "software" else "hardware"} " + + "microphone=${airPlayConfig.microphone} " + + "location=${if (config.locationReportingEnabled) "enabled" else "disabled"} " + + "mfi=${mfiTargetLabel(config.mfiTarget)}", + ) + Log.i( + TAG, + "starting controller display=${size.width}x${size.height} " + + "negotiated=${airPlayConfig.main.widthPixels}x${airPlayConfig.main.heightPixels} " + + "scale=${CarPlayDisplayScale.label(displayScaleTenths)} " + + "hevc=${airPlayConfig.hevc} " + + "softwareHevc=${airPlayConfig.hevc && hevcSoftwareDecoderEnabled} " + + "microphone=${airPlayConfig.microphone} " + + "location=${config.locationReportingEnabled} " + + "mfi=${config.mfiTarget}", + ) + val renderer = createMediaSink( + videoWidth = airPlayConfig.main.widthPixels, + videoHeight = airPlayConfig.main.heightPixels, + controllerGeneration = controllerGeneration, + ) + sink = renderer + currentSurface?.let(::attachSurface) + val media = createMediaEngine(renderer) + val pairings = AirPlayPersistence.loadPairings(this) { id, key -> + AirPlayPersistence.savePairing(this, id, key) + } + val next = CarPlayController( + context = this, + config = config, + airPlayConfig = airPlayConfig, + identity = airPlayIdentity, + pairings = pairings, + listener = createSessionListener(controllerGeneration), + media = media, + reportStatus = createStatusReporter(controllerGeneration), + loadPairRecord = { AirPlayPersistence.loadLockdownRecord(this) }, + savePairRecord = { record -> AirPlayPersistence.saveLockdownRecord(this, record) }, + clearPairRecord = { AirPlayPersistence.clearLockdownRecord(this) }, + locationProvider = locationProvider, + ) + controller = next + CarPlayBackgroundSession.store(next, renderer, size.width, size.height, this) { completion -> + runOnUiThread { + shutdown(terminateProcess = false, reason = "DiPlay disconnect", completion = completion) + finish() + } + } + try { + startForegroundService(Intent(this, DiPlaySessionService::class.java)) + next.start() + } catch (error: RuntimeException) { + appendLog("Connection could not start: ${error.javaClass.simpleName}") + shutdown(false, "foreground service could not start") + setConnectionStage("Could not start CarPlay. Return to DiPlay and check app permissions.") + } + } + + private fun syncAirPlayDarkMode() { + val session = activeAirPlaySession ?: return + val night = darkMode + airPlayCommandExecutor.execute { + try { + val sent = session.setNightMode(night) + Log.i( + TAG, + "AirPlay dark mode=${if (night) "dark" else "light"} eventChannelReady=$sent", + ) + } catch (error: Throwable) { + Log.w(TAG, "Could not send AirPlay dark mode update", error) + } + } + } + + private fun audioCaptureDirectory(): File? { + if (!File(filesDir, AUDIO_CAPTURE_MARKER).isFile) return null + return File(filesDir, AUDIO_CAPTURE_DIRECTORY) + } + + private fun scheduleDisplaySize(width: Int, height: Int) { + if (width <= 0 || height <= 0 || shuttingDown.get()) return + val size = DisplaySize(width, height) + if (size == activeDisplaySize || size == pendingDisplaySize) return + pendingDisplaySize = size + mainHandler.removeCallbacks(applyDisplaySize) + mainHandler.postDelayed(applyDisplaySize, DISPLAY_CHANGE_DEBOUNCE_MILLIS) + } + + private fun applyDisplaySize(size: DisplaySize) { + if (shuttingDown.get() || size == activeDisplaySize) return + val previous = activeDisplaySize + activeDisplaySize = size + recordDetectedMaximum(size) + updateResolutionMenu() + if (previous == null) { + appendLog("Display detected: ${size.width}x${size.height}") + maybeStartCarPlay() + } else if (menuOpen || handshakeResetInProgress) { + appendLog( + "Display updated while handshake is reset: " + + "${previous.width}x${previous.height} -> ${size.width}x${size.height}", + ) + } else { + restartCarPlay( + "Display changed ${previous.width}x${previous.height} -> ${size.width}x${size.height}", + ) + } + } + + private fun recordDetectedMaximum(size: DisplaySize) { + val width = maxOf(maximumDetectedWidthPixels, size.width) + val height = maxOf(maximumDetectedHeightPixels, size.height) + if (width == maximumDetectedWidthPixels && height == maximumDetectedHeightPixels) return + maximumDetectedWidthPixels = width + maximumDetectedHeightPixels = height + AirPlayPersistence.saveMaximumDetectedDisplay(this, width, height) + } + + private fun maybeStartCarPlay() { + if (shuttingDown.get()) return + if (CarPlayBackgroundSession.hasSession() && !CarPlayBackgroundSession.isOwner(this)) { + if (!adoptBackgroundSession()) mainHandler.postDelayed({ maybeStartCarPlay() }, 500) + return + } + if (controller == null && adoptBackgroundSession()) return + val size = activeDisplaySize ?: return + val transportReady = if (wirelessEnabled) wirelessPermissionsReady else vpnReady + val locationReady = !locationReportingEnabled || locationPermissionAvailable + if ( + !transportReady || + !locationReady || + !microphonePermissionResolved || + shuttingDown.get() || + menuOpen || + handshakeResetInProgress || + controller != null + ) { + return + } + startCarPlay(size) + } + + private fun reconnectAfterLoss(reason: String) { + if (!CarPlayBackgroundSession.isOwner(this)) return + if (shuttingDown.get() || menuOpen || handshakeResetInProgress) return + if (reconnectScheduled) return + reconnectScheduled = true + val generation = restartGeneration + val delayMillis = if (reason.contains("AirPlay iAP tunnel", ignoreCase = true)) { + IAP_TUNNEL_RECONNECT_DELAY_MILLIS + } else { + (RECONNECT_DELAY_MILLIS * (1L shl reconnectAttempts.coerceAtMost(4))).coerceAtMost(30_000L) + } + reconnectAttempts += 1 + appendLog("$reason; retrying in ${delayMillis}ms") + mainHandler.postDelayed( + { + reconnectScheduled = false + if ( + shuttingDown.get() || + menuOpen || + handshakeResetInProgress || + generation != restartGeneration + ) { + return@postDelayed + } + restartCarPlay("Reconnecting after $reason") + }, + delayMillis, + ) + } + + /** Full-stack fallback when an AirPlay-only reconnect is unavailable. */ + private fun restartCarPlay(reason: String) { + if (!CarPlayBackgroundSession.isOwner(this)) return + if (shuttingDown.get() || menuOpen || handshakeResetInProgress) return + val size = activeDisplaySize ?: return + appendLog(reason) + activeScreenStreamTypes.clear() + setConnectionStage(reason) + Log.i(TAG, "$reason; rebuilding stack at ${size.width}x${size.height}") + val generation = ++restartGeneration + handshakeResetInProgress = true + val oldController = controller + val oldSink = sink + CarPlayBackgroundSession.clear(oldController, keepOwner = true) + controller = null + sink = null + teardownExecutor.execute { + oldController?.close() + oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) + oldSink?.close() + runOnUiThread { + if (!shuttingDown.get() && generation == restartGeneration) { + handshakeResetInProgress = false + startCarPlay(size) + } + } + } + } + + private fun showDiPlayHome(page: String = "home") { + controller?.sendTouch(emptyList()) + startActivity(Intent(this, DiPlayActivity::class.java) + .putExtra("page", page).addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)) + } + + private fun openSettingsMenu() = showDiPlayHome("settings") + + private fun saveSettingsAndReconnect() { + if (!menuOpen) return + if (!validateMfiSettings()) return + if (!validateManualHotspotSettings()) return + persistMenuSettings() + settingsBaseline = null + finishSettingsMenu("Settings saved") + } + + private fun cancelSettingsEdits() { + if (!menuOpen) return + restoreSettingsBaseline() + finishSettingsMenu("Settings changes discarded") + } + + private fun finishSettingsMenu(prefix: String) { + if (!menuOpen) return + menuOpen = false + settingsMenu?.visibility = View.GONE + gestureOverlay?.visibility = View.VISIBLE + updateDebugOverlays() + logLines.clear() + appendLog( + "$prefix; starting a fresh handshake at " + + "${CarPlayDisplayScale.label(displayScaleTenths)} with " + + (if (hevcEnabled) "HEVC (H.265)" else "H.264") + + ", MFI ${mfiTargetLabel(mfiTarget)}" + + ", Wi-Fi session ${hotspotModeLabel(wirelessHotspotMode)}", + ) + if (handshakeResetInProgress) { + startAfterHandshakeReset = true + } else { + maybeStartCarPlay() + } + } + + private fun exitApplication() { + if (shuttingDown.get()) return + restoreSettingsBaseline() + finishAndRemoveTask() + shutdown(terminateProcess = true, reason = "settings exit application") + } + + private fun shutdown(terminateProcess: Boolean, reason: String, completion: () -> Unit = {}) { + if (!shuttingDown.compareAndSet(false, true)) { completion(); return } + restartGeneration += 1 + mainHandler.removeCallbacks(applyDisplaySize) + val oldController = controller + val oldSink = sink + CarPlayBackgroundSession.clear(oldController) + controller = null + sink = null + Log.i(TAG, "shutdown reason=$reason terminateProcess=$terminateProcess") + teardownExecutor.execute { + oldController?.close() + val clean = oldController?.awaitClosed(CONTROLLER_CLOSE_TIMEOUT_MILLIS) ?: true + oldSink?.close() + airPlayCommandExecutor.shutdown() + if (terminateProcess) { + applicationContext.stopService(Intent(applicationContext, CarPlayVpnService::class.java)) + } + Log.i(TAG, "shutdown complete clean=$clean") + applicationContext.stopService(Intent(applicationContext, DiPlaySessionService::class.java)) + teardownExecutor.shutdown() + mainHandler.post { completion() } + if (terminateProcess) Process.killProcess(Process.myPid()) + } + } + + private fun attachSurface(surface: Surface) { + sink?.setSurface(SCREEN_TYPE_MAIN, surface) + sink?.setSurface(SCREEN_TYPE_ALT, surface) + } + + private fun onHostTouch(view: View, event: MotionEvent): Boolean { + if (menuOpen) return true + + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + gestureSequenceActive = false + gestureTracking = false + } + MotionEvent.ACTION_POINTER_DOWN -> { + if (event.pointerCount == THREE_FINGER_COUNT && !gestureSequenceActive) { + gestureSequenceActive = true + gestureTracking = true + gestureStartX = pointerCentroid(event, horizontal = true) + gestureStartY = pointerCentroid(event, horizontal = false) + controller?.sendTouch(emptyList()) + appendLog("Three-finger swipe tracking started") + return true + } + } + } + + if (gestureSequenceActive) { + if (!gestureTracking || event.pointerCount != THREE_FINGER_COUNT) { + if (event.actionMasked == MotionEvent.ACTION_UP || + event.actionMasked == MotionEvent.ACTION_CANCEL + ) { + gestureSequenceActive = false + gestureTracking = false + } else if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) { + gestureTracking = false + } + return true + } + if (event.actionMasked == MotionEvent.ACTION_MOVE) { + val deltaX = Math.abs(pointerCentroid(event, horizontal = true) - gestureStartX) + val deltaY = pointerCentroid(event, horizontal = false) - gestureStartY + if ( + deltaY >= dp(THREE_FINGER_SWIPE_DISTANCE_DP) && + deltaY >= deltaX * THREE_FINGER_SWIPE_DIRECTION_RATIO + ) { + gestureSequenceActive = false + gestureTracking = false + openSettingsMenu() + return true + } + } + return true + } + + val contacts = CarPlayTouchMapper.contacts(event, view.width, view.height) + val queued = controller?.sendTouch(contacts) ?: false + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_UP, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_CANCEL -> Log.i( + TAG, + "touch action=${MotionEvent.actionToString(event.actionMasked)} " + + "pointers=${event.pointerCount} queued=$queued", + ) + } + return true + } + + private fun pointerCentroid(event: MotionEvent, horizontal: Boolean): Float { + var total = 0f + for (index in 0 until event.pointerCount) { + total += if (horizontal) event.getX(index) else event.getY(index) + } + return total / event.pointerCount + } + + private fun onScreenStreamStateChanged(generation: Int, type: Int, active: Boolean) { + runOnUiThread { + if (shuttingDown.get() || generation != restartGeneration) return@runOnUiThread + if (active) { + activeScreenStreamTypes.add(type) + } else { + activeScreenStreamTypes.remove(type) + } + updateDebugOverlays() + } + } + + private fun setStatus(message: String) { + runOnUiThread { + setConnectionStage(message) + appendLog(message) + } + } + + private fun setConnectionStage(message: String) { + latestStage = message + stageStatusView?.text = friendlyStage(message) + updateDebugOverlays() + } + + private fun updateDebugOverlays() { + statusScrollView?.visibility = View.GONE + connectionPanel?.visibility = if (activeScreenStreamTypes.isEmpty()) View.VISIBLE else View.GONE + } + + private fun friendlyStage(message: String): String = when { + message.contains("Turn on Wi-Fi", true) -> "Turn on Wi-Fi in the head unit’s settings to connect." + message.contains("Allow precise Location", true) -> "Allow precise Location for DiPlay in the head unit’s app permissions." + message.contains("Allow Nearby devices", true) -> "Allow Nearby devices for DiPlay in the head unit’s app permissions." + message.contains("createGroup failed", true) -> "The head unit couldn’t start CarPlay Wi-Fi. Check Wi-Fi and close other projection apps. Retrying…" + message.contains("needs a reset", true) -> "A previous Wi-Fi Direct connection is still running. Reset it to connect." + message.contains("socket", true) || message.contains("RFCOMM", true) -> "Your iPhone isn’t available. Unlock it and check Bluetooth." + message.contains("unsupported", true) || message.contains("not supported", true) -> "This head unit may not support wireless CarPlay. Try a USB connection." + message.contains("denied", true) || message.contains("permission", true) -> "Allow the connection permission to continue" + message.contains("Failed", true) || message.contains("error", true) -> "Connection interrupted. Retrying…" + message.contains("Waiting for iPhone", true) || message.contains("Discovering iPhone", true) -> "Connect your iPhone with a USB cable" + message.contains("paired", true) -> "Looking for your paired iPhone…" + message.contains("Bluetooth", true) -> "Connecting to your iPhone…" + message.contains("reconnect", true) || message.contains("ended", true) -> "Reconnecting to your iPhone…" + message.contains("active", true) || message.contains("running", true) -> "Opening CarPlay…" + else -> "Getting CarPlay ready…" + } + + private fun appendLog(message: String) { + val safe = DiagnosticRedactor.redact(message) ?: return + sessionLog?.append(formattedLogLine(safe, System.currentTimeMillis())) + } + + private fun appendFileLog(message: String) { + sessionLog?.append(formattedLogLine(message, System.currentTimeMillis())) + } + + private fun formattedLogLine(message: String, nowMillis: Long): String = + "${SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date(nowMillis))} $message" + + private fun initializeSessionLog() { + val logFile = File(File(filesDir, "logs"), "diplay.log") + val activeLog = SessionLogFile(logFile) + runCatching { + activeLog.reset( + "DiPlay log started " + + "${SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US).format(Date())} " + + "pid=${Process.myPid()} path=${logFile.absolutePath}", + ) + } + sessionLog = activeLog + } + + private fun refreshLogView(nowMillis: Long) { + val cutoff = nowMillis - LOG_RETENTION_MILLIS + while (logLines.firstOrNull()?.timestampMillis?.let { it <= cutoff } == true) { + logLines.removeFirst() + } + statusView?.text = logLines.joinToString("\n") { it.text } + scrollLogsToBottom() + + mainHandler.removeCallbacks(expireOldLogLines) + logLines.firstOrNull()?.let { oldest -> + val delay = (oldest.timestampMillis + LOG_RETENTION_MILLIS - nowMillis + 1L) + .coerceAtLeast(1L) + mainHandler.postDelayed(expireOldLogLines, delay) + } + } + + private fun scrollLogsToBottom() { + statusScrollView?.post { + statusScrollView?.fullScroll(View.FOCUS_DOWN) + } + } + + private fun applyFullscreenMode() { + val hideTop = hideTopBar + val hideBottom = hideBottomBar + WindowCompat.setDecorFitsSystemWindows(window, !(hideTop && hideBottom)) + val controller = WindowInsetsControllerCompat(window, window.decorView) + if (hideTop) { + controller.hide(WindowInsetsCompat.Type.statusBars()) + } else { + controller.show(WindowInsetsCompat.Type.statusBars()) + } + if (hideBottom) { + controller.hide(WindowInsetsCompat.Type.navigationBars()) + } else { + controller.show(WindowInsetsCompat.Type.navigationBars()) + } + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private fun CarPlayStatus.describe(): String = when (this) { + CarPlayStatus.DiscoveringMfi -> "Preparing MFi authentication" + CarPlayStatus.WaitingForMfi -> "Waiting for MFi coprocessor" + CarPlayStatus.RequestingMfiPermission -> "Requesting MFi USB permission" + CarPlayStatus.MfiReady -> "MFi authentication ready" + CarPlayStatus.StartingHotspot -> "Starting wireless hotspot" + is CarPlayStatus.HotspotReady -> + "Hotspot ready: $backend, $ssid, $band, " + + "channel ${if (channel == 0) "auto" else channel}" + CarPlayStatus.WaitingForPairedIphone -> "Waiting for paired iPhone" + CarPlayStatus.ConnectingBluetooth -> "Connecting Bluetooth" + CarPlayStatus.RunningWireless -> "Wireless CarPlay control running" + CarPlayStatus.WirelessActive -> "Wireless CarPlay active" + CarPlayStatus.DiscoveringIphone -> "Discovering iPhone" + CarPlayStatus.WaitingForIphone -> "Waiting for iPhone over USB" + CarPlayStatus.RequestingIphonePermission -> "Requesting iPhone USB permission" + CarPlayStatus.WaitingForReenumeration -> "Waiting for iPhone re-enumeration" + CarPlayStatus.SelectingConfiguration -> "Selecting CarPlay configuration" + CarPlayStatus.OpeningDataPaths -> "Opening USB data paths" + CarPlayStatus.Pairing -> "Pairing with iPhone" + CarPlayStatus.ConnectingControl -> "Connecting iAP2 control" + CarPlayStatus.AttachingNetwork -> + if (wirelessEnabled) "Starting AirPlay service" else "Attaching NCM/AirPlay network" + CarPlayStatus.RunningControl -> "CarPlay control running" + CarPlayStatus.ControlEnded -> "CarPlay control window ended" + is CarPlayStatus.Failed -> "Failed: ${message}" + } + + private companion object { + const val TAG = "xcertplay-usb" + const val SCREEN_TYPE_MAIN = 110 + const val SCREEN_TYPE_ALT = 111 + const val LOG_RETENTION_MILLIS = 5 * 60_000L + const val DISPLAY_CHANGE_DEBOUNCE_MILLIS = 500L + const val RECONNECT_DELAY_MILLIS = 2_000L + const val IAP_TUNNEL_RECONNECT_DELAY_MILLIS = 15_000L + const val CONTROLLER_CLOSE_TIMEOUT_MILLIS = 4_000L + const val AUDIO_CAPTURE_MARKER = "audio-capture.enabled" + const val AUDIO_CAPTURE_DIRECTORY = "audio-captures" + const val PROTOCOL_TRACE_PREFIX = "TRACE " + const val THREE_FINGER_COUNT = 3 + const val THREE_FINGER_SWIPE_DISTANCE_DP = 72 + const val THREE_FINGER_SWIPE_DIRECTION_RATIO = 1.15f + const val MAX_SETTINGS_MENU_WIDTH_PX = 1200 + val MENU_BACKGROUND = Color.rgb(12, 16, 19) + val MENU_SECONDARY = Color.rgb(170, 180, 190) + val MENU_ACCENT = Color.rgb(127, 205, 154) + val MENU_ACCENT_TRACK = Color.rgb(78, 143, 102) + val MENU_TRACK_OFF = Color.rgb(64, 74, 80) + val MENU_BUTTON_TEXT = Color.rgb(8, 17, 11) + val MENU_DANGER = Color.rgb(190, 45, 45) + val NO_VIDEO_BACKGROUND = Color.rgb(0x16, 0x16, 0x18) + } + + private data class DisplaySize(val width: Int, val height: Int) + private data class LogEntry(val timestampMillis: Long, val text: String) + private data class HotspotStatus( + val state: String, + val ssid: String? = null, + val band: String? = null, + val channel: Int? = null, + val backend: String? = null, + ) +} + +/** Process-local hand-off for keeping the CarPlay session alive while no Activity is visible. */ +internal object CarPlayBackgroundSession { + @Volatile var active = false + private var stopAction: (((() -> Unit)) -> Unit)? = null + private var stopping = false + private var owner: Any? = null + @Synchronized fun isOwner(candidate: Any): Boolean = owner === candidate + @Synchronized fun hasSession(): Boolean = stopAction != null || stopping + private val stopWaiters = mutableListOf<() -> Unit>() + + fun stop(completion: () -> Unit = {}) { + val action: (((() -> Unit)) -> Unit)? + synchronized(this) { + if (stopping) { stopWaiters.add(completion); return } + action = stopAction + if (action != null) { stopping = true; stopWaiters.add(completion) } + } + if (action == null) { completion(); return } + action.invoke { + val callbacks = synchronized(this) { + stopping = false + stopWaiters.toList().also { stopWaiters.clear() } + } + callbacks.forEach { it() } + } + } + + data class Snapshot( + val controller: CarPlayController, + val sink: AndroidMediaSink, + val width: Int, + val height: Int, + ) + + private var controller: CarPlayController? = null + private var sink: AndroidMediaSink? = null + private var width = 0 + private var height = 0 + + @Synchronized + fun snapshot(): Snapshot? { + val currentController = controller ?: return null + val currentSink = sink ?: return null + return Snapshot(currentController, currentSink, width, height) + } + + @Synchronized + fun store(controller: CarPlayController, sink: AndroidMediaSink, width: Int, height: Int, owner: Any, stop: (() -> Unit) -> Unit) { + this.stopAction = stop + this.owner = owner + this.controller = controller + this.sink = sink + this.width = width + this.height = height + } + + @Synchronized + fun clear(expected: CarPlayController? = null, keepOwner: Boolean = false) { + if (expected != null && controller !== expected) return + controller = null + sink = null + if (!keepOwner) { stopAction = null; owner = null } + active = false + width = 0 + height = 0 + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DarkMode.kt b/common/src/main/java/com/shilapi/xcertplay/DarkMode.kt new file mode 100644 index 0000000..14aad54 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DarkMode.kt @@ -0,0 +1,6 @@ +package com.shilapi.xcertplay + +import android.content.res.Configuration + +internal fun isDarkMode(uiMode: Int): Boolean = + uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt new file mode 100644 index 0000000..2137b31 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// UI copy and visual language adapted from DiAuto. See docs/THIRD_PARTY_NOTICES.md. +package com.shilapi.xcertplay + +import android.Manifest +import android.app.AlertDialog +import android.bluetooth.BluetoothManager +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import com.shilapi.xcertplay.host.R +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** DiAuto's visual language, with a connection flow for an independent CarPlay receiver. */ +class DiPlayActivity : ComponentActivity() { + private val handler = Handler(Looper.getMainLooper()) + private var page = "home" + private var setupError: String? = null + private var status: TextView? = null + private var connectButton: Button? = null + private var disconnectButton: Button? = null + private var lastRunning: Boolean? = null + private var pendingWireless = false + private var initialLaunch = true + private var notificationTransport = true + private var exportInProgress = false + private var exportButton: Button? = null + private val notificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + connect(notificationTransport) + } + private val tick = object : Runnable { + override fun run() { refreshStatus(); handler.postDelayed(this, 1000) } + } + private val bluetoothPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) choosePhone() else permissionHelp("Nearby devices", "Allow Nearby devices so DiPlay can connect to your paired iPhone.") + } + private val export = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri -> + if (uri != null) exportDiagnostics(uri) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, true) + window.statusBarColor = BG; window.navigationBarColor = BG + WindowInsetsControllerCompat(window, window.decorView).apply { + isAppearanceLightStatusBars = false + hide(WindowInsetsCompat.Type.statusBars()) + } + setupError = runCatching { DiPlayBootstrap.ensure(this) }.exceptionOrNull()?.let { + "Local setup could not finish. Reinstall the complete DiPlay beta APK and try again." + } + page = savedInstanceState?.getString("page") ?: intent.getStringExtra("page") ?: "home" + render() + handleWirelessRecovery() + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (page != "home") { page = "home"; render() } + else { isEnabled = false; onBackPressedDispatcher.onBackPressed(); isEnabled = true } + } + }) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent); setIntent(intent) + page = intent.getStringExtra("page") ?: "home"; render() + handleWirelessRecovery() + } + override fun onSaveInstanceState(outState: Bundle) { outState.putString("page", page); super.onSaveInstanceState(outState) } + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig); render() } + override fun onResume() { + super.onResume(); handler.removeCallbacks(tick); handler.post(tick) + if (initialLaunch) { + initialLaunch = false + if (setupError == null && !CarPlayBackgroundSession.hasSession() && + DiPlayPreferences.autoConnect(this) && intent.getStringExtra("page") == null) { + handler.post { connect(AirPlayPersistence.loadWirelessEnabled(this)) } + } + } + } + override fun onPause() { handler.removeCallbacks(tick); super.onPause() } + + private fun render() { + status = null; connectButton = null; disconnectButton = null; lastRunning = null + val scroll = ScrollView(this).apply { setBackgroundColor(BG); isFillViewport = true; clipToPadding = false } + val content = column().apply { setPadding(dp(32), dp(24), dp(32), dp(32)) } + scroll.addView(content) + val header = row().apply { gravity = Gravity.CENTER_VERTICAL } + header.addView(ImageView(this).apply { setImageResource(R.drawable.ic_carplay); contentDescription = "CarPlay" }, LinearLayout.LayoutParams(dp(36), dp(36))) + header.addView(label("DiPlay", 26, TEXT, true).apply { setPadding(dp(12), 0, 0, 0) }, LinearLayout.LayoutParams(0, dp(56), 1f)) + header.addView(button(if (page == "home") "Car home" else "Back", false) { + if (page == "home") startActivity(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)) + else { page = "home"; render() } + }, LinearLayout.LayoutParams(dp(130), dp(56))) + content.addView(header) + content.addView(space(24)) + when (page) { + "settings" -> settings(content) + "about" -> about(content) + else -> home(content) + } + setContentView(scroll) + refreshStatus() + } + + private fun home(content: LinearLayout) { + val wide = resources.configuration.screenWidthDp >= 850 + val body = column() + val left = column() + left.addView(label("YOUR PHONE. YOUR DRIVE.", 12, ACCENT, true).apply { letterSpacing = .16f }) + left.addView(label("A familiar drive.", if (wide) 42 else 36, TEXT, true).apply { setPadding(0, dp(12), 0, dp(10)) }) + left.addView(label("Your maps, music and conversations.\nCarPlay, right here on your car display.", 19, MUTED)) + val card = card() + card.addView(label("WIRELESS CARPLAY", 12, ACCENT, true).apply { letterSpacing = .12f }) + status = label("Ready when you are", 24, TEXT, true).apply { setPadding(0, dp(10), 0, dp(16)) } + card.addView(status) + connectButton = button("Connect phone", true) { + if (CarPlayBackgroundSession.hasSession()) openProjection() + else connect(true) + } + card.addView(connectButton, matchButton()) + card.addView(label("Pair your iPhone with the car’s Bluetooth, then connect.\nKeep Bluetooth and Wi-Fi on.", 15, MUTED).apply { setPadding(0, dp(14), 0, 0) }) + card.addView(button("Choose iPhone", false) { choosePhone() }, matchButton(16, 56)) + disconnectButton = button("Disconnect", false) { + disconnectButton?.isEnabled = false + CarPlayBackgroundSession.stop { runOnUiThread { refreshStatus() } } + }.apply { visibility = View.GONE } + card.addView(disconnectButton, matchButton(10, 56)) + val right = column().apply { gravity = Gravity.CENTER_HORIZONTAL } + val logo = ImageView(this).apply { + setImageResource(R.drawable.ic_carplay) + contentDescription = "CarPlay icon" + scaleType = ImageView.ScaleType.FIT_CENTER + } + val branding = column().apply { + gravity = Gravity.CENTER + addView(logo, LinearLayout.LayoutParams(dp(96), dp(96))) + } + right.addView(button("Connect with USB", false) { connect(false) }, matchButton()) + right.addView(label("Plug your iPhone into a USB data port.\nAllow CarPlay when your iPhone asks.", 14, MUTED).apply { gravity = Gravity.CENTER; setPadding(dp(8), dp(10), dp(8), dp(24)) }) + right.addView(button("Settings", false) { page = "settings"; render() }, matchButton()) + right.addView(label("Make DiPlay feel right for your car.", 14, MUTED).apply { gravity = Gravity.CENTER; setPadding(0, dp(10), 0, dp(24)) }) + right.addView(label("PUBLIC PREVIEW · ${version()}", 12, MUTED).apply { letterSpacing = .08f }) + if (wide) { + // Both rows share column widths. The USB button starts at the wireless + // card's top edge, independently of hero wrapping or font scaling. + fun columns(first: View, second: View, stretchSecond: Boolean = false) = row().apply { + gravity = Gravity.TOP + addView(first, LinearLayout.LayoutParams(0, -2, 1.6f)) + addView(space(40), LinearLayout.LayoutParams(dp(40), 1)) + addView(second, LinearLayout.LayoutParams(0, if (stretchSecond) -1 else -2, 1f)) + } + body.addView(columns(left, branding, true)) + body.addView(space(26)) + body.addView(columns(card, right)) + } else { + body.addView(left) + body.addView(space(26)) + body.addView(card) + body.addView(space(26)) + body.addView(branding) + body.addView(space(24)) + body.addView(right) + } + setupError?.let { body.addView(label(it, 16, WARNING).apply { setPadding(0, dp(16), 0, 0) }) } + content.addView(body) + } + + private fun settings(content: LinearLayout) { + content.addView(label("Your drive, your way.", 34, TEXT, true)) + content.addView(label("Apply reconnects CarPlay for icon size, resolution and frame rate. Other changes apply to your next connection.", 17, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) + section(content, "Automatic connection") { card -> + toggle(card, "Connect when DiPlay opens", "Use your last connection type and selected iPhone.", DiPlayPreferences.autoConnect(this)) { DiPlayPreferences.saveAutoConnect(this, it) } + toggle(card, "Open after the car starts", "Availability depends on your head unit’s startup settings.", AirPlayPersistence.loadAutoStartOnBoot(this)) { AirPlayPersistence.saveAutoStartOnBoot(this, it) } + card.addView(button("Choose iPhone · ${DiPlayPreferences.phoneName(this)}", false) { choosePhone() }, matchButton(12, 60)) + } + section(content, "Display and performance") { card -> + iconSizeControl(card) + choice(card, "Resolution", listOf("Native", "80% · lighter load", "60% · lightest load"), listOf(10, 8, 6).indexOf(AirPlayPersistence.loadDisplayScaleTenths(this)).coerceAtLeast(0)) { AirPlayPersistence.saveDisplayScaleTenths(this, listOf(10, 8, 6)[it]) } + choice(card, "Frame rate", listOf("30 fps · lighter load", "60 fps · smoother motion"), if (AirPlayPersistence.loadFps(this) == 60) 1 else 0) { AirPlayPersistence.saveFps(this, if (it == 1) 60 else 30) } + toggle(card, "Efficient video", "Use HEVC. Leave off for the widest head-unit compatibility.", AirPlayPersistence.loadHevcEnabled(this)) { AirPlayPersistence.saveHevcEnabled(this, it) } + toggle(card, "Right-hand drive", "Place CarPlay’s controls closer to the driver.", AirPlayPersistence.loadRightHandDrive(this)) { AirPlayPersistence.saveRightHandDrive(this, it) } + toggle(card, "Full screen", "Hide the car’s system bars while CarPlay is open.", AirPlayPersistence.loadHideTopBar(this) && AirPlayPersistence.loadHideBottomBar(this)) { + AirPlayPersistence.saveHideTopBar(this, it); AirPlayPersistence.saveHideBottomBar(this, it) + } + } + section(content, "Permissions and connection help") { card -> + card.addView(label("Nearby devices connects your iPhone. Microphone enables Siri and calls. Older Android versions also require Location for wireless setup. USB mode may ask for a local VPN connection.", 16, MUTED)) + card.addView(button("App permissions", false) { openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) }, matchButton(16, 60)) + card.addView(button("Bluetooth settings", false) { openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) }, matchButton(10, 60)) + card.addView(button("Wireless connection help", false) { wirelessHelp() }, matchButton(10, 60)) + } + section(content, "About and diagnostics") { card -> + card.addView(button("About DiPlay", false) { page = "about"; render() }, matchButton(0, 60)) + exportButton = button(if (exportInProgress) "Saving report…" else "Save diagnostic report", false) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) exportDiagnostics() + else chooseReportDestination() + }.apply { isEnabled = !exportInProgress } + card.addView(exportButton, matchButton(10, 60)) + val destination = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) "Reports save to Downloads/DiPlay. " else "Choose where to save your report. " + card.addView(label(destination + "Nothing is sent automatically. Protocol payloads and credentials are excluded.", 14, MUTED).apply { setPadding(0, dp(12), 0, 0) }) + } + } + + private fun about(content: LinearLayout) { + content.addView(label("DiPlay", 40, TEXT, true)) + content.addView(label("CarPlay, at home in your car.", 20, MUTED).apply { setPadding(0, dp(8), 0, dp(24)) }) + section(content, "Public preview · ${version()}") { card -> + card.addView(label("An independent CarPlay receiver for Android head units. Wired and wireless connections run on the head unit, with local authentication. A standard iPhone can connect without a jailbreak, Mac, dongle or sign-in.\n\nThis preview uses an experimental accessory identity. Compatibility with every iPhone and head unit is still being tested. It is not an Apple-certified product.", 17, TEXT)) + } + section(content, "Made possible by open source") { card -> + card.addView(label("Receiver based on xcertplay, licensed under GPL-3.0. DiPlay’s interface follows DiAuto’s design, licensed under AGPL-3.0.\n\nIncludes AndroidX, Bouncy Castle, JmDNS and SLF4J. Source and license notices accompany this release.\n\nCarPlay and the CarPlay icon belong to Apple Inc. DiPlay is an independent project.", 16, MUTED)) + } + } + + private fun iconSizeControl(parent: LinearLayout) { + val presets = com.shilapi.xcertplay.airplay.CarPlayUiScale.presets + fun label(percent: Int) = com.shilapi.xcertplay.airplay.CarPlayUiScale.label(percent) + val control = button("Icon and text size · ${label(AirPlayPersistence.loadUiScalePercent(this))}", false) {} + control.setOnClickListener { + val current = AirPlayPersistence.loadUiScalePercent(this) + var selection = presets.indexOf(current) + AlertDialog.Builder(this).setTitle("CarPlay icon and text size") + .setSingleChoiceItems(arrayOf("Smaller · 75%", "Small · 85%", "Default", "Large · 115%"), selection) { _, index -> selection = index } + .setPositiveButton(if (CarPlayBackgroundSession.hasSession()) "Apply and reconnect" else "Save") { _, _ -> + val selected = presets[selection] + DisplayDiagnosticSnapshot.selection(this, current, selected, + selected != current && CarPlayBackgroundSession.hasSession()) + AirPlayPersistence.saveUiScalePercent(this, selected) + control.text = "Icon and text size · ${label(selected)}" + if (selected != current && CarPlayBackgroundSession.hasSession()) { + connect(AirPlayPersistence.loadWirelessEnabled(this)) + } + }.setNegativeButton("Cancel", null).show() + } + parent.addView(control, matchButton(0, 60)) + parent.addView(label("Smaller sizes use more processing power. Choose Default if playback slows. Applying a size reconnects CarPlay.", 14, MUTED).apply { + setPadding(0, dp(8), 0, dp(18)) + }) + } + + private fun connect(wireless: Boolean) { + if (setupError != null) { toast(setupError!!); return } + if (wireless && DiPlayPreferences.phoneAddress(this) == null) { + pendingWireless = true; choosePhone(); return + } + val preferences = getSharedPreferences("diplay", MODE_PRIVATE) + if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED && !preferences.getBoolean("notification_asked", false)) { + preferences.edit().putBoolean("notification_asked", true).apply() + notificationTransport = wireless + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + val open = { + AirPlayPersistence.saveWirelessEnabled(this, wireless) + openProjection() + } + if (CarPlayBackgroundSession.hasSession()) CarPlayBackgroundSession.stop { runOnUiThread { open() } } + else open() + } + private fun openProjection() { + startActivity(Intent(this, CarPlayHostActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)) + } + private fun choosePhone() { + if (Build.VERSION.SDK_INT >= 31 && checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) { + bluetoothPermission.launch(Manifest.permission.BLUETOOTH_CONNECT); return + } + val adapter = getSystemService(BluetoothManager::class.java)?.adapter + if (adapter == null || !adapter.isEnabled) { + AlertDialog.Builder(this).setTitle("Turn on Bluetooth") + .setMessage("Enable the car’s Bluetooth and pair your iPhone first.") + .setPositiveButton("Open Bluetooth") { _, _ -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } + .setNegativeButton("Later", null).show(); return + } + val devices = runCatching { adapter.bondedDevices.sortedBy { it.name ?: "" } }.getOrDefault(emptyList()) + if (devices.isEmpty()) { + AlertDialog.Builder(this).setTitle("Pair your iPhone") + .setMessage("On your iPhone, open Settings → Bluetooth and pair with the car. Then return to DiPlay and choose Connect phone.") + .setPositiveButton("Open Bluetooth") { _, _ -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } + .setNegativeButton("Got it", null).show(); return + } + AlertDialog.Builder(this).setTitle("Choose your iPhone") + .setItems(devices.map { device -> + val name = device.name ?: "Paired device" + if (devices.count { it.name == device.name } > 1) "$name · ${device.address.takeLast(5)}" else name + }.toTypedArray()) { _, index -> + val device = devices[index] + DiPlayPreferences.savePhone(this, device.address, device.name ?: "iPhone") + val start = pendingWireless; pendingWireless = false + render() + if (start) connect(true) + }.setNeutralButton("Pair another") { _, _ -> openSystem(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) } + .setNegativeButton("Cancel") { _, _ -> pendingWireless = false }.show() + } + + private fun wirelessHelp() { + AlertDialog.Builder(this).setTitle("Wireless connection help") + .setMessage("Pair your iPhone with the car’s Bluetooth, keep Wi-Fi on, and allow CarPlay on the iPhone. Close any other phone-projection app.\n\nIf a previous projection app left its connection running, reset CarPlay Wi-Fi below and connect again. Your car’s normal internet Wi-Fi stays on.") + .setPositiveButton("Got it", null) + .setNeutralButton("Reset CarPlay Wi-Fi") { _, _ -> + confirmWirelessReset() + }.show() + } + + private fun handleWirelessRecovery() { + if (page != "wireless-recovery") return + page = "home"; render() + confirmWirelessReset() + } + + private fun confirmWirelessReset() { + AlertDialog.Builder(this).setTitle("Reset CarPlay Wi-Fi?") + .setMessage("This ends the existing Wi-Fi Direct connection, including one left behind after reinstalling. Close other projection apps first. Your car’s internet Wi-Fi stays on.") + .setPositiveButton("Reset and connect") { _, _ -> + CarPlayBackgroundSession.stop { runOnUiThread { resetWirelessGroup() } } + }.setNegativeButton("Cancel", null).show() + } + + private fun resetWirelessGroup() { + val manager = getSystemService(android.net.wifi.p2p.WifiP2pManager::class.java) + if (manager == null) { toast("This head unit does not support Wi-Fi Direct."); return } + val channel = manager.initialize(this, mainLooper, null) + try { + manager.requestGroupInfo(channel) { group -> + if (group == null) { channel.close(); connect(true); return@requestGroupInfo } + manager.removeGroup(channel, object : android.net.wifi.p2p.WifiP2pManager.ActionListener { + override fun onSuccess() { + val deadline = android.os.SystemClock.elapsedRealtime() + 4000 + fun waitUntilRemoved() { + manager.requestGroupInfo(channel) { remaining -> + when { + remaining == null -> { channel.close(); if (!isFinishing && !isDestroyed) connect(true) } + android.os.SystemClock.elapsedRealtime() >= deadline -> { + channel.close(); toast("Wi-Fi Direct is still busy. Close the other projection app and try again.") + } + else -> handler.postDelayed({ waitUntilRemoved() }, 200) + } + } + } + waitUntilRemoved() + } + override fun onFailure(reason: Int) { channel.close(); toast("Could not reset Wi-Fi Direct. Close the other projection app and try again.") } + }) + } + } catch (_: SecurityException) { + channel.close(); permissionHelp("Wireless permissions", "Allow Nearby devices and, on older Android versions, Location before resetting CarPlay Wi-Fi.") + } + } + + private fun refreshStatus() { + val running = CarPlayBackgroundSession.hasSession() + status?.text = when { + setupError != null -> "Setup needs attention" + CarPlayBackgroundSession.active -> "CarPlay connected" + running -> "Connecting to your iPhone…" + DiPlayPreferences.phoneAddress(this) != null -> "Ready for ${DiPlayPreferences.phoneName(this)}" + else -> "Ready when you are" + } + if (lastRunning != running) { + connectButton?.text = if (running) "Open CarPlay" else "Connect phone" + disconnectButton?.visibility = if (running) View.VISIBLE else View.GONE + disconnectButton?.isEnabled = true + lastRunning = running + } + connectButton?.isEnabled = setupError == null + } + private fun reportFileName() = "DiPlay-${SimpleDateFormat("yyyyMMdd-HHmmss-SSS", Locale.US).format(Date())}.txt" + + private fun chooseReportDestination() { + // Some head units omit or disable DocumentsUI. Launch itself can throw, before + // the result callback and the background writer's exception handler ever run. + runCatching { export.launch(reportFileName()) }.onFailure { + toast(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + "This head unit could not open a save location. Please try saving to Downloads again." + else "This head unit has no available file picker to save the report.") + } + } + + private fun exportDiagnostics(uri: Uri? = null) { + if (exportInProgress) return + exportInProgress = true + exportButton?.apply { isEnabled = false; text = "Saving report…" } + val appContext = applicationContext + val fileName = reportFileName() + Thread({ + val result = runCatching { + val report = buildString { + appendLine("DiPlay ${version()} · private beta diagnostic report") + appendLine("Android ${Build.VERSION.RELEASE} / API ${Build.VERSION.SDK_INT}") + appendLine("Head unit: ${Build.MANUFACTURER} ${Build.MODEL}") + appendLine("Connection: ${if (AirPlayPersistence.loadWirelessEnabled(appContext)) "wireless" else "USB"}") + appendLine("Authentication: local experimental beta identity; no remote fallback") + appendLine("Saved video preference (may differ from active session): ${if (AirPlayPersistence.loadHevcEnabled(appContext)) "HEVC" else "H.264"}; ${AirPlayPersistence.loadFps(appContext)} fps") + appendLine("Icon and text size: ${com.shilapi.xcertplay.airplay.CarPlayUiScale.label(AirPlayPersistence.loadUiScalePercent(appContext))}") + appendLine("Saved resolution preference (may differ from active session): ${AirPlayPersistence.loadDisplayScaleTenths(appContext) * 10}%") + appendLine("Session: ${if (CarPlayBackgroundSession.active) "active" else if (CarPlayBackgroundSession.hasSession()) "connecting" else "stopped"}") + appendLine("Head-unit board: ${Build.BOARD}; hardware: ${Build.HARDWARE}; build: ${Build.DISPLAY}") + appendLine() + appendLine("--- Last display negotiation (timestamps distinguish it from current settings) ---") + appendLine(DisplayDiagnosticSnapshot.report(appContext)) + appendLine() + for (name in SessionLogFile.REPORT_NAMES) { + val file = File(appContext.filesDir, "logs/$name") + if (file.isFile) { + appendLine("--- $name ---") + file.useLines { lines -> lines.forEach { line -> DiagnosticRedactor.redact(line)?.let { appendLine(it) } } } + } + } + } + if (uri != null) DiagnosticExportStore.write(appContext.contentResolver, uri, report) + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + DiagnosticExportStore.saveToDownloads(appContext.contentResolver, fileName, report) + } else error("A save location is required") + } + runOnUiThread { + exportInProgress = false + if (isFinishing || isDestroyed) return@runOnUiThread + exportButton?.apply { isEnabled = true; text = "Save diagnostic report" } + if (result.isSuccess) { + AlertDialog.Builder(this).setTitle("Diagnostic report saved") + .setMessage(if (uri == null) "Downloads/DiPlay/$fileName" else "Your report was saved to the selected location.") + .setPositiveButton("Done", null).show() + } else { + AlertDialog.Builder(this).setTitle("Could not save the report") + .setMessage("Check that storage is available, or choose another save location.") + .setPositiveButton("Choose location") { _, _ -> chooseReportDestination() } + .setNegativeButton("Close", null).show() + } + } + }, "diplay-export").start() + } + private fun permissionHelp(title: String, body: String) { + AlertDialog.Builder(this).setTitle(title).setMessage(body).setPositiveButton("App settings") { _, _ -> + openSystem(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName"))) + }.setNegativeButton("Later", null).show() + } + private fun openSystem(intent: Intent) { runCatching { startActivity(intent) }.onFailure { toast("Open this setting from your car’s Settings app.") } } + private fun toast(message: String) { Toast.makeText(this, message, Toast.LENGTH_LONG).show() } + private fun version() = packageManager.getPackageInfo(packageName, 0).versionName ?: "0.1.0-beta.1" + private fun section(parent: LinearLayout, title: String, build: (LinearLayout) -> Unit) { + val card = card(); card.addView(label(title, 22, TEXT, true).apply { setPadding(0, 0, 0, dp(16)) }); build(card) + parent.addView(card, LinearLayout.LayoutParams(-1, -2).apply { bottomMargin = dp(18) }) + } + private fun toggle(parent: LinearLayout, title: String, description: String, value: Boolean, save: (Boolean) -> Unit) { + val line = row().apply { gravity = Gravity.CENTER_VERTICAL; setPadding(0, dp(12), 0, dp(12)) } + val text = column(); text.addView(label(title, 18, TEXT, true)); text.addView(label(description, 14, MUTED).apply { setPadding(0, dp(6), dp(16), 0) }) + line.addView(text, LinearLayout.LayoutParams(0, -2, 1f)) + line.addView(Switch(this).apply { contentDescription = title; isChecked = value; minHeight = dp(56); buttonTintList = ColorStateList.valueOf(ACCENT); setOnCheckedChangeListener { _, checked -> save(checked) } }) + parent.addView(line) + } + private fun choice(parent: LinearLayout, title: String, options: List, current: Int, save: (Int) -> Unit) { + var selection = current + val button = button("$title · ${options[selection]}", false) {} + button.setOnClickListener { + var pendingSelection = selection + AlertDialog.Builder(this).setTitle(title) + .setSingleChoiceItems(options.toTypedArray(), selection) { _, index -> pendingSelection = index } + .setPositiveButton(if (CarPlayBackgroundSession.hasSession()) "Apply and reconnect" else "Save") { _, _ -> + if (pendingSelection != selection) { + selection = pendingSelection + save(selection) + button.text = "$title · ${options[selection]}" + if (CarPlayBackgroundSession.hasSession()) { + connect(AirPlayPersistence.loadWirelessEnabled(this)) + } + } + }.setNegativeButton("Cancel", null).show() + } + parent.addView(button, matchButton(0, 60)); parent.addView(space(12)) + } + private fun card() = column().apply { background = rounded(SURFACE, BORDER); setPadding(dp(24), dp(24), dp(24), dp(24)) } + private fun column() = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } + private fun row() = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; layoutParams = LinearLayout.LayoutParams(-1, -2) } + private fun label(value: String, size: Int, color: Int, bold: Boolean = false) = TextView(this).apply { + text = value; textSize = size.toFloat(); setTextColor(color); gravity = Gravity.CENTER_VERTICAL + typeface = if (bold) Typeface.create("sans-serif-medium", Typeface.NORMAL) else Typeface.create("sans-serif", Typeface.NORMAL) + setLineSpacing(dp(3).toFloat(), 1f) + } + private fun button(title: String, primary: Boolean, click: () -> Unit) = Button(this).apply { + text = title; isAllCaps = false; textSize = 18f; setTextColor(if (primary) BG else TEXT) + typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + background = android.graphics.drawable.RippleDrawable(ColorStateList.valueOf(0x336F9FD9), rounded(if (primary) ACCENT else SURFACE, if (primary) ACCENT else BORDER), null) + setPadding(dp(16), 0, dp(16), 0); minHeight = dp(56); stateListAnimator = null + setOnClickListener { click() } + } + private fun rounded(color: Int, stroke: Int) = GradientDrawable().apply { setColor(color); cornerRadius = dp(20).toFloat(); setStroke(dp(1), stroke) } + private fun matchButton(top: Int = 0, height: Int = 68) = LinearLayout.LayoutParams(-1, dp(height)).apply { topMargin = dp(top) } + private fun space(height: Int) = View(this).apply { layoutParams = LinearLayout.LayoutParams(1, dp(height)) } + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() + companion object { + private val BG = Color.rgb(12, 17, 27) + private val SURFACE = Color.rgb(21, 30, 44) + private val BORDER = Color.rgb(42, 56, 75) + private val ACCENT = Color.rgb(166, 200, 255) + private val TEXT = Color.rgb(241, 245, 252) + private val MUTED = Color.rgb(168, 182, 202) + private val WARNING = Color.rgb(255, 196, 128) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayBluetooth.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayBluetooth.kt new file mode 100644 index 0000000..c4c0e59 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayBluetooth.kt @@ -0,0 +1,16 @@ +package com.shilapi.xcertplay + +import android.bluetooth.BluetoothManager +import android.content.Context +import android.provider.Settings + +internal object DiPlayBluetooth { + fun localAddress(context: Context): String? { + val adapter = runCatching { context.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull() + val setting = runCatching { Settings.Secure.getString(context.contentResolver, "bluetooth_address") }.getOrNull() + return listOfNotNull(adapter, setting).firstOrNull { + Regex("(?i)([0-9a-f]{2}:){5}[0-9a-f]{2}").matches(it) && + !it.startsWith("02:00:00:00:00:") && it != "00:00:00:00:00:00" + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt new file mode 100644 index 0000000..003b254 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlayBootstrap.kt @@ -0,0 +1,62 @@ +package com.shilapi.xcertplay + +import android.content.Context +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.mfi.LocalMfiAuthenticationClient +import com.shilapi.xcertplay.orchestration.MfiTarget +import java.io.File +import java.security.MessageDigest + +/** Installs the private beta's experimental identity. It has no remote fallback. */ +internal object DiPlayBootstrap { + @Volatile private var ready = false + + @Synchronized fun ensure(context: Context) { + if (ready) return + val target = File(context.noBackupFilesDir, LocalMfiAuthenticationClient.DIRECTORY) + if (!target.exists()) { + val staging = File(context.noBackupFilesDir, "offline-mfi-staging") + staging.deleteRecursively() + check(staging.mkdirs()) { "Could not prepare local authentication" } + staging.setReadable(false, false); staging.setReadable(true, true) + staging.setExecutable(false, false); staging.setExecutable(true, true) + try { + for (name in listOf("identity.pk8", "certificate.p7b")) { + val file = File(staging, name) + context.assets.open("offline-mfi/$name").use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + file.setReadable(false, false); file.setReadable(true, true) + file.setWritable(false, false); file.setWritable(true, true) + } + LocalMfiAuthenticationClient.load(staging) + check(staging.renameTo(target)) { "Could not install local authentication" } + } finally { + staging.deleteRecursively() + } + } + LocalMfiAuthenticationClient.load(target) + AirPlayPersistence.saveMfiTarget(context, MfiTarget.LOCAL) + AirPlayPersistence.saveDebugLogsEnabled(context, false) + ready = true + } + + fun deviceId(identity: AirPlayIdentity): String { + val bytes = MessageDigest.getInstance("SHA-256").digest(identity.publicKey).take(6).toByteArray() + bytes[0] = ((bytes[0].toInt() and 0xfc) or 0x02).toByte() + return bytes.joinToString(":") { "%02X".format(it.toInt() and 0xff) } + } +} + +internal object DiPlayPreferences { + private fun prefs(context: Context) = context.getSharedPreferences("diplay", Context.MODE_PRIVATE) + fun phoneAddress(context: Context): String? = prefs(context).getString("phone_address", null) + fun phoneName(context: Context): String = prefs(context).getString("phone_name", null) ?: "Your iPhone" + fun savePhone(context: Context, address: String, name: String) { + prefs(context).edit().putString("phone_address", address).putString("phone_name", name).apply() + } + fun autoConnect(context: Context) = prefs(context).getBoolean("auto_connect", false) + fun saveAutoConnect(context: Context, value: Boolean) { + prefs(context).edit().putBoolean("auto_connect", value).apply() + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt new file mode 100644 index 0000000..31e728a --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt @@ -0,0 +1,52 @@ +package com.shilapi.xcertplay + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import com.shilapi.xcertplay.host.R + +/** Keeps an explicitly started connection alive when another car app is in the foreground. */ +class DiPlaySessionService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + CarPlayBackgroundSession.stop() + stopSelf() + return START_NOT_STICKY + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(NotificationChannel(CHANNEL, "CarPlay connection", NotificationManager.IMPORTANCE_LOW)) + val open = PendingIntent.getActivity(this, 0, Intent(this, CarPlayHostActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val stop = PendingIntent.getService(this, 1, Intent(this, DiPlaySessionService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val notification = Notification.Builder(this, CHANNEL) + .setSmallIcon(R.drawable.ic_diplay_notification) + .setContentTitle("DiPlay") + .setContentText("CarPlay connection running") + .setContentIntent(open).setOngoing(true) + .addAction(Notification.Action.Builder(null, "Disconnect", stop).build()).build() + if (Build.VERSION.SDK_INT >= 29) { + var types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + if (Build.VERSION.SDK_INT >= 30 && checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + startForeground(1, notification, types) + } else startForeground(1, notification) + return START_NOT_STICKY + } + override fun onTaskRemoved(rootIntent: Intent?) { + CarPlayBackgroundSession.stop() + stopSelf() + } + companion object { + const val ACTION_STOP = "com.shihab.diplay.DISCONNECT" + private const val CHANNEL = "diplay_connection" + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiagnosticExportStore.kt b/common/src/main/java/com/shilapi/xcertplay/DiagnosticExportStore.kt new file mode 100644 index 0000000..6939b0a --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DiagnosticExportStore.kt @@ -0,0 +1,43 @@ +package com.shilapi.xcertplay + +import android.content.ContentResolver +import android.content.ContentValues +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.annotation.RequiresApi +import java.io.IOException + +/** Saves an app-owned report without depending on an OEM's document-picker activity. */ +internal object DiagnosticExportStore { + @RequiresApi(Build.VERSION_CODES.Q) + fun saveToDownloads(resolver: ContentResolver, fileName: String, report: String): Uri { + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, fileName) + put(MediaStore.Downloads.MIME_TYPE, "text/plain") + put(MediaStore.Downloads.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/DiPlay") + put(MediaStore.Downloads.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + ?: throw IOException("Downloads could not create the report") + try { + write(resolver, uri, report) + val published = resolver.update(uri, ContentValues().apply { + put(MediaStore.Downloads.IS_PENDING, 0) + }, null, null) + if (published != 1) throw IOException("Downloads could not publish the report") + return uri + } catch (error: Exception) { + // Only remove the entry created by this call; never leave a partial report behind. + runCatching { resolver.delete(uri, null, null) } + throw error + } + } + + fun write(resolver: ContentResolver, uri: Uri, report: String) { + val stream = resolver.openOutputStream(uri, "wt") + ?: throw IOException("Report destination is unavailable") + stream.bufferedWriter(Charsets.UTF_8).use { it.write(report) } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiagnosticRedactor.kt b/common/src/main/java/com/shilapi/xcertplay/DiagnosticRedactor.kt new file mode 100644 index 0000000..5442c29 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/DiagnosticRedactor.kt @@ -0,0 +1,17 @@ +package com.shilapi.xcertplay + +/** Diagnostics describe state transitions; protocol payloads and credentials are never exported. */ +internal object DiagnosticRedactor { + private val secret = Regex("(?i)(pass(word|phrase)?|token|private.?key|certificate|pair.?record|ssid|body=|payload=|hex=)") + private val mac = Regex("(?i)(? "phone" + message.startsWith("Video: decoder=") || message.startsWith("Video: decoder configuration failed") -> "decoder" + message.startsWith("Video: output format ") -> "output" + else -> return + } + val safe = DiagnosticRedactor.redact(message) ?: return + prefs.edit().putString(field, "${stamp()} $safe").apply() + } + + @Synchronized + fun report(context: Context): String { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return fields.mapNotNull { prefs.getString(it, null)?.let(DiagnosticRedactor::redact) } + .joinToString("\n").ifEmpty { "No display negotiation recorded yet." } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/ImageCropActivity.kt b/common/src/main/java/com/shilapi/xcertplay/ImageCropActivity.kt new file mode 100644 index 0000000..0a1dcf6 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/ImageCropActivity.kt @@ -0,0 +1,393 @@ +package com.shilapi.xcertplay + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.RectF +import android.net.Uri +import android.os.Bundle +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.TextView +import java.io.ByteArrayOutputStream +import java.util.concurrent.Executors +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min + +/** Loads one local image and writes a user-positioned 1:1 PNG into the AirPlay icon slot. */ +class ImageCropActivity : Activity() { + private val executor = Executors.newSingleThreadExecutor() + private lateinit var cropView: SquareCropView + private lateinit var statusView: TextView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val uri = intent.data + if (uri == null) { + finish() + return + } + + cropView = SquareCropView(this) + statusView = TextView(this).apply { + setTextColor(Color.WHITE) + textSize = 16f + gravity = Gravity.CENTER + setPadding(dp(12), dp(8), dp(12), dp(8)) + text = "Loading image" + } + + val controls = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER + setPadding(dp(16), dp(10), dp(16), dp(16)) + } + controls.addView( + Button(this).apply { + text = "Cancel" + isAllCaps = false + setOnClickListener { + setResult(RESULT_CANCELED) + finish() + } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f), + ) + controls.addView( + Button(this).apply { + text = "Save 1:1" + isAllCaps = false + setOnClickListener { saveCrop() } + }, + LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f).apply { + marginStart = dp(12) + }, + ) + + val root = FrameLayout(this).apply { + setBackgroundColor(Color.BLACK) + addView( + cropView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), + ) + addView( + statusView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.CENTER_HORIZONTAL, + ).apply { topMargin = dp(12) }, + ) + addView( + controls, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + } + setContentView(root) + + executor.execute { + val bitmap = try { + decodeBitmap(uri) + } catch (_: Exception) { + null + } + runOnUiThread { + if (isFinishing || isDestroyed) { + bitmap?.recycle() + return@runOnUiThread + } + if (bitmap == null) { + statusView.text = "Could not decode image" + } else { + cropView.setBitmap(bitmap) + statusView.text = "Drag to move, pinch to zoom" + } + } + } + } + + override fun onDestroy() { + executor.shutdownNow() + cropView.bitmap?.recycle() + super.onDestroy() + } + + private fun saveCrop() { + val cropped = cropView.cropToSquare() ?: run { + statusView.text = "Image is not ready" + return + } + val encoded = ByteArrayOutputStream().use { output -> + if (!cropped.compress(Bitmap.CompressFormat.PNG, 100, output)) { + cropped.recycle() + statusView.text = "Could not encode image" + return + } + cropped.recycle() + output.toByteArray() + } + try { + AirPlayPersistence.saveCustomAirPlayIcon(applicationContext, encoded) + setResult( + RESULT_OK, + Intent().putExtra(EXTRA_CROP_COMPLETE, true), + ) + finish() + } catch (_: Exception) { + statusView.text = "Could not save image" + } + } + + private fun decodeBitmap(uri: Uri): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, bounds) + } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sampleSize = 1 + while (max(bounds.outWidth, bounds.outHeight) / sampleSize > MAX_DECODE_EDGE) { + sampleSize *= 2 + } + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + return contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, options) + } + } + + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() + + private class SquareCropView(context: android.content.Context) : View(context) { + private val imagePaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG) + private val dimPaint = Paint().apply { color = Color.argb(145, 0, 0, 0) } + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = 2f * resources.displayMetrics.density + } + private val gridPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(150, 255, 255, 255) + style = Paint.Style.STROKE + strokeWidth = 1f * resources.displayMetrics.density + } + private val matrix = Matrix() + private val viewport = RectF() + private val mappedBitmap = RectF() + private var minimumScale = 1f + private var maximumScale = 1f + private var lastX = 0f + private var lastY = 0f + private var lastDistance = 0f + + var bitmap: Bitmap? = null + private set + + fun setBitmap(value: Bitmap) { + bitmap?.recycle() + bitmap = value + if (width > 0 && height > 0) configure() + invalidate() + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + configure() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val source = bitmap ?: return + canvas.drawColor(Color.BLACK) + canvas.drawRect(0f, 0f, width.toFloat(), viewport.top, dimPaint) + canvas.drawRect(0f, viewport.bottom, width.toFloat(), height.toFloat(), dimPaint) + canvas.drawRect(0f, viewport.top, viewport.left, viewport.bottom, dimPaint) + canvas.drawRect(viewport.right, viewport.top, width.toFloat(), viewport.bottom, dimPaint) + + val save = canvas.save() + canvas.clipRect(viewport) + canvas.drawBitmap(source, matrix, imagePaint) + canvas.restoreToCount(save) + + canvas.drawRect(viewport, borderPaint) + val thirdWidth = viewport.width() / 3f + val thirdHeight = viewport.height() / 3f + canvas.drawLine(viewport.left + thirdWidth, viewport.top, viewport.left + thirdWidth, viewport.bottom, gridPaint) + canvas.drawLine(viewport.right - thirdWidth, viewport.top, viewport.right - thirdWidth, viewport.bottom, gridPaint) + canvas.drawLine(viewport.left, viewport.top + thirdHeight, viewport.right, viewport.top + thirdHeight, gridPaint) + canvas.drawLine(viewport.left, viewport.bottom - thirdHeight, viewport.right, viewport.bottom - thirdHeight, gridPaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + if (bitmap == null) return true + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + lastX = event.x + lastY = event.y + return true + } + + MotionEvent.ACTION_POINTER_DOWN -> { + lastDistance = pointerDistance(event) + return true + } + + MotionEvent.ACTION_MOVE -> { + if (event.pointerCount >= 2) { + val distance = pointerDistance(event) + if (lastDistance > 0f) { + val factor = distance / lastDistance + matrix.postScale( + factor, + factor, + (event.getX(0) + event.getX(1)) / 2f, + (event.getY(0) + event.getY(1)) / 2f, + ) + } + lastDistance = distance + } else { + matrix.postTranslate(event.x - lastX, event.y - lastY) + } + lastX = event.x + lastY = event.y + constrainMatrix() + invalidate() + return true + } + + MotionEvent.ACTION_POINTER_UP -> { + lastDistance = 0f + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + lastDistance = 0f + performClick() + return true + } + } + return super.onTouchEvent(event) + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + fun cropToSquare(): Bitmap? { + val source = bitmap ?: return null + if (viewport.width() <= 0f || viewport.height() <= 0f) return null + val inverse = Matrix() + if (!matrix.invert(inverse)) return null + val sourceRect = RectF() + inverse.mapRect(sourceRect, viewport) + val left = floor(sourceRect.left).toInt().coerceIn(0, source.width - 1) + val top = floor(sourceRect.top).toInt().coerceIn(0, source.height - 1) + val right = ceil(sourceRect.right).toInt().coerceIn(left + 1, source.width) + val bottom = ceil(sourceRect.bottom).toInt().coerceIn(top + 1, source.height) + val square = min(right - left, bottom - top) + val centerX = (left + right) / 2 + val centerY = (top + bottom) / 2 + val cropLeft = (centerX - square / 2).coerceIn(0, source.width - square) + val cropTop = (centerY - square / 2).coerceIn(0, source.height - square) + val cropped = Bitmap.createBitmap(source, cropLeft, cropTop, square, square) + val output = Bitmap.createBitmap( + OUTPUT_ICON_PIXELS, + OUTPUT_ICON_PIXELS, + Bitmap.Config.ARGB_8888, + ) + Canvas(output).drawBitmap( + cropped, + null, + RectF(0f, 0f, OUTPUT_ICON_PIXELS.toFloat(), OUTPUT_ICON_PIXELS.toFloat()), + imagePaint, + ) + if (cropped !== source) cropped.recycle() + return output + } + + private fun configure() { + val source = bitmap ?: return + if (width <= 0 || height <= 0) return + val side = min(width, height).toFloat() - 2f * CROP_PADDING_DP * resources.displayMetrics.density + if (side <= 0f) return + viewport.set( + (width - side) / 2f, + (height - side) / 2f, + (width + side) / 2f, + (height + side) / 2f, + ) + minimumScale = max(side / source.width, side / source.height) + maximumScale = minimumScale * MAX_ZOOM + matrix.reset() + matrix.postScale(minimumScale, minimumScale) + matrix.postTranslate( + viewport.centerX() - source.width * minimumScale / 2f, + viewport.centerY() - source.height * minimumScale / 2f, + ) + constrainMatrix() + invalidate() + } + + private fun constrainMatrix() { + val source = bitmap ?: return + val values = FloatArray(9) + matrix.getValues(values) + val scale = values[Matrix.MSCALE_X] + if (scale < minimumScale) { + matrix.postScale(minimumScale / scale, minimumScale / scale, viewport.centerX(), viewport.centerY()) + } else if (scale > maximumScale) { + matrix.postScale(maximumScale / scale, maximumScale / scale, viewport.centerX(), viewport.centerY()) + } + mappedBitmap.set(0f, 0f, source.width.toFloat(), source.height.toFloat()) + matrix.mapRect(mappedBitmap) + var dx = 0f + var dy = 0f + if (mappedBitmap.left > viewport.left) dx = viewport.left - mappedBitmap.left + if (mappedBitmap.right < viewport.right) dx = viewport.right - mappedBitmap.right + if (mappedBitmap.top > viewport.top) dy = viewport.top - mappedBitmap.top + if (mappedBitmap.bottom < viewport.bottom) dy = viewport.bottom - mappedBitmap.bottom + if (dx != 0f || dy != 0f) matrix.postTranslate(dx, dy) + } + + private fun pointerDistance(event: MotionEvent): Float { + if (event.pointerCount < 2) return 0f + val dx = event.getX(0) - event.getX(1) + val dy = event.getY(0) - event.getY(1) + return kotlin.math.sqrt(dx * dx + dy * dy) + } + + private companion object { + const val CROP_PADDING_DP = 24f + const val MAX_ZOOM = 8f + } + } + + companion object { + const val EXTRA_CROP_COMPLETE = "crop_complete" + private const val MAX_DECODE_EDGE = 2048 + private const val OUTPUT_ICON_PIXELS = 1024 + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/MainActivity.kt b/common/src/main/java/com/shilapi/xcertplay/MainActivity.kt new file mode 100644 index 0000000..fa92ac9 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/MainActivity.kt @@ -0,0 +1,122 @@ +package com.shilapi.xcertplay + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +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.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.unit.dp +import com.shilapi.xcertplay.mfi.MfiProtocolMajorResult +import com.shilapi.xcertplay.mfi.MfiSelfCheck +import com.shilapi.xcertplay.mfi.MfiSelfCheckResult +import com.shilapi.xcertplay.transport.LinuxI2cTransport +import com.shilapi.xcertplay.ui.theme.XcertplayTheme +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class MainActivity : ComponentActivity() { + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private var status by mutableStateOf(DiagnosticStatus.Idle) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + XcertplayTheme { + var devicePath by remember { mutableStateOf("/dev/i2c-1") } + Scaffold(modifier = Modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier.padding(padding).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Board I2C diagnostic") + OutlinedTextField( + value = devicePath, + onValueChange = { devicePath = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Linux I2C device") }, + singleLine = true, + enabled = status !is DiagnosticStatus.Running, + ) + Button( + onClick = { runSelfCheck(devicePath) }, + enabled = status !is DiagnosticStatus.Running, + ) { + Text("Run MFi self-check") + } + Spacer(Modifier.height(4.dp)) + Text(status.message()) + Text("CH341 requires deployment-specific VID/PID configuration.") + } + } + } + } + } + + override fun onDestroy() { + executor.shutdownNow() + super.onDestroy() + } + + private fun runSelfCheck(devicePath: String) { + status = DiagnosticStatus.Running + executor.execute { + val next = try { + LinuxI2cTransport.open(devicePath).use { MfiSelfCheck(it).run() } + .let { DiagnosticStatus.Result(it) } + } catch (error: LinkageError) { + DiagnosticStatus.Failure(error.message ?: "I2C native library is unavailable") + } catch (error: Exception) { + DiagnosticStatus.Failure(error.message ?: error.javaClass.simpleName) + } + runOnUiThread { + if (!isFinishing && !isDestroyed) status = next + } + } + } +} + +private sealed class DiagnosticStatus { + data object Idle : DiagnosticStatus() + data object Running : DiagnosticStatus() + data class Result(val selfCheck: MfiSelfCheckResult) : DiagnosticStatus() + data class Failure(val message: String) : DiagnosticStatus() + + fun message(): String = when (this) { + Idle -> "Idle" + Running -> "Running…" + is Failure -> "Failed: $message" + is Result -> { + val chip = selfCheck.chip ?: return if (selfCheck.discovery.interrupted) { + "MFi scan interrupted" + } else { + "Found: none" + } + val major = when (val result = chip.protocolMajor) { + is MfiProtocolMajorResult.Value -> "%d".format(result.major) + is MfiProtocolMajorResult.MfiFailure -> result.error.message ?: result.error.javaClass.simpleName + is MfiProtocolMajorResult.TransportFailure -> result.error.message ?: result.error.javaClass.simpleName + } + "Found: 0x%02X; device version: 0x%02X; protocol major (raw): %s".format( + chip.address7Bit, + chip.deviceVersion, + major, + ) + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/SafeAreaEditorView.kt b/common/src/main/java/com/shilapi/xcertplay/SafeAreaEditorView.kt new file mode 100644 index 0000000..bcfe341 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/SafeAreaEditorView.kt @@ -0,0 +1,186 @@ +package com.shilapi.xcertplay + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.view.MotionEvent +import android.view.View +import com.shilapi.xcertplay.airplay.AirPlaySafeArea +import com.shilapi.xcertplay.airplay.SafeAreaRect + +/** Full-screen editor for the two horizontal and two vertical safe-area boundaries. */ +class SafeAreaEditorView(context: Context) : View(context) { + private enum class Edge { + LEFT, + TOP, + RIGHT, + BOTTOM, + } + + private val density = resources.displayMetrics.density + private val touchRadius = 40f * density + private val dimPaint = Paint().apply { color = Color.argb(118, 0, 0, 0) } + private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.rgb(127, 205, 154) + strokeWidth = 3f * density + style = Paint.Style.STROKE + } + private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(210, 255, 255, 255) + strokeWidth = 1f * density + style = Paint.Style.STROKE + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = 15f * density * resources.configuration.fontScale + typeface = android.graphics.Typeface.MONOSPACE + } + + private var rect: SafeAreaRect? = null + private var sourceRect: SafeAreaRect? = null + private var sourceWidth = 0 + private var sourceHeight = 0 + private var activeEdge: Edge? = null + + init { + isClickable = true + } + + fun setRect(value: SafeAreaRect, sourceWidthPixels: Int, sourceHeightPixels: Int) { + require(sourceWidthPixels > 0 && sourceHeightPixels > 0) { + "Source dimensions must be positive" + } + sourceWidth = sourceWidthPixels + sourceHeight = sourceHeightPixels + sourceRect = value + if (width > 0 && height > 0) { + rect = AirPlaySafeArea.scaleRect( + value, + sourceWidth, + sourceHeight, + width, + height, + ) + invalidate() + } + } + + fun currentRectForSource(): SafeAreaRect? { + sourceRect?.let { return it } + val current = rect ?: return null + if (sourceWidth <= 0 || sourceHeight <= 0 || width <= 0 || height <= 0) return null + return AirPlaySafeArea.scaleRect(current, width, height, sourceWidth, sourceHeight) + } + + override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) { + super.onSizeChanged(width, height, oldWidth, oldHeight) + if (width <= 0 || height <= 0) return + val source = sourceRect ?: AirPlaySafeArea.default(width, height) + rect = if (sourceWidth > 0 && sourceHeight > 0) { + AirPlaySafeArea.scaleRect(source, sourceWidth, sourceHeight, width, height) + } else { + source.clampTo(width, height) + } + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + val safe = rect ?: return + val width = width.toFloat() + val height = height.toFloat() + val left = safe.left.toFloat() + val top = safe.top.toFloat() + val right = safe.right.toFloat() + val bottom = safe.bottom.toFloat() + + canvas.drawRect(0f, 0f, width, top, dimPaint) + canvas.drawRect(0f, bottom, width, height, dimPaint) + canvas.drawRect(0f, top, left, bottom, dimPaint) + canvas.drawRect(right, top, width, bottom, dimPaint) + + canvas.drawRect(left, top, right, bottom, borderPaint) + canvas.drawLine(left, 0f, left, height, linePaint) + canvas.drawLine(right, 0f, right, height, linePaint) + canvas.drawLine(0f, top, width, top, linePaint) + canvas.drawLine(0f, bottom, width, bottom, linePaint) + + drawLabel(canvas, "x=${safe.left}", left + 8f * density, top + 20f * density) + drawLabel(canvas, "x=${safe.right}", right + 8f * density, bottom - 8f * density) + drawLabel(canvas, "y=${safe.top}", left + 8f * density, top - 8f * density) + drawLabel(canvas, "y=${safe.bottom}", right - 88f * density, bottom + 20f * density) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val safe = rect ?: return true + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + activeEdge = nearestEdge(event.x, event.y, safe) + return true + } + + MotionEvent.ACTION_MOVE -> { + val edge = activeEdge ?: return true + val moved = moveEdge(safe, edge, event.x, event.y) + rect = moved + if (sourceWidth > 0 && sourceHeight > 0 && width > 0 && height > 0) { + sourceRect = AirPlaySafeArea.scaleRect( + moved, + width, + height, + sourceWidth, + sourceHeight, + ) + } + invalidate() + return true + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL -> { + activeEdge = null + performClick() + return true + } + } + return super.onTouchEvent(event) + } + + override fun performClick(): Boolean { + super.performClick() + return true + } + + private fun nearestEdge(x: Float, y: Float, safe: SafeAreaRect): Edge? { + val candidates = listOf( + Edge.LEFT to kotlin.math.abs(x - safe.left), + Edge.RIGHT to kotlin.math.abs(x - safe.right), + Edge.TOP to kotlin.math.abs(y - safe.top), + Edge.BOTTOM to kotlin.math.abs(y - safe.bottom), + ).filter { it.second <= touchRadius } + return candidates.minByOrNull { it.second }?.first + } + + private fun moveEdge(safe: SafeAreaRect, edge: Edge, x: Float, y: Float): SafeAreaRect { + val pointX = x.toInt().coerceIn(0, width) + val pointY = y.toInt().coerceIn(0, height) + return when (edge) { + Edge.LEFT -> safe.copy(left = pointX.coerceIn(0, safe.right - 1)) + Edge.TOP -> safe.copy(top = pointY.coerceIn(0, safe.bottom - 1)) + Edge.RIGHT -> safe.copy(right = pointX.coerceIn(safe.left + 1, width)) + Edge.BOTTOM -> safe.copy(bottom = pointY.coerceIn(safe.top + 1, height)) + } + } + + private fun drawLabel(canvas: Canvas, value: String, x: Float, y: Float) { + val horizontalMin = 4f * density + val horizontalMax = (width - textPaint.measureText(value) - 4f * density) + .coerceAtLeast(horizontalMin) + val verticalMin = textPaint.textSize + 4f * density + val verticalMax = (height - 4f * density).coerceAtLeast(verticalMin) + val safeX = x.coerceIn(horizontalMin, horizontalMax) + val safeY = y.coerceIn(verticalMin, verticalMax) + canvas.drawText(value, safeX, safeY, textPaint) + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/SessionLogFile.kt b/common/src/main/java/com/shilapi/xcertplay/SessionLogFile.kt new file mode 100644 index 0000000..fd8ca78 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/SessionLogFile.kt @@ -0,0 +1,45 @@ +package com.shilapi.xcertplay + +import java.io.Closeable +import java.io.File + +/** Bounded, private diagnostics. Each write is redacted before touching storage. */ +internal class SessionLogFile(val file: File) : Closeable { + private val lock = Any() + private var closed = false + fun reset(header: String) = synchronized(lock) { + if (!closed) { + file.parentFile?.mkdirs() + rotate() + file.writeText("") + append(header) + } + } + fun append(line: String) = synchronized(lock) { + if (closed) return@synchronized + val safe = DiagnosticRedactor.redact(line) ?: return@synchronized + runCatching { + if (file.length() > MAX_BYTES) { + rotate() + file.writeText("") + } + file.appendText(safe + "\n") + } + Unit + } + private fun rotate() { + if (!file.exists() || file.length() == 0L) return + for (index in ARCHIVE_NAMES.lastIndex downTo 1) { + val source = File(file.parentFile, ARCHIVE_NAMES[index - 1]) + val destination = File(file.parentFile, ARCHIVE_NAMES[index]) + if (source.exists()) source.copyTo(destination, overwrite = true) + } + file.copyTo(File(file.parentFile, ARCHIVE_NAMES.first()), overwrite = true) + } + override fun close() = synchronized(lock) { closed = true } + companion object { + const val MAX_BYTES = 512 * 1024L + private val ARCHIVE_NAMES = listOf("previous.log") + (2..7).map { "previous-$it.log" } + val REPORT_NAMES = ARCHIVE_NAMES.reversed() + "diplay.log" + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Color.kt b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Color.kt new file mode 100644 index 0000000..c40139c --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.shilapi.xcertplay.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Theme.kt b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Theme.kt new file mode 100644 index 0000000..1e43c3d --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.shilapi.xcertplay.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun XcertplayTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/common/src/main/java/com/shilapi/xcertplay/ui/theme/Type.kt b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Type.kt new file mode 100644 index 0000000..879bc46 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.shilapi.xcertplay.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/common/src/main/res/drawable/ic_carplay.png b/common/src/main/res/drawable/ic_carplay.png new file mode 100644 index 0000000..1f7e3f0 Binary files /dev/null and b/common/src/main/res/drawable/ic_carplay.png differ diff --git a/common/src/main/res/drawable/ic_diplay_notification.xml b/common/src/main/res/drawable/ic_diplay_notification.xml new file mode 100644 index 0000000..397c3c7 --- /dev/null +++ b/common/src/main/res/drawable/ic_diplay_notification.xml @@ -0,0 +1,3 @@ + + + diff --git a/common/src/main/res/raw/placeholder_icon.png b/common/src/main/res/raw/placeholder_icon.png new file mode 100644 index 0000000..3c9996a Binary files /dev/null and b/common/src/main/res/raw/placeholder_icon.png differ diff --git a/common/src/main/res/values/config.xml b/common/src/main/res/values/config.xml new file mode 100644 index 0000000..08ac42d --- /dev/null +++ b/common/src/main/res/values/config.xml @@ -0,0 +1,3 @@ + + false + diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml new file mode 100644 index 0000000..401758e --- /dev/null +++ b/common/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + DiPlay + diff --git a/common/src/main/res/values/themes.xml b/common/src/main/res/values/themes.xml new file mode 100644 index 0000000..b9f51b9 --- /dev/null +++ b/common/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + diff --git a/common/src/main/res/xml/backup_rules.xml b/common/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..8acd663 --- /dev/null +++ b/common/src/main/res/xml/backup_rules.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/common/src/main/res/xml/data_extraction_rules.xml b/common/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..b853a8d --- /dev/null +++ b/common/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/common/src/main/res/xml/usb_device_filter.xml b/common/src/main/res/xml/usb_device_filter.xml new file mode 100644 index 0000000..5337436 --- /dev/null +++ b/common/src/main/res/xml/usb_device_filter.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/common/src/test/java/com/shilapi/xcertplay/DarkModeTest.kt b/common/src/test/java/com/shilapi/xcertplay/DarkModeTest.kt new file mode 100644 index 0000000..f1b90cf --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/DarkModeTest.kt @@ -0,0 +1,19 @@ +package com.shilapi.xcertplay + +import android.content.res.Configuration +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DarkModeTest { + @Test + fun darkModeIsDetectedWithUnrelatedConfigurationBits() { + assertTrue(isDarkMode(Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_CAR)) + } + + @Test + fun lightAndUndefinedModesAreNotDark() { + assertFalse(isDarkMode(Configuration.UI_MODE_NIGHT_NO)) + assertFalse(isDarkMode(Configuration.UI_MODE_NIGHT_UNDEFINED)) + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/DiagnosticExportStoreTest.kt b/common/src/test/java/com/shilapi/xcertplay/DiagnosticExportStoreTest.kt new file mode 100644 index 0000000..157590a --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/DiagnosticExportStoreTest.kt @@ -0,0 +1,119 @@ +package com.shilapi.xcertplay + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import java.io.File +import java.io.IOException + +/** Exercise the Android 10 storage path, including OEM/provider failure responses. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], manifest = Config.NONE) +class DiagnosticExportStoreTest { + private lateinit var provider: DownloadsProvider + private val resolver get() = RuntimeEnvironment.getApplication().contentResolver + + @Before fun setup() { + val context = RuntimeEnvironment.getApplication() + provider = DownloadsProvider(File(context.cacheDir, "export-test.txt")) + provider.attachInfo(context, ProviderInfo().apply { authority = "media" }) + ShadowContentResolver.registerProviderInternal("media", provider) + } + + @Test fun android10SavesUtf8ReportToDownloadsAndPublishesAfterClosingIt() { + val report = "DiPlay · diagnostic report\nVideo: H.264\n" + val uri = DiagnosticExportStore.saveToDownloads(resolver, "DiPlay-test.txt", report) + assertEquals(provider.uri, uri) + assertEquals(report, provider.file.readText()) + assertEquals("Download/DiPlay", provider.insertValues!!.getAsString(MediaStore.Downloads.RELATIVE_PATH)) + assertEquals("text/plain", provider.insertValues!!.getAsString(MediaStore.Downloads.MIME_TYPE)) + assertEquals(1, provider.insertValues!!.getAsInteger(MediaStore.Downloads.IS_PENDING)) + assertEquals(0, provider.publishValues!!.getAsInteger(MediaStore.Downloads.IS_PENDING)) + assertEquals(report, provider.contentAtPublish) + assertFalse(provider.deleted) + } + + @Test fun unavailableDownloadsDoesNotReturnFalseSuccess() { + provider.refuseInsert = true + assertThrows(IOException::class.java) { + DiagnosticExportStore.saveToDownloads(resolver, "test.txt", "report") + } + assertNull(provider.publishValues) + assertFalse(provider.deleted) + } + + @Test fun deniedWriteRemovesOnlyTheNewPendingEntry() { + provider.denyWrite = true + assertThrows(SecurityException::class.java) { + DiagnosticExportStore.saveToDownloads(resolver, "test.txt", "report") + } + assertTrue(provider.deleted) + assertNull(provider.publishValues) + } + + @Test fun failedPublishDoesNotLeaveAnInvisibleReport() { + provider.refusePublish = true + assertThrows(IOException::class.java) { + DiagnosticExportStore.saveToDownloads(resolver, "test.txt", "report") + } + assertTrue(provider.deleted) + } + + @Test fun selectedDocumentIsWrittenButNeverDeletedOnFailure() { + provider.denyWrite = true + assertThrows(SecurityException::class.java) { + DiagnosticExportStore.write(resolver, provider.uri, "report") + } + assertFalse(provider.deleted) + } + + private class DownloadsProvider(val file: File) : ContentProvider() { + val uri: Uri = Uri.parse("content://media/external/downloads/41") + var insertValues: ContentValues? = null + var publishValues: ContentValues? = null + var contentAtPublish: String? = null + var deleted = false + var refuseInsert = false + var denyWrite = false + var refusePublish = false + override fun onCreate() = true + override fun insert(uri: Uri, values: ContentValues?): Uri? { + assertEquals(MediaStore.Downloads.EXTERNAL_CONTENT_URI, uri) + insertValues = ContentValues(values) + return if (refuseInsert) null else this.uri + } + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + assertEquals(this.uri, uri) + assertEquals("wt", mode) + if (denyWrite) throw SecurityException("Test provider refused write") + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE or + ParcelFileDescriptor.MODE_WRITE_ONLY or ParcelFileDescriptor.MODE_TRUNCATE) + } + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int { + assertEquals(this.uri, uri) + publishValues = ContentValues(values) + contentAtPublish = file.readText() + return if (refusePublish) 0 else 1 + } + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { + assertEquals(this.uri, uri) + deleted = true + file.delete() + return 1 + } + override fun getType(uri: Uri) = "text/plain" + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/DiagnosticRedactorTest.kt b/common/src/test/java/com/shilapi/xcertplay/DiagnosticRedactorTest.kt new file mode 100644 index 0000000..8bb67e0 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/DiagnosticRedactorTest.kt @@ -0,0 +1,63 @@ +package com.shilapi.xcertplay + +import org.junit.Assert.* +import org.junit.Test +import java.nio.file.Files + +class DiagnosticRedactorTest { + @Test fun payloadAndCredentialLinesNeverReachReports() { + for (line in listOf("TRACE IAP2 tx key", "hotspot passphrase=secret", "token=secret", "certificate bytes=607", "rx body={phone: 'Jane'}", "ok\nsecret", "wifi ssid=Home", "wireless name=Jane Smith’s iPhone")) { + assertNull(line, DiagnosticRedactor.redact(line)) + } + } + @Test fun stateTransitionsSurviveWithoutAddressesOrIdentifiers() { + val line = DiagnosticRedactor.redact("connected peer=C0:A6:00:29:58:0A ip=192.168.31.71 id=0123456789abcdef0123456789abcdef ipv6=fe80::1234:5678:abcd:9%p2p0")!! + assertTrue(line.contains("connected")) + assertFalse(line.contains("C0:A6")); assertFalse(line.contains("192.168")); assertFalse(line.contains("012345")); assertFalse(line.contains("fe80")) + } + @Test fun logRotationIsBoundedAndRedactionHappensBeforeDisk() { + val folder = Files.createTempDirectory("diplay-log-test").toFile() + try { + val log = SessionLogFile(folder.resolve("diplay.log")) + log.reset("started") + log.append("password=secret") + repeat(1600) { log.append("connection state " + "x".repeat(690)) } + log.append("CarPlay connected") + log.close() + assertTrue(folder.resolve("diplay.log").length() <= SessionLogFile.MAX_BYTES + 701) + assertTrue(folder.resolve("previous.log").length() <= SessionLogFile.MAX_BYTES + 701) + assertTrue(folder.resolve("diplay.log").readText().contains("CarPlay connected")) + assertFalse(folder.listFiles()!!.any { it.readText().contains("secret") }) + } finally { folder.deleteRecursively() } + } + @Test fun failuresSurviveLaterSuccessfulSessionsAndOldestHistoryExpires() { + val folder = Files.createTempDirectory("diplay-history-test").toFile() + try { + repeat(10) { session -> + SessionLogFile(folder.resolve("diplay.log")).use { + it.reset("session=$session") + it.append(if (session == 3) "Wi-Fi P2P create rejected code=0" else "CarPlay connected") + it.append("password=secret") + } + } + val history = SessionLogFile.REPORT_NAMES.map { folder.resolve(it).readText() } + assertEquals(8, folder.listFiles()!!.size) + assertTrue(history.first().contains("session=2")) + assertTrue(history.last().contains("session=9")) + assertTrue(history.any { it.contains("rejected code=0") }) + assertFalse(history.any { it.contains("secret") }) + } finally { folder.deleteRecursively() } + } + @Test fun safeWifiMetadataSurvivesWithoutWeakeningCredentialFilters() { + val lines = listOf( + "Wi-Fi P2P preflight wifiEnabled=true locationEnabled=false permissionGranted=true stationMHz=5180", + "Wi-Fi P2P create mode=FIXED_2_GHZ frequencyMHz=2437", + "Wi-Fi P2P create rejected code=0 reason=generic error", + "Wi-Fi P2P ready mode=FIXED_2_GHZ band=2.4 GHz channel=6 frequencyMHz=2437", + "Wi-Fi P2P channel requestedMHz=2437 actualMHz=2412 matched=false", + "wireless hotspot backend=Wi-Fi P2P iface=p2p0 host=192.168.49.1 band=5 GHz channel=36 frequency=5180MHz", + ) + for (line in lines) assertNotNull(line, DiagnosticRedactor.redact(line)) + assertFalse(DiagnosticRedactor.redact(lines.last())!!.contains("192.168.49.1")) + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/DisplayDiagnosticSnapshotTest.kt b/common/src/test/java/com/shilapi/xcertplay/DisplayDiagnosticSnapshotTest.kt new file mode 100644 index 0000000..edf2aab --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/DisplayDiagnosticSnapshotTest.kt @@ -0,0 +1,60 @@ +package com.shilapi.xcertplay + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29], manifest = Config.NONE) +class DisplayDiagnosticSnapshotTest { + private val context get() = RuntimeEnvironment.getApplication() + + @Test fun requestedSizeSurvivesAFallbackAndSubsequentPreferenceReset() { + DisplayDiagnosticSnapshot.selection(context, 100, 75, true) + DisplayDiagnosticSnapshot.begin(context, + "Display request selected=Smaller percent=75 surface=1920x1080 candidate=2560x1440", + "Decoder capability codec=c2.test hardware=true sizeSupported=false result=canvas_dimensions_unsupported", + "Display effective percent=100 canvas=1920x1080 decision=canvas_dimensions_unsupported") + AirPlayPersistence.saveUiScalePercent(context, 100) + val report = DisplayDiagnosticSnapshot.report(context) + assertTrue(report.contains("selected=75% reconnect=true")) + assertTrue(report.contains("percent=75")) + assertTrue(report.contains("percent=100")) + assertTrue(report.contains("canvas_dimensions_unsupported")) + } + + @Test fun actualOutputAndPhoneVersionsSurviveReportRedaction() { + val attempt = DisplayDiagnosticSnapshot.begin(context, "Display request selected=Smaller", + "Decoder capability result=supported", "Display effective canvas=2560x1440") + DisplayDiagnosticSnapshot.record(context, attempt, "Phone display peer model=iPhone11,2 ios=18.7.10 airplay=950.7.1") + DisplayDiagnosticSnapshot.record(context, attempt, "Video: decoder=c2.mtk.avc.decoder mime=video/avc size=2560x1440") + DisplayDiagnosticSnapshot.record(context, attempt, "Video: output format requested=2560x1440 coded=1920x1088 crop=0,0,1919,1079") + val report = DisplayDiagnosticSnapshot.report(context) + assertTrue(report.contains("ios=18.7.10")) + assertTrue(report.contains("decoder=c2.mtk.avc.decoder")) + assertTrue(report.contains("coded=1920x1088")) + assertTrue(report.contains("crop=0,0,1919,1079")) + } + + @Test fun reconnectClearsOldOutputAndRejectsLateCallbacks() { + val old = DisplayDiagnosticSnapshot.begin(context, "old request", "old capability", "old effective") + DisplayDiagnosticSnapshot.record(context, old, "Video: output format coded=2560x1440") + val current = DisplayDiagnosticSnapshot.begin(context, "new request", "new capability", "new effective") + DisplayDiagnosticSnapshot.record(context, old, "Video: output format coded=800x480") + assertFalse(DisplayDiagnosticSnapshot.report(context).contains("coded=")) + assertEquals(current, DisplayDiagnosticSnapshot.currentAttempt(context)) + } + + @Test fun onlySupportedMetadataIsStoredAndItIsBounded() { + val attempt = DisplayDiagnosticSnapshot.begin(context, "request", "capability", "effective") + DisplayDiagnosticSnapshot.record(context, attempt, "payload=secret") + DisplayDiagnosticSnapshot.record(context, attempt, "Video: output format password=secret") + DisplayDiagnosticSnapshot.record(context, attempt, "Video: decoder=" + "x".repeat(2000)) + val report = DisplayDiagnosticSnapshot.report(context) + assertFalse(report.contains("secret")) + assertTrue(report.lines().all { it.length <= 700 }) + } +} diff --git a/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt b/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt new file mode 100644 index 0000000..aa5bc47 --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/WifiP2pGroupManagerTest.kt @@ -0,0 +1,375 @@ +package com.shilapi.xcertplay + +import android.Manifest +import android.content.Context +import android.net.wifi.SupplicantState +import android.net.wifi.WifiManager +import android.net.wifi.p2p.WifiP2pConfig +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager +import com.shilapi.xcertplay.network.P2pResetRequiredException +import com.shilapi.xcertplay.network.WifiP2pGroupManager +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowWifiP2pManager +import org.robolectric.util.ReflectionHelpers +import java.net.InetAddress +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [32], manifest = Config.NONE, + shadows = [WifiP2pGroupManagerTest.P2pRadio::class, WifiP2pGroupManagerTest.P2pChannel::class]) +class WifiP2pGroupManagerTest { + private val context get() = RuntimeEnvironment.getApplication() + private val radio get() = shadowOf(context.getSystemService(WifiP2pManager::class.java)) as P2pRadio + + @Before fun setup() { + shadowOf(context).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + val wifi = context.getSystemService(WifiManager::class.java) + wifi.isWifiEnabled = true + shadowOf(wifi.connectionInfo).setFrequency(5180) + shadowOf(wifi.connectionInfo).setSupplicantState(SupplicantState.COMPLETED) + } + + @Test fun disconnectedStationCannotPinNewGroupToStaleTwoGhzChannel() { + val info = context.getSystemService(WifiManager::class.java).connectionInfo + shadowOf(info).setFrequency(2462) + shadowOf(info).setSupplicantState(SupplicantState.DISCONNECTED) + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> + background { manager.start(5000) } + assertEquals(listOf(5180), radio.requests.map { it?.groupOwnerBand }) + assertTrue(logs.any { it.contains("stationMHz=unknown stationState=DISCONNECTED reportedStationMHz=2462") }) + } + } + + @Test fun associatingStationDoesNotSelectOrReuseStaleChannel() { + seedMemory(station = 2462) + val info = context.getSystemService(WifiManager::class.java).connectionInfo + shadowOf(info).setFrequency(2462) + shadowOf(info).setSupplicantState(SupplicantState.ASSOCIATING) + WifiP2pGroupManager(context).use { manager -> + background { manager.start(5000) } + assertEquals(listOf(5180), radio.requests.map { it?.groupOwnerBand }) + } + } + + private val memory get() = context.getSharedPreferences("carplay_wifi_p2p_success", Context.MODE_PRIVATE) + private fun seedMemory(kind: String = "frequency", requested: Int = 2437, station: Int = 5180): String { + val encoded = "$kind|$requested|2437|$station|00000000-0000-0000-0000-000000000001" + memory.edit().putString("confirmed", encoded).commit() + return encoded + } + + @Test fun hotspotCreationAloneAndLateConfirmationAfterCloseAreNeverLearned() { + val manager = WifiP2pGroupManager(context) + background { manager.start(5000) } + assertNull(memory.getString("confirmed", null)) + manager.close() + manager.onCarPlayConfirmed() + assertNull(memory.getString("confirmed", null)) + } + + @Test fun confirmedTwoGhzConfigurationSurvivesManagerRecreationAndGoesFirst() { + radio.fixed24Only = true + WifiP2pGroupManager(context).use { manager -> + val info = background { manager.start(6000) } + assertEquals(3, radio.requests.size) + assertNull(memory.getString("confirmed", null)) + manager.onCarPlayConfirmed() + val record = memory.getString("confirmed", null)!! + manager.onCarPlayConfirmed() + assertEquals(record, memory.getString("confirmed", null)) + assertTrue(record.startsWith("frequency|2437|2437|5180|")) + assertFalse(record.contains(info.ssid) || record.contains(info.passphrase)) + } + radio.requests.clear() + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> + val info = background { manager.start(5000) } + assertEquals(6, info.channel) + assertEquals(listOf(2437), radio.requests.map { it?.groupOwnerBand }) + assertTrue(logs.any { it.contains("remembered first mode=FIXED_2_GHZ") }) + manager.onCarPlayConfirmed() + } + } + + @Test fun rejectedRememberedChannelIsClearedAndNewSuccessReplacesIt() { + seedMemory() + radio.fixed24Only = true + radio.allowed24 = setOf(2462) + WifiP2pGroupManager(context).use { manager -> + val info = background { manager.start(7000) } + assertEquals(listOf(2437, 5180, 5745, 2412, 2462), radio.requests.map { it?.groupOwnerBand }) + assertEquals(11, info.channel) + assertNull(memory.getString("confirmed", null)) + manager.onCarPlayConfirmed() + assertTrue(memory.getString("confirmed", null)!!.startsWith("frequency|2462|2462|")) + } + } + + @Test fun rememberedGroupThatNeverReachesCarPlayIsEvictedOnClose() { + seedMemory() + WifiP2pGroupManager(context).use { manager -> background { manager.start(5000) } } + assertEquals(listOf(2437), radio.requests.map { it?.groupOwnerBand }) + assertNull(memory.getString("confirmed", null)) + } + + @Test fun changedInternetChannelUsesCurrentAlignmentInsteadOfOldPreference() { + val previous = seedMemory() + shadowOf(context.getSystemService(WifiManager::class.java).connectionInfo).setFrequency(2412) + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> background { manager.start(5000) } } + assertEquals(listOf(2412), radio.requests.map { it?.groupOwnerBand }) + assertEquals(previous, memory.getString("confirmed", null)) + assertTrue(logs.any { it.contains("skipped=station_channel_changed") }) + } + + @Test fun rememberedDefaultUsesSystemCreationInsteadOfInventingAFixedChannel() { + seedMemory(kind = "system", requested = 0) + WifiP2pGroupManager(context).use { manager -> + background { manager.start(5000) } + assertEquals(1, radio.requests.size) + assertNull(radio.requests.single()) + manager.onCarPlayConfirmed() + assertTrue(memory.getString("confirmed", null)!!.startsWith("system|0|2437|")) + } + } + + @Test fun malformedStoredPreferenceDoesNotBlockConnection() { + memory.edit().putInt("confirmed", 123).commit() + WifiP2pGroupManager(context).use { manager -> + background { manager.start(5000) } + assertEquals(listOf(5180), radio.requests.map { it?.groupOwnerBand }) + manager.onCarPlayConfirmed() + assertTrue(memory.getString("confirmed", null)!!.startsWith("frequency|5180|5180|")) + } + } + + @Test fun staleCloseDoesNotEraseANewerConfirmedRecord() { + seedMemory() + WifiP2pGroupManager(context).use { manager -> + background { manager.start(5000) } + val newer = "frequency|2462|2462|5180|00000000-0000-0000-0000-000000000002" + memory.edit().putString("confirmed", newer).commit() + } + assertTrue(memory.getString("confirmed", null)!!.startsWith("frequency|2462|")) + } + + @Test fun workingRadioKeepsAlignedChannelAndRemovesOnlyItsGroupOnClose() { + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> + val info = background { manager.start(6000) } + assertEquals(5180, info.frequencyMHz) + assertEquals(1, radio.requests.size) + assertEquals(5180, radio.requests.single()!!.groupOwnerBand) + assertEquals(info.ssid, context.getSharedPreferences("carplay_wifi_p2p", Context.MODE_PRIVATE).getString("owned_ssid", null)) + assertTrue(logs.any { it.contains("ready mode=ALIGNED_5_GHZ") }) + } + assertEquals(1, radio.removals) + assertTrue(context.getSystemService(WifiManager::class.java).isWifiEnabled) + } + + @Test fun genericErrorsRecoverWithRealSystemCredentialsAndChannel() { + radio.rejectCustom = true + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> + val info = background { manager.start(6000) } + assertEquals(6, radio.requests.size) + assertNull(radio.requests.last()) + assertEquals("DIRECT-system-test", info.ssid) + assertEquals("test-generated-key", info.passphrase) + assertEquals(6, info.channel) + assertEquals("2.4 GHz", info.bandLabel) + assertEquals(info.ssid, context.getSharedPreferences("carplay_wifi_p2p", Context.MODE_PRIVATE).getString("owned_ssid", null)) + assertTrue(logs.all { DiagnosticRedactor.redact(it) != null }) + assertFalse(logs.any { it.contains(info.ssid) || it.contains(info.passphrase) }) + } + assertEquals(1, radio.removals) + } + + @Test fun twoGhzOnlyNoAcsDriverConnectsUsingAnExplicitChannelAndOwnedCredentials() { + radio.fixed24Only = true + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> + val info = background { manager.start(6000) } + assertEquals(listOf(5180, 5745, 2437), radio.requests.map { it?.groupOwnerBand }) + assertTrue(radio.requests.all { it != null && it.groupOwnerBand > 1000 }) + assertEquals(2437, info.frequencyMHz) + assertEquals(6, info.channel) + assertEquals("2.4 GHz", info.bandLabel) + assertTrue(info.ssid.startsWith("DIRECT-dp")) + assertEquals(radio.requests.last()!!.passphrase, info.passphrase) + assertTrue(logs.any { it.contains("ready mode=FIXED_2_GHZ") }) + assertTrue(logs.any { it.contains("requestedMHz=2437 actualMHz=2437 matched=true") }) + assertTrue(logs.all { DiagnosticRedactor.redact(it) != null }) + assertFalse(logs.any { it.contains(info.ssid) || it.contains(info.passphrase) }) + } + assertEquals(1, radio.removals) + assertTrue(context.getSystemService(WifiManager::class.java).isWifiEnabled) + assertEquals(5180, context.getSystemService(WifiManager::class.java).connectionInfo.frequency) + } + + @Test fun twoGhzStationAlignsImmediatelyWithoutFiveGhzOrAutomaticRequests() { + shadowOf(context.getSystemService(WifiManager::class.java).connectionInfo).setFrequency(2462) + radio.fixed24Only = true + WifiP2pGroupManager(context).use { manager -> + val info = background { manager.start(5000) } + assertEquals(listOf(2462), radio.requests.map { it?.groupOwnerBand }) + assertEquals(11, info.channel) + } + } + + @Test fun channelRejectionsReachChannelElevenWithoutAutomaticSelection() { + radio.fixed24Only = true + radio.allowed24 = setOf(2462) + WifiP2pGroupManager(context).use { manager -> + val info = background { manager.start(6000) } + assertEquals(listOf(5180, 5745, 2437, 2412, 2462), radio.requests.map { it?.groupOwnerBand }) + assertEquals(11, info.channel) + } + } + + @Test fun reportedChannelIsUsedWhenFirmwareIgnoresTheRequestedFrequency() { + radio.fixed24Only = true + radio.reportedFrequency = 2412 + val logs = mutableListOf() + WifiP2pGroupManager(context, logs::add).use { manager -> + val info = background { manager.start(6000) } + assertEquals(1, info.channel) + assertTrue(logs.any { it.contains("requestedMHz=2437 actualMHz=2412 matched=false") }) + } + } + + @Test fun unknownExistingGroupIsNeverRemovedOrReplaced() { + radio.group = radio.makeGroup(null) + WifiP2pGroupManager(context).use { manager -> + assertTrue(failure { manager.start(3000) } is P2pResetRequiredException) + } + assertTrue(radio.requests.isEmpty()) + assertEquals(0, radio.removals) + } + + @Test fun groupAppearingAfterRejectionIsNotRemovedDuringFallback() { + radio.rejectCustom = true + radio.competingGroup = true + WifiP2pGroupManager(context).use { manager -> + assertTrue(failure { manager.start(3000) } is P2pResetRequiredException) + } + assertEquals(1, radio.requests.size) + assertEquals(0, radio.removals) + } + + @Test fun missingSystemCredentialsDoNotReuseTheRejectedCustomCredentials() { + radio.rejectCustom = true + radio.missingCredentials = true + WifiP2pGroupManager(context).use { manager -> + assertTrue(failure { manager.start(4000) }.message!!.contains("usable Wi-Fi P2P group")) + } + assertEquals(6, radio.requests.size) + assertEquals(1, radio.removals) + } + + @Test fun unansweredCreateIsNotFollowedByASecondRequest() { + radio.noCreateReply = true + WifiP2pGroupManager(context).use { manager -> + assertTrue(failure { manager.start(300) }.message!!.contains("group creation")) + } + assertEquals(1, radio.requests.size) + assertEquals(0, radio.removals) + } + + @Test fun disabledWifiHasAnActionableErrorWithoutCreatingAnything() { + context.getSystemService(WifiManager::class.java).isWifiEnabled = false + WifiP2pGroupManager(context).use { manager -> + assertTrue(failure { manager.start(3000) }.message!!.contains("Turn on Wi-Fi")) + } + assertTrue(radio.requests.isEmpty()) + assertEquals(0, radio.removals) + } + + private fun failure(block: () -> Any): Throwable { + try { background(block); fail("Expected failure") } + catch (failure: ExecutionException) { return failure.cause!! } + error("unreachable") + } + + private fun background(block: () -> T): T { + val executor = Executors.newSingleThreadExecutor() + return try { executor.submit { block() }.get(10, TimeUnit.SECONDS) } + finally { executor.shutdownNow() } + } + + // Robolectric's manager creates a channel without the framework's AsyncChannel transport. + @Implements(WifiP2pManager.Channel::class) + class P2pChannel { + @Implementation protected fun close() {} + } + + @Implements(WifiP2pManager::class) + class P2pRadio : ShadowWifiP2pManager() { + val requests = mutableListOf() + var group: WifiP2pGroup? = null + var removals = 0 + var rejectCustom = false + var competingGroup = false + var missingCredentials = false + var noCreateReply = false + var fixed24Only = false + var allowed24 = setOf(2412, 2437, 2462) + var reportedFrequency: Int? = null + + @Implementation protected fun requestP2pState(channel: WifiP2pManager.Channel, listener: WifiP2pManager.P2pStateListener) { + listener.onP2pStateAvailable(WifiP2pManager.WIFI_P2P_STATE_ENABLED) + } + + @Implementation override fun requestGroupInfo(channel: WifiP2pManager.Channel, listener: WifiP2pManager.GroupInfoListener) { + listener.onGroupInfoAvailable(group) + } + + @Implementation override fun requestConnectionInfo(channel: WifiP2pManager.Channel, listener: WifiP2pManager.ConnectionInfoListener) { + listener.onConnectionInfoAvailable(WifiP2pInfo().apply { + groupFormed = true + isGroupOwner = true + groupOwnerAddress = InetAddress.getByName("192.168.49.1") + }) + } + + @Implementation override fun createGroup(channel: WifiP2pManager.Channel, config: WifiP2pConfig?, listener: WifiP2pManager.ActionListener) { + requests += config + if (noCreateReply) return + if ((rejectCustom && config != null) || (fixed24Only && config?.groupOwnerBand !in allowed24)) { + if (competingGroup) group = makeGroup(null) + listener.onFailure(WifiP2pManager.ERROR) + } else { + group = makeGroup(config) + listener.onSuccess() + } + } + + @Implementation override fun removeGroup(channel: WifiP2pManager.Channel, listener: WifiP2pManager.ActionListener) { + removals++ + group = null + listener.onSuccess() + } + + fun makeGroup(config: WifiP2pConfig?) = WifiP2pGroup().apply { + shadowOf(this).setIsGroupOwner(true) + shadowOf(this).setNetworkName(config?.networkName ?: "DIRECT-system-test") + shadowOf(this).setPassphrase(if (missingCredentials) null else config?.passphrase ?: "test-generated-key") + shadowOf(this).setInterface("p2p-test-missing") + ReflectionHelpers.setField(this, "mFrequency", reportedFrequency ?: config?.groupOwnerBand ?: 2437) + } + } +} diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..9971a25 --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,17 @@ +# Source-only builds + +Public APK distribution is suspended while accessory authentication is reviewed. +The recovered identity must not be extracted from old releases, imported or bundled again. +Local compilation does not make a distributed private key confidential. + +Requirements: JDK 25, Android SDK 37, NDK 28.2.13676358 and the included Gradle wrapper. + +```sh +./gradlew :shared:testDebugUnitTest :common:testDebugUnitTest :mobile:lintDebug :mobile:assembleDebug +``` + +This produces a development APK without accessory identity assets. The current product bootstrap requires authentication provisioning, so this build does not provide a working standalone CarPlay connection. It is for source validation only. + +The automatic `.private/assets` import has been removed. The mobile pre-build check rejects credential files in APK asset source directories. The retired `scripts/build-beta.py` exits without building. Tests generate fresh self-signed synthetic identities at runtime; no static private key is stored in this repository. + +A future distributable build needs an authorized authentication/provisioning design. The separate Android release-signing key remains local and must never be committed. See SECURITY.md. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..309b801 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -0,0 +1,3 @@ +# Distribution suspended + +The 0.1.0 APK has been withdrawn. Do not install or redistribute it. No replacement APK is available. See [SECURITY.md](../SECURITY.md) and [source-only build instructions](BUILD.md). diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..309b801 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,3 @@ +# Distribution suspended + +The 0.1.0 APK has been withdrawn. Do not install or redistribute it. No replacement APK is available. See [SECURITY.md](../SECURITY.md) and [source-only build instructions](BUILD.md). diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 0000000..8bf82fe --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,11 @@ +# Privacy and diagnostics + +DiPlay's product flow uses local authentication and a direct USB/Wi-Fi connection to the iPhone. No account, remote authentication service or automatic diagnostic upload is used. The iPhone's CarPlay apps have their own internet and privacy behavior. + +The head unit stores app preferences, paired-device selections, pairing data and bounded diagnostic logs in app storage. Authentication and pairing material are kept out of Android backup. Uninstalling removes app-private data; exported reports in Downloads remain until you delete them. + +Diagnostic export is initiated by you. Reports include app/device versions, display settings and negotiation, connection transitions, Wi-Fi band/channel and state, and decoder recovery events. The exporter filters protocol payloads, credential-bearing lines and common identifiers. Redaction cannot promise to recognize every vendor-specific string: review reports before posting them publicly. A GitHub issue is public. + +Microphone access supports Siri and calls. Bluetooth/Nearby devices and Wi-Fi/Location permissions support discovery and transport. The optional local VPN permission supports the USB link; it does not provide a remote internet VPN. + +The static website has no analytics script or account. GitHub Pages, GitHub and Telegram apply their own policies when you use those services. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..309b801 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,3 @@ +# Distribution suspended + +The 0.1.0 APK has been withdrawn. Do not install or redistribute it. No replacement APK is available. See [SECURITY.md](../SECURITY.md) and [source-only build instructions](BUILD.md). diff --git a/docs/THIRD_PARTY_NOTICES.md b/docs/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..6e9f1a5 --- /dev/null +++ b/docs/THIRD_PARTY_NOTICES.md @@ -0,0 +1,37 @@ +# Credits and license notices + +## Receiver + +DiPlay is a modified version of [xcertplay by shilapi](https://github.com/shilapi/xcertplay). The upstream receiver is licensed under GNU GPL version 3; the full text is in `LICENSE` and the original README is retained in `docs/UPSTREAM-README.md`. + +Upstream credits [LIVI](https://github.com/f-io/LIVI) and [Showcase](https://github.com/amineross/showcase) for protocol research. Existing source comments and attribution are preserved. + +## Home and settings UI + +`common/src/main/java/com/shilapi/xcertplay/DiPlayActivity.kt` adapts the palette, visual arrangement and interface copy of the [DiAuto project](https://github.com/shihabal3amri/DiAuto). DiAuto's source is licensed under AGPL version 3. The UI file is marked AGPL-3.0-only; its license text is included in `docs/licenses/DiAuto-AGPL-3.0.txt`. + +## CarPlay icon + +The unmodified icon was obtained from Apple's developer site at: + +https://developer.apple.com/assets/elements/icons/carplay/carplay-96x96_2x.png + +CarPlay and the CarPlay icon are Apple Inc. marks/assets. This asset is not covered by the project's open-source code license. Its use here does not imply Apple approval or certification. + +## Runtime dependencies + +- AndroidX and Jetpack Compose — Android Open Source Project; Apache License 2.0. +- Kotlin standard library — JetBrains; Apache License 2.0. +- Bouncy Castle 1.79 — The Legion of the Bouncy Castle Inc.; Bouncy Castle license (MIT-style). +- JmDNS 3.6.3 — JmDNS contributors; Apache License 2.0. +- SLF4J — QOS.ch; MIT license. + +Gradle dependency declarations and version catalog accompany the source. License files available in the resolved artifacts are included under `docs/licenses/dependencies/`. + +## Experimental authentication data + +The withdrawn 0.1.0 preview APK included an accessory certificate/key pair recovered from public Carlinkit C2Air Allwinner V821 firmware during the owner's local investigation. These data are not newly generated Apple-issued credentials for DiPlay and are not relicensed as project source code. That distribution has been suspended. The credential must not be redistributed in new builds; the current source snapshot contains no accessory identity files. The source archive does not contain the private key, and the separate Android APK-signing key is never distributed. + +## Download website + +The static site layout, CSS and generator adapt DiAuto (AGPL-3.0). The AGPL license text is included with the source. diff --git a/docs/UPSTREAM-README.md b/docs/UPSTREAM-README.md new file mode 100644 index 0000000..ad4a2c3 --- /dev/null +++ b/docs/UPSTREAM-README.md @@ -0,0 +1,117 @@ +
+ xcertplay icon +

xcertplay

+ English | 中文 +

An Android head-unit CarPlay receiver. It supports connecting to the MFi chip through a CH341 I2C bridge or directly through the board's I2C controller, and supports both wired and wireless CarPlay connections.

+
+ +## Features + +- CarPlay host applications for Android and Android Automotive OS. +- Support for MFI chips connected through a CH341 bridge or native + `/dev/i2c-N` devices, and Remote MFI authentication (see the API below). +- Wired and wireless CarPlay connections. +- CarPlay Ultra triggering (the protocol stack is untested/incomplete, but it + can trigger the CarPlay Ultra prompt on an iPhone). +- Voice, navigation, and music multi-channel audio output mapped to the + corresponding Android channels. +- Dynamic Activity resizing with automatic re-handshaking to the new + resolution. +- Vehicle head-unit location reporting. +- Android 9 (API 28) support. + +## Usage + +1. Pair your iPhone with the head unit via Bluetooth. +2. In the app, swipe down with three fingers to open the Settings page. +3. Make sure all the settings are configured as desired. +4. Scroll to the bottom and select `Save & Reconnect`. +5. Connect your MFi chip using the method you selected. +6. Wait for the connection to complete, then enjoy. + +## TODO + +- [ ] Bluetooth activity popup +- [ ] more gesture for setting page +- [ ] debug log caused stuck bug +- [ ] microphone passthrough bug + +## Current progress + +It works 👍. It has been tested on car head units and phones. If you encounter +an incompatible car head unit, please open an issue and attach your log from +`/sdcard/Android/data/com.shilapi.xcertplay/files/logs/xcertplay.log`. + +Adapter board: [CH341-to-MFI](https://github.com/shilapi/ch341-to-mfi-chip) + +Waiting for the MFI chip adapter board to arrive... + +## Project structure + +| Path | Purpose | +| --- | --- | +| `common/` | Shared CarPlay host activity, settings UI, persistence, and app resources used by both targets. | +| `mobile/` | Standard Android target using the shared CarPlay host UI. | +| `automotive/` | Android Automotive OS target with the shared host UI and advanced audio channel mapping. | +| `shared/` | Car App Library code plus the CH341, I2C, MFi, iPhone, iAP2, NCM, VPN, AirPlay, and media implementations. | + +## Remote MFI + +The Remote MFi client treats a remote service as an MFi chip for remote calls, +or uses BAA authentication. Remote authentication avoids the process of +connecting to a local MFi chip for authentication. + +### Endpoints + +| Method | Path | Purpose | Request body | Success response | Failure response | +| --- | --- | --- | --- | --- | --- | +| `GET` | `/mfi/certificate` | Get the MFi chip version, certificate type, and certificate contents; cached by the client after the first call | None | Certificate JSON | `{"detail":"..."}` | +| `POST` | `/mfi/sign` | Sign the challenge | `{"challenge":"...","requestId":"..."}` | `{"signature":"..."}` | `{"detail":"..."}` | +| `POST` | `/mfi/reset` | Request a reset of the remote MFi chip | `{}` | `{"detail":""}` | `{"detail":"..."}` | + +(Optional) Standard Bearer Authentication can be used for verification. + +**Currently, only BAA Authentication has been tested.** + +## Requirements + +- JDK 17 or newer to launch Gradle. The daemon resolves Java 25 through the + Gradle toolchain. +- Android SDK Platform 37. +- Android 9 (API 28) or newer. + On Android 9, Wi-Fi P2P 5 GHz mode is unavailable and LocalOnlyHotspot is used instead. +- Android NDK `28.2.13676358`. +- A physical USB Host/OTG Android device and MFi hardware are required for + hardware validation. + +## Build + +On Windows PowerShell: + +```powershell +$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" +.\gradlew.bat :shared:testDebugUnitTest :common:lintDebug :mobile:lintDebug :automotive:lintDebug :mobile:assembleDebug :automotive:assembleDebug +``` + +On macOS or Linux: + +```bash +./gradlew :shared:testDebugUnitTest :common:lintDebug :mobile:lintDebug :automotive:lintDebug :mobile:assembleDebug :automotive:assembleDebug +``` + +Unsigned release APKs: + +```powershell +.\gradlew.bat :mobile:assembleRelease :automotive:assembleRelease +``` + +## Acknowledgements + +Thanks to [LIVI](https://github.com/f-io/LIVI) for providing important +reference for this project. +Thanks to the [showcase](https://github.com/amineross/showcase) project for +providing important reference for the BAA authentication in this project. + +## License + +Licensed under the [GNU General Public License v3.0](LICENSE). diff --git a/docs/UPSTREAM-README.zh-CN.md b/docs/UPSTREAM-README.zh-CN.md new file mode 100644 index 0000000..9b4366f --- /dev/null +++ b/docs/UPSTREAM-README.zh-CN.md @@ -0,0 +1,98 @@ +
+ xcertplay icon +

xcertplay

+ English | 中文 +

xcertplay 是面向 Android 车机的 CarPlay 接收端项目。支持通过 CH341 I2C 桥接到 MFi 芯片,亦可通过板载 I2C 控制器直连,支持 CarPlay 有线和无线连接。

+
+ +## Features + +- 面向 Android 和 Android Automotive OS 的 CarPlay 主机应用。 +- 支持 CH341 桥接 MFI 芯片、原生 `/dev/i2c-N` 设备连接的 MFI芯片、Remote MFI 认证(API见下)。 +- 支持 CarPlay 有线或无线连接。 +- 支持触发 CarPlay Ultra (未测试/未完成的协议栈,但是确实可以在 iPhone 上触发 CarPlay Ultra 的提示)。 +- 支持语音、导航、音乐多通道音频输出并 mapping 至 Android 的对应通道。 +- 支持动态 Activity resize ,并自动重新握手至新的分辨率。 +- 支持车机位置回传。 +- 支持 Android 9 (API 28) 。 + +## 使用方法 + +1. 通过蓝牙将 iPhone 与车机配对。 +2. 在应用中用三指向下滑动,打开设置页面。 +3. 确认所有设置均已按需配置。 +4. 滑动到底部,选择 `Save & Reconnect`。 +5. 按照你选择的方式连接 MFi 芯片。 +6. 等待连接完成,然后开始使用。 + +## 当前进度 + +他运转👍,已在车机/手机平台测试,如果出现部分车机不适配的情况欢迎 issue (并附上你的 log ,位于 `/sdcard/Android/data/com.shilapi.xcertplay/files/logs/xcertplay.log`) + +转接板:[CH341-to-MFI](https://github.com/shilapi/ch341-to-mfi-chip) + +正在 MFI 芯片转接板到货... + +## 工程结构 + +| 路径 | 用途 | +| --- | --- | +| `common/` | 两个目标共用的 CarPlay 宿主界面、设置、持久化和应用资源。 | +| `mobile/` | 使用共享 CarPlay 主机界面的 Android 应用。 | +| `automotive/` | 使用共享主机界面并支持高级音频通道映射的 Android Automotive OS 应用。 | +| `shared/` | Car App Library 代码,以及 CH341、I2C、MFi、iPhone、iAP2、NCM、VPN、AirPlay 和媒体实现。 | + +## Remote MFI 功能 + +Remote MFi 客户端把远程服务当作一块 MFi 芯片远程调用,抑或是采用 BAA 认证,通过远程进行认证免去了本地连接 MFI 芯片进行认证的流程。 + +### 端点 + +| Method | Path | 用途 | Request body | Success response | 失败 response | +| --- | --- | --- | --- | --- | --- | +| `GET` | `/mfi/certificate` | 获取 MFI 芯片版本、证书类型和证书内容,客户端首次调用后缓存 | 无 | 证书 JSON | `{"detail":"..."}` | +| `POST` | `/mfi/sign` | 对 challenge 签名 | `{"challenge":"...","requestId":"..."}` | `{"signature":"..."}` | `{"detail":"..."}` | +| `POST` | `/mfi/reset` | 请求重置远程 MFI 芯片 | `{}` | `{"detail":""}` | `{"detail":"..."}` | + +(可选)采用标准 Bearer Authentication 进行验证。 + +**当前仅测试了 BAA Authentication** + +## 环境要求 + +- 启动 Gradle 需要 JDK 17 或更高版本;daemon 通过 Gradle toolchain 解析 Java 25。 +- Android SDK Platform 37。 +- Android 9(API 28)或更高版本。 + 在 Android 9 上不可用 Wi-Fi P2P 5 GHz 模式,应用会改用 LocalOnlyHotspot。 +- Android NDK `28.2.13676358`。 +- 硬件验证需要支持 USB Host/OTG 的 Android 设备以及 MFi 硬件。 + +## 构建 + +在 Windows PowerShell 中: + +```powershell +$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" +.\gradlew.bat :shared:testDebugUnitTest :common:lintDebug :mobile:lintDebug :automotive:lintDebug :mobile:assembleDebug :automotive:assembleDebug +``` + +在 macOS 或 Linux 中: + +```bash +./gradlew :shared:testDebugUnitTest :common:lintDebug :mobile:lintDebug :automotive:lintDebug :mobile:assembleDebug :automotive:assembleDebug +``` + +构建未签名 release APK: + +```powershell +.\gradlew.bat :mobile:assembleRelease :automotive:assembleRelease +``` + +## 致谢 + +感谢 [LIVI](https://github.com/f-io/LIVI) 项目为本项目提供了重要参考。 +感谢 [showcase](https://github.com/amineross/showcase) 项目为本项目的 BAA 认证提供重要参考。 + +## 许可证 + +本项目采用 [GNU General Public License v3.0](LICENSE) 许可。 diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..fd56421 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,10 @@ +# Source snapshot validation — 2026-09-25 + +- 172 JVM/Robolectric tests passed with freshly generated synthetic test identities. +- Source-only debug APK build and debug lint passed. +- The credential-asset guard rejected a synthetic `.pk8` probe and passed after it was removed. +- APK inspection found no `offline-mfi` assets or credential-container files. +- Static test private-key/certificate files have been removed from the source snapshot. +- No new public APK or CarPlay compatibility claim is made. This development build lacks accessory authentication provisioning. + +The original release and incident records are preserved locally with restricted access. See SECURITY.md. diff --git a/docs/licenses/DiAuto-AGPL-3.0.txt b/docs/licenses/DiAuto-AGPL-3.0.txt new file mode 100644 index 0000000..dbbe355 --- /dev/null +++ b/docs/licenses/DiAuto-AGPL-3.0.txt @@ -0,0 +1,661 @@ + 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/docs/licenses/dependencies/Apache-2.0.txt b/docs/licenses/dependencies/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/docs/licenses/dependencies/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/docs/licenses/dependencies/Bouncy-Castle-LICENSE.txt b/docs/licenses/dependencies/Bouncy-Castle-LICENSE.txt new file mode 100644 index 0000000..24506f0 --- /dev/null +++ b/docs/licenses/dependencies/Bouncy-Castle-LICENSE.txt @@ -0,0 +1,17 @@ +Copyright (c) 2000-2023 The Legion of the Bouncy Castle Inc. (https://www.bouncycastle.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/docs/licenses/dependencies/SLF4J-LICENSE.txt b/docs/licenses/dependencies/SLF4J-LICENSE.txt new file mode 100644 index 0000000..bb09a9a --- /dev/null +++ b/docs/licenses/dependencies/SLF4J-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) 2004-2023 QOS.ch +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..99fc155 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,19 @@ +# 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 +# When enabled, the Configuration Cache allows Gradle to skip the configuration +# phase entirely if nothing that affects the build configuration (such as build scripts) +# has changed. Additionally, Gradle applies performance optimizations to task execution. +org.gradle.configuration-cache=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ 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..fa4ed51 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..04e825e --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,36 @@ +[versions] +agp = "9.3.0" +coreKtx = "1.19.0" +lifecycleRuntimeKtx = "2.6.1" +activityCompose = "1.8.0" +kotlin = "2.2.10" +composeBom = "2026.02.01" +appcompat = "1.8.0" +appProjected = "1.4.0" +appAutomotive = "1.7.0" +app = "1.4.0" +junit = "4.13.2" +bouncycastle = "1.79" +jmdns = "3.6.3" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +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-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-app-projected = { group = "androidx.car.app", name = "app-projected", version.ref = "appProjected" } +androidx-app-automotive = { group = "androidx.car.app", name = "app-automotive", version.ref = "appAutomotive" } +androidx-app = { group = "androidx.car.app", name = "app", version.ref = "app" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +bouncycastle = { group = "org.bouncycastle", name = "bcprov-jdk18on", version.ref = "bouncycastle" } +jmdns = { group = "org.jmdns", name = "jmdns", version.ref = "jmdns" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +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..8bdaf60 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..9e8b714 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +#Fri Sep 11 01:27:33 CST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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 ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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=. +@rem This is normally unused +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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 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! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts new file mode 100644 index 0000000..d408d1c --- /dev/null +++ b/mobile/build.gradle.kts @@ -0,0 +1,87 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.shilapi.xcertplay" + compileSdk { + version = release(37) + } + + defaultConfig { + applicationId = "com.shihab.diplay" + minSdk = 28 + targetSdk = 37 + versionCode = 10 + versionName = "0.1.0" + + } + + + signingConfigs { + create("release") { + storeFile = file( + providers.environmentVariable("ANDROID_KEYSTORE_PATH") + .getOrElse("missing-release-keystore.jks"), + ) + storePassword = providers.environmentVariable("ANDROID_KEYSTORE_PASSWORD").getOrElse("") + keyAlias = providers.environmentVariable("ANDROID_KEY_ALIAS").getOrElse("") + keyPassword = providers.environmentVariable("ANDROID_KEY_PASSWORD").getOrElse("") + } + } + + buildTypes { + release { + optimization { + enable = false + } + signingConfig = signingConfigs.getByName("release") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform(libs.androidx.compose.bom)) + implementation(project(":common")) + implementation(project(":shared")) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.app.projected) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + debugImplementation(libs.androidx.compose.ui.tooling) +} + +// Source builds must never silently import a shared accessory private key. +val credentialAssets = files(android.sourceSets.flatMap { source -> + source.assets.directories.map { directory -> + fileTree(directory) { + include("**/offline-mfi/**", "**/*.pk8", "**/*.p7b", "**/*.key", + "**/*.pem", "**/*.p12", "**/*.pfx", "**/*.jks", "**/*.keystore") + } + } +}) +val rejectBundledCredentials by tasks.registering { + group = "verification" + description = "Reject credential files in every APK asset source directory." + val filesToCheck = credentialAssets + inputs.files(filesToCheck) + doLast { + check(filesToCheck.isEmpty) { + "Credential assets are forbidden in distributable APKs: " + + filesToCheck.files.joinToString { it.name } + } + } +} +tasks.named("preBuild") { dependsOn(rejectBundledCredentials) } diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6bda892 --- /dev/null +++ b/mobile/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/mobile/src/main/keepRules/rules.keep b/mobile/src/main/keepRules/rules.keep new file mode 100644 index 0000000..d7e081a --- /dev/null +++ b/mobile/src/main/keepRules/rules.keep @@ -0,0 +1,12 @@ +# Add project specific R8 rules here. +# AGP will combine all keep rule files in src/main/keepRules to pass to R8 +# +# For more details, see +# https://d.android.com/r/tools/r8/keep-rules + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} \ No newline at end of file diff --git a/mobile/src/main/res/values/config.xml b/mobile/src/main/res/values/config.xml new file mode 100644 index 0000000..08ac42d --- /dev/null +++ b/mobile/src/main/res/values/config.xml @@ -0,0 +1,3 @@ + + false + diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml new file mode 100644 index 0000000..401758e --- /dev/null +++ b/mobile/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + DiPlay + diff --git a/scripts/build-beta.py b/scripts/build-beta.py new file mode 100644 index 0000000..6109cb6 --- /dev/null +++ b/scripts/build-beta.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +"""The credential-bearing beta release path has been withdrawn.""" +raise SystemExit( + "Release packaging is suspended. Do not bundle a recovered accessory identity. " + "See docs/BUILD.md for source-only validation." +) diff --git a/scripts/build_site.py b/scripts/build_site.py new file mode 100644 index 0000000..523acaf --- /dev/null +++ b/scripts/build_site.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the public status pages. No APK download URL is published.""" +from pathlib import Path +from html import escape as e +import json +ROOT = Path(__file__).resolve().parents[1] +SITE = ROOT / 'site' +BASE = 'https://shihabal3amri.github.io/DiPlay/' +REPO = 'https://github.com/shihabal3amri/DiPlay' +data = json.loads((SITE / 'content.json').read_text()) +for lang, d in data.items(): + folder = SITE if lang == 'en' else SITE / lang + folder.mkdir(exist_ok=True) + prefix = './' if lang == 'en' else '../' + url = BASE + ('' if lang == 'en' else lang + '/') + nav = ''.join(f'{e(v["name"])}' for code,v in data.items()) + (folder/'index.html').write_text(f''' + +DiPlay · {e(d['badge'])} + +
DiPlay
+
{e(d['badge'])}

{e(d['title']).replace(chr(10),'
')}

{e(d['body'])}

{e(d['detail'])}

+

{e(d['footer'])}

''') +print('Generated', len(data), 'status pages') diff --git a/scripts/check_public_tree.py b/scripts/check_public_tree.py new file mode 100644 index 0000000..ec8b16a --- /dev/null +++ b/scripts/check_public_tree.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Fail CI if credential containers or private-key blocks enter the source tree.""" +from pathlib import Path +import re +import subprocess + +root = Path(__file__).resolve().parents[1] +names = subprocess.check_output(['git', 'ls-files', '-z'], cwd=root).decode().split('\0') +blocked_suffixes = {'.pk8', '.p7b', '.pem', '.key', '.p12', '.pfx', '.jks', '.keystore', '.apk', '.aab'} +private_block = re.compile(rb'-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----\s+[A-Za-z0-9+/=\r\n]{40,}') +failures = [] +for name in filter(None, names): + path = root / name + if not path.is_file(): + continue + if '.private' in path.relative_to(root).parts or path.suffix.lower() in blocked_suffixes: + failures.append(name) + elif private_block.search(path.read_bytes()): + failures.append(name) +if failures: + raise SystemExit('Credential or distribution files are forbidden in the public tree:\n' + '\n'.join(failures)) +print('Public tree check passed: no credential containers or private-key blocks.') diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..9ed2d50 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,29 @@ +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() + } +} + +rootProject.name = "xcertplay" +include(":common") +include(":mobile") +include(":automotive") +include(":shared") diff --git a/shared/.gitignore b/shared/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/shared/.gitignore @@ -0,0 +1 @@ +/build diff --git a/shared/build.gradle b/shared/build.gradle new file mode 100644 index 0000000..f31ec30 --- /dev/null +++ b/shared/build.gradle @@ -0,0 +1,40 @@ +plugins { + id 'com.android.library' +} +android { + namespace = 'com.shilapi.xcertplay.shared' + compileSdk = 37 + + defaultConfig { + minSdk = 28 + targetSdk = 37 + + ndk { + abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' + } + + externalNativeBuild { + ndkBuild { + arguments 'APP_PLATFORM=android-28' + } + } + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + ndkVersion = '28.2.13676358' + externalNativeBuild { + ndkBuild { + path = file('src/main/jni/Android.mk') + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} +dependencies { + implementation libs.androidx.app + implementation libs.bouncycastle + implementation libs.jmdns + testImplementation libs.junit +} diff --git a/shared/src/main/AndroidManifest.xml b/shared/src/main/AndroidManifest.xml new file mode 100644 index 0000000..37eef87 --- /dev/null +++ b/shared/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shared/src/main/ic_launcher-playstore.png b/shared/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..b7be9eb Binary files /dev/null and b/shared/src/main/ic_launcher-playstore.png differ diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt new file mode 100644 index 0000000..5f11d8f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayConfig.kt @@ -0,0 +1,50 @@ +package com.shilapi.xcertplay.airplay + +/** Display insets in pixels, used for CarPlay viewArea and safeArea declarations. */ +data class AirPlayInsets( + val top: Int = 0, + val bottom: Int = 0, + val left: Int = 0, + val right: Int = 0, +) + +/** One display advertised to the phone in /info. */ +data class AirPlayDisplayConfig( + val widthPixels: Int, + val heightPixels: Int, + val widthPhysicalMm: Int? = null, + val heightPhysicalMm: Int? = null, + val fps: Int = 60, + val primaryInputDevice: Int = 1, + val viewArea: AirPlayInsets? = null, + val safeArea: AirPlayInsets? = null, + val safeAreaDrawOutside: Boolean? = null, + val initialUrl: String? = null, +) + +/** One OEM homescreen icon. */ +data class AirPlayIcon( + val widthPixels: Int, + val heightPixels: Int, + val data: ByteArray, +) + +/** Immutable accessory configuration consumed by the AirPlay session server. */ +data class AirPlayConfig( + val deviceName: String, + val deviceId: String, + val btMac: String, + val sourceVersion: String, + val main: AirPlayDisplayConfig, + val cluster: AirPlayDisplayConfig? = null, + val rightHandDrive: Boolean = false, + val port: Int = 7000, + val entertainmentSampleRate: Int = 48000, + val hevc: Boolean = false, + val disableAudioOutput: Boolean = false, + val microphone: Boolean = false, + val manufacturer: String = "xcertplay", + val model: String = "xcertplay", + val oemLabel: String = "xcertplay", + val icons: List = emptyList(), +) diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayCrypto.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayCrypto.kt new file mode 100644 index 0000000..a5a1a99 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayCrypto.kt @@ -0,0 +1,130 @@ +package com.shilapi.xcertplay.airplay + +import org.bouncycastle.crypto.Digest +import org.bouncycastle.crypto.digests.SHA512Digest +import org.bouncycastle.crypto.generators.HKDFBytesGenerator +import org.bouncycastle.crypto.modes.ChaCha20Poly1305 +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters +import org.bouncycastle.crypto.params.HKDFParameters +import org.bouncycastle.crypto.params.KeyParameter +import org.bouncycastle.crypto.params.X25519PrivateKeyParameters +import org.bouncycastle.crypto.params.X25519PublicKeyParameters +import org.bouncycastle.crypto.signers.Ed25519Signer +import java.security.SecureRandom + +/** BouncyCastle-backed primitives for the CarPlay pairing and control channel. */ +object AirPlayCrypto { + private const val MAC_BITS = 128 + private const val NONCE_SIZE = 12 + private const val LABEL_SIZE = 8 + private val random = SecureRandom() + + data class X25519KeyPair(val privateKey: ByteArray, val publicKey: ByteArray) + data class Ed25519KeyPair(val privateKey: ByteArray, val publicKey: ByteArray) + + fun x25519Generate(): X25519KeyPair { + val privateKey = X25519PrivateKeyParameters(random) + return X25519KeyPair(privateKey.encoded, privateKey.generatePublicKey().encoded) + } + + fun x25519Shared(privateKeyRaw: ByteArray, peerPublicKeyRaw: ByteArray): ByteArray { + val privateKey = X25519PrivateKeyParameters(privateKeyRaw, 0) + val peerPublicKey = X25519PublicKeyParameters(peerPublicKeyRaw, 0) + val shared = ByteArray(X25519PrivateKeyParameters.SECRET_SIZE) + privateKey.generateSecret(peerPublicKey, shared, 0) + return shared + } + + fun ed25519Generate(): Ed25519KeyPair { + val privateKey = Ed25519PrivateKeyParameters(random) + return Ed25519KeyPair(privateKey.encoded, privateKey.generatePublicKey().encoded) + } + + fun ed25519Sign(privateKeyRaw: ByteArray, data: ByteArray): ByteArray { + val signer = Ed25519Signer() + signer.init(true, Ed25519PrivateKeyParameters(privateKeyRaw, 0)) + signer.update(data, 0, data.size) + return signer.generateSignature() + } + + fun ed25519Verify(publicKeyRaw: ByteArray, data: ByteArray, signature: ByteArray): Boolean = try { + val signer = Ed25519Signer() + signer.init(false, Ed25519PublicKeyParameters(publicKeyRaw, 0)) + signer.update(data, 0, data.size) + signer.verifySignature(signature) + } catch (_: Exception) { + false + } + + fun hkdfSha512( + inputKeyMaterial: ByteArray, + salt: ByteArray, + info: ByteArray, + length: Int = 32, + ): ByteArray { + val generator = HKDFBytesGenerator(SHA512Digest()) + generator.init(HKDFParameters(inputKeyMaterial, salt, info)) + val output = ByteArray(length) + generator.generateBytes(output, 0, length) + return output + } + + fun sha512(vararg parts: ByteArray): ByteArray = digest(SHA512Digest(), parts) + + fun chachaSeal( + key: ByteArray, + nonce: ByteArray, + plaintext: ByteArray, + aad: ByteArray = ByteArray(0), + ): ByteArray { + val cipher = ChaCha20Poly1305() + cipher.init(true, AEADParameters(KeyParameter(key), MAC_BITS, nonce, aad)) + val output = ByteArray(cipher.getOutputSize(plaintext.size)) + val length = cipher.processBytes(plaintext, 0, plaintext.size, output, 0) + cipher.doFinal(output, length) + return output + } + + fun chachaOpen( + key: ByteArray, + nonce: ByteArray, + ciphertextAndTag: ByteArray, + aad: ByteArray = ByteArray(0), + ): ByteArray { + val cipher = ChaCha20Poly1305() + cipher.init(false, AEADParameters(KeyParameter(key), MAC_BITS, nonce, aad)) + val output = ByteArray(cipher.getOutputSize(ciphertextAndTag.size)) + val processed = cipher.processBytes(ciphertextAndTag, 0, ciphertextAndTag.size, output, 0) + val finalized = cipher.doFinal(output, processed) + val outputLength = processed + finalized + return if (outputLength == output.size) output else output.copyOf(outputLength) + } + + /** 12-byte nonce: four zero bytes followed by an eight-byte little-endian counter. */ + fun nonce64(counter: Long): ByteArray { + val nonce = ByteArray(NONCE_SIZE) + var value = counter + for (index in 4 until NONCE_SIZE) { + nonce[index] = value.toByte() + value = value ushr 8 + } + return nonce + } + + /** 12-byte nonce from an eight-byte ASCII label placed after four zero bytes. */ + fun nonceLabel(label: String): ByteArray { + val nonce = ByteArray(NONCE_SIZE) + val ascii = label.asciiBytes() + ascii.copyInto(nonce, 4, 0, minOf(ascii.size, LABEL_SIZE)) + return nonce + } + + private fun digest(digest: Digest, parts: Array): ByteArray { + for (part in parts) digest.update(part, 0, part.size) + val output = ByteArray(digest.digestSize) + digest.doFinal(output, 0) + return output + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayDisplaySettings.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayDisplaySettings.kt new file mode 100644 index 0000000..93d2904 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayDisplaySettings.kt @@ -0,0 +1,91 @@ +package com.shilapi.xcertplay.airplay + +enum class AirPlayPhysicalSizeBasis { + WIDTH, + HEIGHT, +} + +data class AirPlayPhysicalSizeMm( + val widthMm: Int, + val heightMm: Int, +) + +/** User-adjustable display values with the same stepped ranges used by the settings UI. */ +object AirPlayDisplaySettings { + const val MIN_FPS = 30 + const val MAX_FPS = 60 + const val FPS_STEP = 5 + const val DEFAULT_FPS = MAX_FPS + + const val MIN_WIDTH_PHYSICAL_MM = 100 + const val MAX_WIDTH_PHYSICAL_MM = 400 + const val WIDTH_PHYSICAL_MM_STEP = 50 + const val DEFAULT_WIDTH_PHYSICAL_MM = 200 + val DEFAULT_PHYSICAL_SIZE_BASIS = AirPlayPhysicalSizeBasis.WIDTH + const val MIN_REPORTED_PHYSICAL_MM = 1 + const val MAX_REPORTED_PHYSICAL_MM = 2_000 + + fun sanitizeFps(value: Int): Int = + snapToStep(value, MIN_FPS, MAX_FPS, FPS_STEP) + + fun sanitizeWidthPhysicalMm(value: Int): Int = + snapToStep( + value, + MIN_WIDTH_PHYSICAL_MM, + MAX_WIDTH_PHYSICAL_MM, + WIDTH_PHYSICAL_MM_STEP, + ) + + fun sanitizeReportedPhysicalMm(value: Int): Int = + value.coerceIn(MIN_REPORTED_PHYSICAL_MM, MAX_REPORTED_PHYSICAL_MM) + + fun resolvePhysicalSizeMm( + currentWidthPixels: Int, + currentHeightPixels: Int, + maximumWidthPixels: Int, + maximumHeightPixels: Int, + referenceMillimeters: Int, + basis: AirPlayPhysicalSizeBasis, + ): AirPlayPhysicalSizeMm { + require(currentWidthPixels > 0 && currentHeightPixels > 0) { + "Current display dimensions must be positive" + } + require(maximumWidthPixels > 0 && maximumHeightPixels > 0) { + "Maximum display dimensions must be positive" + } + val referencePixels = when (basis) { + AirPlayPhysicalSizeBasis.WIDTH -> + currentWidthPixels.toDouble() / maximumWidthPixels + AirPlayPhysicalSizeBasis.HEIGHT -> + currentHeightPixels.toDouble() / maximumHeightPixels + } + val referenceMm = referenceMillimeters.coerceAtLeast(1) * referencePixels + val widthMm: Double + val heightMm: Double + when (basis) { + AirPlayPhysicalSizeBasis.WIDTH -> { + widthMm = referenceMm + heightMm = widthMm * currentHeightPixels / currentWidthPixels + } + AirPlayPhysicalSizeBasis.HEIGHT -> { + heightMm = referenceMm + widthMm = heightMm * currentWidthPixels / currentHeightPixels + } + } + return AirPlayPhysicalSizeMm( + widthMm = sanitizeReportedPhysicalMm(Math.round(widthMm).toInt()), + heightMm = sanitizeReportedPhysicalMm(Math.round(heightMm).toInt()), + ) + } + + fun fpsProgress(value: Int): Int = + (sanitizeFps(value) - MIN_FPS) / FPS_STEP + + fun widthPhysicalMmProgress(value: Int): Int = + (sanitizeWidthPhysicalMm(value) - MIN_WIDTH_PHYSICAL_MM) / WIDTH_PHYSICAL_MM_STEP + + private fun snapToStep(value: Int, minimum: Int, maximum: Int, step: Int): Int { + val clamped = value.coerceIn(minimum, maximum) + return (((clamped - minimum) + step / 2) / step) * step + minimum + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayHid.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayHid.kt new file mode 100644 index 0000000..f1fc154 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayHid.kt @@ -0,0 +1,141 @@ +package com.shilapi.xcertplay.airplay + +/** One normalized touch contact (0..1 coordinates). */ +data class AirPlayContact( + val id: Int, + val x: Double, + val y: Double, + val down: Boolean, +) + +/** Momentary knob/button state for the head-unit rotary controller. */ +data class AirPlayKnobState( + val select: Boolean = false, + val home: Boolean = false, + val back: Boolean = false, + val x: Int = 0, + val y: Int = 0, + val wheel: Int = 0, +) + +/** CarPlay HID device descriptors and report encoders. */ +object AirPlayHid { + const val TOUCH_HID_UID = 0x2a2a2a2a + const val KNOB_HID_UID = 0x2a2a2a2b + const val MEDIA_HID_UID = 0x2a2a2a2c + const val TELEPHONY_HID_UID = 0x2a2a2a2d + + private const val TOUCH_CONTACTS = 2 + private const val BYTES_PER_FINGER = 6 + + fun touchHidDevice(xMax: Int, yMax: Int, displayUuid: String): Map = + hidDeviceEntry(TOUCH_HID_UID, "xcertplay Touchscreen", multitouchDescriptor(xMax, yMax), displayUuid) + + fun knobHidDevice(displayUuid: String): Map = + hidDeviceEntry(KNOB_HID_UID, "xcertplay Knob", knobDescriptor, displayUuid) + + fun mediaHidDevice(displayUuid: String): Map = + hidDeviceEntry(MEDIA_HID_UID, "xcertplay Media", mediaDescriptor, displayUuid) + + fun telephonyHidDevice(displayUuid: String): Map = + hidDeviceEntry(TELEPHONY_HID_UID, "xcertplay Telephony", telephonyDescriptor, displayUuid) + + fun touchReport(contacts: List): ByteArray { + val report = ByteArray(BYTES_PER_FINGER * TOUCH_CONTACTS) + for (slot in 0 until TOUCH_CONTACTS) { + val offset = slot * BYTES_PER_FINGER + report[offset] = slot.toByte() + val contact = contacts.getOrNull(slot) ?: continue + report[offset + 1] = if (contact.down) 0x01 else 0x00 + writeU16Le(report, offset + 2, Math.round(contact.x.coerceAtLeast(0.0)).toInt()) + writeU16Le(report, offset + 4, Math.round(contact.y.coerceAtLeast(0.0)).toInt()) + } + return report + } + + fun knobReport(state: AirPlayKnobState): ByteArray { + val report = ByteArray(4) + report[0] = ( + (if (state.select) 0x01 else 0) or + (if (state.home) 0x02 else 0) or + (if (state.back) 0x04 else 0) + ).toByte() + report[1] = clampAxis(state.x) + report[2] = clampAxis(state.y) + report[3] = clampAxis(state.wheel) + return report + } + + fun mediaReport(index: Int): ByteArray = byteArrayOf(index.toByte()) + + fun telephonyReport(index: Int): ByteArray = byteArrayOf(index.toByte()) + + private fun hidDeviceEntry( + uid: Int, + name: String, + descriptor: ByteArray, + displayUuid: String, + ): Map = linkedMapOf( + "hidProductID" to 1L, + "hidVendorID" to 2L, + "hidCountryCode" to 0L, + "uuid" to uid.toString(16), + "name" to name, + "displayUUID" to displayUuid, + "hidDescriptor" to descriptor, + ) + + private fun multitouchDescriptor(xMax: Int, yMax: Int): ByteArray { + val bytes = ArrayList() + bytes.addAll(intArrayOf(0x05, 0x0d, 0x09, 0x04, 0xa1, 0x01).toList()) + repeat(TOUCH_CONTACTS) { bytes.addAll(fingerCollection(xMax, yMax).toList()) } + bytes.add(0xc0) + return bytes.map { it.toByte() }.toByteArray() + } + + private fun fingerCollection(xMax: Int, yMax: Int): IntArray = intArrayOf( + 0x05, 0x0d, 0x09, 0x22, 0xa1, 0x02, + 0x09, 0x38, 0x75, 0x08, 0x95, 0x01, 0x81, 0x02, + 0x15, 0x00, 0x25, 0x01, 0x09, 0x33, 0x75, 0x01, 0x95, 0x01, 0x81, 0x02, + 0x95, 0x07, 0x81, 0x03, + 0x05, 0x01, 0x26, xMax and 0xff, (xMax shr 8) and 0xff, 0x09, 0x30, 0x75, 0x10, 0x95, 0x01, 0x81, 0x02, + 0x26, yMax and 0xff, (yMax shr 8) and 0xff, 0x09, 0x31, 0x81, 0x02, 0xc0, + ) + + private val knobDescriptor = descriptor( + 0x05, 0x01, 0x09, 0x08, 0xa1, 0x01, + 0x05, 0x09, 0x09, 0x01, 0x15, 0x00, 0x25, 0x01, 0x75, 0x01, 0x95, 0x01, 0x81, 0x02, + 0x05, 0x0c, 0x0a, 0x23, 0x02, 0x0a, 0x24, 0x02, 0x95, 0x02, 0x81, 0x02, + 0x95, 0x05, 0x81, 0x01, + 0x05, 0x01, 0x09, 0x01, 0xa1, 0x00, + 0x09, 0x30, 0x09, 0x31, 0x15, 0x81, 0x25, 0x7f, 0x75, 0x08, 0x95, 0x02, 0x81, 0x02, 0xc0, + 0x09, 0x38, 0x15, 0x81, 0x25, 0x7f, 0x75, 0x08, 0x95, 0x01, 0x81, 0x06, 0xc0, + ) + + private val mediaDescriptor = descriptor( + 0x05, 0x0c, 0x09, 0x01, 0xa1, 0x01, + 0x15, 0x00, 0x25, 0x06, 0x05, 0x0c, + 0x0a, 0x00, 0x00, 0x0a, 0xb0, 0x00, 0x0a, 0xb1, 0x00, 0x0a, 0xcd, 0x00, + 0x0a, 0xb5, 0x00, 0x0a, 0xb6, 0x00, 0x0a, 0x9e, 0x02, + 0x75, 0x08, 0x95, 0x01, 0x81, 0x00, 0xc0, + ) + + private val telephonyDescriptor = descriptor( + 0x05, 0x0b, 0x09, 0x07, 0xa1, 0x01, + 0x15, 0x00, 0x25, 0x11, 0x05, 0x0b, + 0x09, 0x00, 0x09, 0x20, 0x09, 0x21, 0x09, 0x26, 0x09, 0x2f, + 0x09, 0xb0, 0x09, 0xb1, 0x09, 0xb2, 0x09, 0xb3, 0x09, 0xb4, 0x09, 0xb5, + 0x09, 0xb6, 0x09, 0xb7, 0x09, 0xb8, 0x09, 0xb9, 0x09, 0xba, 0x09, 0xbb, + 0x05, 0x07, 0x09, 0x2a, + 0x75, 0x08, 0x95, 0x01, 0x81, 0x00, 0xc0, + ) + + private fun descriptor(vararg bytes: Int): ByteArray = ByteArray(bytes.size) { bytes[it].toByte() } + + private fun clampAxis(value: Int): Byte = value.coerceIn(-127, 127).toByte() + + private fun writeU16Le(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value and 0xff).toByte() + target[offset + 1] = ((value ushr 8) and 0xff).toByte() + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayIapTunnelStream.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayIapTunnelStream.kt new file mode 100644 index 0000000..37af88d --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayIapTunnelStream.kt @@ -0,0 +1,174 @@ +package com.shilapi.xcertplay.airplay + +import com.shilapi.xcertplay.transport.BlockingDuplexByteStream +import java.io.IOException +import java.util.ArrayDeque +import kotlin.math.min + +/** + * Full-duplex adapter for the CarPlay iAP2 DataStream. + * + * Inbound iAP2 bytes come from [IapTunnel]; outbound bytes are sent over the encrypted AirPlay + * event channel using the `iAPSendMessage` command. + */ +internal class AirPlayIapTunnelStream( + private val session: AirPlaySession, + private val tunnel: IapTunnel, + private val sendCommand: (ByteArray, Long) -> Boolean = { data, timeoutMillis -> + session.sendIapMessage(data, timeoutMillis) + }, +) : BlockingDuplexByteStream { + private val lock = Object() + private val pending = ArrayDeque() + private var pendingBytes = 0 + private var closed = false + private var terminalFailure: Throwable? = null + private var closeLocation: String? = null + + fun listen(): Int = tunnel.listen( + object : IapTunnel.Listener { + override fun onOpen(remoteAddress: String?) { + session.logTrace( + "AirPlay iAP tunnel connected remote=${remoteAddress ?: "unknown"}", + ) + } + + override fun onIap(bytes: ByteArray) = offer(bytes) + + override fun onDebug(message: String) { + session.logTrace(message) + } + + override fun onClosed(cause: Throwable?) { + session.logTrace( + "AirPlay iAP tunnel closed cause=" + + "${cause?.javaClass?.simpleName ?: "peer EOF"}: " + + "${cause?.message ?: "no detail"}", + ) + val shouldCloseSession = markFailed(cause) + if (shouldCloseSession) session.close() + } + }, + ) + + override fun send(data: ByteArray) { + synchronized(lock) { + throwTerminalFailureLocked() + if (closed) throw closedFailureLocked() + } + if (!tunnel.awaitPeerConnection(PEER_CONNECT_TIMEOUT_MILLIS)) { + throw IOException("CarPlay iAP tunnel peer did not connect") + } + synchronized(lock) { + throwTerminalFailureLocked() + if (closed) throw closedFailureLocked() + } + if (!sendCommand(data, EVENT_READY_TIMEOUT_MILLIS)) { + throw IOException("AirPlay event channel rejected an iAP message") + } + } + + override fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? { + require(maxBytes > 0) { "maxBytes must be positive" } + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + + val deadlineNanos = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + synchronized(lock) { + while (true) { + takePendingLocked(maxBytes)?.let { return it } + throwTerminalFailureLocked() + if (closed) return EMPTY + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return null + try { + lock.wait( + remainingNanos / NANOS_PER_MILLISECOND, + (remainingNanos % NANOS_PER_MILLISECOND).toInt(), + ) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return null + } + } + } + } + + override fun close() { + synchronized(lock) { + if (closed) return + closed = true + closeLocation = closeCaller() + lock.notifyAll() + } + tunnel.close() + } + + private fun offer(bytes: ByteArray) { + if (bytes.isEmpty()) return + synchronized(lock) { + if (closed || terminalFailure != null) return + if (pendingBytes + bytes.size > MAX_PENDING_BYTES) { + terminalFailure = IOException("AirPlay iAP tunnel pending-data limit exceeded") + lock.notifyAll() + tunnel.close() + return + } + pending.addLast(bytes.copyOf()) + pendingBytes += bytes.size + lock.notifyAll() + } + } + + private fun markFailed(cause: Throwable?): Boolean { + synchronized(lock) { + if (closed || terminalFailure != null) return false + terminalFailure = cause ?: IOException("AirPlay iAP tunnel terminated") + lock.notifyAll() + return true + } + } + + private fun throwTerminalFailureLocked() { + val failure = terminalFailure ?: return + throw IOException( + "AirPlay iAP tunnel failed: " + + "${failure.javaClass.simpleName}: ${failure.message ?: "unknown error"}", + failure, + ) + } + + private fun closedFailureLocked(): IOException = IOException( + buildString { + append("AirPlay iAP tunnel is closed") + closeLocation?.let { append("; requested by $it") } + }, + ) + + private fun closeCaller(): String = + Thread.currentThread().stackTrace + .drop(2) + .take(8) + .joinToString(" <- ") { frame -> + "${frame.className.substringAfterLast('.')}.${frame.methodName}:${frame.lineNumber}" + } + + private fun takePendingLocked(maxBytes: Int): ByteArray? { + val chunk = pending.pollFirst() ?: return null + pendingBytes -= chunk.size + if (chunk.size <= maxBytes) return chunk + + val head = chunk.copyOf(maxBytes) + val tail = chunk.copyOfRange(maxBytes, chunk.size) + pending.addFirst(tail) + pendingBytes += tail.size + return head + } + + private companion object { + const val MAX_PENDING_BYTES = 1_048_576 + const val PEER_CONNECT_TIMEOUT_MILLIS = 15_000L + const val EVENT_READY_TIMEOUT_MILLIS = 10_000L + const val NANOS_PER_MILLISECOND = 1_000_000L + val EMPTY = ByteArray(0) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayIdentity.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayIdentity.kt new file mode 100644 index 0000000..fde0015 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayIdentity.kt @@ -0,0 +1,19 @@ +package com.shilapi.xcertplay.airplay + +import java.util.UUID + +/** Long-term Ed25519 identity of the CarPlay accessory, persisted by the caller. */ +data class AirPlayIdentity( + val privateKey: ByteArray, + val publicKey: ByteArray, + val pairingId: String, +) { + val publicKeyHex: String get() = publicKey.toHexString() + + companion object { + fun generate(): AirPlayIdentity { + val keyPair = AirPlayCrypto.ed25519Generate() + return AirPlayIdentity(keyPair.privateKey, keyPair.publicKey, UUID.randomUUID().toString()) + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt new file mode 100644 index 0000000..ba22b5a --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlist.kt @@ -0,0 +1,199 @@ +package com.shilapi.xcertplay.airplay + +/** + * Builds the /info response the phone reads before it requests media streams. + * + * The declaration is complete on purpose: it must carry the display, audio formats/latencies, + * CarPlay resource modes, and the HID input devices, otherwise the phone aborts the session. + */ +object AirPlayInfoPlist { + const val MAIN_UUID = "b7e6c5a0-1111-4000-8000-000000000001" + const val ALT_UUID = "b7e6c5a0-2222-4000-8000-000000000002" + + private const val STREAM_TYPE_MAIN_SCREEN = 110 + private const val STREAM_TYPE_ALT_SCREEN = 111 + private const val DISPLAY_FEATURE_KNOBS = 0x02 + private const val DISPLAY_FEATURE_HIGH_FIDELITY_TOUCH = 0x08 + private const val CARPLAY_FEATURES = 0x615653aee2L + private const val CARPLAY_AUDIO_FEATURES = 0x10004540a00L + private val CARPLAY_FEATURES_NO_AUDIO = CARPLAY_FEATURES and CARPLAY_AUDIO_FEATURES.inv() + + private const val RESOURCE_SCREEN = 1 + private const val RESOURCE_AUDIO = 2 + private const val TRANSFER_TAKE = 1 + private const val PRIORITY_NICE_TO_HAVE = 100 + private const val CONSTRAINT_ANYTIME = 100 + + fun build(config: AirPlayConfig): Map { + val displays = arrayListOf( + displayEntry(config.main, STREAM_TYPE_MAIN_SCREEN, MAIN_UUID), + ) + config.cluster?.let { displays.add(displayEntry(it, STREAM_TYPE_ALT_SCREEN, ALT_UUID)) } + + val info = linkedMapOf( + "sourceVersion" to config.sourceVersion, + "features" to if (config.disableAudioOutput) CARPLAY_FEATURES_NO_AUDIO else CARPLAY_FEATURES, + "statusFlags" to 4L, + "model" to config.model, + "manufacturer" to config.manufacturer, + "deviceID" to config.deviceId, + "bluetoothIDs" to listOf(config.btMac), + "name" to config.deviceName, + "rightHandDrive" to config.rightHandDrive, + "keepAliveLowPower" to false, + "keepAliveSendStatsAsBody" to false, + "modes" to modes(), + ) + if (!config.disableAudioOutput) { + info["audioLatencies"] = audioLatencies() + info["audioFormats"] = audioFormats(config.entertainmentSampleRate, config.microphone) + } + info["extendedFeatures"] = listOf("vocoderInfo", "enhancedRequestCarUI") + info["displays"] = displays + info["hidDevices"] = listOf( + AirPlayHid.touchHidDevice(config.main.widthPixels, config.main.heightPixels, MAIN_UUID), + AirPlayHid.knobHidDevice(MAIN_UUID), + AirPlayHid.mediaHidDevice(MAIN_UUID), + AirPlayHid.telephonyHidDevice(MAIN_UUID), + ) + if (config.icons.isNotEmpty()) { + info["oemIconVisible"] = true + info["oemIconLabel"] = config.oemLabel + info["oemIcons"] = config.icons.map { icon -> + linkedMapOf( + "imageData" to icon.data, + "widthPixels" to icon.widthPixels, + "heightPixels" to icon.heightPixels, + "prerendered" to true, + ) + } + } + if (config.hevc) info["hevcInfo"] = emptyMap() + return info + } + + private fun resource(resourceId: Int): Map = linkedMapOf( + "resourceID" to resourceId, + "transferType" to TRANSFER_TAKE, + "transferPriority" to PRIORITY_NICE_TO_HAVE, + "takeConstraint" to CONSTRAINT_ANYTIME, + "borrowConstraint" to CONSTRAINT_ANYTIME, + "unborrowConstraint" to CONSTRAINT_ANYTIME, + ) + + private fun modes(): Map = linkedMapOf( + "resources" to listOf(resource(RESOURCE_SCREEN), resource(RESOURCE_AUDIO)), + "appStates" to listOf( + linkedMapOf("appStateID" to 2, "state" to false), + linkedMapOf("appStateID" to 1, "speechMode" to -1), + linkedMapOf("appStateID" to 3, "state" to false), + ), + ) + + private fun audioLatencies(): List> { + fun base(type: Int, audioType: String? = null): Map { + val entry = linkedMapOf( + "type" to type, + "inputLatencyMicros" to 0L, + "outputLatencyMicros" to 0L, + ) + if (audioType != null) entry["audioType"] = audioType + return entry + } + return listOf( + base(100), base(100, "default"), base(100, "media"), base(100, "telephony"), + base(100, "speechRecognition"), base(100, "alert"), base(101), base(101, "default"), + base(102, "default"), + ) + } + + private fun audioFormats( + entertainmentRate: Int, + microphone: Boolean, + ): List> { + fun format(type: Int, audioType: String, outputFormats: Int, inputFormats: Int? = null): Map { + val entry = linkedMapOf( + "type" to type, + "audioType" to audioType, + "audioOutputFormats" to outputFormats, + ) + if (inputFormats != null) entry["audioInputFormats"] = inputFormats + return entry + } + + val is48 = entertainmentRate == 48000 + val pcmVoice = 0x3fc + val pcm = pcmVoice or (if (is48) 0xc000 else 0xc00) + val pcmMono = 0x154 or (if (is48) 0x4000 else 0x400) + val opus = 0x70000000 + val aacLc = if (is48) 0x800000 else 0x400000 + val pcmInput = if (microphone) pcmMono else null + val wirelessInput = if (microphone) pcmMono or opus else null + + return listOf( + format(100, "compatibility", pcm, pcmInput), + format(101, "compatibility", pcm), + format(100, "default", pcm or opus, wirelessInput), + format(100, "alert", pcm or opus), + format(100, "media", pcm), + format(100, "telephony", pcmMono or opus, wirelessInput), + format(100, "speechRecognition", pcmMono or opus, wirelessInput), + format(101, "default", pcm or opus), + format(102, "media", aacLc), + ) + } + + private fun displayEntry(display: AirPlayDisplayConfig, type: Int, uuid: String): Map { + val widthPhysical = AirPlayDisplaySettings.sanitizeReportedPhysicalMm( + display.widthPhysicalMm ?: AirPlayDisplaySettings.DEFAULT_WIDTH_PHYSICAL_MM, + ) + val heightPhysical = AirPlayDisplaySettings.sanitizeReportedPhysicalMm( + display.heightPhysicalMm + ?: Math.round( + widthPhysical * display.heightPixels.toDouble() / display.widthPixels, + ).toInt(), + ) + val fps = AirPlayDisplaySettings.sanitizeFps(display.fps) + + val entry = linkedMapOf( + "uuid" to uuid, + "type" to type, + "maxFPS" to fps, + "widthPixels" to display.widthPixels, + "heightPixels" to display.heightPixels, + "widthPhysical" to widthPhysical, + "heightPhysical" to heightPhysical, + "features" to (DISPLAY_FEATURE_HIGH_FIDELITY_TOUCH or DISPLAY_FEATURE_KNOBS), + "primaryInputDevice" to display.primaryInputDevice, + ) + + entry["viewAreas"] = listOf(areaDict(display)) + entry["initialViewArea"] = 0 + if (display.initialUrl != null) entry["initialURL"] = display.initialUrl + return entry + } + + private fun areaDict(display: AirPlayDisplayConfig): Map { + // The session SETUP response enables "viewAreas", so /info must always describe one. + // A display without custom insets uses the full panel for both the view and safe areas. + val view = display.viewArea ?: AirPlayInsets() + val width = display.widthPixels + val height = display.heightPixels + val result = linkedMapOf( + "widthPixels" to (width - view.left - view.right), + "heightPixels" to (height - view.top - view.bottom), + "originXPixels" to view.left, + "originYPixels" to view.top, + ) + val safe = display.safeArea ?: AirPlayInsets() + val safeArea = linkedMapOf( + "widthPixels" to (width - safe.left - safe.right), + "heightPixels" to (height - safe.top - safe.bottom), + "originXPixels" to safe.left, + "originYPixels" to safe.top, + "drawUIOutsideSafeArea" to (display.safeAreaDrawOutside ?: true), + ) + result["safeArea"] = safeArea + return result + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayPeerDiagnostics.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayPeerDiagnostics.kt new file mode 100644 index 0000000..6b2e99e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlayPeerDiagnostics.kt @@ -0,0 +1,13 @@ +package com.shilapi.xcertplay.airplay + +/** Only product/version tokens, never the phone's name, addresses or pairing identifiers. */ +object AirPlayPeerDiagnostics { + private val modelToken = Regex("(?:iPhone|iPad|iPod)[0-9]{1,3},[0-9]{1,3}") + private val versionToken = Regex("[0-9][0-9A-Za-z._-]{0,39}") + + fun summary(model: String, osVersion: String, sourceVersion: String): String { + fun version(value: String) = value.takeIf(versionToken::matches) ?: "not_reported" + return "Phone display peer model=${model.takeIf(modelToken::matches) ?: "not_reported"} " + + "ios=${version(osVersion)} airplay=${version(sourceVersion)}" + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlaySession.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlaySession.kt new file mode 100644 index 0000000..c5e933e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AirPlaySession.kt @@ -0,0 +1,736 @@ +package com.shilapi.xcertplay.airplay + +import android.util.Log +import com.shilapi.xcertplay.mfi.MfiAuthenticator +import com.shilapi.xcertplay.transport.BlockingDuplexByteStream +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.Closeable +import java.math.BigInteger +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean + +data class AirPlayDeviceInfo( + val name: String, + val deviceId: String, + val wifiMac: String, + val model: String, +) + +/** Session lifecycle and command callbacks for the driver/UI layer. */ +interface AirPlaySessionListener { + fun onSessionActive(session: AirPlaySession) {} + fun onSessionEnded(session: AirPlaySession) {} + fun onVideoFrameRendered(session: AirPlaySession) {} + fun onTransportError(message: String) {} + fun onDeviceInfo(session: AirPlaySession, info: AirPlayDeviceInfo) {} + fun onHostUiRequested(session: AirPlaySession) {} + fun onCommand(session: AirPlaySession, type: String, params: Map) {} + fun onDebugLog(message: String) {} +} + +/** Stream transport seam; media decode/render is supplied by a later layer. */ +interface AirPlayMediaHandler { + fun onScreen(session: AirPlaySession, type: Int, stream: Map): Int? = null + fun onAudio(session: AirPlaySession, type: Int, stream: Map): Map? = null + fun onDataStream(session: AirPlaySession, stream: Map): Map? = null + fun onFeedback(session: AirPlaySession): Map? = null + fun onTeardown(session: AirPlaySession, type: Int) {} + fun onSessionClosed(session: AirPlaySession) {} + fun setIapTunnelHandler(handler: ((BlockingDuplexByteStream) -> Boolean)?) {} + fun onSetupResponseSent(session: AirPlaySession) {} +} + +/** + * One CarPlay AirPlay control connection. + * + * It owns the RTSP framing, pairing/auth/info routing, the encrypted event channel used for HID + * input, stream SETUP/TEARDOWN routing, the NTP timing exchange, and the keep-alive socket. + */ +class AirPlaySession( + private val socket: Socket, + private val config: AirPlayConfig, + private val identity: AirPlayIdentity, + private val pairings: PairingStore, + private val mfi: MfiAuthenticator?, + private val listener: AirPlaySessionListener, + private val media: AirPlayMediaHandler, +) : Closeable { + internal val pairSetup = PairSetup(identity, pairings) + internal val pairVerify = PairVerify(identity, pairings) + internal var cipher: ControlCipher? = null + internal var encBuf = ByteArray(0) + internal var deviceBtMac = "" + internal val activeStreams = linkedSetOf() + + private val closed = AtomicBoolean(false) + private val notified = AtomicBoolean(false) + private var eventServer: ServerSocket? = null + private var eventSocket: Socket? = null + private var eventCipher: ControlCipher? = null + private var eventCseq = 0 + private var pendingNightMode: Boolean? = null + private val firstTouchSendLogged = AtomicBoolean(false) + private val touchSendFailureLogged = AtomicBoolean(false) + private val ntp = NtpClock() + private var keepAliveSocket: DatagramSocket? = null + private var keepAliveThread: Thread? = null + private val eventWriteLock = Any() + private val eventThreads = CopyOnWriteArrayList() + + val host: String = socket.inetAddress?.hostAddress ?: "" + val localAddress: InetAddress? = socket.localAddress + private val peerAddress: InetAddress? = socket.inetAddress + internal val remoteAddress: InetAddress? + get() = (socket.remoteSocketAddress as? InetSocketAddress)?.address + val controllerId: String? get() = pairVerify.verifiedControllerId + val sharedSecret: ByteArray? get() = pairVerify.shared?.copyOf() + + fun syncedNtp(): BigInteger = ntp.syncedNtp() + + internal fun logDebug(message: String) = debugLog(message) + + internal fun videoFrameRendered() { + if (!closed.get()) listener.onVideoFrameRendered(this) + } + + internal fun logTrace(message: String) = trace(message) + + fun start() { + Thread(::runControl, "airplay-control").apply { + isDaemon = true + start() + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + safeClose(socket) + try { + media.onSessionClosed(this) + } catch (error: Exception) { + Log.w(TAG, "airplay media stream teardown failed", error) + } + teardown() + if (notified.compareAndSet(false, true)) listener.onSessionEnded(this) + } + + fun sendCommand(command: Map): Boolean = synchronized(eventWriteLock) { + sendCommandLocked(command) + } + + private fun sendCommandLocked(command: Map): Boolean { + val socket = eventSocket ?: return false + val cipher = eventCipher ?: return false + eventCseq++ + val body = BplistCodec.encode(command) + val head = "POST /command RTSP/1.0\r\n" + + "Content-Type: $PLIST_CONTENT_TYPE\r\n" + + "Content-Length: ${body.size}\r\n" + + "CSeq: $eventCseq\r\n\r\n" + trace("airplay event tx headers=$head bodyHex=${body.toHex()}") + return try { + val bytes = cipher.encrypt(head.toByteArray(Charsets.US_ASCII) + body) + val output = socket.getOutputStream() + output.write(bytes) + output.flush() + true + } catch (error: Exception) { + Log.w(TAG, "airplay event command failed type=${command["type"]}", error) + close() + false + } + } + + fun sendTouch(contacts: List): Boolean { + val scaled = contacts.map { + it.copy(x = it.x * config.main.widthPixels, y = it.y * config.main.heightPixels) + } + val report = AirPlayHid.touchReport(scaled) + val sent = sendHidReport(AirPlayHid.TOUCH_HID_UID, report) + if (sent && firstTouchSendLogged.compareAndSet(false, true)) { + val first = scaled.firstOrNull() + Log.i( + TAG, + "airplay touch report sent contacts=${scaled.size} first=" + + "(${first?.x},${first?.y},down=${first?.down}) report=${report.toHexString()}", + ) + } else if (!sent && touchSendFailureLogged.compareAndSet(false, true)) { + Log.w(TAG, "airplay touch dropped: event channel is not ready") + } + return sent + } + + fun sendKnob(state: AirPlayKnobState, momentary: Boolean = true) { + sendHidReport(AirPlayHid.KNOB_HID_UID, AirPlayHid.knobReport(state)) + if (momentary) sendHidReport(AirPlayHid.KNOB_HID_UID, AirPlayHid.knobReport(AirPlayKnobState())) + } + + fun sendKnobSelect(down: Boolean) = + sendHidReport(AirPlayHid.KNOB_HID_UID, AirPlayHid.knobReport(AirPlayKnobState(select = down))) + + fun sendMedia(index: Int) { + sendHidReport(AirPlayHid.MEDIA_HID_UID, AirPlayHid.mediaReport(index)) + sendHidReport(AirPlayHid.MEDIA_HID_UID, AirPlayHid.mediaReport(0)) + } + + fun sendTelephony(index: Int) { + sendHidReport(AirPlayHid.TELEPHONY_HID_UID, AirPlayHid.telephonyReport(index)) + sendHidReport(AirPlayHid.TELEPHONY_HID_UID, AirPlayHid.telephonyReport(0)) + } + + fun invokeSiri() { + sendCommand(linkedMapOf("type" to "requestSiri", "params" to linkedMapOf("siriAction" to 2))) + sendCommand(linkedMapOf("type" to "requestSiri", "params" to linkedMapOf("siriAction" to 3))) + } + + fun sendIapMessage(data: ByteArray, timeoutMillis: Long = 0L): Boolean { + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + val command = linkedMapOf( + "type" to "iAPSendMessage", + "params" to linkedMapOf("data" to data), + ) + if (timeoutMillis == 0L) return sendCommand(command) + + val deadlineNanos = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + while (true) { + val sent = synchronized(eventWriteLock) { + if (eventSocket == null || eventCipher == null) { + null + } else { + sendCommandLocked(command) + } + } + if (sent != null) return sent + if (closed.get()) return false + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return false + val sleepMillis = minOf( + EVENT_READY_POLL_MILLIS, + (remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND, + ).coerceAtLeast(1) + try { + Thread.sleep(sleepMillis) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + } + + fun setNightMode(night: Boolean): Boolean = synchronized(eventWriteLock) { + pendingNightMode = night + sendPendingNightModeLocked() + } + + private fun sendPendingNightModeLocked(): Boolean { + val night = pendingNightMode ?: return true + val sent = sendCommandLocked( + linkedMapOf("type" to "setNightMode", "params" to linkedMapOf("nightMode" to night)), + ) + if (sent) pendingNightMode = null + return sent + } + + private fun sendHidReport(uid: Int, report: ByteArray): Boolean = + sendCommand( + linkedMapOf( + "type" to "hidSendReport", + "uuid" to uid.toString(16), + "hidReport" to report, + ), + ) + + private fun runControl() { + val input = BufferedInputStream(socket.getInputStream()) + val output = BufferedOutputStream(socket.getOutputStream()) + var accumulated = ByteArray(0) + val buffer = ByteArray(READ_CHUNK_BYTES) + var closeReason = "session closed" + try { + while (!closed.get()) { + val count = input.read(buffer) + if (count < 0) { + closeReason = "peer EOF" + break + } + var plaintext = buffer.copyOf(count) + val activeCipher = cipher + if (activeCipher != null) { + encBuf += plaintext + val decrypted = try { + activeCipher.decrypt(encBuf) + } catch (error: Exception) { + closeReason = "control decrypt failed: ${error.message ?: error.javaClass.simpleName}" + Log.e(TAG, "airplay $closeReason encrypted=${encBuf.size}", error) + break + } + encBuf = decrypted.rest + plaintext = decrypted.data + } + accumulated += plaintext + val parsed = RtspMessage.parseMessages(accumulated) + accumulated = parsed.rest + for (request in parsed.messages) { + val cseq = request.headers["cseq"] ?: "-" + val path = request.path.lowercase() + val showInDebugOverlay = + !path.endsWith("/feedback") && + !(request.method == "POST" && path.endsWith("/command")) + debugLog( + "airplay rx ${request.method} ${request.path} cseq=$cseq body=${request.body.size}", + showInDebugOverlay, + ) + trace( + "airplay control rx headers=${request.headers} " + + "bodyHex=${request.body.toHex()}", + ) + val response = try { + handle(request) + } catch (error: Exception) { + Log.e( + TAG, + "airplay handler failed ${request.method} ${request.path} cseq=$cseq", + error, + ) + RtspMessage.Response(status = 500) + } + debugLog( + "airplay tx status=${response.status ?: 200} cseq=$cseq body=${response.body.size}", + showInDebugOverlay, + ) + val wire = RtspMessage.buildResponse(request, response) + trace("airplay control tx wireHex=${wire.toHex()}") + output.write(cipher?.encrypt(wire) ?: wire) + if (cipher == null && pairVerify.controlKeys != null) { + val keys = pairVerify.controlKeys!! + cipher = ControlCipher(keys.readKey, keys.writeKey) + debugLog("airplay control encryption enabled") + } + } + output.flush() + notifySetupResponseSent() + } + } catch (error: Exception) { + closeReason = "control I/O failed: ${error.message ?: error.javaClass.simpleName}" + if (!closed.get()) Log.e(TAG, "airplay $closeReason", error) + } finally { + debugLog("airplay control closing reason=$closeReason activeStreams=$activeStreams") + close() + } + } + + private fun handle(request: RtspMessage.Request): RtspMessage.Response { + when (request.method) { + "SETUP" -> return handleSetup(request) + "RECORD" -> { + listener.onSessionActive(this) + return RtspMessage.Response(status = 200) + } + "TEARDOWN" -> return handleTeardown(request) + } + + val path = request.path.lowercase() + return when { + path.endsWith("/pair-setup") -> RtspMessage.Response( + headers = mapOf("Content-Type" to PAIRING_CONTENT_TYPE), + body = pairSetup.handle(request.body), + ) + path.endsWith("/pair-verify") -> RtspMessage.Response( + headers = mapOf("Content-Type" to PAIRING_CONTENT_TYPE), + body = pairVerify.handle(request.body), + ) + path.endsWith("/auth-setup") -> { + val body = mfi?.let { MfiSapAuthSetup.handle(request.body, it) } + if (body == null) RtspMessage.Response(status = 400) + else RtspMessage.Response(headers = mapOf("Content-Type" to OCTET_CONTENT_TYPE), body = body) + } + path.endsWith("/info") -> { + val info = AirPlayInfoPlist.build(config) + if (request.body.isNotEmpty()) { + val requestInfo = try { + BplistCodec.decode(request.body).toString() + } catch (_: Exception) { + "" + } + debugLog("airplay /info request=$requestInfo") + } + Log.i( + TAG, + "airplay /info features=${info["features"]} " + + "audioFormats=${(info["audioFormats"] as? List<*>)?.size ?: 0} " + + "audioLatencies=${(info["audioLatencies"] as? List<*>)?.size ?: 0}", + ) + debugLog("airplay /info displays=${info["displays"]}") + RtspMessage.Response( + headers = mapOf("Content-Type" to PLIST_CONTENT_TYPE), + body = BplistCodec.encode(info), + ) + } + request.method == "POST" && path.endsWith("/command") -> handleCommand(request) + request.method == "POST" && path.endsWith("/feedback") -> { + val body = media.onFeedback(this) + if (body == null) { + RtspMessage.Response(status = 200) + } else { + RtspMessage.Response( + headers = mapOf("Content-Type" to PLIST_CONTENT_TYPE), + body = BplistCodec.encode(body), + ) + } + } + else -> RtspMessage.Response(status = 200) + } + } + + private fun notifySetupResponseSent() { + try { + media.onSetupResponseSent(this) + } catch (error: Exception) { + Log.w(TAG, "airplay SETUP response callback failed", error) + } + } + + private fun debugLog(message: String, uiVisible: Boolean = true) { + Log.i(TAG, message) + if (!uiVisible) return + try { + listener.onDebugLog(message) + } catch (error: Exception) { + Log.w(TAG, "debug log callback failed", error) + } + } + + private fun trace(message: String) { + try { + listener.onDebugLog("TRACE $message") + } catch (error: Exception) { + Log.w(TAG, "trace log callback failed", error) + } + } + + private fun handleSetup(request: RtspMessage.Request): RtspMessage.Response { + val dict = try { + asMap(BplistCodec.decode(request.body)) ?: return RtspMessage.Response(status = 400) + } catch (error: Exception) { + Log.e(TAG, "airplay SETUP plist decode failed body=${request.body.size}", error) + return RtspMessage.Response(status = 400) + } + debugLog("airplay SETUP keys=${dict.keys.sorted()}") + val streams = dict["streams"] as? List<*> + if (streams != null) { + val responseStreams = handleStreams(streams) + debugLog("airplay SETUP response streams=$responseStreams") + val body = BplistCodec.encode(linkedMapOf("streams" to responseStreams)) + trace("airplay SETUP response bplistHex=${body.toHex()}") + return RtspMessage.Response(headers = mapOf("Content-Type" to PLIST_CONTENT_TYPE), body = body) + } + + val name = string(dict["name"]) + val deviceId = string(dict["deviceID"]) + val wifiMac = string(dict["macAddress"]).lowercase() + val model = string(dict["model"]) + debugLog(AirPlayPeerDiagnostics.summary(model, string(dict["osVersion"]), string(dict["sourceVersion"]))) + if (deviceId.isNotEmpty()) deviceBtMac = deviceId + if (name.isNotEmpty() || deviceId.isNotEmpty() || wifiMac.isNotEmpty()) { + listener.onDeviceInfo(this, AirPlayDeviceInfo(name, deviceId, wifiMac, model)) + } + + val peerTimingPort = long(dict["timingPort"])?.toInt() ?: 0 + val response = linkedMapOf( + "timingPort" to openTiming(peerTimingPort), + "eventPort" to openEvent(), + ) + if (dict["keepAliveLowPower"] == true || dict["keepAliveLowPower"] == 1L) { + response["keepAlivePort"] = openKeepAlive() + } + val features = mutableListOf() + if (config.hevc) features.add("hevc") + features.add("iAPChannel") + features.add("viewAreas") + if (config.cluster != null) features.add("altScreen") + response["enabledFeatures"] = features + return RtspMessage.Response( + headers = mapOf("Content-Type" to PLIST_CONTENT_TYPE), + body = BplistCodec.encode(response), + ) + } + + private fun handleStreams(streams: List<*>): List { + val result = arrayListOf() + for (entry in streams) { + val stream = asMap(entry) ?: continue + val type = long(stream["type"])?.toInt() ?: continue + debugLog("airplay SETUP stream type=$type payload=$stream") + when (type) { + STREAM_TYPE_MAIN_SCREEN, STREAM_TYPE_ALT_SCREEN -> { + val port = media.onScreen(this, type, stream) + debugLog("airplay screen stream type=$type dataPort=${port ?: "rejected"}") + if (port != null) { + activeStreams.add(type) + result.add(linkedMapOf("type" to type, "dataPort" to port)) + } + } + STREAM_TYPE_MAIN_AUDIO, STREAM_TYPE_ALT_AUDIO, STREAM_TYPE_MAIN_HIGH_AUDIO -> { + val streamResponse = media.onAudio(this, type, stream) + debugLog( + "airplay audio stream type=$type accepted=${streamResponse != null} " + + "dataPort=${streamResponse?.get("dataPort") ?: "none"} " + + "controlPort=${streamResponse?.get("controlPort") ?: "none"}", + ) + if (streamResponse != null) { + activeStreams.add(type) + result.add(streamResponse) + } + } + STREAM_TYPE_DATA -> { + val streamResponse = media.onDataStream(this, stream) + debugLog( + "airplay data stream type=$type accepted=${streamResponse != null} " + + "dataPort=${streamResponse?.get("dataPort") ?: "none"}", + ) + if (streamResponse != null) { + activeStreams.add(type) + result.add(streamResponse) + } + } + else -> debugLog("airplay unsupported stream type=$type") + } + } + return result + } + + private fun handleCommand(request: RtspMessage.Request): RtspMessage.Response { + val body = try { + asMap(BplistCodec.decode(request.body)) ?: emptyMap() + } catch (_: Exception) { + emptyMap() + } + val type = string(body["type"]) + val params = asMap(body["params"]) ?: emptyMap() + debugLog("airplay command type=$type keys=${params.keys.sorted()}") + if (type == "requestUI") listener.onHostUiRequested(this) + listener.onCommand(this, type, params) + return RtspMessage.Response(status = 200) + } + + private fun handleTeardown(request: RtspMessage.Request): RtspMessage.Response { + var decodedBody: Any? = null + val types = try { + val decoded = BplistCodec.decode(request.body) + decodedBody = decoded + val dict = asMap(decoded) + (dict?.get("streams") as? List<*>) + ?.mapNotNull { entry -> long(asMap(entry)?.get("type"))?.toInt() } + ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + debugLog( + "airplay TEARDOWN types=$types activeBefore=$activeStreams " + + "body=${request.body.size} bytes payload=$decodedBody", + ) + trace("airplay TEARDOWN raw${request.body.size}Hex=${request.body.toHex()}") + + if (types.isEmpty()) { + activeStreams.toList().forEach { media.onTeardown(this, it) } + activeStreams.clear() + } else { + types.forEach { type -> if (activeStreams.remove(type)) media.onTeardown(this, type) } + } + return RtspMessage.Response(status = 200) + } + + private fun openTiming(peerPort: Int): Int { + val port = ntp.listen() + if (peerPort > 0) peerAddress?.let { ntp.start(it, peerPort) } + return port + } + + private fun openKeepAlive(): Int { + val socket = DatagramSocket(null) + socket.reuseAddress = true + socket.bind(InetSocketAddress(InetAddress.getByName("::"), 0)) + keepAliveSocket = socket + keepAliveThread = Thread({ runKeepAlive(socket) }, "airplay-keepalive").apply { + isDaemon = true + start() + } + return socket.localPort + } + + private fun runKeepAlive(socket: DatagramSocket) { + val buffer = ByteArray(512) + while (!closed.get()) { + try { + socket.receive(DatagramPacket(buffer, buffer.size)) + } catch (_: Exception) { + if (closed.get()) return + } + } + } + + private fun openEvent(): Int { + val server = ServerSocket(0, 50, InetAddress.getByName("::")) + eventServer = server + spawnEvent("airplay-event-accept") { acceptEvent(server) } + return server.localPort + } + + private fun teardown() { + ntp.close() + safeClose(keepAliveSocket) + keepAliveSocket = null + keepAliveThread?.interrupt() + keepAliveThread = null + safeClose(eventServer) + eventServer = null + safeClose(eventSocket) + eventSocket = null + eventCipher = null + eventThreads.forEach { it.interrupt() } + eventThreads.clear() + } + + private fun acceptEvent(server: ServerSocket) { + try { + val socket = server.accept() + socket.setSoLinger(true, 0) + debugLog("airplay event connection accepted from ${socket.remoteSocketAddress}") + eventSocket = socket + val shared = pairVerify.shared + if (shared == null) { + Log.e(TAG, "airplay event rejected: pair-verify shared secret unavailable") + safeClose(socket) + close() + return + } + val writeKey = AirPlayCrypto.hkdfSha512( + shared, + "Events-Salt".asciiBytes(), + "Events-Write-Encryption-Key".asciiBytes(), + 32, + ) + val readKey = AirPlayCrypto.hkdfSha512( + shared, + "Events-Salt".asciiBytes(), + "Events-Read-Encryption-Key".asciiBytes(), + 32, + ) + eventCipher = ControlCipher(readKey, writeKey) + synchronized(eventWriteLock) { + sendPendingNightModeLocked() + } + runEventRead(socket) + } catch (error: Exception) { + if (!closed.get()) { + Log.e(TAG, "airplay event accept failed", error) + close() + } + } + } + + private fun runEventRead(socket: Socket) { + try { + val input = BufferedInputStream(socket.getInputStream()) + val output = BufferedOutputStream(socket.getOutputStream()) + var encrypted = ByteArray(0) + var plaintext = ByteArray(0) + val buffer = ByteArray(READ_CHUNK_BYTES) + while (!closed.get()) { + val count = input.read(buffer) + if (count < 0) break + val cipher = eventCipher ?: break + encrypted += buffer.copyOf(count) + val decrypted = try { + cipher.decrypt(encrypted) + } catch (error: Exception) { + Log.e(TAG, "airplay event decrypt failed encrypted=${encrypted.size}", error) + break + } + encrypted = decrypted.rest + plaintext += decrypted.data + val parsed = RtspMessage.parseMessages(plaintext) + plaintext = parsed.rest + for (message in parsed.messages) { + if (message.method.startsWith("RTSP/") || message.method.startsWith("HTTP/")) continue + debugLog( + "airplay event rx ${message.method} ${message.path} cseq=${message.headers["cseq"] ?: "-"} body=${message.body.size}", + ) + val response = RtspMessage.buildResponse(message, RtspMessage.Response(status = 200)) + trace( + "airplay event rx headers=${message.headers} " + + "bodyHex=${message.body.toHex()}", + ) + trace("airplay event tx wireHex=${response.toHex()}") + synchronized(eventWriteLock) { + output.write(cipher.encrypt(response)) + output.flush() + } + } + } + } catch (error: Exception) { + if (!closed.get()) Log.e(TAG, "airplay event read failed", error) + } finally { + debugLog("airplay event connection closed") + if (eventSocket === socket) eventSocket = null + eventCipher = null + safeClose(socket) + if (!closed.get()) close() + } + } + + private fun spawnEvent(name: String, body: () -> Unit) { + val thread = Thread(body, name).apply { isDaemon = true } + eventThreads.add(thread) + thread.start() + } + + private companion object { + const val TAG = "xcertplay-usb" + const val PLIST_CONTENT_TYPE = "application/x-apple-binary-plist" + const val PAIRING_CONTENT_TYPE = "application/pairing+tlv8" + const val OCTET_CONTENT_TYPE = "application/octet-stream" + + const val STREAM_TYPE_MAIN_SCREEN = 110 + const val STREAM_TYPE_ALT_SCREEN = 111 + const val STREAM_TYPE_MAIN_AUDIO = 100 + const val STREAM_TYPE_ALT_AUDIO = 101 + const val STREAM_TYPE_MAIN_HIGH_AUDIO = 102 + const val STREAM_TYPE_DATA = 130 + + const val READ_CHUNK_BYTES = 16 * 1024 + const val EVENT_READY_POLL_MILLIS = 25L + const val NANOS_PER_MILLISECOND = 1_000_000L + } +} + +internal fun safeClose(closeable: Closeable?) { + try { + closeable?.close() + } catch (_: Exception) { + // Best-effort close. + } +} + +private fun ByteArray.toHex(): String = + joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) } + +private fun asMap(value: Any?): Map? { + val map = value as? Map<*, *> ?: return null + val result = LinkedHashMap(map.size) + for ((key, entry) in map) result[key.toString()] = entry + return result +} + +private fun string(value: Any?): String = value as? String ?: "" + +private fun long(value: Any?): Long? = (value as? Number)?.toLong() diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioPacketCapture.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioPacketCapture.kt new file mode 100644 index 0000000..832995a --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioPacketCapture.kt @@ -0,0 +1,136 @@ +package com.shilapi.xcertplay.airplay + +import android.util.Log +import java.io.BufferedOutputStream +import java.io.Closeable +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Diagnostic capture for the encrypted UDP datagrams and decrypted RTP packets of one audio + * stream. The caller must opt in by supplying a capture directory. + * + * File format: + * bytes 0..7: "XCAUDIO1" + * int32 version + * int32 stream type + * int64 wall-clock start time (milliseconds) + * repeated records: + * int32 wire length + * int32 decrypted RTP length, or -1 when authentication failed + * int32 RTP sample position, or -1 when unavailable + * int64 monotonic timestamp (nanoseconds) + * int32 UTF-8 error length + * wire bytes + * decrypted RTP bytes when length >= 0 + * UTF-8 error bytes + */ +class AudioPacketCapture( + directory: File, + private val streamType: Int, + private val maxPackets: Int = 512, + private val maxBytes: Long = 16L * 1024L * 1024L, +) : Closeable { + private val closed = AtomicBoolean(false) + private var packets = 0 + private var bytesWritten: Long = (MAGIC.size + HEADER_BYTES).toLong() + private val output: DataOutputStream? + + val file: File + + init { + val captureDirectory = if (directory.isDirectory || directory.mkdirs()) { + directory + } else { + null + } + file = if (captureDirectory == null) { + File(directory, "audio-capture-unavailable-type-$streamType.xcap") + } else { + File( + captureDirectory, + "audio-${System.currentTimeMillis()}-type-$streamType.xcap", + ) + } + output = if (captureDirectory == null) { + null + } else { + try { + DataOutputStream(BufferedOutputStream(FileOutputStream(file, false))).also { + it.write(MAGIC) + it.writeInt(VERSION) + it.writeInt(streamType) + it.writeLong(System.currentTimeMillis()) + it.flush() + Log.i(TAG, "audio capture started type=$streamType file=${file.absolutePath}") + } + } catch (error: Exception) { + Log.w(TAG, "audio capture could not open type=$streamType", error) + null + } + } + } + + fun record( + wire: ByteArray, + rtp: ByteArray?, + sample: Int?, + error: Throwable?, + ) { + if (output == null || closed.get()) return + synchronized(this) { + if (closed.get() || packets >= maxPackets) return + val errorBytes = error?.message?.toByteArray(Charsets.UTF_8) ?: ByteArray(0) + val recordBytes = 4L * 4 + 8 + wire.size + (rtp?.size ?: 0) + errorBytes.size + if (bytesWritten + recordBytes > maxBytes) { + close() + return + } + try { + output.writeInt(wire.size) + output.writeInt(rtp?.size ?: -1) + output.writeInt(sample ?: -1) + output.writeLong(System.nanoTime()) + output.writeInt(errorBytes.size) + output.write(wire) + if (rtp != null) output.write(rtp) + output.write(errorBytes) + packets += 1 + bytesWritten += recordBytes + if (packets % FLUSH_INTERVAL_PACKETS == 0) output.flush() + if (packets >= maxPackets) close() + } catch (failure: Exception) { + Log.w(TAG, "audio capture write failed type=$streamType", failure) + close() + } + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + try { + output?.flush() + output?.close() + } catch (error: Exception) { + Log.w(TAG, "audio capture close failed type=$streamType", error) + } finally { + if (output != null) { + Log.i( + TAG, + "audio capture stopped type=$streamType packets=$packets " + + "bytes=$bytesWritten file=${file.absolutePath}", + ) + } + } + } + + private companion object { + const val TAG = "xcertplay-usb" + const val VERSION = 1 + const val HEADER_BYTES = 16 + const val FLUSH_INTERVAL_PACKETS = 16 + val MAGIC = "XCAUDIO1".toByteArray(Charsets.US_ASCII) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt new file mode 100644 index 0000000..a81b3f2 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/AudioStream.kt @@ -0,0 +1,229 @@ +package com.shilapi.xcertplay.airplay + +import java.io.Closeable +import java.io.IOException +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +enum class AudioCodecKind { AAC_LC, OPUS, LPCM } + +data class AudioFormat( + val codec: AudioCodecKind, + val sampleRate: Int, + val channels: Int, + val payloadType: Int, + val audioType: String = "media", +) + +/** + * Binds the RTP data and RTCP control UDP ports for one CarPlay audio stream. + * + * Wire layout follows LIVI `livi_audio_stream`: one RTP packet per datagram, a 12-byte header, + * ciphertext, a 16-byte tag, then an 8-byte little-endian nonce. The header's last eight bytes + * (timestamp + SSRC) are the AEAD associated data. + */ +class AudioStream( + private val key: ByteArray, + private val streamType: Int = -1, +) : Closeable { + interface Listener { + fun onStarted(firstSample: Int) {} + fun onRtp(rtp: ByteArray, sample: Int) {} + fun onPacket( + wire: ByteArray, + rtp: ByteArray?, + sample: Int?, + error: Throwable?, + ) {} + } + + private val closed = AtomicBoolean(false) + private val receivedPackets = AtomicInteger() + private val decryptedPackets = AtomicInteger() + private val authenticationFailures = AtomicInteger() + private var dataSocket: DatagramSocket? = null + private var controlSocket: DatagramSocket? = null + private var dataThread: Thread? = null + private var controlThread: Thread? = null + private var started = false + + fun listen(listener: Listener): Pair { + val data = bindAnyPort() + val control = bindAnyPort() + dataSocket = data + controlSocket = control + dataThread = Thread({ runData(data, listener) }, "airplay-audio-rx").apply { + isDaemon = true + start() + } + controlThread = Thread({ runControl(control) }, "airplay-rtcp-rx").apply { + isDaemon = true + start() + } + return data.localPort to control.localPort + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + dataSocket?.close() + controlSocket?.close() + dataThread?.interrupt() + controlThread?.interrupt() + } + + private fun runData(socket: DatagramSocket, listener: Listener) { + val buffer = ByteArray(DATAGRAM_BYTES) + while (!closed.get()) { + val packet = DatagramPacket(buffer, buffer.size) + try { + socket.receive(packet) + } catch (_: Exception) { + if (closed.get()) return else continue + } + val wire = packet.data.copyOf(packet.length) + val packetNumber = receivedPackets.incrementAndGet() + if (wire.size < RTP_HEADER_LEN + TAIL_LEN) { + if (packetNumber == 1) { + android.util.Log.w( + TAG, + "audio stream type=$streamType short packet bytes=${wire.size}", + ) + } + listener.onPacket( + wire, + null, + null, + IOException("audio packet shorter than RTP header plus tail"), + ) + continue + } + + val aad = wire.copyOfRange(4, RTP_HEADER_LEN) + val sealedEnd = wire.size - NONCE_LEN + val sealed = wire.copyOfRange(RTP_HEADER_LEN, sealedEnd) + val shortNonce = wire.copyOfRange(sealedEnd, wire.size) + val nonce = ByteArray(12).also { shortNonce.copyInto(it, 4) } + val sample = readU32Be(wire, 4) + + val payload = try { + AirPlayCrypto.chachaOpen(key, nonce, sealed, aad) + } catch (error: Exception) { + val failureNumber = authenticationFailures.incrementAndGet() + if (failureNumber == 1) { + android.util.Log.w( + TAG, + "audio stream type=$streamType first decrypt failure " + + "wire=${wire.toHexString()}", + error, + ) + } + listener.onPacket(wire, null, sample, error) + continue + } + val rtp = wire.copyOf(RTP_HEADER_LEN) + payload + val decryptedNumber = decryptedPackets.incrementAndGet() + if (decryptedNumber <= FIRST_PACKET_LOG_COUNT) { + android.util.Log.i( + TAG, + "audio stream type=$streamType packet=$decryptedNumber sample=$sample " + + "wireBytes=${wire.size} payloadBytes=${payload.size} " + + "payloadHead=${payload.copyOf(minOf(payload.size, 16)).toHexString()}", + ) + } else if (decryptedNumber % PACKET_LOG_INTERVAL == 0) { + android.util.Log.i( + TAG, + "audio stream type=$streamType decrypted=$decryptedNumber " + + "authFailures=${authenticationFailures.get()}", + ) + } + listener.onPacket(wire, rtp, sample, null) + if (!started) { + started = true + listener.onStarted(sample) + } + listener.onRtp(rtp, sample) + } + } + + private fun runControl(socket: DatagramSocket) { + val buffer = ByteArray(DATAGRAM_BYTES) + while (!closed.get()) { + try { + socket.receive(DatagramPacket(buffer, buffer.size)) + } catch (_: Exception) { + if (closed.get()) return + } + } + } + + private fun bindAnyPort(): DatagramSocket { + val socket = DatagramSocket(null) + socket.reuseAddress = true + socket.bind(InetSocketAddress(InetAddress.getByName("::"), 0)) + return socket + } + + private fun readU32Be(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 24) or + ((source[offset + 1].toInt() and 0xff) shl 16) or + ((source[offset + 2].toInt() and 0xff) shl 8) or + (source[offset + 3].toInt() and 0xff) + + private fun ByteArray.toHexString(): String = + joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private companion object { + const val TAG = "xcertplay-usb" + const val DATAGRAM_BYTES = 4_096 + const val RTP_HEADER_LEN = 12 + const val TAG_LEN = 16 + const val NONCE_LEN = 8 + const val TAIL_LEN = TAG_LEN + NONCE_LEN + const val FIRST_PACKET_LOG_COUNT = 3 + const val PACKET_LOG_INTERVAL = 100 + } +} + +/** Maps the phone's negotiated audioFormat bits to a decode/render format. */ +object AudioStreamCodec { + fun fromFormatBits(bits: Long, payloadType: Int, audioType: String = "media"): AudioFormat { + val isAacLc = (bits and (AAC_LC_44K_STEREO or AAC_LC_48K_STEREO)) != 0L + val isOpus = (bits and OPUS_MONO) != 0L + val pcm = PCM_FORMAT[bits] + return when { + isOpus -> AudioFormat(AudioCodecKind.OPUS, 48_000, 1, payloadType, audioType) + isAacLc -> AudioFormat( + AudioCodecKind.AAC_LC, + if ((bits and AAC_LC_48K_STEREO) != 0L) 48_000 else 44_100, + 2, + payloadType, + audioType, + ) + pcm != null -> AudioFormat(AudioCodecKind.LPCM, pcm.first, pcm.second, payloadType, audioType) + else -> AudioFormat(AudioCodecKind.LPCM, 44_100, 2, payloadType, audioType) + } + } + + private const val AAC_LC_44K_STEREO = 0x400000L + private const val AAC_LC_48K_STEREO = 0x800000L + private const val OPUS_MONO = 0x10000000L or 0x20000000L or 0x40000000L + + private val PCM_FORMAT = mapOf( + 0x4L to (8_000 to 1), + 0x8L to (8_000 to 2), + 0x10L to (16_000 to 1), + 0x20L to (16_000 to 2), + 0x40L to (24_000 to 1), + 0x80L to (24_000 to 2), + 0x100L to (32_000 to 1), + 0x200L to (32_000 to 2), + 0x400L to (44_100 to 1), + 0x800L to (44_100 to 2), + 0x4000L to (48_000 to 1), + 0x8000L to (48_000 to 2), + ) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/BplistCodec.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/BplistCodec.kt new file mode 100644 index 0000000..786faf8 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/BplistCodec.kt @@ -0,0 +1,274 @@ +package com.shilapi.xcertplay.airplay + +/** + * Minimal Apple binary property list (bplist00) codec for the CarPlay control channel. + * + * It covers the subset the stack emits and reads: dictionaries, arrays, ASCII and UTF-16 + * strings, raw data, non-negative integers, 32/64-bit reals, and booleans. Dictionary keys are + * serialized in insertion order, matching the reference implementation this stack targets. + */ +object BplistCodec { + private val magic = "bplist00".toByteArray(Charsets.US_ASCII) + + fun decode(bytes: ByteArray): Any? { + require(bytes.size >= 40) { "bplist: too short" } + require(bytes.copyOfRange(0, magic.size).contentEquals(magic)) { "bplist: bad magic" } + + val trailer = bytes.size - 32 + val offsetSize = bytes[trailer + 6].toInt() and 0xff + val refSize = bytes[trailer + 7].toInt() and 0xff + val numObjects = readBigEndianLong(bytes, (trailer + 8).toLong(), 8).toInt() + val topObject = readBigEndianLong(bytes, (trailer + 16).toLong(), 8).toInt() + val offsetTable = readBigEndianLong(bytes, (trailer + 24).toLong(), 8) + + val offsets = LongArray(numObjects) + for (index in offsets.indices) { + offsets[index] = readBigEndianLong(bytes, offsetTable + index.toLong() * offsetSize, offsetSize) + } + return readObject(bytes, offsets, refSize, topObject) + } + + fun encode(root: Any?): ByteArray { + val nodes = ArrayList() + + fun add(value: Any?): Int { + val index = nodes.size + nodes.add(Leaf(ByteArray(0))) + when (value) { + is Boolean -> nodes[index] = Leaf(byteArrayOf(if (value) 0x09 else 0x08)) + is java.math.BigInteger -> { + require(value.signum() >= 0) { "bplist: negative integers are not supported" } + require(value.bitLength() <= 64) { "bplist: integer exceeds 64 bits" } + nodes[index] = Leaf(encodeUnsignedInt(value)) + } + is Byte, is Short, is Int, is Long -> { + val number = (value as Number).toLong() + nodes[index] = if (number >= 0) { + Leaf(encodeInt(number)) + } else { + Leaf(byteArrayOf(0x23) + bigEndian(number.toDouble().toRawBits(), 8)) + } + } + is Float, is Double -> { + val number = (value as Number).toDouble() + nodes[index] = Leaf(byteArrayOf(0x23) + bigEndian(number.toRawBits(), 8)) + } + is String -> nodes[index] = Leaf(encodeString(value)) + is ByteArray -> nodes[index] = Leaf(marker(0x4, value.size) + value) + is List<*> -> { + val refs = value.map { add(it) }.toIntArray() + nodes[index] = Container(marker(0xa, value.size), refs) + } + is Map<*, *> -> { + val entries = value.entries.toList() + val keyRefs = entries.map { add(it.key.toString()) }.toIntArray() + val valueRefs = entries.map { add(it.value) }.toIntArray() + nodes[index] = Container(marker(0xd, entries.size), keyRefs + valueRefs) + } + else -> throw IllegalArgumentException("bplist: unsupported value $value") + } + return index + } + + val topIndex = add(root) + val refSize = when { + nodes.size > 0xffff -> 4 + nodes.size > 0xff -> 2 + else -> 1 + } + + val parts = ArrayList(nodes.size + 2) + parts.add(magic) + var cursor = magic.size + val offsets = ArrayList(nodes.size) + for (node in nodes) { + offsets.add(cursor) + val serialized = when (node) { + is Leaf -> node.body + is Container -> node.head + refBytes(node.refs, refSize) + } + parts.add(serialized) + cursor += serialized.size + } + + val offsetTableOffset = cursor + val offsetSize = when { + cursor > 0xffff -> 4 + cursor > 0xff -> 2 + else -> 1 + } + for (offset in offsets) parts.add(bigEndian(offset.toLong(), offsetSize)) + + val trailer = ByteArray(32) + trailer[6] = offsetSize.toByte() + trailer[7] = refSize.toByte() + writeBigEndian(trailer, 8, nodes.size.toLong(), 8) + writeBigEndian(trailer, 16, topIndex.toLong(), 8) + writeBigEndian(trailer, 24, offsetTableOffset.toLong(), 8) + parts.add(trailer) + return concatBytes(*parts.toTypedArray()) + } + + private fun readObject(bytes: ByteArray, offsets: LongArray, refSize: Int, index: Int): Any? { + var position = offsets[index].toInt() + val markerByte = bytes[position].toInt() and 0xff + val type = markerByte ushr 4 + val nibble = markerByte and 0x0f + position++ + + fun readCount(): Int { + if (nibble != 0x0f) return nibble + val sizeMarker = bytes[position].toInt() and 0xff + position++ + val intBytes = 1 shl (sizeMarker and 0x0f) + val count = readBigEndianLong(bytes, position.toLong(), intBytes).toInt() + position += intBytes + return count + } + + return when (type) { + 0x0 -> when (nibble) { + 0x08 -> false + 0x09 -> true + else -> throw IllegalArgumentException("bplist: unsupported primitive 0x0$nibble") + } + 0x1 -> { + val size = 1 shl nibble + readBigEndianLong(bytes, position.toLong(), size) + } + 0x2 -> { + val size = 1 shl nibble + when (size) { + 4 -> Float.fromBits(readBigEndianLong(bytes, position.toLong(), size).toInt()) + 8 -> Double.fromBits(readBigEndianLong(bytes, position.toLong(), size)) + else -> throw IllegalArgumentException("bplist: unsupported real size $size") + } + } + 0x4 -> { + val count = readCount() + bytes.copyOfRange(position, position + count) + } + 0x5 -> { + val count = readCount() + String(bytes, position, count, Charsets.US_ASCII) + } + 0x6 -> { + val count = readCount() + String(bytes, position, count * 2, Charsets.UTF_16BE) + } + 0xa -> { + val count = readCount() + val array = ArrayList(count) + for (i in 0 until count) { + val reference = readBigEndianLong(bytes, position + i.toLong() * refSize, refSize).toInt() + array.add(readObject(bytes, offsets, refSize, reference)) + } + array + } + 0xd -> { + val count = readCount() + val dict = LinkedHashMap(count) + for (i in 0 until count) { + val keyReference = readBigEndianLong(bytes, position + i.toLong() * refSize, refSize).toInt() + val valueReference = + readBigEndianLong(bytes, position + (count + i).toLong() * refSize, refSize).toInt() + dict[readObject(bytes, offsets, refSize, keyReference).toString()] = + readObject(bytes, offsets, refSize, valueReference) + } + dict + } + else -> throw IllegalArgumentException("bplist: unsupported object type 0x${type.toString(16)}") + } + } + + private fun encodeString(value: String): ByteArray { + val ascii = value.all { it.code <= 0x7f } + return if (ascii) { + marker(0x5, value.length) + value.toByteArray(Charsets.US_ASCII) + } else { + marker(0x6, value.length) + value.toByteArray(Charsets.UTF_16BE) + } + } + + private fun encodeInt(number: Long): ByteArray { + val size = when { + number > 0xffffffffL -> 8 + number > 0xffffL -> 4 + number > 0xffL -> 2 + else -> 1 + } + val log = when (size) { + 8 -> 3 + 4 -> 2 + 2 -> 1 + else -> 0 + } + return byteArrayOf((0x10 or log).toByte()) + bigEndian(number, size) + } + + private fun encodeUnsignedInt(number: java.math.BigInteger): ByteArray { + val size = when { + number.bitLength() > 32 -> 8 + number.bitLength() > 16 -> 4 + number.bitLength() > 8 -> 2 + else -> 1 + } + val log = when (size) { + 8 -> 3 + 4 -> 2 + 2 -> 1 + else -> 0 + } + val raw = number.toByteArray() + return byteArrayOf((0x10 or log).toByte()) + raw.copyOfRange(raw.size - size, raw.size) + } + + private fun marker(type: Int, count: Int): ByteArray { + if (count < 0x0f) return byteArrayOf(((type shl 4) or count).toByte()) + val size = when { + count > 0xffff -> 4 + count > 0xff -> 2 + else -> 1 + } + val log = when (size) { + 4 -> 2 + 2 -> 1 + else -> 0 + } + return byteArrayOf(((type shl 4) or 0x0f).toByte(), (0x10 or log).toByte()) + + bigEndian(count.toLong(), size) + } + + private fun refBytes(refs: IntArray, refSize: Int): ByteArray { + val output = ByteArray(refs.size * refSize) + for (index in refs.indices) writeBigEndian(output, index * refSize, refs[index].toLong(), refSize) + return output + } + + private fun bigEndian(value: Long, size: Int): ByteArray { + val output = ByteArray(size) + writeBigEndian(output, 0, value, size) + return output + } + + private fun writeBigEndian(target: ByteArray, offset: Int, value: Long, size: Int) { + var current = value + for (index in size - 1 downTo 0) { + target[offset + index] = (current and 0xff).toByte() + current = current ushr 8 + } + } + + private fun readBigEndianLong(bytes: ByteArray, offset: Long, size: Int): Long { + var value = 0L + var base = offset.toInt() + for (index in 0 until size) { + value = (value shl 8) or (bytes[base + index].toLong() and 0xffL) + } + return value + } + + private sealed class Node + private class Leaf(val body: ByteArray) : Node() + private class Container(val head: ByteArray, val refs: IntArray) : Node() +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/Bytes.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/Bytes.kt new file mode 100644 index 0000000..aa33f84 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/Bytes.kt @@ -0,0 +1,24 @@ +package com.shilapi.xcertplay.airplay + +internal fun concatBytes(vararg arrays: ByteArray): ByteArray { + val output = ByteArray(arrays.sumOf { it.size }) + var offset = 0 + for (array in arrays) { + array.copyInto(output, offset) + offset += array.size + } + return output +} + +internal fun String.asciiBytes(): ByteArray = toByteArray(Charsets.US_ASCII) + +internal fun ByteArray.toHexString(): String { + val alphabet = "0123456789abcdef" + val output = CharArray(size * 2) + for (index in indices) { + val value = this[index].toInt() and 0xff + output[index * 2] = alphabet[value ushr 4] + output[index * 2 + 1] = alphabet[value and 0x0f] + } + return String(output) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayDisplayScale.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayDisplayScale.kt new file mode 100644 index 0000000..61ced98 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayDisplayScale.kt @@ -0,0 +1,31 @@ +package com.shilapi.xcertplay.airplay + +/** + * Display scaling uses tenths so the UI can expose only the supported 0.3x..1.0x steps. + */ +object CarPlayDisplayScale { + const val MIN_TENTHS = 3 + const val MAX_TENTHS = 10 + const val DEFAULT_TENTHS = MAX_TENTHS + + fun sanitize(tenths: Int): Int = tenths.coerceIn(MIN_TENTHS, MAX_TENTHS) + + fun label(tenths: Int): String { + val value = sanitize(tenths) + return "${value / 10}.${value % 10}x" + } + + fun apply(display: AirPlayDisplayConfig, tenths: Int): AirPlayDisplayConfig { + val value = sanitize(tenths) + return display.copy( + widthPixels = scalePixels(display.widthPixels, value), + heightPixels = scalePixels(display.heightPixels, value), + ) + } + + private fun scalePixels(pixels: Int, tenths: Int): Int { + require(pixels > 0) { "pixels must be positive" } + val scaled = ((pixels.toLong() * tenths + 5L) / 10L).toInt().coerceAtLeast(1) + return if (scaled % 2 == 0) scaled else scaled + 1 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt new file mode 100644 index 0000000..a68d38f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngine.kt @@ -0,0 +1,402 @@ +package com.shilapi.xcertplay.airplay + +import android.util.Log +import com.shilapi.xcertplay.transport.BlockingDuplexByteStream +import java.io.Closeable +import java.io.File +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.math.BigInteger +import java.util.concurrent.ConcurrentHashMap + +/** Rendering seam for the decrypted CarPlay media streams. */ +interface MediaSink { + fun onVideoCodec(type: Int, codec: VideoCodec) {} + fun onVideoConfig(type: Int, codecData: ByteArray) {} + fun onVideoFrame(type: Int, naluBytes: ByteArray) {} + fun setVideoRecoveryHandler(type: Int, handler: () -> Unit) {} + fun setVideoDiagnosticHandler(type: Int, handler: (String) -> Unit) {} + fun onScreenStreamActive(type: Int, active: Boolean) {} + fun onAudioStarted(type: Int, format: AudioFormat, firstSample: Int) {} + fun onAudioRtp(type: Int, format: AudioFormat, rtp: ByteArray, sample: Int) {} + fun onAudioStopped(type: Int) {} + fun onMicrophoneStarted(type: Int, config: MicrophoneConfig) {} + fun onMicrophoneStopped(type: Int) {} + fun onIapMessage(bytes: ByteArray) {} +} + +/** + * Concrete [AirPlayMediaHandler] that binds the screen, audio and iAP2 DataStream ports, + * decrypts their payloads, and hands decoded media to a [MediaSink]. Telephony and speech + * streams can additionally return a PCM microphone uplink through the sink. + */ +class CarPlayMediaEngine( + private val sink: MediaSink, + private val microphoneEnabled: Boolean = false, + private val audioCaptureDirectory: File? = null, +) : AirPlayMediaHandler { + internal data class StreamKey( + val session: AirPlaySession, + val type: Int, + ) + + private data class AudioMeta( + val type: Int, + val format: AudioFormat, + val connectionId: Any?, + val playoutLatencyMs: Int, + @Volatile var firstSample: Int? = null, + @Volatile var originNs: Long? = null, + ) + + private data class PendingIapTunnel( + val bridge: AirPlayIapTunnelStream, + val handler: (BlockingDuplexByteStream) -> Boolean, + ) + + private val streams = ConcurrentHashMap() + private val audioMeta = ConcurrentHashMap() + private val pendingMicrophone = ConcurrentHashMap() + private val audioCaptures = ConcurrentHashMap() + private val pendingIapTunnels = ConcurrentHashMap() + @Volatile private var iapTunnelHandler: ((BlockingDuplexByteStream) -> Boolean)? = null + + override fun setIapTunnelHandler(handler: ((BlockingDuplexByteStream) -> Boolean)?) { + iapTunnelHandler = handler + } + + override fun onScreen(session: AirPlaySession, type: Int, stream: Map): Int? { + val key = outputKey(session, stream) ?: return null + val streamKey = StreamKey(session, type) + Log.i(TAG, "airplay screen key connectionID=${unsignedPlistDecimal(stream["streamConnectionID"])}") + val screen = ScreenStream(key) + sink.setVideoDiagnosticHandler(type) { + if (it == "first frame rendered") session.videoFrameRendered() + session.logDebug("Video: $it") + } + sink.setVideoRecoveryHandler(type) { + if (streams[streamKey] === screen) { + val sent = session.sendCommand(mapOf("type" to "forceKeyFrame")) + session.logDebug("Video recovery: requested keyframe sent=$sent") + } + } + val port = screen.listen( + object : ScreenStream.Listener { + override fun onCodec(codec: VideoCodec) = sink.onVideoCodec(type, codec) + override fun onConfig(codecData: ByteArray) = sink.onVideoConfig(type, codecData) + override fun onFrame(naluBytes: ByteArray) = sink.onVideoFrame(type, naluBytes) + override fun onClosed(cause: Throwable?) { + Log.w( + TAG, + "screen stream ended type=$type reason=${cause?.message ?: "peer EOF"}", + ) + if (streams.remove(streamKey, screen)) { + sink.onScreenStreamActive(type, false) + } + session.close() + } + }, + ) + streams.put(streamKey, screen)?.close() + sink.onScreenStreamActive(type, true) + return port + } + + override fun onAudio(session: AirPlaySession, type: Int, stream: Map): Map? { + val streamKey = StreamKey(session, type) + streams.remove(streamKey)?.close() + audioMeta.remove(type) + audioCaptures.remove(type)?.close() + if (pendingMicrophone.remove(type) != null) sink.onMicrophoneStopped(type) + sink.onAudioStopped(type) + + val key = outputKey(session, stream) ?: return null + val audioType = stream["audioType"]?.toString()?.lowercase() ?: "default" + val format = AudioStreamCodec.fromFormatBits( + (stream["audioFormat"] as? Number)?.toLong() ?: 0L, + type, + audioType, + ) + Log.i( + TAG, + "airplay audio format type=$type audioType=$audioType codec=${format.codec} " + + "rate=${format.sampleRate} channels=${format.channels} " + + "micPort=${(stream["dataPort"] as? Number)?.toInt() ?: 0}", + ) + val connectionId = stream["streamConnectionID"] + val latencyMs = (stream["audioLatencyMs"] as? Number)?.toInt() ?: 0 + val meta = AudioMeta(type, format, connectionId, latencyMs) + val microphone = microphoneConfig(session, type, stream, format) + if (microphone != null) pendingMicrophone[type] = microphone + + val capture = audioCaptureDirectory?.let { AudioPacketCapture(it, type) } + if (capture != null) audioCaptures[type] = capture + val audio = AudioStream(key, type) + val (dataPort, controlPort) = audio.listen( + object : AudioStream.Listener { + override fun onStarted(firstSample: Int) { + meta.firstSample = firstSample + meta.originNs = System.nanoTime() + sink.onAudioStarted(type, format, firstSample) + microphone?.let { sink.onMicrophoneStarted(type, it) } + } + + override fun onRtp(rtp: ByteArray, sample: Int) = + sink.onAudioRtp(type, format, rtp, sample) + + override fun onPacket( + wire: ByteArray, + rtp: ByteArray?, + sample: Int?, + error: Throwable?, + ) { + capture?.record(wire, rtp, sample, error) + } + }, + ) + streams[streamKey] = audio + audioMeta[type] = meta + return linkedMapOf( + "type" to type, + "dataPort" to dataPort, + "controlPort" to controlPort, + "streamConnectionID" to unsignedPlistInteger(connectionId ?: 0L), + ) + } + + override fun onDataStream(session: AirPlaySession, stream: Map): Map? { + val uuid = (stream["clientTypeUUID"] as? String)?.uppercase() ?: return null + if (uuid != IAP_DATASTREAM_UUID) return null + val shared = session.sharedSecret ?: return null + val seed = unsignedPlistDecimal(stream["seed"]) ?: return null + session.logDebug( + "AirPlay iAP SETUP uuid=$uuid seed=$seed " + + "streamConnectionID=${unsignedPlistDecimal(stream["streamConnectionID"]) ?: "none"}", + ) + val key = AirPlayCrypto.hkdfSha512( + shared, + "DataStream-Salt$seed".toByteArray(Charsets.US_ASCII), + DATASTREAM_OUTPUT_KEY.toByteArray(Charsets.US_ASCII), + 32, + ) + val tunnel = IapTunnel( + readKey = key, + bindAddress = session.localAddress + ?: when (session.remoteAddress) { + is Inet6Address -> InetAddress.getByName("::") + is Inet4Address -> InetAddress.getByName("0.0.0.0") + else -> InetAddress.getByName("0.0.0.0") + }, + ) + val bridge = AirPlayIapTunnelStream(session, tunnel) + val handler = iapTunnelHandler + val port = try { + if (handler != null) { + val boundPort = bridge.listen() + session.logDebug( + "AirPlay iAP tunnel listening address=" + + "${session.localAddress?.hostAddress ?: "wildcard"} port=$boundPort", + ) + replacePendingIapTunnel(session, PendingIapTunnel(bridge, handler)) + boundPort + } else { + tunnel.listen( + object : IapTunnel.Listener { + override fun onIap(bytes: ByteArray) = sink.onIapMessage(bytes) + + override fun onClosed(cause: Throwable?) { + Log.w( + TAG, + "iAP tunnel ended reason=${cause?.message ?: "peer EOF"}", + ) + session.close() + } + }, + ) + } + } catch (error: Throwable) { + bridge.close() + throw error + } + streams[StreamKey(session, STREAM_TYPE_DATA)] = if (handler != null) bridge else tunnel + return linkedMapOf("type" to STREAM_TYPE_DATA, "streamID" to 1L, "dataPort" to port) + .apply { + stream["streamConnectionID"]?.let { connectionId -> + this["streamConnectionID"] = unsignedPlistInteger(connectionId) + } + } + } + + override fun onSetupResponseSent(session: AirPlaySession) { + val pending = pendingIapTunnels.remove(session) ?: return + val attached = try { + pending.handler(pending.bridge) + } catch (error: Throwable) { + Log.w(TAG, "iAP tunnel relay attachment failed", error) + false + } + if (!attached) { + Log.w(TAG, "iAP tunnel relay attachment was rejected after SETUP") + pending.bridge.close() + session.close() + } + } + + override fun onFeedback(session: AirPlaySession): Map? { + val active = audioMeta.values.toList() + if (active.isEmpty()) return null + val streams = active.map { meta -> + val entry = linkedMapOf( + "type" to meta.type, + "sampleRate" to meta.format.sampleRate, + ) + val firstSample = meta.firstSample + val originNs = meta.originNs + if (firstSample != null && originNs != null) { + val nowNs = System.nanoTime() + val elapsedSec = Math.max( + 0.0, + (nowNs - originNs) / 1e9 - meta.playoutLatencyMs / 1000.0, + ) + val firstUnsigned = firstSample.toLong() and 0xffff_ffffL + val sampleTime = (firstUnsigned + Math.round(elapsedSec * meta.format.sampleRate)) and + 0xffff_ffffL + entry["streamConnectionID"] = unsignedPlistInteger(meta.connectionId ?: 0L) + entry["timestamp"] = session.syncedNtp() + entry["timestampRawNs"] = nowNs + entry["sampleTime"] = sampleTime + } + entry + } + return linkedMapOf("streams" to streams) + } + + override fun onTeardown(session: AirPlaySession, type: Int) { + if (type == STREAM_TYPE_DATA) clearPendingIapTunnel(session) + if (pendingMicrophone.remove(type) != null) sink.onMicrophoneStopped(type) + audioMeta.remove(type) + audioCaptures.remove(type)?.close() + sink.onAudioStopped(type) + streams.remove(StreamKey(session, type))?.close() + if (isScreenStreamType(type)) sink.onScreenStreamActive(type, false) + } + + override fun onSessionClosed(session: AirPlaySession) { + clearPendingIapTunnel(session) + val sessionStreams = streams.keys.filter { it.session === session } + sessionStreams + .filter { isScreenStreamType(it.type) } + .forEach { sink.onScreenStreamActive(it.type, false) } + sessionStreams.forEach { streams.remove(it)?.close() } + audioMeta.clear() + pendingMicrophone.clear() + audioCaptures.values.forEach(AudioPacketCapture::close) + audioCaptures.clear() + } + + private fun replacePendingIapTunnel(session: AirPlaySession, next: PendingIapTunnel) { + val previous = pendingIapTunnels.put(session, next) + previous?.bridge?.close() + } + + private fun clearPendingIapTunnel(session: AirPlaySession? = null) { + if (session == null) { + val pending = pendingIapTunnels.values.toList() + pendingIapTunnels.clear() + pending.forEach { it.bridge.close() } + return + } + pendingIapTunnels.remove(session)?.bridge?.close() + } + + private fun outputKey(session: AirPlaySession, stream: Map): ByteArray? { + return dataStreamKey(session, stream, DATASTREAM_OUTPUT_KEY) + } + + private fun microphoneConfig( + session: AirPlaySession, + type: Int, + stream: Map, + format: AudioFormat, + ): MicrophoneConfig? { + if (!microphoneEnabled || type != STREAM_TYPE_MAIN_AUDIO) return null + if (format.audioType != "telephony" && format.audioType != "speechrecognition") return null + val port = (stream["dataPort"] as? Number)?.toInt() ?: return null + if (port !in 1..65535) return null + val host = session.remoteAddress ?: return null + val key = dataStreamKey(session, stream, DATASTREAM_INPUT_KEY) ?: return null + val formatBits = (stream["audioFormat"] as? Number)?.toLong() ?: 0L + val framesPerPacket = (stream["framesPerPacket"] as? Number)?.toInt() ?: 0 + val frameMillis = if (format.codec == AudioCodecKind.OPUS) { + 20 + } else if (framesPerPacket > 0) { + Math.round(framesPerPacket * 1000.0 / format.sampleRate).toInt().coerceIn(5, 60) + } else { + 20 + } + val opusBitrate = when { + formatBits and OPUS_48K != 0L -> 96_000 + formatBits and OPUS_24K != 0L -> 64_000 + else -> 48_000 + } + return MicrophoneConfig( + audioType = format.audioType, + sampleRate = format.sampleRate, + channels = format.channels, + payloadType = type, + frameMillis = frameMillis, + host = host, + port = port, + key = key, + codec = format.codec, + bitrate = if (format.codec == AudioCodecKind.OPUS) opusBitrate else null, + ) + } + + private fun dataStreamKey( + session: AirPlaySession, + stream: Map, + label: String, + ): ByteArray? { + val shared = session.sharedSecret ?: return null + val connectionId = unsignedPlistDecimal(stream["streamConnectionID"]) ?: return null + return AirPlayCrypto.hkdfSha512( + shared, + "DataStream-Salt$connectionId".toByteArray(Charsets.US_ASCII), + label.toByteArray(Charsets.US_ASCII), + 32, + ) + } + + private fun isScreenStreamType(type: Int): Boolean = + type == STREAM_TYPE_MAIN_SCREEN || type == STREAM_TYPE_ALT_SCREEN + + private companion object { + const val TAG = "xcertplay-usb" + const val STREAM_TYPE_MAIN_SCREEN = 110 + const val STREAM_TYPE_ALT_SCREEN = 111 + const val STREAM_TYPE_MAIN_AUDIO = 100 + const val STREAM_TYPE_DATA = 130 + const val DATASTREAM_OUTPUT_KEY = "DataStream-Output-Encryption-Key" + const val DATASTREAM_INPUT_KEY = "DataStream-Input-Encryption-Key" + const val IAP_DATASTREAM_UUID = "E9459FD0-BCAD-4C45-820F-1E72447EF2F2" + const val OPUS_24K = 0x20000000L + const val OPUS_48K = 0x40000000L + } +} + +internal fun unsignedPlistDecimal(value: Any?): String? = when (value) { + is Long -> java.lang.Long.toUnsignedString(value) + is Int -> Integer.toUnsignedString(value) + is Short -> (value.toInt() and 0xffff).toString() + is Byte -> (value.toInt() and 0xff).toString() + is BigInteger -> if (value.signum() >= 0) value.toString() else null + else -> (value as? Number)?.toLong()?.let(java.lang.Long::toUnsignedString) +} + +internal fun unsignedPlistInteger(value: Any?): Any = when (value) { + is Long -> if (value < 0) BigInteger(java.lang.Long.toUnsignedString(value)) else value + is Int -> if (value < 0) BigInteger(Integer.toUnsignedString(value)) else value + else -> value ?: 0L +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayUiScale.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayUiScale.kt new file mode 100644 index 0000000..9082a21 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/CarPlayUiScale.kt @@ -0,0 +1,39 @@ +package com.shilapi.xcertplay.airplay + +import kotlin.math.roundToInt + +/** Request a larger canvas for smaller CarPlay controls, then fit it to the same touch surface. */ +object CarPlayUiScale { + const val DEFAULT = 100 + val presets = listOf(75, 85, DEFAULT, 115) + + fun sanitize(percent: Int): Int = percent.takeIf { it in presets } ?: DEFAULT + + fun label(percent: Int): String = when (sanitize(percent)) { + 75 -> "Smaller" + 85 -> "Small" + 115 -> "Large" + else -> "Default" + } + + fun apply(display: AirPlayDisplayConfig, percent: Int): AirPlayDisplayConfig { + val scale = sanitize(percent) + if (scale == DEFAULT) return display + require(display.widthPixels > 0 && display.heightPixels > 0) + fun pixels(value: Int): Int = ((value.toLong() * 100 / scale + 1) / 2 * 2).toInt() + val width = pixels(display.widthPixels) + val height = pixels(display.heightPixels) + // Never request an enlarged canvas beyond 4K. The host also checks its actual decoder. + if (scale < DEFAULT && (maxOf(width, height) > 3840 || minOf(width, height) > 2160)) return display + fun insets(value: AirPlayInsets?): AirPlayInsets? = value?.let { + it.copy( + top = (it.top * height.toDouble() / display.heightPixels).roundToInt(), + bottom = (it.bottom * height.toDouble() / display.heightPixels).roundToInt(), + left = (it.left * width.toDouble() / display.widthPixels).roundToInt(), + right = (it.right * width.toDouble() / display.widthPixels).roundToInt(), + ) + } + return display.copy(widthPixels = width, heightPixels = height, + viewArea = insets(display.viewArea), safeArea = insets(display.safeArea)) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/ControlCipher.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/ControlCipher.kt new file mode 100644 index 0000000..00621ff --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/ControlCipher.kt @@ -0,0 +1,55 @@ +package com.shilapi.xcertplay.airplay + +/** + * ChaCha20-Poly1305 framing for the CarPlay control channel. + * + * Each frame is [2-byte little-endian ciphertext length][ciphertext][16-byte tag]; the length + * header is the AEAD associated data and each direction uses an 8-byte little-endian nonce + * counter plus its own key. + */ +class ControlCipher(private val readKey: ByteArray, private val writeKey: ByteArray) { + data class Decrypted(val data: ByteArray, val rest: ByteArray) + + private var readCounter = 0L + private var writeCounter = 0L + + fun decrypt(buffer: ByteArray): Decrypted { + val output = ArrayList() + var offset = 0 + while (buffer.size - offset >= HEADER_SIZE) { + val length = readU16Le(buffer, offset) + val frameEnd = offset + HEADER_SIZE + length + TAG_SIZE + if (buffer.size < frameEnd) break + val aad = buffer.copyOfRange(offset, offset + HEADER_SIZE) + val ciphertextAndTag = buffer.copyOfRange(offset + HEADER_SIZE, frameEnd) + output.add(AirPlayCrypto.chachaOpen(readKey, AirPlayCrypto.nonce64(readCounter), ciphertextAndTag, aad)) + readCounter++ + offset = frameEnd + } + return Decrypted(concatBytes(*output.toTypedArray()), buffer.copyOfRange(offset, buffer.size)) + } + + fun encrypt(plaintext: ByteArray): ByteArray { + val output = ArrayList() + var offset = 0 + do { + val chunk = plaintext.copyOfRange(offset, minOf(offset + MAX_PAYLOAD, plaintext.size)) + val header = byteArrayOf(chunk.size.toByte(), (chunk.size ushr 8).toByte()) + val ciphertextAndTag = AirPlayCrypto.chachaSeal(writeKey, AirPlayCrypto.nonce64(writeCounter), chunk, header) + output.add(header) + output.add(ciphertextAndTag) + writeCounter++ + offset += MAX_PAYLOAD + } while (offset < plaintext.size) + return concatBytes(*output.toTypedArray()) + } + + private fun readU16Le(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or ((source[offset + 1].toInt() and 0xff) shl 8) + + private companion object { + const val HEADER_SIZE = 2 + const val TAG_SIZE = 16 + const val MAX_PAYLOAD = 0x4000 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/IapTunnel.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/IapTunnel.kt new file mode 100644 index 0000000..230b96f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/IapTunnel.kt @@ -0,0 +1,219 @@ +package com.shilapi.xcertplay.airplay + +import java.io.Closeable +import java.io.InputStream +import java.net.Inet4Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Receive-only iAP2-over-CarPlay DataStream tunnel (stream type 130). + * + * The TCP stream is NetSocketChaCha20Poly1305 framed, then carries APTransportPackage records. + * iAP2 bodies (messageType "comm") are emitted verbatim for the wired iAP2 relay. + */ +class IapTunnel( + private val readKey: ByteArray, + bindAddress: InetAddress = InetAddress.getByName("0.0.0.0"), +) : Closeable { + interface Listener { + fun onOpen(remoteAddress: String?) {} + fun onIap(bytes: ByteArray) {} + fun onDebug(message: String) {} + fun onClosed(cause: Throwable?) {} + } + + private val closed = AtomicBoolean(false) + private val readCounter = AtomicLong(0) + private val bindAddress = + if (bindAddress is Inet4Address) InetAddress.getByName("0.0.0.0") else bindAddress + private val servers = mutableListOf() + private var socket: Socket? = null + private val threads = mutableListOf() + private val peerConnected = CountDownLatch(1) + @Volatile private var listener: Listener = object : Listener {} + + fun listen(listener: Listener): Int { + this.listener = listener + val bound = bindAny() + servers += bound + listener.onDebug("AirPlay iAP tunnel listener bound=${bound.localSocketAddress}") + val secondaryAddress = if (bindAddress is java.net.Inet6Address) { + InetAddress.getByName("0.0.0.0") + } else { + InetAddress.getByName("::") + } + val secondary = ServerSocket() + runCatching { + secondary.apply { + reuseAddress = true + bind(InetSocketAddress(secondaryAddress, bound.localPort)) + } + }.onSuccess { secondary -> + servers += secondary + listener.onDebug( + "AirPlay iAP tunnel secondary listener bound=" + + "${secondary.localSocketAddress}", + ) + }.onFailure { error -> + safeClose(secondary) + listener.onDebug( + "AirPlay iAP tunnel secondary listener failed address=" + + "$secondaryAddress port=${bound.localPort}: ${error.message}", + ) + } + servers.forEach { server -> + threads += Thread({ accept(server) }, "airplay-iap-tunnel").apply { + isDaemon = true + start() + } + } + return bound.localPort + } + + private fun bindAny(): ServerSocket = + ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(bindAddress, 0)) + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + safeClose(socket) + servers.toList().forEach(::safeClose) + servers.clear() + threads.toList().forEach(Thread::interrupt) + threads.clear() + peerConnected.countDown() + } + + /** Waits until the iPhone has connected to the advertised dataPort. */ + fun awaitPeerConnection(timeoutMillis: Long): Boolean { + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + return try { + peerConnected.await(timeoutMillis, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + } + + private fun accept(bound: ServerSocket) { + listener.onDebug( + "AirPlay iAP tunnel accepting local=${bound.localSocketAddress}", + ) + while (!closed.get()) { + val accepted = try { + bound.accept() + } catch (error: Exception) { + if (!closed.get()) listener.onClosed(error) + return + } + if (closed.get()) { + safeClose(accepted) + return + } + accepted.setSoLinger(true, 0) + socket = accepted + readCounter.set(0) + peerConnected.countDown() + listener.onOpen(accepted.remoteSocketAddress?.toString()) + run(accepted) + } + } + + private fun run(sock: Socket) { + var ciphertext = ByteArray(0) + var plaintext = ByteArray(0) + var failure: Throwable? = null + var announcedData = false + try { + val input = sock.getInputStream() + val buffer = ByteArray(READ_CHUNK_BYTES) + while (!closed.get()) { + val read = input.read(buffer) + if (read < 0) { + listener.onDebug("AirPlay iAP tunnel peer EOF") + break + } + if (!announcedData) { + announcedData = true + listener.onDebug("AirPlay iAP tunnel received data") + } + ciphertext += buffer.copyOf(read) + val decrypted = decryptFrames(ciphertext) + plaintext += decrypted.first + ciphertext = decrypted.second + plaintext = parsePackages(plaintext) + } + } catch (error: Exception) { + failure = error + } finally { + if (socket === sock) socket = null + safeClose(sock) + if (!closed.get() && failure != null) listener.onClosed(failure) + } + } + + private fun decryptFrames(buffer: ByteArray): Pair { + val output = ArrayList() + var offset = 0 + while (buffer.size - offset >= FRAME_HEADER_LEN) { + val length = readU16Le(buffer, offset) + val frameLength = FRAME_HEADER_LEN + length + TAG_SIZE + if (buffer.size - offset < frameLength) break + val aad = buffer.copyOfRange(offset, offset + FRAME_HEADER_LEN) + val sealed = buffer.copyOfRange(offset + FRAME_HEADER_LEN, offset + frameLength) + val plain = AirPlayCrypto.chachaOpen( + readKey, AirPlayCrypto.nonce64(readCounter.get()), sealed, aad, + ) + readCounter.incrementAndGet() + output.add(plain) + offset += frameLength + } + return concatBytes(*output.toTypedArray()) to buffer.copyOfRange(offset, buffer.size) + } + + private fun parsePackages(buffer: ByteArray): ByteArray { + var offset = 0 + while (buffer.size - offset >= PACKAGE_HEADER_LEN) { + val size = readU32Be(buffer, offset) + if (size < PACKAGE_HEADER_LEN || size > MAX_PACKAGE) break + if (buffer.size - offset < size) break + val messageType = readU32Be(buffer, offset + MESSAGE_TYPE_OFFSET) + if (messageType == MSG_TYPE_COMM) { + listener.onDebug( + "AirPlay iAP tunnel package type=comm body=${size - PACKAGE_HEADER_LEN}", + ) + listener.onIap(buffer.copyOfRange(offset + PACKAGE_HEADER_LEN, offset + size)) + } + offset += size + } + return buffer.copyOfRange(offset, buffer.size) + } + + private fun readU16Le(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or ((source[offset + 1].toInt() and 0xff) shl 8) + + private fun readU32Be(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 24) or + ((source[offset + 1].toInt() and 0xff) shl 16) or + ((source[offset + 2].toInt() and 0xff) shl 8) or + (source[offset + 3].toInt() and 0xff) + + private companion object { + const val FRAME_HEADER_LEN = 2 + const val TAG_SIZE = 16 + const val PACKAGE_HEADER_LEN = 32 + const val MESSAGE_TYPE_OFFSET = 16 + const val MSG_TYPE_COMM = 0x636f6d6d + const val MAX_PACKAGE = 4 * 1024 * 1024 + const val READ_CHUNK_BYTES = 16 * 1024 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/MfiSapAuthSetup.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/MfiSapAuthSetup.kt new file mode 100644 index 0000000..b005a8f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/MfiSapAuthSetup.kt @@ -0,0 +1,183 @@ +package com.shilapi.xcertplay.airplay + +import com.shilapi.xcertplay.mfi.MfiAuthenticator +import com.shilapi.xcertplay.mfi.MfiCertificateType +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * CarPlay /auth-setup MFiSAP responder (accessory side). + * + * Proves MFi licensing to the phone. The request is [1-byte version=1][32-byte controller X25519 + * public key]; the response is this accessory's ephemeral public key, its MFi certificate, and its + * coprocessor signature over the two public keys, AES-128-CTR encrypted under a key derived from + * the shared secret. It runs over the already-encrypted control channel. + * + * [MfiAuthenticator] performs blocking I/O and must be called off the main thread. + */ +object MfiSapAuthSetup { + private const val VERSION = 0x01 + private const val PUBLIC_KEY_BYTES = 32 + + /** Returns the raw binary response, or null when the request is malformed. */ + fun handle(body: ByteArray, mfi: MfiAuthenticator): ByteArray? { + if (body.size != 1 + PUBLIC_KEY_BYTES || body[0].toInt() != VERSION) return null + val peerPublicKey = body.copyOfRange(1, body.size) + + val pair = AirPlayCrypto.x25519Generate() + val shared = AirPlayCrypto.x25519Shared(pair.privateKey, peerPublicKey) + if (shared.all { it.toInt() == 0 }) return null + + val aesKey = sha1("AES-KEY".asciiBytes(), shared).copyOf(16) + val aesIv = sha1("AES-IV".asciiBytes(), shared).copyOf(16) + + return when (mfi.certificateType) { + MfiCertificateType.MFI -> { + val certificate = mfi.readCertificate() + val digest = if (mfi.protocolMajor() == 2) { + sha1(pair.publicKey, peerPublicKey) + } else { + sha256(pair.publicKey, peerPublicKey) + } + val encryptedSignature = aesCtr128( + aesKey, + aesIv, + mfi.signChallenge(digest), + ) + buildMfiResponse(pair.publicKey, certificate, encryptedSignature) + } + + MfiCertificateType.BAA -> { + val certificates = mfi.baaCertificates() + val encryptedSignature = aesCtr128( + aesKey, + aesIv, + mfi.signChallenge(pair.publicKey + peerPublicKey), + ) + buildBaaResponse( + pair.publicKey, + certificates.leaf, + encryptedSignature, + encodeBaaIntermediate(certificates.intermediate), + ) + } + } + } + + private fun buildMfiResponse( + publicKey: ByteArray, + certificate: ByteArray, + encryptedSignature: ByteArray, + ): ByteArray { + val response = ByteArray(32 + 4 + certificate.size + 4 + encryptedSignature.size) + publicKey.copyInto(response, 0) + putU32(response, 32, certificate.size) + certificate.copyInto(response, 36) + putU32(response, 36 + certificate.size, encryptedSignature.size) + encryptedSignature.copyInto(response, 40 + certificate.size) + return response + } + + private fun buildBaaResponse( + publicKey: ByteArray, + leaf: ByteArray, + encryptedSignature: ByteArray, + baicBlob: ByteArray, + ): ByteArray { + val response = ByteArray( + 32 + 4 + leaf.size + 4 + encryptedSignature.size + 4 + baicBlob.size, + ) + publicKey.copyInto(response, 0) + var offset = 32 + putU32(response, offset, leaf.size) + offset += 4 + leaf.copyInto(response, offset) + offset += leaf.size + putU32(response, offset, encryptedSignature.size) + offset += 4 + encryptedSignature.copyInto(response, offset) + offset += encryptedSignature.size + putU32(response, offset, baicBlob.size) + offset += 4 + baicBlob.copyInto(response, offset) + return response + } + + /** OPACK `{"baIC": intermediate}` used by Showcase and CarPlay Simulator. */ + private fun encodeBaaIntermediate(intermediate: ByteArray): ByteArray { + val header = when { + intermediate.size <= 0x20 -> byteArrayOf( + 0xE1.toByte(), + 0x44, + 'b'.code.toByte(), + 'a'.code.toByte(), + 'I'.code.toByte(), + 'C'.code.toByte(), + (0x70 + intermediate.size).toByte(), + ) + + intermediate.size <= 0xFF -> byteArrayOf( + 0xE1.toByte(), + 0x44, + 'b'.code.toByte(), + 'a'.code.toByte(), + 'I'.code.toByte(), + 'C'.code.toByte(), + 0x91.toByte(), + intermediate.size.toByte(), + ) + + intermediate.size <= 0xFFFF -> byteArrayOf( + 0xE1.toByte(), + 0x44, + 'b'.code.toByte(), + 'a'.code.toByte(), + 'I'.code.toByte(), + 'C'.code.toByte(), + 0x92.toByte(), + intermediate.size.toByte(), + (intermediate.size ushr 8).toByte(), + ) + + else -> byteArrayOf( + 0xE1.toByte(), + 0x44, + 'b'.code.toByte(), + 'a'.code.toByte(), + 'I'.code.toByte(), + 'C'.code.toByte(), + 0x93.toByte(), + intermediate.size.toByte(), + (intermediate.size ushr 8).toByte(), + (intermediate.size ushr 16).toByte(), + (intermediate.size ushr 24).toByte(), + ) + } + return header + intermediate + } + + private fun sha1(vararg parts: ByteArray): ByteArray = digest("SHA-1", parts) + + private fun sha256(vararg parts: ByteArray): ByteArray = digest("SHA-256", parts) + + private fun digest(algorithm: String, parts: Array): ByteArray { + val digest = MessageDigest.getInstance(algorithm) + for (part in parts) digest.update(part) + return digest.digest() + } + + private fun aesCtr128(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), IvParameterSpec(iv)) + return cipher.doFinal(data) + } + + private fun putU32(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 24).toByte() + target[offset + 1] = (value ushr 16).toByte() + target[offset + 2] = (value ushr 8).toByte() + target[offset + 3] = value.toByte() + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt new file mode 100644 index 0000000..3ef77f8 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/MicrophonePacketizer.kt @@ -0,0 +1,116 @@ +package com.shilapi.xcertplay.airplay + +import java.net.InetAddress + +/** Everything one captured microphone stream needs to send samples to the phone. */ +data class MicrophoneConfig( + val audioType: String, + val sampleRate: Int, + val channels: Int, + val payloadType: Int, + val frameMillis: Int, + val host: InetAddress, + val port: Int, + val key: ByteArray, + val codec: AudioCodecKind = AudioCodecKind.LPCM, + val bitrate: Int? = null, +) { + val samplesPerPacket: Int + get() = if (codec == AudioCodecKind.OPUS) { + OPUS_SAMPLES_PER_PACKET + } else { + maxOf(1, sampleRate * frameMillis / 1000) + } + + val frameBytes: Int + get() = samplesPerPacket * channels * 2 + + private companion object { + const val OPUS_SAMPLES_PER_PACKET = 960 + } +} + +/** Mutable RTP/ChaCha counters for one microphone uplink. */ +class MicrophoneCounters( + var sequence: Int = 0, + var timestamp: Int = 0, + var nonce: Long = 0, +) + +/** + * Seals one PCM microphone frame in the wired CarPlay RTP layout. + * + * The 12-byte RTP header is clear; its timestamp and SSRC are authenticated as AAD. The packet + * tail repeats the eight-byte little-endian nonce counter after the 16-byte Poly1305 tag. + */ +object MicrophonePacketizer { + const val RTP_HEADER_LEN = 12 + const val TAG_LEN = 16 + const val NONCE_LEN = 8 + + fun sealPacket( + key: ByteArray, + payloadType: Int, + counters: MicrophoneCounters, + body: ByteArray, + samples: Int, + ): ByteArray { + val header = ByteArray(RTP_HEADER_LEN) + header[0] = 0x80.toByte() + header[1] = (payloadType and 0x7f).toByte() + putU16Be(header, 2, counters.sequence) + putU32Be(header, 4, counters.timestamp) + + val nonce = ByteArray(12) + putU64Le(nonce, 4, counters.nonce) + val sealed = AirPlayCrypto.chachaSeal( + key = key, + nonce = nonce, + plaintext = body, + aad = header.copyOfRange(4, RTP_HEADER_LEN), + ) + + val packet = ByteArray(RTP_HEADER_LEN + sealed.size + NONCE_LEN) + header.copyInto(packet, 0) + sealed.copyInto(packet, RTP_HEADER_LEN) + putU64Le(packet, RTP_HEADER_LEN + sealed.size, counters.nonce) + + counters.sequence = (counters.sequence + 1) and 0xffff + counters.timestamp += samples + counters.nonce++ + return packet + } + + /** Captured Android PCM is little-endian; CarPlay's wired microphone payload is big-endian. */ + fun toWirePcm(pcmLittleEndian: ByteArray): ByteArray { + val output = pcmLittleEndian.copyOf() + var index = 0 + while (index + 1 < output.size) { + val first = output[index] + output[index] = output[index + 1] + output[index + 1] = first + index += 2 + } + return output + } + + private fun putU16Be(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 8).toByte() + target[offset + 1] = value.toByte() + } + + private fun putU32Be(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 24).toByte() + target[offset + 1] = (value ushr 16).toByte() + target[offset + 2] = (value ushr 8).toByte() + target[offset + 3] = value.toByte() + } + + private fun putU64Le(target: ByteArray, offset: Int, value: Long) { + var remaining = value + for (index in 0 until 8) { + target[offset + index] = remaining.toByte() + remaining = remaining ushr 8 + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/NtpClock.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/NtpClock.kt new file mode 100644 index 0000000..297ad99 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/NtpClock.kt @@ -0,0 +1,239 @@ +package com.shilapi.xcertplay.airplay + +import java.io.Closeable +import java.math.BigInteger +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicBoolean + +/** + * CarPlay timing-port clock sync. + * + * The accessory drives a two-way RTCP-style exchange with the phone: PT_REQUEST (210) carries our + * transmit timestamp and PT_RESPONSE (211) echoes it while adding the phone's receive and + * transmit stamps. The resulting offset and round-trip time steer a local monotonic clock onto + * the phone's media-clock domain, which /feedback reports. + */ +class NtpClock : Closeable { + private val running = AtomicBoolean(false) + private val socketLock = Any() + private val clockLock = Any() + + private var socket: DatagramSocket? = null + private var receiver: Thread? = null + private var sender: Thread? = null + private var peer: InetSocketAddress? = null + + private var clockOffsetNs = wallClockNtpOffsetNs(System.nanoTime()) + private val delays = DoubleArray(DELAY_WINDOW) { Double.POSITIVE_INFINITY } + private var delayIndex = 0 + private var pickCount = PICK_COUNT + private var pickRtt = Double.POSITIVE_INFINITY + private var pickOffset = 0.0 + private var pendingT1: BigInteger? = null + private var synced = false + + fun listen(): Int { + check(!running.getAndSet(true)) { "NtpClock is already running" } + val bound = DatagramSocket(null) + bound.reuseAddress = true + bound.bind(InetSocketAddress(InetAddress.getByName("::"), 0)) + synchronized(socketLock) { socket = bound } + receiver = Thread(::runReceiver, "airplay-ntp-rx").apply { isDaemon = true; start() } + return bound.localPort + } + + fun start(peerAddress: InetAddress, port: Int) { + require(port > 0) { "timing port must be positive" } + peer = InetSocketAddress(peerAddress, port) + sender = Thread(::runSender, "airplay-ntp-tx").apply { isDaemon = true; start() } + } + + /** Now in the phone's synchronized clock domain, as an unsigned NTP64 value. */ + fun syncedNtp(): BigInteger = synchronized(clockLock) { + ntpFromNanos(System.nanoTime() + clockOffsetNs) + } + + override fun close() { + running.set(false) + synchronized(socketLock) { + socket?.close() + socket = null + } + sender?.interrupt() + receiver?.interrupt() + } + + private fun runSender() { + while (running.get()) { + try { + sendRequest() + Thread.sleep(REQUEST_INTERVAL_MS) + } catch (_: InterruptedException) { + return + } catch (_: Exception) { + try { + Thread.sleep(REQUEST_INTERVAL_MS) + } catch (_: InterruptedException) { + return + } + } + } + } + + private fun sendRequest() { + val destination = peer ?: return + val packet = ByteArray(NTP_PACKET_BYTES) + packet[0] = 0x80.toByte() + packet[1] = PT_REQUEST.toByte() + writeU16Be(packet, 2, 7) + val t1 = syncedNtp() + synchronized(clockLock) { pendingT1 = t1 } + ntpBytes(t1).copyInto(packet, NTP_TRANSMIT_OFFSET) + currentSocket()?.send(DatagramPacket(packet, packet.size, destination)) + } + + private fun runReceiver() { + val buffer = ByteArray(RECEIVE_BUFFER_BYTES) + while (running.get()) { + val sock = currentSocket() ?: return + val packet = DatagramPacket(buffer, buffer.size) + try { + sock.receive(packet) + } catch (_: Exception) { + if (running.get()) continue else return + } + val message = packet.data.copyOf(packet.length) + val address = packet.address + if (message.size >= NTP_PACKET_BYTES && address != null) { + handleMessage(message, InetSocketAddress(address, packet.port)) + } + } + } + + private fun handleMessage(message: ByteArray, source: InetSocketAddress) { + when (message[1].toInt() and 0xff) { + PT_REQUEST -> respondToRequest(message, source) + PT_RESPONSE -> handleResponse(message) + } + } + + private fun respondToRequest(request: ByteArray, source: InetSocketAddress) { + val response = ByteArray(NTP_PACKET_BYTES) + response[0] = 0x80.toByte() + response[1] = PT_RESPONSE.toByte() + writeU16Be(response, 2, 7) + request.copyInto(response, NTP_ORIGINATE_OFFSET, NTP_TRANSMIT_OFFSET, NTP_PACKET_BYTES) + ntpBytes(syncedNtp()).copyInto(response, NTP_RECEIVE_OFFSET) + ntpBytes(syncedNtp()).copyInto(response, NTP_TRANSMIT_OFFSET) + currentSocket()?.send(DatagramPacket(response, response.size, source)) + } + + private fun handleResponse(response: ByteArray) { + val t4 = syncedNtp() + val t1 = readNtp(response, NTP_ORIGINATE_OFFSET) + val t2 = readNtp(response, NTP_RECEIVE_OFFSET) + val t3 = readNtp(response, NTP_TRANSMIT_OFFSET) + + synchronized(clockLock) { + val pending = pendingT1 + if (pending == null || pending != t1) return + pendingT1 = null + } + + val offset = t2.subtract(t1).add(t3.subtract(t4)).toDouble() * 0.5 / TWO32_DOUBLE + val rtt = t4.subtract(t1).subtract(t3.subtract(t2)).toDouble() / TWO32_DOUBLE + if (rtt < 0.0) return + + if (rtt < pickRtt) { + pickRtt = rtt + pickOffset = offset + } + if (--pickCount > 0) return + + val selectedRtt = pickRtt + val selectedOffset = pickOffset + pickCount = PICK_COUNT + pickRtt = Double.POSITIVE_INFINITY + + synchronized(clockLock) { + val useSample = delays.all { selectedRtt <= it } + delays[delayIndex] = selectedRtt + delayIndex = (delayIndex + 1) % DELAY_WINDOW + if (useSample) applyOffset(selectedOffset) + } + } + + private fun applyOffset(offsetSec: Double) { + val stepping = !synced || Math.abs(offsetSec) > STEP_THRESHOLD_SEC + val applied = if (stepping) offsetSec else offsetSec * SLEW_GAIN + clockOffsetNs += Math.round(applied * 1e9) + if (stepping) { + delays.fill(Double.POSITIVE_INFINITY) + delayIndex = 0 + pendingT1 = null + synced = true + } + } + + private fun currentSocket(): DatagramSocket? = synchronized(socketLock) { socket } + + private companion object { + const val PT_REQUEST = 210 + const val PT_RESPONSE = 211 + const val NTP_PACKET_BYTES = 32 + const val NTP_ORIGINATE_OFFSET = 8 + const val NTP_RECEIVE_OFFSET = 16 + const val NTP_TRANSMIT_OFFSET = 24 + const val REQUEST_INTERVAL_MS = 1_000L + const val RECEIVE_BUFFER_BYTES = 2_048 + const val STEP_THRESHOLD_SEC = 0.128 + const val SLEW_GAIN = 1.0 / 8.0 + const val DELAY_WINDOW = 8 + const val PICK_COUNT = 2 + const val TWO32_DOUBLE = 0x1_0000_0000L.toDouble() + } +} + +private const val NTP_EPOCH_OFFSET = 2_208_988_800L +private val TWO32 = BigInteger.ONE.shiftLeft(32) +private val NANOS_PER_SECOND = BigInteger.valueOf(1_000_000_000L) + +private fun wallClockNtpOffsetNs(monoNs: Long): Long { + val ntpNs = ntp64Now() + .and(BigInteger.ONE.shiftLeft(64).subtract(BigInteger.ONE)) + .multiply(NANOS_PER_SECOND) + .shiftRight(32) + return ntpNs.subtract(BigInteger.valueOf(monoNs)).toLong() +} + +private fun ntp64Now(): BigInteger { + val seconds = System.currentTimeMillis() / 1000.0 + NTP_EPOCH_OFFSET + val whole = Math.floor(seconds).toLong() + val fraction = Math.floor((seconds - whole) * TWO32.toDouble()).toLong() + return BigInteger.valueOf(whole).shiftLeft(32).or(BigInteger.valueOf(fraction)) +} + +private fun ntpFromNanos(ns: Long): BigInteger { + val seconds = BigInteger.valueOf(Math.floorDiv(ns, 1_000_000_000L)) + val nanos = BigInteger.valueOf(Math.floorMod(ns, 1_000_000_000L)) + return seconds.shiftLeft(32).or(nanos.shiftLeft(32).divide(NANOS_PER_SECOND)) +} + +private fun ntpBytes(value: BigInteger): ByteArray { + val bytes = value.and(BigInteger.ONE.shiftLeft(64).subtract(BigInteger.ONE)).toByteArray() + return ByteArray(8).also { target -> + val source = if (bytes.size > 8) bytes.copyOfRange(bytes.size - 8, bytes.size) else bytes + source.copyInto(target, target.size - source.size) + } +} + +private fun readNtp(value: ByteArray, offset: Int): BigInteger = + BigInteger(1, value.copyOfRange(offset, offset + 8)) + +private fun writeU16Be(target: ByteArray, offset: Int, value: Int) { + target[offset] = ((value ushr 8) and 0xff).toByte() + target[offset + 1] = (value and 0xff).toByte() +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/PairSetup.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/PairSetup.kt new file mode 100644 index 0000000..820c298 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/PairSetup.kt @@ -0,0 +1,137 @@ +package com.shilapi.xcertplay.airplay + +/** + * CarPlay pair-setup responder (accessory/server side). + * + * Uses unauthenticated SRP-6a with the fixed PIN "3939", then exchanges encrypted long-term + * Ed25519 keys. The controller's key is persisted for later pair-verify. TLV8 over + * "application/pairing+tlv8". + */ +class PairSetup( + private val identity: AirPlayIdentity, + private val pairings: PairingStore, +) { + private var srp: Srp6aSession? = null + private var sessionKey: ByteArray? = null + var complete: Boolean = false + private set + + fun handle(body: ByteArray): ByteArray { + val tlv = Tlv8Codec.decode(body) + val state = tlv[TYPE_STATE]?.firstOrNull()?.toInt()?.and(0xff) + return try { + when (state) { + 1 -> m2() + 3 -> m4(tlv) + 5 -> m6(tlv) + else -> err(state ?: 0) + } + } catch (_: Exception) { + err(state ?: 0) + } + } + + private fun m2(): ByteArray { + val session = Srp6a.start(SETUP_USERNAME, SETUP_CODE) + srp = session + return Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_STATE, byteArrayOf(2)), + Tlv8Item(TYPE_PUBLIC_KEY, session.publicKey), + Tlv8Item(TYPE_SALT, session.salt), + ), + ) + } + + private fun m4(tlv: Map): ByteArray { + val publicKeyA = tlv[TYPE_PUBLIC_KEY] + val proof = tlv[TYPE_PROOF] + val session = srp + if (session == null || publicKeyA == null || proof == null) return err(4) + val result = session.verify(publicKeyA, proof) + if (!result.ok || result.sessionKey == null || result.serverProof == null) return err(4) + sessionKey = result.sessionKey + return Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_STATE, byteArrayOf(4)), + Tlv8Item(TYPE_PROOF, result.serverProof), + ), + ) + } + + private fun m6(tlv: Map): ByteArray { + val encrypted = tlv[TYPE_ENCRYPTED_DATA] + val key = sessionKey + if (key == null || encrypted == null) return err(6) + + val encryptionKey = AirPlayCrypto.hkdfSha512(key, ENCRYPT_SALT.asciiBytes(), ENCRYPT_INFO.asciiBytes()) + val sub = Tlv8Codec.decode(AirPlayCrypto.chachaOpen(encryptionKey, AirPlayCrypto.nonceLabel(MSG05), encrypted)) + val controllerId = sub[TYPE_IDENTIFIER] + val controllerLtpk = sub[TYPE_PUBLIC_KEY] + val controllerSignature = sub[TYPE_SIGNATURE] + if (controllerId == null || controllerLtpk == null || controllerSignature == null) return err(6) + + val controllerSignKey = AirPlayCrypto.hkdfSha512( + key, + CONTROLLER_SIGN_SALT.asciiBytes(), + CONTROLLER_SIGN_INFO.asciiBytes(), + ) + val controllerSignData = concatBytes(controllerSignKey, controllerId, controllerLtpk) + if (!AirPlayCrypto.ed25519Verify(controllerLtpk, controllerSignData, controllerSignature)) return err(6) + pairings.save(controllerId.toString(Charsets.UTF_8), controllerLtpk) + + val accessoryId = identity.pairingId.toByteArray(Charsets.UTF_8) + val accessorySignKey = AirPlayCrypto.hkdfSha512( + key, + ACCESSORY_SIGN_SALT.asciiBytes(), + ACCESSORY_SIGN_INFO.asciiBytes(), + ) + val accessorySignData = concatBytes(accessorySignKey, accessoryId, identity.publicKey) + val accessorySignature = AirPlayCrypto.ed25519Sign(identity.privateKey, accessorySignData) + val subResponse = Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_IDENTIFIER, accessoryId), + Tlv8Item(TYPE_PUBLIC_KEY, identity.publicKey), + Tlv8Item(TYPE_SIGNATURE, accessorySignature), + ), + ) + val sealed = AirPlayCrypto.chachaSeal(encryptionKey, AirPlayCrypto.nonceLabel(MSG06), subResponse) + complete = true + return Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_STATE, byteArrayOf(6)), + Tlv8Item(TYPE_ENCRYPTED_DATA, sealed), + ), + ) + } + + private fun err(state: Int): ByteArray = Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_STATE, byteArrayOf(state.toByte())), + Tlv8Item(TYPE_ERROR, byteArrayOf(ERROR_AUTHENTICATION.toByte())), + ), + ) + + private companion object { + const val TYPE_METHOD = 0x00 + const val TYPE_IDENTIFIER = 0x01 + const val TYPE_SALT = 0x02 + const val TYPE_PUBLIC_KEY = 0x03 + const val TYPE_PROOF = 0x04 + const val TYPE_ENCRYPTED_DATA = 0x05 + const val TYPE_STATE = 0x06 + const val TYPE_ERROR = 0x07 + const val TYPE_SIGNATURE = 0x0a + const val SETUP_USERNAME = "Pair-Setup" + const val SETUP_CODE = "3939" + const val ERROR_AUTHENTICATION = 2 + const val ENCRYPT_SALT = "Pair-Setup-Encrypt-Salt" + const val ENCRYPT_INFO = "Pair-Setup-Encrypt-Info" + const val CONTROLLER_SIGN_SALT = "Pair-Setup-Controller-Sign-Salt" + const val CONTROLLER_SIGN_INFO = "Pair-Setup-Controller-Sign-Info" + const val ACCESSORY_SIGN_SALT = "Pair-Setup-Accessory-Sign-Salt" + const val ACCESSORY_SIGN_INFO = "Pair-Setup-Accessory-Sign-Info" + const val MSG05 = "PS-Msg05" + const val MSG06 = "PS-Msg06" + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/PairVerify.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/PairVerify.kt new file mode 100644 index 0000000..67d349d --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/PairVerify.kt @@ -0,0 +1,127 @@ +package com.shilapi.xcertplay.airplay + +/** + * CarPlay pair-verify responder (accessory/server side). + * + * Uses ephemeral X25519 plus Ed25519 signatures against the controller's stored long-term key, + * then derives the ChaCha20-Poly1305 control-channel keys. + */ +class PairVerify( + private val identity: AirPlayIdentity, + private val pairings: PairingStore, +) { + data class ControlKeys(val readKey: ByteArray, val writeKey: ByteArray) + + private var ephemeralPublicKey: ByteArray? = null + private var clientEphemeralPublicKey: ByteArray? = null + private var sharedSecret: ByteArray? = null + private var encryptionKey: ByteArray? = null + private var keys: ControlKeys? = null + private var verified = false + private var controllerId: String? = null + + val isVerified: Boolean get() = verified + val verifiedControllerId: String? get() = controllerId + val controlKeys: ControlKeys? get() = keys + val shared: ByteArray? get() = sharedSecret + + fun handle(body: ByteArray): ByteArray { + val tlv = Tlv8Codec.decode(body) + val state = tlv[TYPE_STATE]?.firstOrNull()?.toInt()?.and(0xff) + return try { + when (state) { + 1 -> m2(tlv) + 3 -> m4(tlv) + else -> err(state ?: 0) + } + } catch (_: Exception) { + err(state ?: 0) + } + } + + private fun m2(tlv: Map): ByteArray { + val clientEphemeralPublicKey = tlv[TYPE_PUBLIC_KEY] ?: return err(2) + val pair = AirPlayCrypto.x25519Generate() + ephemeralPublicKey = pair.publicKey + this.clientEphemeralPublicKey = clientEphemeralPublicKey + + val shared = AirPlayCrypto.x25519Shared(pair.privateKey, clientEphemeralPublicKey) + sharedSecret = shared + val encryptionKey = AirPlayCrypto.hkdfSha512(shared, VERIFY_ENCRYPT_SALT.asciiBytes(), VERIFY_ENCRYPT_INFO.asciiBytes()) + this.encryptionKey = encryptionKey + + val accessoryId = identity.pairingId.toByteArray(Charsets.UTF_8) + val signature = AirPlayCrypto.ed25519Sign( + identity.privateKey, + concatBytes(pair.publicKey, accessoryId, clientEphemeralPublicKey), + ) + val sub = Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_IDENTIFIER, accessoryId), + Tlv8Item(TYPE_SIGNATURE, signature), + ), + ) + val sealed = AirPlayCrypto.chachaSeal(encryptionKey, AirPlayCrypto.nonceLabel(MSG02), sub) + return Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_STATE, byteArrayOf(2)), + Tlv8Item(TYPE_PUBLIC_KEY, pair.publicKey), + Tlv8Item(TYPE_ENCRYPTED_DATA, sealed), + ), + ) + } + + private fun m4(tlv: Map): ByteArray { + val encrypted = tlv[TYPE_ENCRYPTED_DATA] + val encryptionKey = this.encryptionKey + val shared = sharedSecret + val ownEphemeralPublicKey = ephemeralPublicKey + val clientEphemeralPublicKey = this.clientEphemeralPublicKey + if ( + encryptionKey == null || shared == null || ownEphemeralPublicKey == null || + clientEphemeralPublicKey == null || encrypted == null + ) { + return err(4) + } + + val sub = Tlv8Codec.decode(AirPlayCrypto.chachaOpen(encryptionKey, AirPlayCrypto.nonceLabel(MSG03), encrypted)) + val controllerId = sub[TYPE_IDENTIFIER] + val controllerSignature = sub[TYPE_SIGNATURE] + if (controllerId == null || controllerSignature == null) return err(4) + + val controllerLtpk = pairings.get(controllerId.toString(Charsets.UTF_8)) ?: return err(4) + val signatureData = concatBytes(clientEphemeralPublicKey, controllerId, ownEphemeralPublicKey) + if (!AirPlayCrypto.ed25519Verify(controllerLtpk, signatureData, controllerSignature)) return err(4) + + val readKey = AirPlayCrypto.hkdfSha512(shared, CONTROL_SALT.asciiBytes(), CONTROL_WRITE_KEY_INFO.asciiBytes()) + val writeKey = AirPlayCrypto.hkdfSha512(shared, CONTROL_SALT.asciiBytes(), CONTROL_READ_KEY_INFO.asciiBytes()) + keys = ControlKeys(readKey, writeKey) + verified = true + this.controllerId = controllerId.toString(Charsets.UTF_8) + return Tlv8Codec.encode(listOf(Tlv8Item(TYPE_STATE, byteArrayOf(4)))) + } + + private fun err(state: Int): ByteArray = Tlv8Codec.encode( + listOf( + Tlv8Item(TYPE_STATE, byteArrayOf(state.toByte())), + Tlv8Item(TYPE_ERROR, byteArrayOf(ERROR_AUTHENTICATION.toByte())), + ), + ) + + private companion object { + const val TYPE_IDENTIFIER = 0x01 + const val TYPE_PUBLIC_KEY = 0x03 + const val TYPE_ENCRYPTED_DATA = 0x05 + const val TYPE_STATE = 0x06 + const val TYPE_ERROR = 0x07 + const val TYPE_SIGNATURE = 0x0a + const val ERROR_AUTHENTICATION = 2 + const val VERIFY_ENCRYPT_SALT = "Pair-Verify-Encrypt-Salt" + const val VERIFY_ENCRYPT_INFO = "Pair-Verify-Encrypt-Info" + const val CONTROL_SALT = "Control-Salt" + const val CONTROL_READ_KEY_INFO = "Control-Read-Encryption-Key" + const val CONTROL_WRITE_KEY_INFO = "Control-Write-Encryption-Key" + const val MSG02 = "PV-Msg02" + const val MSG03 = "PV-Msg03" + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/PairingStore.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/PairingStore.kt new file mode 100644 index 0000000..f853ff1 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/PairingStore.kt @@ -0,0 +1,16 @@ +package com.shilapi.xcertplay.airplay + +/** Store of paired controllers keyed by their long-term Ed25519 public key. */ +class PairingStore(private val onSave: ((String, ByteArray) -> Unit)? = null) { + private val entries = HashMap() + + fun save(identifier: String, longTermPublicKey: ByteArray) { + val copy = longTermPublicKey.copyOf() + entries[identifier] = copy + onSave?.invoke(identifier, copy) + } + + fun get(identifier: String): ByteArray? = entries[identifier]?.copyOf() + + fun clear() = entries.clear() +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt new file mode 100644 index 0000000..df2dd92 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/RtspMessage.kt @@ -0,0 +1,108 @@ +package com.shilapi.xcertplay.airplay + +/** + * RTSP/HTTP-style request framing for the CarPlay control channel. + * + * The control connection on TCP :7000 speaks a text request line plus headers plus an optional + * body, using the same framing RTSP and HTTP share. Encryption, once pair-verify completes, wraps + * this framing in a separate layer. + */ +object RtspMessage { + data class Request( + val method: String, + val path: String, + val protocol: String, + val headers: Map, + val body: ByteArray, + ) + + data class Response( + val protocol: String? = null, + val status: Int? = null, + val statusText: String? = null, + val headers: Map = emptyMap(), + val body: ByteArray = ByteArray(0), + ) + + data class Parsed(val messages: List, val rest: ByteArray) + + private val headerEnd = "\r\n\r\n".toByteArray(Charsets.US_ASCII) + private val statusText = mapOf( + 200 to "OK", + 400 to "Bad Request", + 404 to "Not Found", + 500 to "Internal Server Error", + ) + + /** Parses as many complete messages as are buffered, retaining any partial remainder. */ + fun parseMessages(buffer: ByteArray): Parsed { + val messages = ArrayList() + var offset = 0 + while (offset < buffer.size) { + val headerEndIndex = indexOf(buffer, headerEnd, offset) ?: break + val headerText = String(buffer, offset, headerEndIndex - offset, Charsets.US_ASCII) + val lines = headerText.split("\r\n") + if (lines.isEmpty()) { + offset = headerEndIndex + headerEnd.size + continue + } + val requestLine = lines.first().split(" ", limit = 3) + val headers = HashMap() + for (line in lines.drop(1)) { + val separator = line.indexOf(':') + if (separator == -1) continue + headers[line.substring(0, separator).trim().lowercase()] = + line.substring(separator + 1).trim() + } + val bodyStart = headerEndIndex + headerEnd.size + val contentLength = headers["content-length"]?.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val bodyEnd = bodyStart + contentLength + if (bodyEnd > buffer.size) break + messages.add( + Request( + method = requestLine[0], + path = requestLine.getOrElse(1) { "" }, + protocol = requestLine.getOrElse(2) { "RTSP/1.0" }, + headers = headers, + body = buffer.copyOfRange(bodyStart, bodyEnd), + ), + ) + offset = bodyEnd + } + return Parsed(messages, buffer.copyOfRange(offset, buffer.size)) + } + + /** Builds a response, echoing the request protocol and CSeq. */ + fun buildResponse(request: Request, response: Response): ByteArray { + val protocol = response.protocol ?: request.protocol + val status = response.status ?: 200 + val statusLine = response.statusText ?: statusText[status] ?: "OK" + val body = response.body + val headers = LinkedHashMap(response.headers) + request.headers["cseq"]?.let { headers["CSeq"] = it } + headers["Content-Length"] = body.size.toString() + + val head = buildString { + append(protocol).append(' ').append(status).append(' ').append(statusLine).append("\r\n") + for ((name, value) in headers) append(name).append(": ").append(value).append("\r\n") + append("\r\n") + } + return head.toByteArray(Charsets.US_ASCII) + body + } + + private fun indexOf(source: ByteArray, target: ByteArray, start: Int): Int? { + if (target.isEmpty()) return start + val first = target[0] + for (index in start..source.size - target.size) { + if (source[index] == first && matches(source, index, target)) return index + } + return null + } + + private fun matches(source: ByteArray, offset: Int, target: ByteArray): Boolean { + for (index in target.indices) { + if (source[offset + index] != target[index]) return false + } + return true + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/SafeArea.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/SafeArea.kt new file mode 100644 index 0000000..3cdadb3 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/SafeArea.kt @@ -0,0 +1,172 @@ +package com.shilapi.xcertplay.airplay + +/** A safe-area rectangle in the activity's current pixel coordinate space. */ +data class SafeAreaRect( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, +) { + init { + require(left >= 0 && top >= 0 && right > left && bottom > top) { + "Safe area coordinates must describe a non-empty rectangle" + } + } + + val width: Int get() = right - left + val height: Int get() = bottom - top + + fun clampTo(widthPixels: Int, heightPixels: Int): SafeAreaRect { + require(widthPixels > 0 && heightPixels > 0) { "Activity dimensions must be positive" } + val clampedLeft = left.coerceIn(0, widthPixels - 1) + val clampedTop = top.coerceIn(0, heightPixels - 1) + val clampedRight = right.coerceIn(clampedLeft + 1, widthPixels) + val clampedBottom = bottom.coerceIn(clampedTop + 1, heightPixels) + return SafeAreaRect(clampedLeft, clampedTop, clampedRight, clampedBottom) + } +} + +/** Resolves activity-space safe areas into the scaled AirPlay display coordinate space. */ +object AirPlaySafeArea { + fun full(widthPixels: Int, heightPixels: Int): SafeAreaRect { + require(widthPixels > 0 && heightPixels > 0) { "Activity dimensions must be positive" } + return SafeAreaRect(0, 0, widthPixels, heightPixels) + } + + /** + * Default editor boundaries are one quarter of the activity from each edge, leaving the middle + * half of the activity as the safe area. + */ + fun default(widthPixels: Int, heightPixels: Int): SafeAreaRect { + require(widthPixels > 0 && heightPixels > 0) { "Activity dimensions must be positive" } + val left = roundFraction(widthPixels, 1, 4).coerceIn(0, widthPixels - 1) + val top = roundFraction(heightPixels, 1, 4).coerceIn(0, heightPixels - 1) + val right = roundFraction(widthPixels, 3, 4) + .coerceIn(left + 1, widthPixels) + val bottom = roundFraction(heightPixels, 3, 4) + .coerceIn(top + 1, heightPixels) + return SafeAreaRect(left, top, right, bottom) + } + + fun resolve( + mapping: SafeAreaRect?, + activityWidthPixels: Int, + activityHeightPixels: Int, + ): SafeAreaRect = mapping?.clampTo(activityWidthPixels, activityHeightPixels) + ?: full(activityWidthPixels, activityHeightPixels) + + fun scaleRect( + rect: SafeAreaRect, + sourceWidthPixels: Int, + sourceHeightPixels: Int, + targetWidthPixels: Int, + targetHeightPixels: Int, + ): SafeAreaRect { + require(sourceWidthPixels > 0 && sourceHeightPixels > 0) { + "Source dimensions must be positive" + } + require(targetWidthPixels > 0 && targetHeightPixels > 0) { + "Target dimensions must be positive" + } + val clamped = rect.clampTo(sourceWidthPixels, sourceHeightPixels) + val left = scaleCoordinate(clamped.left, sourceWidthPixels, targetWidthPixels) + .coerceIn(0, targetWidthPixels - 1) + val top = scaleCoordinate(clamped.top, sourceHeightPixels, targetHeightPixels) + .coerceIn(0, targetHeightPixels - 1) + val right = scaleCoordinate(clamped.right, sourceWidthPixels, targetWidthPixels) + .coerceIn(left + 1, targetWidthPixels) + val bottom = scaleCoordinate(clamped.bottom, sourceHeightPixels, targetHeightPixels) + .coerceIn(top + 1, targetHeightPixels) + return SafeAreaRect(left, top, right, bottom) + } + + /** + * Converts a safe area stored against the activity size into insets for the scaled AirPlay + * display. Coordinates are normalized by axis, so a stored width of 1900/1920 remains 1900/1920 + * of the negotiated display after the user's display scale has been applied. + */ + fun toInsets( + mapping: SafeAreaRect?, + activityWidthPixels: Int, + activityHeightPixels: Int, + displayWidthPixels: Int, + displayHeightPixels: Int, + ): AirPlayInsets { + require(activityWidthPixels > 0 && activityHeightPixels > 0) { + "Activity dimensions must be positive" + } + require(displayWidthPixels > 0 && displayHeightPixels > 0) { + "Display dimensions must be positive" + } + val rect = resolve(mapping, activityWidthPixels, activityHeightPixels) + val left = scaleCoordinate(rect.left, activityWidthPixels, displayWidthPixels) + .coerceIn(0, displayWidthPixels - 1) + val top = scaleCoordinate(rect.top, activityHeightPixels, displayHeightPixels) + .coerceIn(0, displayHeightPixels - 1) + val right = scaleCoordinate(rect.right, activityWidthPixels, displayWidthPixels) + .coerceIn(left + 1, displayWidthPixels) + val bottom = scaleCoordinate(rect.bottom, activityHeightPixels, displayHeightPixels) + .coerceIn(top + 1, displayHeightPixels) + val aligned = alignDimensionsToEven( + SafeAreaRect(left, top, right, bottom), + displayWidthPixels, + displayHeightPixels, + ) + return AirPlayInsets( + top = aligned.top, + bottom = displayHeightPixels - aligned.bottom, + left = aligned.left, + right = displayWidthPixels - aligned.right, + ) + } + + private fun alignDimensionsToEven( + rect: SafeAreaRect, + widthPixels: Int, + heightPixels: Int, + ): SafeAreaRect { + var left = rect.left + var top = rect.top + var right = rect.right + var bottom = rect.bottom + if ((right - left) % 2 != 0) { + if (right < widthPixels) right += 1 else left -= 1 + } + if ((bottom - top) % 2 != 0) { + if (bottom < heightPixels) bottom += 1 else top -= 1 + } + return SafeAreaRect( + left = left.coerceIn(0, widthPixels - 1), + top = top.coerceIn(0, heightPixels - 1), + right = right.coerceIn(left + 1, widthPixels), + bottom = bottom.coerceIn(top + 1, heightPixels), + ) + } + + private fun scaleCoordinate(coordinate: Int, source: Int, target: Int): Int = + ((coordinate.toLong() * target + source / 2L) / source).toInt() + + private fun roundFraction(value: Int, numerator: Int, denominator: Int): Int = + ((value.toLong() * numerator + denominator / 2L) / denominator).toInt() +} + +/** Stable encoding used by the settings store for one activity-size mapping. */ +object SafeAreaCodec { + fun encode(rect: SafeAreaRect): String = + listOf(rect.left, rect.top, rect.right, rect.bottom).joinToString(",") + + fun decode(value: String?): SafeAreaRect? { + val parts = value?.split(',') ?: return null + if (parts.size != 4) return null + return try { + SafeAreaRect( + left = parts[0].toInt(), + top = parts[1].toInt(), + right = parts[2].toInt(), + bottom = parts[3].toInt(), + ) + } catch (_: IllegalArgumentException) { + null + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt new file mode 100644 index 0000000..1c2ae68 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/ScreenStream.kt @@ -0,0 +1,215 @@ +package com.shilapi.xcertplay.airplay + +import android.util.Log +import java.io.Closeable +import java.io.InputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +enum class VideoCodec { H264, H265 } + +/** + * Receives one CarPlay screen stream on a TCP data port. + * + * Each message is a 128-byte AirPlayScreenHeader followed by a body: a clear VideoConfig + * (avcC/hvcC) or a ChaCha20-Poly1305 sealed VideoFrame. The key is the DataStream output key + * and the per-frame nonce is an 8-byte little-endian counter. + */ +class ScreenStream(private val key: ByteArray) : Closeable { + interface Listener { + fun onCodec(codec: VideoCodec) {} + fun onConfig(codecData: ByteArray) {} + fun onFrame(naluBytes: ByteArray) {} + fun onClosed(cause: Throwable?) {} + } + + private val closed = AtomicBoolean(false) + private val frameCounter = AtomicLong(0) + private val firstFrameLogged = AtomicBoolean(false) + private var server: ServerSocket? = null + private var socket: Socket? = null + private var thread: Thread? = null + @Volatile private var listener: Listener = object : Listener {} + + fun listen(listener: Listener): Int { + this.listener = listener + val bound = ServerSocket() + bound.reuseAddress = true + bound.bind(InetSocketAddress(InetAddress.getByName("::"), 0)) + server = bound + thread = Thread({ accept(bound) }, "airplay-screen").apply { isDaemon = true; start() } + return bound.localPort + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + safeClose(socket) + safeClose(server) + thread?.interrupt() + } + + private fun accept(bound: ServerSocket) { + try { + val accepted = bound.accept() + socket = accepted + run(accepted) + } catch (error: Exception) { + if (!closed.get()) listener.onClosed(error) + } + } + + private fun run(sock: Socket) { + var failure: Throwable? = null + try { + val input = sock.getInputStream() + while (!closed.get()) { + val header = readFully(input, HEADER_LEN) ?: break + val bodySize = readU32Le(header, 0) + if (bodySize > MAX_BODY) break + val body = readFully(input, bodySize) ?: break + onMessage(header, body) + } + } catch (error: Exception) { + failure = error + } finally { + if (socket === sock) socket = null + safeClose(sock) + if (!closed.get()) listener.onClosed(failure) + } + } + + private fun onMessage(header: ByteArray, body: ByteArray) { + when (header[OPCODE_OFFSET].toInt() and 0xff) { + OP_VIDEO_FRAME -> { + val payload = if (body.size >= ScreenCodec.TAG_SIZE) { + ScreenCodec.decryptFrame(key, frameCounter.get(), header, body) + .also { frameCounter.incrementAndGet() } + } else { + body + } + if (firstFrameLogged.compareAndSet(false, true)) { + Log.i( + TAG, + "video first decrypted frame sealed=${body.size} plain=${payload.size} " + + "head=${payload.hexPrefix(16)}", + ) + } + listener.onFrame(ScreenCodec.lengthPrefixedToAnnexB(payload)) + } + OP_VIDEO_CONFIG -> { + val (codec, codecData) = ScreenCodec.detectConfig(body) + Log.i(TAG, "video codec config codec=$codec body=${body.size} data=${codecData.size}") + listener.onCodec(codec) + listener.onConfig(codecData) + } + } + } + + private fun readFully(input: InputStream, length: Int): ByteArray? { + if (length < 0) return null + val output = ByteArray(length) + var offset = 0 + while (offset < length) { + val read = input.read(output, offset, length - offset) + if (read < 0) return null + offset += read + } + return output + } + + private companion object { + const val TAG = "xcertplay-usb" + const val HEADER_LEN = 128 + const val OPCODE_OFFSET = 4 + const val OP_VIDEO_FRAME = 0 + const val OP_VIDEO_CONFIG = 1 + const val MAX_BODY = 8 * 1024 * 1024 + } +} + +private fun ByteArray.hexPrefix(length: Int): String = + take(length).joinToString("") { "%02x".format(it.toInt() and 0xff) } + +/** Extracts the avcC/hvcC codec-data record from a VideoConfig payload. */ +object ScreenCodec { + fun decryptFrame(key: ByteArray, counter: Long, header: ByteArray, body: ByteArray): ByteArray = + if (body.size < TAG_SIZE) body + else AirPlayCrypto.chachaOpen(key, AirPlayCrypto.nonce64(counter), body, header) + + /** + * Replaces each four-byte NAL length with an Annex B start code in place. + * + * The payload is left untouched unless every length-prefixed NAL is valid, so malformed + * input keeps its original bytes for the normal decoder error path. + */ + fun lengthPrefixedToAnnexB(payload: ByteArray): ByteArray { + if (payload.size < 4 || payload.startsWithStartCode()) return payload + + var offset = 0 + while (offset + 4 <= payload.size) { + val length = readU32Be(payload, offset) + offset += 4 + if (length <= 0 || offset + length > payload.size) return payload + offset += length + } + if (offset != payload.size) return payload + + offset = 0 + while (offset + 4 <= payload.size) { + val length = readU32Be(payload, offset) + payload[offset] = 0 + payload[offset + 1] = 0 + payload[offset + 2] = 0 + payload[offset + 3] = 1 + offset += 4 + length + } + return payload + } + + fun detectConfig(payload: ByteArray): Pair { + for (index in 4..payload.size - 4) { + val fourcc = String(payload, index, 4, Charsets.US_ASCII) + when (fourcc) { + "hvcC" -> return VideoCodec.H265 to payload.copyOfRange(index + 4, payload.size) + "avcC" -> return VideoCodec.H264 to payload.copyOfRange(index + 4, payload.size) + } + } + return if (looksLikeAvcC(payload)) VideoCodec.H264 to payload else VideoCodec.H265 to payload + } + + private fun looksLikeAvcC(payload: ByteArray): Boolean { + if (payload.size < 9) return false + if ((payload[5].toInt() and 0x1f) < 1) return false + val spsLength = readU16Be(payload, 6) + if (8 + spsLength > payload.size) return false + return (payload[8].toInt() and 0x1f) == 7 + } + + private fun readU16Be(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 8) or (source[offset + 1].toInt() and 0xff) + + const val TAG_SIZE = 16 +} + +private fun ByteArray.startsWithStartCode(): Boolean = + size >= 4 && + this[0] == 0.toByte() && + this[1] == 0.toByte() && + this[2] == 0.toByte() && + this[3] == 1.toByte() + +private fun readU32Be(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 24) or + ((source[offset + 1].toInt() and 0xff) shl 16) or + ((source[offset + 2].toInt() and 0xff) shl 8) or + (source[offset + 3].toInt() and 0xff) + +private fun readU32Le(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or + ((source[offset + 1].toInt() and 0xff) shl 8) or + ((source[offset + 2].toInt() and 0xff) shl 16) or + ((source[offset + 3].toInt() and 0xff) shl 24) diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/Srp6a.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/Srp6a.kt new file mode 100644 index 0000000..5a2cc37 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/Srp6a.kt @@ -0,0 +1,110 @@ +package com.shilapi.xcertplay.airplay + +import java.math.BigInteger +import java.security.SecureRandom + +/** + * SRP-6a server (RFC 5054 3072-bit group, SHA-512), the AirPlay pair-setup variant. + * + * The username is the literal "Pair-Setup" and the password is the fixed setup code. This is a + * clean-room port of LIVI's `srp.ts` over java.math.BigInteger. + */ +object Srp6a { + private const val SALT_BYTES = 16 + private const val PRIVATE_BYTES = 32 + private val random = SecureRandom() + private val n = BigInteger(N_HEX, 16) + private val g = BigInteger.valueOf(5) + private val multiplier = toBigInt(AirPlayCrypto.sha512(toBytes(n), pad(g))) + + fun start(username: String, password: String): Srp6aSession { + val salt = ByteArray(SALT_BYTES).also(random::nextBytes) + val identifier = username.toByteArray(Charsets.UTF_8) + val pass = password.toByteArray(Charsets.UTF_8) + val x = toBigInt(AirPlayCrypto.sha512(salt, AirPlayCrypto.sha512(identifier, COLON, pass))) + val verifier = g.modPow(x, n) + val privateExponent = toBigInt(ByteArray(PRIVATE_BYTES).also(random::nextBytes)) + val publicKeyB = (multiplier * verifier + g.modPow(privateExponent, n)).mod(n) + return Srp6aSession(salt, publicKeyB, identifier, verifier, privateExponent) + } + + internal fun toBytes(value: BigInteger): ByteArray { + if (value.signum() == 0) return byteArrayOf(0) + val encoded = value.toByteArray() + return if (encoded.size > 1 && encoded[0] == 0.toByte()) encoded.copyOfRange(1, encoded.size) else encoded + } + + internal fun toBigInt(bytes: ByteArray): BigInteger = BigInteger(1, bytes) + + internal fun pad(value: BigInteger): ByteArray { + val bytes = toBytes(value) + if (bytes.size >= N_BYTES) return bytes + return ByteArray(N_BYTES).also { bytes.copyInto(it, N_BYTES - bytes.size) } + } + + internal fun modulus(): BigInteger = n + internal fun generator(): BigInteger = g + + /** RFC 5054 3072-bit prime. */ + private const val N_HEX = + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + + "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + + "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + + "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + + "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183" + + "995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A" + + "85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7AB" + + "F5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D8" + + "7602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208" + + "E24FA074E5AB3143DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF" + + private const val N_BYTES = 384 + private val COLON = byteArrayOf(':'.code.toByte()) +} + +data class Srp6aResult( + val ok: Boolean, + val sessionKey: ByteArray? = null, + val serverProof: ByteArray? = null, +) + +class Srp6aSession internal constructor( + val salt: ByteArray, + private val publicKeyB: BigInteger, + private val identifier: ByteArray, + private val verifier: BigInteger, + private val privateExponent: BigInteger, +) { + val publicKey: ByteArray get() = Srp6a.pad(publicKeyB) + + fun verify(publicKeyA: ByteArray, clientProof: ByteArray): Srp6aResult { + val a = Srp6a.toBigInt(publicKeyA) + val n = Srp6a.modulus() + if (a.mod(n).signum() == 0) return Srp6aResult(false) + + val u = Srp6a.toBigInt(AirPlayCrypto.sha512(Srp6a.pad(a), Srp6a.pad(publicKeyB))) + val s = (a * verifier.modPow(u, n)).mod(n).modPow(privateExponent, n) + val sessionKey = AirPlayCrypto.sha512(Srp6a.toBytes(s)) + + val hashN = AirPlayCrypto.sha512(Srp6a.toBytes(n)) + val hashG = AirPlayCrypto.sha512(Srp6a.toBytes(Srp6a.generator())) + val hashXor = ByteArray(hashN.size) + for (index in hashN.indices) { + hashXor[index] = (hashN[index].toInt() xor hashG[index].toInt()).toByte() + } + val expected = AirPlayCrypto.sha512( + hashXor, + AirPlayCrypto.sha512(identifier), + salt, + Srp6a.pad(a), + Srp6a.pad(publicKeyB), + sessionKey, + ) + if (!expected.contentEquals(clientProof)) return Srp6aResult(false) + + val serverProof = AirPlayCrypto.sha512(Srp6a.pad(a), clientProof, sessionKey) + return Srp6aResult(true, sessionKey, serverProof) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/airplay/Tlv8Codec.kt b/shared/src/main/java/com/shilapi/xcertplay/airplay/Tlv8Codec.kt new file mode 100644 index 0000000..9334839 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/airplay/Tlv8Codec.kt @@ -0,0 +1,52 @@ +package com.shilapi.xcertplay.airplay + +/** HomeKit/AirPlay-style TLV8 codec used by the pairing messages. */ +object Tlv8Codec { + private const val SEPARATOR_TYPE = 0xff + private const val MAX_FRAGMENT = 255 + + fun encode(items: List): ByteArray { + val chunks = ArrayList() + var previousType: Int? = null + for (item in items) { + if (previousType == item.type) { + chunks.add(byteArrayOf(SEPARATOR_TYPE.toByte(), 0)) + } + var offset = 0 + do { + val length = minOf(MAX_FRAGMENT, item.value.size - offset) + chunks.add(byteArrayOf(item.type.toByte(), length.toByte())) + chunks.add(item.value.copyOfRange(offset, offset + length)) + offset += length + } while (offset < item.value.size) + previousType = item.type + } + return concatBytes(*chunks.toTypedArray()) + } + + fun decode(buffer: ByteArray): Map { + val output = HashMap() + var position = 0 + var lastType: Int? = null + var lastLength = 0 + while (position + 2 <= buffer.size) { + val type = buffer[position].toInt() and 0xff + val length = buffer[position + 1].toInt() and 0xff + position += 2 + if (position + length > buffer.size) break + val value = buffer.copyOfRange(position, position + length) + position += length + if (type == lastType && lastLength == MAX_FRAGMENT) { + val previous = output[type] + output[type] = if (previous != null) previous + value else value + } else { + output[type] = value + } + lastType = type + lastLength = length + } + return output + } +} + +data class Tlv8Item(val type: Int, val value: ByteArray) diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/body/Iap2BodyBuilder.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/body/Iap2BodyBuilder.kt new file mode 100644 index 0000000..23cb9c0 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/body/Iap2BodyBuilder.kt @@ -0,0 +1,174 @@ +package com.shilapi.xcertplay.iap2.body + +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoint +import com.shilapi.xcertplay.iap2.catalog.Iap2FieldSpec +import com.shilapi.xcertplay.iap2.catalog.Iap2WireType +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.iap2.wire.Iap2Parameter +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import com.shilapi.xcertplay.iap2.wire.Iap2ProtocolException +import com.shilapi.xcertplay.iap2.wire.Iap2WireCodec + +/** + * Ordered, schema-aware builder for one CSM body. + * + * The builder preserves insertion order and repeated IDs. When [endpoint] carries a schema, known + * IDs and wire types are validated at [build]; schema-less/opaque endpoints remain fully usable. + */ +class Iap2BodyBuilder private constructor( + private val endpoint: Iap2Endpoint?, + private val explicitMessageId: Int?, + private val fields: List, + private val validateSchema: Boolean, +) { + private data class Entry(val parameter: Iap2Parameter, val wireType: Iap2WireType) + + private val entries = ArrayList() + + fun void(id: Int): Iap2BodyBuilder = add(id, Iap2WireCodec.void(), Iap2WireType.VOID) + + fun u8(id: Int, value: Int): Iap2BodyBuilder = + add(id, Iap2WireCodec.u8(value), Iap2WireType.U8) + + fun bool(id: Int, value: Boolean): Iap2BodyBuilder = + add(id, Iap2WireCodec.bool(value), Iap2WireType.U8) + + fun i8(id: Int, value: Int): Iap2BodyBuilder = + add(id, Iap2WireCodec.i8(value), Iap2WireType.I8) + + fun u16(id: Int, value: Int): Iap2BodyBuilder = + add(id, Iap2WireCodec.u16(value), Iap2WireType.U16) + + fun i16(id: Int, value: Int): Iap2BodyBuilder = + add(id, Iap2WireCodec.i16(value), Iap2WireType.I16) + + fun u32(id: Int, value: Int): Iap2BodyBuilder = u32(id, value.toLong()) + + fun u32(id: Int, value: Long): Iap2BodyBuilder = + add(id, Iap2WireCodec.u32(value), Iap2WireType.U32) + + fun i32(id: Int, value: Int): Iap2BodyBuilder = + add(id, Iap2WireCodec.i32(value), Iap2WireType.I32) + + fun u64(id: Int, value: Long): Iap2BodyBuilder = + add(id, Iap2WireCodec.u64(value), Iap2WireType.U64) + + fun bytes(id: Int, value: ByteArray): Iap2BodyBuilder = + add(id, Iap2WireCodec.bytes(value), Iap2WireType.BYTES) + + fun string(id: Int, value: String): Iap2BodyBuilder = + add(id, Iap2WireCodec.string(value), Iap2WireType.STRING) + + fun strings(id: Int, values: Iterable): Iap2BodyBuilder = + add(id, Iap2WireCodec.strings(values), Iap2WireType.STRING_LIST) + + fun u16List(id: Int, values: Iterable): Iap2BodyBuilder = + add(id, Iap2WireCodec.u16List(values), Iap2WireType.U16_LIST) + + fun raw(id: Int, payload: ByteArray): Iap2BodyBuilder = + add(id, payload.copyOf(), Iap2WireType.OPAQUE) + + fun group(id: Int, block: Iap2BodyBuilder.() -> Unit): Iap2BodyBuilder { + val child = Iap2BodyBuilder( + endpoint = null, + explicitMessageId = null, + fields = schemaField(id)?.children.orEmpty(), + validateSchema = validateSchema, + ).apply(block) + val encoded = child.encodeValidated() + add(id, encoded, Iap2WireType.GROUP) + return this + } + + fun optionalVoid(id: Int, present: Boolean): Iap2BodyBuilder = + if (present) void(id) else this + + fun optionalU8(id: Int, value: Int?): Iap2BodyBuilder = + if (value == null) this else u8(id, value) + + fun optionalI8(id: Int, value: Int?): Iap2BodyBuilder = + if (value == null) this else i8(id, value) + + fun optionalU16(id: Int, value: Int?): Iap2BodyBuilder = + if (value == null) this else u16(id, value) + + fun optionalI16(id: Int, value: Int?): Iap2BodyBuilder = + if (value == null) this else i16(id, value) + + fun optionalU32(id: Int, value: Long?): Iap2BodyBuilder = + if (value == null) this else u32(id, value) + + fun optionalI32(id: Int, value: Int?): Iap2BodyBuilder = + if (value == null) this else i32(id, value) + + fun optionalU64(id: Int, value: Long?): Iap2BodyBuilder = + if (value == null) this else u64(id, value) + + fun optionalBytes(id: Int, value: ByteArray?): Iap2BodyBuilder = + if (value == null) this else bytes(id, value) + + fun optionalString(id: Int, value: String?): Iap2BodyBuilder = + if (value == null) this else string(id, value) + + fun build(): Iap2Frame { + val target = endpoint?.id ?: explicitMessageId + ?: throw IllegalStateException("A raw body builder needs an explicit message id") + return build(target) + } + + fun build(messageId: Int): Iap2Frame { + return Iap2Frame(messageId, encodeValidated()) + } + + private fun encodeValidated(): ByteArray { + if (validateSchema && fields.isNotEmpty()) validate(fields) + return Iap2ParameterList.of(entries.map { it.parameter }).encode() + } + + private fun add(id: Int, payload: ByteArray, wireType: Iap2WireType): Iap2BodyBuilder { + require(id in 0..0xffff) { "CSM parameter id must fit in u16" } + entries += Entry(Iap2Parameter(id, payload), wireType) + return this + } + + private fun schemaField(id: Int): Iap2FieldSpec? = fields.firstOrNull { it.id == id } + + private fun validate(specs: List) { + val byId = specs.associateBy(Iap2FieldSpec::id) + for (entry in entries) { + val spec = byId[entry.parameter.id] + ?: throw Iap2ProtocolException( + "Unknown body parameter 0x${entry.parameter.id.toString(16).padStart(4, '0')}", + ) + if (!wireTypeMatches(spec.type, entry.wireType)) { + throw Iap2ProtocolException( + "Body parameter 0x${spec.id.toString(16).padStart(4, '0')} expected ${spec.type}, " + + "got ${entry.wireType}", + ) + } + if (!spec.repeatable && entries.count { it.parameter.id == spec.id } > 1) { + throw Iap2ProtocolException( + "Body parameter 0x${spec.id.toString(16).padStart(4, '0')} is not repeatable", + ) + } + } + for (spec in specs.filter { it.required }) { + if (entries.none { it.parameter.id == spec.id }) { + throw Iap2ProtocolException( + "Missing required body parameter 0x${spec.id.toString(16).padStart(4, '0')} (${spec.name})", + ) + } + } + } + + private fun wireTypeMatches(expected: Iap2WireType, actual: Iap2WireType): Boolean = + expected == Iap2WireType.OPAQUE || expected == actual + + companion object { + fun of(endpoint: Iap2Endpoint): Iap2BodyBuilder = + Iap2BodyBuilder(endpoint, null, endpoint.fields, validateSchema = true) + + fun opaque(messageId: Int): Iap2BodyBuilder = + Iap2BodyBuilder(null, messageId, emptyList(), validateSchema = false) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/body/Iap2BodyReader.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/body/Iap2BodyReader.kt new file mode 100644 index 0000000..5d0c550 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/body/Iap2BodyReader.kt @@ -0,0 +1,196 @@ +package com.shilapi.xcertplay.iap2.body + +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoint +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.iap2.wire.Iap2Parameter +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import com.shilapi.xcertplay.iap2.wire.Iap2ProtocolException +import com.shilapi.xcertplay.iap2.wire.Iap2WireCodec + +/** + * Typed reader over an ordered CSM body. + * + * The reader does not discard unknown or repeated parameters. Callers can use the scalar helpers + * for known fields and [raw] or [all] for forward-compatible values. + */ +class Iap2BodyReader private constructor( + private val parameters: Iap2ParameterList, +) { + val size: Int get() = parameters.size + + fun isEmpty(): Boolean = parameters.isEmpty() + + fun has(id: Int): Boolean = parameters.has(id) + + fun first(id: Int): Iap2Parameter? = parameters.first(id) + + fun require(id: Int, description: String = defaultDescription(id)): Iap2Parameter = + parameters.require(id, description) + + fun all(id: Int): List = parameters.all(id) + + fun raw(id: Int): ByteArray = require(id).payload + + fun optionalRaw(id: Int): ByteArray? = first(id)?.payload + + fun void(id: Int): Boolean { + val parameter = require(id) + requireEmpty(parameter, id) + return true + } + + fun optionalVoid(id: Int): Boolean = first(id)?.let { + requireEmpty(it, id) + true + } ?: false + + fun u8(id: Int): Int = Iap2WireCodec.readU8(scalar(id, 1)) + + fun optionalU8(id: Int): Int? = first(id)?.let { + requireSize(it, id, 1) + Iap2WireCodec.readU8(it.payload) + } + + fun i8(id: Int): Int = Iap2WireCodec.readI8(scalar(id, 1)) + + fun optionalI8(id: Int): Int? = first(id)?.let { + requireSize(it, id, 1) + Iap2WireCodec.readI8(it.payload) + } + + fun bool(id: Int): Boolean = u8(id) != 0 + + fun optionalBool(id: Int): Boolean? = optionalU8(id)?.let { it != 0 } + + fun u16(id: Int): Int = Iap2WireCodec.readU16(scalar(id, 2)) + + fun optionalU16(id: Int): Int? = first(id)?.let { + requireSize(it, id, 2) + Iap2WireCodec.readU16(it.payload) + } + + fun i16(id: Int): Int = Iap2WireCodec.readI16(scalar(id, 2)) + + fun optionalI16(id: Int): Int? = first(id)?.let { + requireSize(it, id, 2) + Iap2WireCodec.readI16(it.payload) + } + + fun u32(id: Int): Long = Iap2WireCodec.readU32(scalar(id, 4)) + + fun optionalU32(id: Int): Long? = first(id)?.let { + requireSize(it, id, 4) + Iap2WireCodec.readU32(it.payload) + } + + fun i32(id: Int): Int = Iap2WireCodec.readI32(scalar(id, 4)) + + fun optionalI32(id: Int): Int? = first(id)?.let { + requireSize(it, id, 4) + Iap2WireCodec.readI32(it.payload) + } + + fun u64(id: Int): Long = Iap2WireCodec.readU64(scalar(id, 8)) + + fun optionalU64(id: Int): Long? = first(id)?.let { + requireSize(it, id, 8) + Iap2WireCodec.readU64(it.payload) + } + + fun bytes(id: Int): ByteArray = raw(id) + + fun optionalBytes(id: Int): ByteArray? = optionalRaw(id) + + fun string(id: Int): String = try { + Iap2WireCodec.readString(raw(id)) + } catch (failure: Iap2ProtocolException) { + throw malformed(id, failure) + } + + fun optionalString(id: Int): String? = first(id)?.let { + try { + Iap2WireCodec.readString(it.payload) + } catch (failure: Iap2ProtocolException) { + throw malformed(id, failure) + } + } + + fun strings(id: Int): List = try { + Iap2WireCodec.readStrings(raw(id)) + } catch (failure: Iap2ProtocolException) { + throw malformed(id, failure) + } + + fun optionalStrings(id: Int): List? = first(id)?.let { + try { + Iap2WireCodec.readStrings(it.payload) + } catch (failure: Iap2ProtocolException) { + throw malformed(id, failure) + } + } + + fun u16List(id: Int): List = try { + Iap2WireCodec.readU16List(raw(id)) + } catch (failure: Iap2ProtocolException) { + throw malformed(id, failure) + } + + fun optionalU16List(id: Int): List? = first(id)?.let { + try { + Iap2WireCodec.readU16List(it.payload) + } catch (failure: Iap2ProtocolException) { + throw malformed(id, failure) + } + } + + fun group(id: Int): Iap2BodyReader = + Iap2BodyReader(Iap2ParameterList.parse(raw(id))) + + fun optionalGroup(id: Int): Iap2BodyReader? = optionalRaw(id)?.let { + Iap2BodyReader(Iap2ParameterList.parse(it)) + } + + fun list(): List = parameters.asList() + + private fun scalar(id: Int, expectedBytes: Int): ByteArray { + val parameter = require(id) + requireSize(parameter, id, expectedBytes) + return parameter.payload + } + + private fun requireSize(parameter: Iap2Parameter, id: Int, expectedBytes: Int) { + if (parameter.payload.size != expectedBytes) { + throw Iap2ProtocolException( + "Parameter 0x${hex(id)} expected $expectedBytes bytes, got ${parameter.payload.size}", + ) + } + } + + private fun requireEmpty(parameter: Iap2Parameter, id: Int) { + if (parameter.payload.isNotEmpty()) { + throw Iap2ProtocolException("Parameter 0x${hex(id)} must be void") + } + } + + private fun malformed(id: Int, failure: Iap2ProtocolException): Iap2ProtocolException = + Iap2ProtocolException("Malformed parameter 0x${hex(id)}", failure) + + private fun defaultDescription(id: Int): String = "parameter 0x${hex(id)}" + + private fun hex(id: Int): String = id.toString(16).padStart(4, '0') + + companion object { + fun of(frame: Iap2Frame): Iap2BodyReader = + Iap2BodyReader(Iap2ParameterList.parse(frame.payload)) + + fun of(body: Iap2ParameterList): Iap2BodyReader = Iap2BodyReader(body) + + /** Validates the endpoint name without requiring a caller-side cast or map lookup. */ + fun of(endpoint: Iap2Endpoint, frame: Iap2Frame): Iap2BodyReader { + require(frame.messageId == endpoint.id) { + "Frame 0x${frame.messageId.toString(16)} is not ${endpoint.name}" + } + return of(frame) + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/catalog/Iap2Endpoint.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/catalog/Iap2Endpoint.kt new file mode 100644 index 0000000..76e2f19 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/catalog/Iap2Endpoint.kt @@ -0,0 +1,920 @@ +package com.shilapi.xcertplay.iap2.catalog + +enum class Iap2Direction { + IPHONE_TO_ACCESSORY, + ACCESSORY_TO_IPHONE, + BIDIRECTIONAL, +} + +enum class Iap2Confidence { + HIGH, + MEDIUM, +} + +/** A wire-level body value kind from the endpoint reference. */ +enum class Iap2WireType { + VOID, + U8, + I8, + U16, + I16, + U32, + I32, + U64, + U16_LIST, + BYTES, + STRING, + STRING_LIST, + GROUP, + OPAQUE, +} + +/** + * One endpoint field description. + * + * A group's [children] use the same parameter namespace as its parent. [repeatable] describes + * repeated parameter IDs or repeated group records; the body builder and reader preserve both. + */ +data class Iap2FieldSpec( + val id: Int, + val name: String, + val type: Iap2WireType, + val required: Boolean = false, + val repeatable: Boolean = false, + val children: List = emptyList(), +) + +/** + * Metadata for one CSM endpoint. + * + * [fields] may be empty for endpoints whose reference body is intentionally opaque or has not yet + * received a typed schema. The endpoint itself is still registered, named, and usable through the + * generic body builder. + */ +data class Iap2Endpoint( + val id: Int, + val name: String, + val feature: String, + val direction: Iap2Direction, + val confidence: Iap2Confidence = Iap2Confidence.HIGH, + val fields: List = emptyList(), +) + +/** + * The endpoint registry from IAP2_ENDPOINT_BODY_REFERENCE.zh-CN.md. + * + * The registry is intentionally source-level rather than loaded from markdown at runtime. This + * keeps Android builds deterministic and lets typed message code refer to named endpoints. + */ +object Iap2Endpoints { + val START_IDENTIFICATION = endpoint( + 0x1d00, + "StartIdentification", + "Identification", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val IDENTIFICATION_INFORMATION = endpoint( + 0x1d01, + "IdentificationInformation", + "Identification", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf( + field(0, "name", Iap2WireType.STRING, required = true), + field(1, "modelIdentifier", Iap2WireType.STRING, required = true), + field(2, "manufacturer", Iap2WireType.STRING, required = true), + field(3, "serialNumber", Iap2WireType.STRING, required = true), + field(4, "firmwareVersion", Iap2WireType.STRING, required = true), + field(5, "hardwareVersion", Iap2WireType.STRING, required = true), + field(6, "MessagesSentByAccessory", Iap2WireType.U16_LIST, required = true), + field(7, "MessagesReceivedFromDevice", Iap2WireType.U16_LIST, required = true), + field(8, "powerProvidingCapability", Iap2WireType.U8), + field(9, "maximumCurrentDrawnFromDevice", Iap2WireType.U16), + field(10, "externalAccessoryProtocols", Iap2WireType.GROUP), + field(12, "currentLanguage", Iap2WireType.STRING, required = true), + field(13, "supportedLanguages", Iap2WireType.STRING_LIST, required = true), + field( + 16, + "USBHostTransport", + Iap2WireType.GROUP, + children = listOf( + field(0, "componentID", Iap2WireType.U16), + field(1, "name", Iap2WireType.STRING), + field(2, "isSupported", Iap2WireType.VOID), + field(3, "interfaceNumber", Iap2WireType.U8), + field(4, "isAvailable", Iap2WireType.VOID), + ), + ), + field( + 17, + "BluetoothTransport", + Iap2WireType.GROUP, + children = listOf( + field(0, "componentID", Iap2WireType.U16), + field(1, "name", Iap2WireType.STRING), + field(2, "isSupported", Iap2WireType.VOID), + field(3, "bluetoothMAC", Iap2WireType.BYTES), + field(4, "transportName", Iap2WireType.STRING), + field(5, "isAvailable", Iap2WireType.VOID), + ), + ), + field(20, "VehicleInformation", Iap2WireType.GROUP), + field(21, "VehicleStatus", Iap2WireType.GROUP), + field(22, "LocationInformation", Iap2WireType.GROUP), + field( + 24, + "WirelessCarPlayTransport", + Iap2WireType.GROUP, + children = listOf( + field(0, "componentID", Iap2WireType.U16), + field(1, "SSID", Iap2WireType.STRING), + field(2, "isSupported", Iap2WireType.VOID), + field(3, "transportIdentifier", Iap2WireType.U16), + field(4, "isAvailable", Iap2WireType.VOID), + field(5, "isEnabled", Iap2WireType.VOID), + ), + ), + ), + ) + val IDENTIFICATION_ACCEPTED = endpoint( + 0x1d02, + "IdentificationAccepted", + "Identification", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val IDENTIFICATION_REJECTED = endpoint( + 0x1d03, + "IdentificationRejected", + "Identification", + Iap2Direction.BIDIRECTIONAL, + ) + val CANCEL_IDENTIFICATION = endpoint( + 0x1d05, + "CancelIdentification", + "Identification", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val IDENTIFICATION_INFORMATION_UPDATE = endpoint( + 0x1d06, + "IdentificationInformationUpdate", + "Identification", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + + val REQUEST_AUTHENTICATION_CERTIFICATE = endpoint( + 0xaa00, + "RequestAuthenticationCertificate", + "AccAuthentication", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val AUTHENTICATION_CERTIFICATE = endpoint( + 0xaa01, + "AuthenticationCertificate", + "AccAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf(field(0, "certificate", Iap2WireType.BYTES, required = true)), + ) + val REQUEST_AUTHENTICATION_CHALLENGE_RESPONSE = endpoint( + 0xaa02, + "RequestAuthenticationChallengeResponse", + "AccAuthentication", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf(field(0, "challenge", Iap2WireType.BYTES, required = true)), + ) + val AUTHENTICATION_RESPONSE = endpoint( + 0xaa03, + "AuthenticationResponse", + "AccAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf(field(0, "signatureResponse", Iap2WireType.BYTES, required = true)), + ) + val AUTHENTICATION_FAILED = endpoint( + 0xaa04, + "AuthenticationFailed", + "AccAuthentication", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val AUTHENTICATION_SUCCEEDED = endpoint( + 0xaa05, + "AuthenticationSucceeded", + "AccAuthentication", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val AUTHENTICATION_CERTIFICATE_SERIAL = endpoint( + 0xaa06, + "AuthenticationCertificateSerial", + "AccAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + + val REQUEST_DEVICE_CERTIFICATE = endpoint( + 0xaa10, + "RequestDeviceCertificate", + "DeviceAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val DEVICE_AUTHENTICATION_CERTIFICATE = endpoint( + 0xaa11, + "AuthenticationCertificate", + "DeviceAuthentication", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val REQUEST_DEVICE_CHALLENGE_RESPONSE = endpoint( + 0xaa12, + "RequestDeviceChallengeResponse", + "DeviceAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val DEVICE_AUTHENTICATION_RESPONSE = endpoint( + 0xaa13, + "AuthenticationResponse", + "DeviceAuthentication", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val DEVICE_AUTHENTICATION_FAILED = endpoint( + 0xaa14, + "DeviceAuthenticationFailed", + "DeviceAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val DEVICE_AUTHENTICATION_SUCCEEDED = endpoint( + 0xaa15, + "DeviceAuthenticationSucceeded", + "DeviceAuthentication", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + + val CARPLAY_AVAILABILITY = endpoint( + 0x4300, + "CarPlayAvailability", + "CarPlayConnectionRequest", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf( + field( + 0, + "wired", + Iap2WireType.GROUP, + children = listOf( + field(0, "wiredAvailable", Iap2WireType.U8), + field(1, "usbIdentifier", Iap2WireType.STRING), + ), + ), + field( + 1, + "wireless", + Iap2WireType.GROUP, + children = listOf( + field(0, "wirelessAvailable", Iap2WireType.U8), + field(1, "bluetoothIdentifier", Iap2WireType.STRING), + ), + ), + field( + 2, + "themeAssets", + Iap2WireType.GROUP, + children = listOf(field(0, "themeAssetsAvailable", Iap2WireType.U8)), + ), + ), + ) + val CARPLAY_START_SESSION = endpoint( + 0x4301, + "CarPlayStartSession", + "CarPlayConnectionRequest", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf( + field( + 0, + "wired", + Iap2WireType.GROUP, + children = listOf( + field(0, "wiredIP", Iap2WireType.STRING, repeatable = true), + field(1, "reserved", Iap2WireType.U32), + ), + ), + field( + 1, + "wireless", + Iap2WireType.GROUP, + children = listOf( + field(0, "SSID", Iap2WireType.STRING), + field(1, "passphrase", Iap2WireType.STRING), + field(2, "channel", Iap2WireType.U8), + field(3, "wirelessIP", Iap2WireType.STRING, repeatable = true), + field(4, "securityType", Iap2WireType.U8), + ), + ), + field(2, "airPlayPort", Iap2WireType.U32), + field(3, "deviceIdentifier", Iap2WireType.STRING), + field(4, "publicKey", Iap2WireType.STRING), + field(5, "sourceVersion", Iap2WireType.STRING), + field(6, "SDKVersion", Iap2WireType.STRING), + field( + 7, + "clusterAsset", + Iap2WireType.GROUP, + children = listOf( + field(0, "assetID", Iap2WireType.STRING), + field(1, "assetVersion", Iap2WireType.U32), + ), + ), + field(8, "mutualAuth", Iap2WireType.U8), + ), + ) + val AVAILABLE_DIGITAL_CAR_KEYS = endpoint( + 0x4302, + "AvailableDigitalCarKeys", + "DigitalCarKey_Matching", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val MATCHED_DIGITAL_CAR_KEYS = endpoint( + 0x4303, + "MatchedDigitalCarKeys", + "DigitalCarKey_Matching", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + + val REQUEST_WIFI_INFORMATION = endpoint( + 0x5700, + "RequestWiFiInformation", + "WiFiSharing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val WIFI_INFORMATION = endpoint( + 0x5701, + "WiFiInformation", + "WiFiSharing", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf( + field(0, "status", Iap2WireType.U8, required = true), + field(1, "securityType", Iap2WireType.U8), + field(2, "SSID", Iap2WireType.STRING), + field(3, "passphrase", Iap2WireType.STRING), + ), + ) + val REQUEST_ACCESSORY_WIFI_CONFIGURATION = endpoint( + 0x5702, + "RequestAccessoryWiFiConfigurationInformation", + "WiFiSharing", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val ACCESSORY_WIFI_CONFIGURATION = endpoint( + 0x5703, + "AccessoryWiFiConfigurationInformation", + "WiFiSharing", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf( + field(0, "BSSID", Iap2WireType.BYTES), + field(1, "SSID", Iap2WireType.STRING), + field(2, "passphrase", Iap2WireType.STRING), + field(3, "securityType", Iap2WireType.U8), + field(4, "channel", Iap2WireType.U8), + ), + ) + + val GPRMC_DATA_STATUS_VALUES = endpoint( + 0xfff0, + "GPRMCDataStatusValues", + "Location", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val START_LOCATION_INFORMATION = endpoint( + 0xfffa, + "StartLocationInformation", + "Location", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val LOCATION_INFORMATION = endpoint( + 0xfffb, + "LocationInformation", + "Location", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf(field(0, "NMEA", Iap2WireType.STRING)), + ) + val STOP_LOCATION_INFORMATION = endpoint( + 0xfffc, + "StopLocationInformation", + "Location", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + + val START_POWER_UPDATES = endpoint(0xae00, "StartPowerUpdates", "Power", Iap2Direction.ACCESSORY_TO_IPHONE) + val POWER_UPDATE = endpoint(0xae01, "PowerUpdate", "Power", Iap2Direction.IPHONE_TO_ACCESSORY) + val STOP_POWER_UPDATES = endpoint(0xae02, "StopPowerUpdates", "Power", Iap2Direction.ACCESSORY_TO_IPHONE) + val POWER_SOURCE_UPDATE = endpoint( + 0xae03, + "PowerSourceUpdate", + "Power", + Iap2Direction.ACCESSORY_TO_IPHONE, + fields = listOf( + field(0, "AvailableCurrentForDevice", Iap2WireType.U16), + field(1, "IsDeviceBatteryCharging", Iap2WireType.U8), + field(3, "AvailableSiphoningCurrent", Iap2WireType.U16), + field(4, "ReserveCurrent", Iap2WireType.U16), + field(5, "MaxNonSiphoningCurrentExceeded", Iap2WireType.VOID), + ), + ) + val ACCESSORY_POWER_UPDATE = endpoint(0xae05, "AccessoryPowerUpdate", "Power", Iap2Direction.ACCESSORY_TO_IPHONE) + val ACCESSORY_RESET_BASE_CURRENT = + endpoint(0xae06, "AccessoryResetBaseCurrent", "Power", Iap2Direction.ACCESSORY_TO_IPHONE) + + val START_ROUTE_GUIDANCE_UPDATES = endpoint( + 0x5200, + "StartRouteGuidanceUpdates", + "Navigation", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val STOP_ROUTE_GUIDANCE_UPDATES = endpoint( + 0x5203, + "StopRouteGuidanceUpdates", + "Navigation", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val START_NOW_PLAYING_UPDATES = endpoint( + 0x5000, + "StartNowPlayingUpdates", + "Now Playing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val STOP_NOW_PLAYING_UPDATES = endpoint( + 0x5002, + "StopNowPlayingUpdates", + "Now Playing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val START_COMMUNICATIONS_UPDATES = endpoint( + 0x4157, + "startCommunicationsUpdatesHandler", + "Communications", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val START_CALL_STATE_UPDATES = endpoint( + 0x4154, + "startCallStateUpdatesHandler", + "Communications", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val STOP_COMMUNICATIONS_UPDATES = endpoint( + 0x4159, + "stopCommunicationsUpdatesHandler", + "Communications", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + val STOP_CALL_STATE_UPDATES = endpoint( + 0x4156, + "stopCallStateUpdatesHandler", + "Communications", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + + val DEVICE_INFORMATION_UPDATE = endpoint( + 0x4e09, + "DeviceInformationUpdate", + "DeviceNotifications", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf(field(0, "deviceName", Iap2WireType.STRING)), + ) + val DEVICE_LANGUAGE_UPDATE = endpoint( + 0x4e0a, + "DeviceLanguageUpdate", + "DeviceNotifications", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf(field(0, "language", Iap2WireType.STRING)), + ) + val DEVICE_TIME_UPDATE = endpoint( + 0x4e0b, + "DeviceTimeUpdate", + "DeviceNotifications", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf( + field(0, "secondsSinceReferenceDate", Iap2WireType.U64), + field(1, "timeZoneOffsetMinutes", Iap2WireType.I16), + field(2, "daylightSavingsOffsetMinutes", Iap2WireType.I8), + ), + ) + val DEVICE_UUID_UPDATE = endpoint( + 0x4e0c, + "DeviceUuidUpdate", + "DeviceNotifications", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf(field(0, "deviceUUID", Iap2WireType.STRING)), + ) + val WIRELESS_CARPLAY_UPDATE = endpoint( + 0x4e0d, + "WirelessCarPlayUpdate", + "DeviceNotifications", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf(field(0, "availability", Iap2WireType.U8, required = true)), + ) + val DEVICE_TRANSPORT_IDENTIFIER = endpoint( + 0x4e0e, + "DeviceTransportIdentifierNotification", + "DeviceNotifications", + Iap2Direction.IPHONE_TO_ACCESSORY, + fields = listOf( + field(0, "bluetoothMAC", Iap2WireType.STRING), + field(1, "usbTransportIdentifier", Iap2WireType.STRING), + ), + ) + + val EXTERNAL_ACCESSORY_OPEN_SESSION = endpoint( + 0xea00, + "externalAccessory_openEASession", + "ExternalAccessory", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val EXTERNAL_ACCESSORY_CLOSE_SESSION = endpoint( + 0xea01, + "externalAccessory_closeEASession", + "ExternalAccessory", + Iap2Direction.IPHONE_TO_ACCESSORY, + ) + val EXTERNAL_ACCESSORY_STATUS_SESSION = endpoint( + 0xea03, + "externalaccessory_statusEASessionHandler", + "ExternalAccessory", + Iap2Direction.ACCESSORY_TO_IPHONE, + ) + + val PING = endpoint(0x0000, "pingHandler", "Test", Iap2Direction.ACCESSORY_TO_IPHONE) + val USER_NOTIFICATION = endpoint(0x0001, "userNotificationHandler", "Test", Iap2Direction.ACCESSORY_TO_IPHONE) + val SEND_PING_ACC = endpoint(0x0004, "sendPingAccHandler", "Test", Iap2Direction.ACCESSORY_TO_IPHONE) + val PING_ACC = endpoint(0x0005, "pingAccHandler", "Test", Iap2Direction.ACCESSORY_TO_IPHONE) + val DIGITAL_AUDIO_START = endpoint(0xda00, "digitalAudio_start", "DigitalAudio", Iap2Direction.ACCESSORY_TO_IPHONE) + val DIGITAL_AUDIO_INFORMATION_UPDATE = + endpoint(0xda01, "DigitalAudioInformationUpdate", "DigitalAudio", Iap2Direction.IPHONE_TO_ACCESSORY) + val DIGITAL_AUDIO_STOP = endpoint(0xda02, "digitalAudio_stop", "DigitalAudio", Iap2Direction.ACCESSORY_TO_IPHONE) + + /** + * The remaining reference endpoints are registered here without a claimed schema. Their body + * can still be built and read through the generic body API, but no enum or field ID is invented. + */ + val ALL: List = listOf( + START_IDENTIFICATION, + IDENTIFICATION_INFORMATION, + IDENTIFICATION_ACCEPTED, + IDENTIFICATION_REJECTED, + CANCEL_IDENTIFICATION, + IDENTIFICATION_INFORMATION_UPDATE, + REQUEST_AUTHENTICATION_CERTIFICATE, + AUTHENTICATION_CERTIFICATE, + REQUEST_AUTHENTICATION_CHALLENGE_RESPONSE, + AUTHENTICATION_RESPONSE, + AUTHENTICATION_FAILED, + AUTHENTICATION_SUCCEEDED, + AUTHENTICATION_CERTIFICATE_SERIAL, + REQUEST_DEVICE_CERTIFICATE, + DEVICE_AUTHENTICATION_CERTIFICATE, + REQUEST_DEVICE_CHALLENGE_RESPONSE, + DEVICE_AUTHENTICATION_RESPONSE, + DEVICE_AUTHENTICATION_FAILED, + DEVICE_AUTHENTICATION_SUCCEEDED, + CARPLAY_AVAILABILITY, + CARPLAY_START_SESSION, + AVAILABLE_DIGITAL_CAR_KEYS, + MATCHED_DIGITAL_CAR_KEYS, + REQUEST_WIFI_INFORMATION, + WIFI_INFORMATION, + REQUEST_ACCESSORY_WIFI_CONFIGURATION, + ACCESSORY_WIFI_CONFIGURATION, + GPRMC_DATA_STATUS_VALUES, + START_LOCATION_INFORMATION, + LOCATION_INFORMATION, + STOP_LOCATION_INFORMATION, + START_POWER_UPDATES, + POWER_UPDATE, + STOP_POWER_UPDATES, + POWER_SOURCE_UPDATE, + ACCESSORY_POWER_UPDATE, + ACCESSORY_RESET_BASE_CURRENT, + START_ROUTE_GUIDANCE_UPDATES, + STOP_ROUTE_GUIDANCE_UPDATES, + START_NOW_PLAYING_UPDATES, + STOP_NOW_PLAYING_UPDATES, + START_COMMUNICATIONS_UPDATES, + START_CALL_STATE_UPDATES, + STOP_COMMUNICATIONS_UPDATES, + STOP_CALL_STATE_UPDATES, + DEVICE_INFORMATION_UPDATE, + DEVICE_LANGUAGE_UPDATE, + DEVICE_TIME_UPDATE, + DEVICE_UUID_UPDATE, + WIRELESS_CARPLAY_UPDATE, + DEVICE_TRANSPORT_IDENTIFIER, + EXTERNAL_ACCESSORY_OPEN_SESSION, + EXTERNAL_ACCESSORY_CLOSE_SESSION, + EXTERNAL_ACCESSORY_STATUS_SESSION, + PING, + USER_NOTIFICATION, + SEND_PING_ACC, + PING_ACC, + DIGITAL_AUDIO_START, + DIGITAL_AUDIO_INFORMATION_UPDATE, + DIGITAL_AUDIO_STOP, + registryEndpoint(0xA100, "StartVehicleStatusUpdates", "Vehicle", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0xA101, "VehicleStatusUpdate", "Vehicle", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0xA102, "StopVehicleStatusUpdates", "Vehicle", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x5001, "NowPlayingUpdate", "Now Playing", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x5003, "SetNowPlayingInfo", "Now Playing", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C00, "StartMediaLibraryInformation", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C01, "MediaLibraryInformation", "Media Library", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x4C02, "StopMediaLibraryInformation", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C03, "StartMediaLibraryUpdates", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C04, "MediaLibraryUpdate", "Media Library", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x4C05, "StopMediaLibraryUpdates", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C06, "PlayMediaLibraryCurrentSelection", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C07, "PlayMediaLibraryItems", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C08, "PlayMediaLibraryCollection", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4C09, "PlayMediaLibrarySpecial", "Media Library", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x5201, "RouteGuidanceUpdate", "Navigation", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x5202, "RouteGuidanceManeuverUpdate", "Navigation", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x5204, "LaneGuidanceInfoUpdate", "Navigation", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x4155, "CallStateUpdate", "Communications", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x4158, "CommunicationsUpdate", "Communications", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x415A, "initiateCallHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x415B, "acceptCallHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x415C, "endCallHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x415D, "swapCallHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x415E, "mergeCallHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x415F, "holdStatusUpdateHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4160, "muteStatusUpdateHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4161, "sendDTMFHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4170, "startListUpdatesHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x4171, "ListUpdate", "Communications", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x4172, "stopListUpdatesHandler", "Communications", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint( + 0x4E01, + "BluetoothComponentInfo", + "Bluetooth connection status", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x4E03, + "StartBluetoothConnectionUpdates", + "Bluetooth connection status", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x4E04, + "BluetoothConnectionUpdate", + "Bluetooth connection status", + Iap2Direction.IPHONE_TO_ACCESSORY, + ), + registryEndpoint( + 0x4E05, + "StopBluetoothConnectionUpdates", + "Bluetooth connection status", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0xAD00, "appLinks_start", "App links and launch", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0xAD01, "AppLinksUpdate", "App links and launch", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0xAD02, "appLinks_stop", "App links and launch", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0xAD03, "appLinks_requestAppIcons", "App links and launch", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0xAD04, "AppIconResponse", "App links and launch", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0xEA02, "requestAppLaunchHandler", "External Accessory", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x6800, "hid_StartHIDMsgHandler", "HID and USB host", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x6801, "hid_OutReport", "HID and USB host", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x6802, "hid_AccessoryHIDReportHandler", "HID and USB host", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x6803, "hid_StopHIDMsgHandler", "HID and USB host", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x6804, "hid_GetReport", "HID and USB host", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0x6805, + "hid_AccessoryHIDGetReportResponseHandler", + "HID and USB host", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0x6807, "hid_ComponentUpdate", "HID and USB host", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0x7E00, + "usbHostMode_StartUSBHostModeHandler", + "HID and USB host", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x7E02, + "usbHostMode_StopUSBHostModeHandler", + "HID and USB host", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5400, + "assistiveTouch_startAssistiveTouchHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5401, + "assistiveTouch_stopAssistiveTouchHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5402, + "assistiveTouch_startAssistiveTouchUpdateHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0x5403, "NotifyStatusUpdate", "VoiceOver and AssistiveTouch", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0x5404, + "assistiveTouch_stopAssistiveTouchUpdateHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5601, + "voiceOver_moveCursorHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5602, + "voiceOver_selectCursorHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5603, + "voiceOver_scrollPageHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5606, + "voiceOver_speakTextHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5608, + "voiceOver_pauseSpeakingHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5609, + "voiceOver_resumeSpeakingHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x560B, + "voiceOver_startInformationHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x560C, + "VoiceOverInformation", + "VoiceOver and AssistiveTouch", + Iap2Direction.IPHONE_TO_ACCESSORY, + ), + registryEndpoint( + 0x560D, + "voiceOver_stopInformationHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x560E, + "voiceOver_updateInformationHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x560F, + "voiceOver_startCursorInformationHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5610, + "VoiceOverCursorInformation", + "VoiceOver and AssistiveTouch", + Iap2Direction.IPHONE_TO_ACCESSORY, + ), + registryEndpoint( + 0x5611, + "voiceOver_stopCursorInformationHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5612, + "VoiceOver_startVoiceOverHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x5613, + "VoiceOver_stopVoiceOverHandler", + "VoiceOver and AssistiveTouch", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0x0B00, "oobBtPairing2_startPairing", "BLE and OOB pairing", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0x0B01, + "oobBtPairing2_accessoryInfoHandler", + "BLE and OOB pairing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0x0B02, "oobBtPairing2_statusHandler", "BLE and OOB pairing", Iap2Direction.ACCESSORY_TO_IPHONE), + registryEndpoint(0x0B03, "oobBtPairing2_stopPairing", "BLE and OOB pairing", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0x10DA, + "destinationSharing_StartDestinationInformation", + "Destination sharing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x10DB, + "DestinationInformationUpdate", + "Destination sharing", + Iap2Direction.IPHONE_TO_ACCESSORY, + ), + registryEndpoint( + 0x10DC, + "destinationSharing_StopDestinationInformation", + "Destination sharing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x10DD, + "destinationSharing_DestinationInformationStatus", + "Destination sharing", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0x00B0, "oobBtPairing_startPairing", "Other endpoints", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0x00B1, + "oobBtPairing_accessoryInfoHandler", + "Other endpoints", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0x00B2, + "oobBtPairing_pairingLinkKeyInfo", + "Other endpoints", + Iap2Direction.IPHONE_TO_ACCESSORY, + ), + registryEndpoint( + 0x00B3, + "oobBtPairing_completionInfoHandler", + "Other endpoints", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0x00B4, "oobBtPairing_stopPairing", "Other endpoints", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x0D00, "StartRoadObjectDetection", "Other endpoints", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x0D01, "RoadObjectDetectionUpdate", "Other endpoints", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint(0x0D02, "StopRoadObjectDetection", "Other endpoints", Iap2Direction.IPHONE_TO_ACCESSORY), + registryEndpoint( + 0xB102, + "blePairing_accessoryStateUpdateHandler", + "Other endpoints", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0xB103, + "blePairing_accessoryPairingInfoHandler", + "Other endpoints", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint( + 0xB105, + "blePairing_accessoryPairingDataHandler", + "Other endpoints", + Iap2Direction.ACCESSORY_TO_IPHONE, + ), + registryEndpoint(0xB107, "blePairing_stopBLEUpdates", "Other endpoints", Iap2Direction.IPHONE_TO_ACCESSORY), + ) + + private val byId: Map = ALL.associateBy(Iap2Endpoint::id) + + fun byId(id: Int): Iap2Endpoint? = byId[id] + + fun require(id: Int): Iap2Endpoint = + byId(id) ?: throw IllegalArgumentException("Unknown iAP2 endpoint 0x${id.toString(16).padStart(4, '0')}") + + private fun endpoint( + id: Int, + name: String, + feature: String, + direction: Iap2Direction, + confidence: Iap2Confidence = Iap2Confidence.HIGH, + fields: List = emptyList(), + ): Iap2Endpoint = Iap2Endpoint(id, name, feature, direction, confidence, fields) + + private fun registryEndpoint( + id: Int, + name: String, + feature: String, + direction: Iap2Direction, + ): Iap2Endpoint = Iap2Endpoint(id, name, feature, direction, fields = emptyList()) + + private fun field( + id: Int, + name: String, + type: Iap2WireType, + required: Boolean = false, + repeatable: Boolean = false, + children: List = emptyList(), + ): Iap2FieldSpec = Iap2FieldSpec(id, name, type, required, repeatable, children) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2Messages.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2Messages.kt new file mode 100644 index 0000000..81368b4 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2Messages.kt @@ -0,0 +1,314 @@ +package com.shilapi.xcertplay.iap2.message + +import com.shilapi.xcertplay.iap2.body.Iap2BodyBuilder +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoint +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoints +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import com.shilapi.xcertplay.iap2.wire.Iap2ProtocolException + +/** Reusable entry points for constructing and reading iAP2 CSM bodies. */ +object Iap2Messages { + inline fun build( + endpoint: Iap2Endpoint, + block: Iap2BodyBuilder.() -> Unit, + ): Iap2Frame = Iap2BodyBuilder.of(endpoint).apply(block).build() + + inline fun buildRaw( + messageId: Int, + block: Iap2BodyBuilder.() -> Unit, + ): Iap2Frame = Iap2BodyBuilder.opaque(messageId).apply(block).build() + + fun reader(frame: Iap2Frame): Iap2BodyReader = Iap2BodyReader.of(frame) +} + +/** Parameters carried by the optional `wireless` group of 0x4301. */ +data class Iap2WirelessSessionParameters( + val ssid: String, + val passphrase: String, + val channel: Int, + val ipAddresses: List, + val securityType: Int, +) { + init { + require(ssid.isNotBlank()) { "wireless SSID must not be blank" } + require(passphrase.isNotBlank()) { "wireless passphrase must not be blank" } + require(channel in 0..0xff) { "wireless channel must be in 0..255" } + require(ipAddresses.isNotEmpty()) { "wireless IP address list must not be empty" } + require(securityType in 0..0xff) { "wireless security type must be in 0..255" } + } +} + +data class Iap2ClusterAsset( + val assetId: String, + val assetVersion: Long, +) { + init { + require(assetId.isNotBlank()) { "cluster asset ID must not be blank" } + require(assetVersion in 0..0xffff_ffffL) { "cluster asset version must fit in u32" } + } +} + +/** Typed codec for the high-value CarPlay endpoints. */ +object Iap2CarPlayMessages { + fun startSession( + airPlayPort: Int, + publicKey: String, + sourceVersion: String, + wiredIpv6Addresses: List = emptyList(), + wiredReserved: Long? = null, + wireless: Iap2WirelessSessionParameters? = null, + deviceIdentifier: String? = null, + sdkVersion: String? = null, + clusterAsset: Iap2ClusterAsset? = null, + mutualAuth: Boolean? = null, + ): Iap2Frame { + require(airPlayPort in 1..0xffff) { "airPlayPort must be in 1..65535" } + require(publicKey.isNotEmpty()) { "publicKey must not be empty" } + require(sourceVersion.isNotEmpty()) { "sourceVersion must not be empty" } + + return Iap2Messages.build(Iap2Endpoints.CARPLAY_START_SESSION) { + if (wiredIpv6Addresses.isNotEmpty() || wiredReserved != null) { + group(0) { + wiredIpv6Addresses.forEach { string(0, it) } + optionalU32(1, wiredReserved) + } + } + wireless?.let { + group(1) { + string(0, it.ssid) + string(1, it.passphrase) + u8(2, it.channel) + it.ipAddresses.forEach { address -> string(3, address) } + u8(4, it.securityType) + } + } + u32(2, airPlayPort) + optionalString(3, deviceIdentifier) + string(4, publicKey) + string(5, sourceVersion) + optionalString(6, sdkVersion) + clusterAsset?.let { + group(7) { + string(0, it.assetId) + u32(1, it.assetVersion) + } + } + optionalU8(8, mutualAuth?.let { if (it) 1 else 0 }) + } + } + + fun availability(frame: Iap2Frame): Iap2CarPlayAvailability { + require(frame.messageId == Iap2Endpoints.CARPLAY_AVAILABILITY.id) { + "Expected CarPlayAvailability, got 0x${frame.messageId.toString(16)}" + } + return availability(frame.payload) + } + + /** Decodes an already extracted 0x4300 body without requiring the caller to rebuild a frame. */ + fun availability(payload: ByteArray): Iap2CarPlayAvailability { + val body = Iap2BodyReader.of(Iap2ParameterList.parse(payload)) + val wired = body.optionalGroup(0)?.let { + Iap2AvailabilityState( + available = decodeAvailability(it.optionalU8(0), "wired"), + identifier = it.optionalString(1), + ) + } + val wireless = body.optionalGroup(1)?.let { + Iap2AvailabilityState( + available = decodeAvailability(it.optionalU8(0), "wireless"), + identifier = it.optionalString(1), + ) + } + val themeAssets = body.optionalGroup(2)?.let { + Iap2AvailabilityState( + available = decodeAvailability(it.optionalU8(0), "themeAssets"), + identifier = null, + ) + } + return Iap2CarPlayAvailability(wired, wireless, themeAssets) + } + + private fun decodeAvailability(value: Int?, label: String): Boolean? { + if (value == null) return null + if (value !in 0..1) { + throw Iap2ProtocolException("$label availability must be 0 or 1, got $value") + } + return value == 1 + } +} + +data class Iap2AvailabilityState( + val available: Boolean?, + val identifier: String?, +) + +data class Iap2CarPlayAvailability( + val wired: Iap2AvailabilityState?, + val wireless: Iap2AvailabilityState?, + val themeAssets: Iap2AvailabilityState?, +) + +/** Typed codecs for Wi-Fi sharing and Wireless CarPlay notifications. */ +object Iap2WirelessMessages { + fun accessoryWiFiConfiguration( + ssid: String, + passphrase: String, + channel: Int, + securityType: Int, + bssid: ByteArray? = null, + ): Iap2Frame = Iap2Messages.build(Iap2Endpoints.ACCESSORY_WIFI_CONFIGURATION) { + optionalBytes(0, bssid) + string(1, ssid) + string(2, passphrase) + u8(3, securityType) + u8(4, channel) + } + + fun wifiInformation(frame: Iap2Frame): Iap2WifiInformation { + require(frame.messageId == Iap2Endpoints.WIFI_INFORMATION.id) { + "Expected WiFiInformation, got 0x${frame.messageId.toString(16)}" + } + val body = Iap2Messages.reader(frame) + val status = body.u8(0) + if (status != 0) return Iap2WifiInformation(status) + return Iap2WifiInformation( + status = status, + securityType = body.optionalU8(1), + ssid = body.optionalString(2), + passphrase = body.optionalString(3), + ) + } + + fun wirelessCarPlayAvailability(frame: Iap2Frame): Boolean { + require(frame.messageId == Iap2Endpoints.WIRELESS_CARPLAY_UPDATE.id) { + "Expected WirelessCarPlayUpdate, got 0x${frame.messageId.toString(16)}" + } + return when (val value = Iap2Messages.reader(frame).u8(0)) { + 0 -> false + 1 -> true + else -> throw Iap2ProtocolException("WirelessCarPlayUpdate availability must be 0 or 1, got $value") + } + } + + fun deviceTransportIdentifier(frame: Iap2Frame): Iap2DeviceTransportIdentifier { + require(frame.messageId == Iap2Endpoints.DEVICE_TRANSPORT_IDENTIFIER.id) { + "Expected DeviceTransportIdentifierNotification, got 0x${frame.messageId.toString(16)}" + } + val body = Iap2Messages.reader(frame) + return Iap2DeviceTransportIdentifier( + bluetoothMac = body.optionalString(0), + usbTransportIdentifier = body.optionalString(1), + ) + } +} + +/** Typed accessory-authentication bodies, including the BAA certificate package layout. */ +object Iap2AuthenticationMessages { + fun accessoryCertificate(certificate: ByteArray): Iap2Frame = + Iap2Messages.build(Iap2Endpoints.AUTHENTICATION_CERTIFICATE) { + bytes(0, certificate) + } + + /** + * Wraps an already encoded 0xAA01 body, as returned by the remote MFi/BAA transport. + * The body is parsed before wrapping so malformed parameter framing cannot escape this layer. + */ + fun accessoryCertificateBody(body: ByteArray): Iap2Frame { + Iap2ParameterList.parse(body) + return Iap2Frame(Iap2Endpoints.AUTHENTICATION_CERTIFICATE.id, body) + } + + /** + * The remote BAA service returns a package whose three fields are already the final 0xAA01 + * parameter sequence. It is intentionally built through the raw endpoint wrapper because this + * package uses a schema variant that is not represented by the baseline MFi endpoint. + */ + fun baaCertificatePackage(leaf: ByteArray, intermediate: ByteArray): Iap2Frame = + Iap2Messages.buildRaw(Iap2Endpoints.AUTHENTICATION_CERTIFICATE.id) { + bytes(0, leaf) + u8(1, 1) + bytes(2, intermediate) + } + + fun response(signature: ByteArray): Iap2Frame = + Iap2Messages.build(Iap2Endpoints.AUTHENTICATION_RESPONSE) { + bytes(0, signature) + } + + fun challenge(frame: Iap2Frame): ByteArray { + require(frame.messageId == Iap2Endpoints.REQUEST_AUTHENTICATION_CHALLENGE_RESPONSE.id) { + "Expected RequestAuthenticationChallengeResponse, got 0x${frame.messageId.toString(16)}" + } + return Iap2Messages.reader(frame).bytes(0) + } +} + +data class Iap2WifiInformation( + val status: Int, + val securityType: Int? = null, + val ssid: String? = null, + val passphrase: String? = null, +) { + val successful: Boolean get() = status == 0 +} + +data class Iap2DeviceTransportIdentifier( + val bluetoothMac: String?, + val usbTransportIdentifier: String?, +) + +/** Typed codecs for power and subscription requests used during bring-up. */ +object Iap2ControlMessages { + fun powerSourceUpdate( + availableCurrentMilliAmps: Int, + charging: Boolean? = null, + availableSiphoningCurrentMilliAmps: Int? = null, + reserveCurrentMilliAmps: Int? = null, + maxNonSiphoningCurrentExceeded: Boolean = false, + ): Iap2Frame = Iap2Messages.build(Iap2Endpoints.POWER_SOURCE_UPDATE) { + u16(0, availableCurrentMilliAmps) + optionalU8(1, charging?.let { if (it) 1 else 0 }) + optionalU16(3, availableSiphoningCurrentMilliAmps) + optionalU16(4, reserveCurrentMilliAmps) + optionalVoid(5, maxNonSiphoningCurrentExceeded) + } + + fun subscriptions(): List = listOf( + Iap2Messages.build(Iap2Endpoints.START_NOW_PLAYING_UPDATES) { + group(0) { + listOf(1, 4, 6, 12, 26).forEach(::void) + } + group(1) { + listOf(0, 1, 7).forEach(::void) + } + }, + Iap2Messages.build(Iap2Endpoints.START_ROUTE_GUIDANCE_UPDATES) {}, + Iap2Messages.build(Iap2Endpoints.START_POWER_UPDATES) { + void(4) + void(5) + void(6) + }, + Iap2Messages.build(Iap2Endpoints.START_COMMUNICATIONS_UPDATES) { + void(0) + void(4) + void(5) + }, + Iap2Messages.build(Iap2Endpoints.START_CALL_STATE_UPDATES) { + void(0) + void(1) + void(2) + void(3) + void(4) + void(11) + }, + ) + + fun locationInformation(nmeaSentence: String): Iap2Frame { + require(nmeaSentence.isNotEmpty()) { "NMEA sentence must not be empty" } + return Iap2Messages.build(Iap2Endpoints.LOCATION_INFORMATION) { + string(0, nmeaSentence) + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt new file mode 100644 index 0000000..df92521 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt @@ -0,0 +1,145 @@ +package com.shilapi.xcertplay.iap2.session + +import com.shilapi.xcertplay.iap2.body.Iap2BodyBuilder +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoint +import com.shilapi.xcertplay.iap2.message.Iap2Messages +import com.shilapi.xcertplay.iap2.trace.Iap2FrameFormatter +import com.shilapi.xcertplay.iap2.trace.Iap2TraceDirection +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.transport.BlockingDuplexByteStream +import com.shilapi.xcertplay.transport.Iap2CsmChannel + +/** + * One immediately readable and writable iAP2 CSM session. + * + * [Iap2CsmChannel] remains the lower-level frame transport. This class is the protocol-facing + * facade used by services: it accepts endpoint builders, sends complete frames, receives complete + * frames, and exposes the typed body reader. + */ +class Iap2Session private constructor( + private val channel: Iap2CsmChannel, + private val traceContext: String, + private val onTrace: (String) -> Unit, +) : AutoCloseable { + val isClosed: Boolean get() = channel.isClosed + + fun awaitReady(timeoutMillis: Long): Boolean { + val ready = channel.awaitReady(timeoutMillis) + emitTrace("IAP2 READY [$traceContext] ready=$ready") + return ready + } + + fun send(frame: Iap2Frame, timeoutMillis: Long = DEFAULT_SEND_TIMEOUT_MILLIS) { + try { + channel.send(frame, timeoutMillis) + emitFrameTrace(Iap2TraceDirection.TX, frame) + } catch (failure: Throwable) { + emitTrace( + Iap2FrameFormatter.formatFailure( + Iap2TraceDirection.TX, + traceContext, + frame.messageId, + failure, + ), + ) + throw failure + } + } + + fun send( + endpoint: Iap2Endpoint, + timeoutMillis: Long = DEFAULT_SEND_TIMEOUT_MILLIS, + block: Iap2BodyBuilder.() -> Unit, + ) { + send(Iap2Messages.build(endpoint, block), timeoutMillis) + } + + fun sendRaw( + messageId: Int, + timeoutMillis: Long = DEFAULT_SEND_TIMEOUT_MILLIS, + block: Iap2BodyBuilder.() -> Unit, + ) { + send(Iap2Messages.buildRaw(messageId, block), timeoutMillis) + } + + fun recv(timeoutMillis: Long): Iap2Frame? { + return try { + channel.recv(timeoutMillis)?.also { emitFrameTrace(Iap2TraceDirection.RX, it) } + } catch (failure: Throwable) { + emitTrace( + Iap2FrameFormatter.formatFailure( + Iap2TraceDirection.RX, + traceContext, + messageId = null, + failure = failure, + ), + ) + throw failure + } + } + + fun reader(frame: Iap2Frame): Iap2BodyReader = Iap2Messages.reader(frame) + + override fun close() { + try { + channel.close() + } finally { + emitTrace("IAP2 CLOSE [$traceContext]") + } + } + + private fun emitFrameTrace(direction: Iap2TraceDirection, frame: Iap2Frame) { + try { + emitTrace(Iap2FrameFormatter.format(direction, traceContext, frame)) + } catch (failure: Exception) { + emitTrace( + Iap2FrameFormatter.formatFailure( + direction, + traceContext, + frame.messageId, + failure, + ), + ) + } + } + + private fun emitTrace(message: String) { + try { + onTrace(message) + } catch (_: Exception) { + // Logging must never change protocol behavior. + } + } + + companion object { + private const val DEFAULT_SEND_TIMEOUT_MILLIS = 5_000L + + fun open( + underlying: BlockingDuplexByteStream, + traceContext: String = "wired", + onTrace: (String) -> Unit = {}, + ): Iap2Session = + Iap2Session(Iap2CsmChannel.open(underlying), traceContext, onTrace) + + fun openWireless( + underlying: BlockingDuplexByteStream, + traceContext: String = "wireless", + onTrace: (String) -> Unit = {}, + ): Iap2Session = + Iap2Session(Iap2CsmChannel.openWireless(underlying), traceContext, onTrace) + + fun openTunnel( + underlying: BlockingDuplexByteStream, + traceContext: String = "wireless-tunnel", + onTrace: (String) -> Unit = {}, + ): Iap2Session = + Iap2Session(Iap2CsmChannel.openTunnel(underlying), traceContext, onTrace) + + fun wrap( + channel: Iap2CsmChannel, + traceContext: String = "iap2", + onTrace: (String) -> Unit = {}, + ): Iap2Session = Iap2Session(channel, traceContext, onTrace) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/trace/Iap2FrameFormatter.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/trace/Iap2FrameFormatter.kt new file mode 100644 index 0000000..0647d8f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/trace/Iap2FrameFormatter.kt @@ -0,0 +1,211 @@ +package com.shilapi.xcertplay.iap2.trace + +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoints +import com.shilapi.xcertplay.iap2.catalog.Iap2FieldSpec +import com.shilapi.xcertplay.iap2.catalog.Iap2WireType +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.iap2.wire.Iap2Parameter +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import com.shilapi.xcertplay.iap2.wire.Iap2ProtocolException +import com.shilapi.xcertplay.iap2.wire.Iap2WireCodec + +enum class Iap2TraceDirection(val label: String) { + TX("TX"), + RX("RX"), +} + +/** + * Human-readable rendering for the on-device iAP2 trace. + * + * Known endpoints use their field names and wire types. Unknown/medium-confidence endpoints still + * show ordered parameter IDs and safe scalar/string/byte summaries, so no frame is invisible just + * because it has not received a strict schema yet. + */ +object Iap2FrameFormatter { + private const val MAX_PARAMETERS = 80 + private const val MAX_STRING_CHARS = 240 + private const val MAX_HEX_BYTES = 24 + + fun format( + direction: Iap2TraceDirection, + context: String, + frame: Iap2Frame, + ): String { + val endpoint = Iap2Endpoints.byId(frame.messageId) + val encoded = frame.encodedFrame() + val parameters = try { + Iap2ParameterList.parse(frame.payload) + } catch (_: Iap2ProtocolException) { + null + } + val lines = ArrayList() + if (parameters == null) { + lines += " body=malformed TLV" + } else { + appendParameters( + parameters = parameters, + specs = endpoint?.fields.orEmpty(), + depth = 1, + lines = lines, + ) + } + lines += " raw-body=${hexPreview(frame.payload)}" + + val endpointText = endpoint?.let { " ${it.name}" }.orEmpty() + return buildString { + append("IAP2 ") + append(direction.label) + append(" [") + append(context) + append("] 0x") + append(frame.messageId.toString(16).padStart(4, '0')) + append(endpointText) + append(" frame=") + append(encoded.size) + append("B body=") + append(if (parameters == null) "malformed" else "${parameters.size} params") + lines.forEach { line -> + append('\n') + append(line) + } + } + } + + fun formatFailure( + direction: Iap2TraceDirection, + context: String, + messageId: Int?, + failure: Throwable, + ): String = buildString { + append("IAP2 ") + append(direction.label) + append(" [") + append(context) + append(']') + messageId?.let { + append(" 0x") + append(it.toString(16).padStart(4, '0')) + append(Iap2Endpoints.byId(it)?.let { endpoint -> " ${endpoint.name}" }.orEmpty()) + } + append(" FAILED: ") + append(failure.message ?: failure.javaClass.simpleName) + } + + private fun appendParameters( + parameters: Iap2ParameterList, + specs: List, + depth: Int, + lines: MutableList, + ) { + val fields = specs.associateBy(Iap2FieldSpec::id) + parameters.asList().take(MAX_PARAMETERS).forEach { parameter -> + val field = fields[parameter.id] + val name = field?.name ?: "param" + val value = if (field == null) { + formatOpaque(parameter.payload) + } else { + formatKnown(parameter.payload, field, depth) + } + lines += "${indent(depth)}[${parameter.id}] $name: $value" + } + if (parameters.size > MAX_PARAMETERS) { + lines += "${indent(depth)}... ${parameters.size - MAX_PARAMETERS} more parameters" + } + } + + private fun formatKnown( + payload: ByteArray, + spec: Iap2FieldSpec, + depth: Int, + ): String = try { + when (spec.type) { + Iap2WireType.VOID -> if (payload.isEmpty()) "void" else "void? ${hexPreview(payload)}" + Iap2WireType.U8 -> scalar("u8", payload, 1, Iap2WireCodec.readU8(payload)) + Iap2WireType.I8 -> scalar("i8", payload, 1, Iap2WireCodec.readI8(payload)) + Iap2WireType.U16 -> scalar("u16", payload, 2, Iap2WireCodec.readU16(payload)) + Iap2WireType.I16 -> scalar("i16", payload, 2, Iap2WireCodec.readI16(payload)) + Iap2WireType.U32 -> scalar("u32", payload, 4, Iap2WireCodec.readU32(payload)) + Iap2WireType.I32 -> scalar("i32", payload, 4, Iap2WireCodec.readI32(payload)) + Iap2WireType.U64 -> scalar("u64", payload, 8, Iap2WireCodec.readU64(payload)) + Iap2WireType.U16_LIST -> Iap2WireCodec.readU16List(payload) + .joinToString(prefix = "[", postfix = "]") { "0x${it.toString(16).padStart(4, '0')}" } + Iap2WireType.BYTES -> bytes(payload) + Iap2WireType.STRING -> quote(truncate(Iap2WireCodec.readString(payload))) + Iap2WireType.STRING_LIST -> Iap2WireCodec.readStrings(payload) + .joinToString(prefix = "[", postfix = "]") { quote(truncate(it)) } + Iap2WireType.GROUP -> { + val nested = Iap2ParameterList.parse(payload) + val nestedLines = ArrayList() + appendParameters(nested, spec.children, depth + 1, nestedLines) + if (nestedLines.isEmpty()) "{}" else nestedLines.joinToString("\n", prefix = "{\n", postfix = "\n${indent(depth)}}") + } + Iap2WireType.OPAQUE -> formatOpaque(payload) + } + } catch (_: Exception) { + formatOpaque(payload) + } + + private fun formatOpaque(payload: ByteArray): String { + if (payload.isEmpty()) return "void" + readableString(payload)?.let { return quote(truncate(it)) } + return when (payload.size) { + 1 -> "u8=${u8(payload[0])} (0x${hexPreview(payload)})" + 2 -> "u16=${Iap2WireCodec.readU16(payload)} (0x${hexPreview(payload)})" + 4 -> "u32=${Iap2WireCodec.readU32(payload)} (0x${hexPreview(payload)})" + 8 -> "u64=${Iap2WireCodec.readU64(payload)} (0x${hexPreview(payload)})" + else -> bytes(payload) + } + } + + private fun scalar( + name: String, + payload: ByteArray, + size: Int, + value: Number, + ): String { + require(payload.size == size) { "expected $size bytes" } + return "$name=$value (0x${hexPreview(payload)})" + } + + private fun bytes(payload: ByteArray): String = + "bytes=${payload.size} [${hexPreview(payload)}]" + + private fun readableString(payload: ByteArray): String? { + if (payload.isEmpty() || payload.last() != 0.toByte()) return null + val content = payload.copyOf(payload.size - 1) + if (content.any { byte -> + val value = byte.toInt() and 0xff + value < 0x20 && value != 0x09 + } + ) { + return null + } + val decoded = content.decodeToString() + if (decoded.contains('\uFFFD')) return null + return decoded + } + + private fun quote(value: String): String = + "\"" + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "\\r") + .replace("\n", "\\n") + .replace("\t", "\\t") + "\"" + + private fun truncate(value: String): String = + if (value.length <= MAX_STRING_CHARS) value + else value.take(MAX_STRING_CHARS) + "..." + + private fun hexPreview(payload: ByteArray): String { + if (payload.isEmpty()) return "" + val shown = payload.take(MAX_HEX_BYTES).joinToString(" ") { + "%02x".format(it.toInt() and 0xff) + } + return if (payload.size <= MAX_HEX_BYTES) shown else "$shown ... (+${payload.size - MAX_HEX_BYTES}B)" + } + + private fun u8(value: Byte): Int = value.toInt() and 0xff + + private fun indent(depth: Int): String = " ".repeat(depth) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2CsmFramer.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2CsmFramer.kt new file mode 100644 index 0000000..a8f1c3c --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2CsmFramer.kt @@ -0,0 +1,174 @@ +package com.shilapi.xcertplay.iap2.wire + +/** + * Pure CSM framing for iAP2 control-session bytes. + * + * CSM is `0x4040 | u16 length | u16 messageId | body`, big-endian and without a checksum. + * The retained receive data is bounded to one largest-valid frame. This class deliberately has no + * threading, I/O, link-engine, or message-specific knowledge. + */ +class Iap2CsmFramer { + private val receive = ReceiveBuffer(MAX_FRAME_BYTES) + + /** + * Adds control-session bytes and returns every newly complete CSM frame. + * + * It tolerates arbitrary transport chunking, concatenated frames, garbage before `0x4040`, and + * invalid lengths below [HEADER_BYTES] by advancing one byte and searching again. + */ + fun offer(chunk: ByteArray): List { + if (chunk.isEmpty()) return emptyList() + + val frames = ArrayList() + var offset = 0 + while (offset < chunk.size) { + drain(frames) + val count = minOf(chunk.size - offset, receive.remainingCapacity()) + check(count > 0) { "CSM receive buffer could not make progress" } + receive.append(chunk, offset, count) + offset += count + } + drain(frames) + return frames + } + + private fun drain(frames: MutableList) { + while (true) { + while (receive.size >= 2 && !receive.hasStart()) receive.discard(1) + if (receive.size < HEADER_BYTES) return + + val length = receive.u16(2) + if (length < HEADER_BYTES) { + receive.discard(1) + continue + } + if (receive.size < length) return + + val messageId = receive.u16(4) + val payload = receive.copyOfRange(HEADER_BYTES, length) + receive.discard(length) + frames += Iap2Frame(messageId, payload) + } + } + + private class ReceiveBuffer(private val maximumSize: Int) { + private val bytes = ByteArray(maximumSize) + private var head = 0 + private var tail = 0 + + val size: Int get() = tail - head + + fun remainingCapacity(): Int { + compact() + return maximumSize - tail + } + + fun append(source: ByteArray, offset: Int, count: Int) { + require(count in 0..remainingCapacity()) + source.copyInto(bytes, tail, offset, offset + count) + tail += count + } + + fun discard(count: Int) { + require(count in 0..size) + head += count + if (head == tail) { + head = 0 + tail = 0 + } + } + + fun hasStart(): Boolean = bytes[head] == START_HIGH && bytes[head + 1] == START_LOW + + fun u16(offset: Int): Int = + ((bytes[head + offset].toInt() and 0xff) shl 8) or + (bytes[head + offset + 1].toInt() and 0xff) + + fun copyOfRange(from: Int, to: Int): ByteArray = bytes.copyOfRange(head + from, head + to) + + private fun compact() { + if (head == 0) return + if (head < tail) bytes.copyInto(bytes, 0, head, tail) + tail -= head + head = 0 + } + } + + companion object { + const val START = 0x4040 + const val HEADER_BYTES = 6 + const val MIN_FRAME_BYTES = HEADER_BYTES + const val MAX_FRAME_BYTES = 0xffff + const val MAX_BODY_BYTES = MAX_FRAME_BYTES - HEADER_BYTES + const val MAX_PARAM_BYTES = 0xffff + const val MAX_LINK_CHUNK_BYTES = 65_525 + + private const val START_HIGH: Byte = 0x40 + private const val START_LOW: Byte = 0x40 + + /** Encodes one complete CSM frame from a message id and its header-excluded body. */ + fun encodeFrame(messageId: Int, payload: ByteArray): ByteArray { + require(messageId in 0..0xffff) { "CSM message id must fit in u16" } + require(payload.size <= MAX_BODY_BYTES) { + "CSM payload exceeds $MAX_BODY_BYTES bytes" + } + val frame = ByteArray(HEADER_BYTES + payload.size) + writeU16(frame, 0, START) + writeU16(frame, 2, frame.size) + writeU16(frame, 4, messageId) + payload.copyInto(frame, HEADER_BYTES) + return frame + } + + /** Encodes one CSM parameter: `u16 length | u16 parameterId | payload`. */ + fun encodeParam(parameterId: Int, payload: ByteArray): ByteArray { + require(parameterId in 0..0xffff) { "CSM parameter id must fit in u16" } + require(payload.size <= MAX_PARAM_BYTES - Iap2Parameter.HEADER_BYTES) { + "CSM parameter payload exceeds ${MAX_PARAM_BYTES - Iap2Parameter.HEADER_BYTES} bytes" + } + val parameter = ByteArray(Iap2Parameter.HEADER_BYTES + payload.size) + writeU16(parameter, 0, parameter.size) + writeU16(parameter, 2, parameterId) + payload.copyInto(parameter, Iap2Parameter.HEADER_BYTES) + return parameter + } + + /** Splits a semantic frame into link payloads no larger than [linkChunkSize]. */ + fun splitForLink(frame: Iap2Frame, linkChunkSize: Int): List = + splitForLink(frame.encodedFrame(), linkChunkSize) + + /** + * Splits exactly one complete CSM frame into link payloads no larger than [linkChunkSize]. + * The complete frame is validated before any defensive-copy chunks are returned. + */ + fun splitForLink(completeFrame: ByteArray, linkChunkSize: Int): List { + require(linkChunkSize in 1..MAX_LINK_CHUNK_BYTES) { + "iAP2 link chunk size must be in 1..$MAX_LINK_CHUNK_BYTES" + } + requireCompleteFrame(completeFrame) + return List((completeFrame.size + linkChunkSize - 1) / linkChunkSize) { index -> + val start = index * linkChunkSize + completeFrame.copyOfRange(start, minOf(start + linkChunkSize, completeFrame.size)) + } + } + + private fun requireCompleteFrame(frame: ByteArray) { + require(frame.size in MIN_FRAME_BYTES..MAX_FRAME_BYTES) { + "CSM frame size must be 6..65535" + } + require(readU16(frame, 0) == START) { "CSM frame must start with 0x4040" } + require(readU16(frame, 2) == frame.size) { + "CSM frame length must equal its complete size" + } + } + + private fun readU16(bytes: ByteArray, offset: Int): Int = + ((bytes[offset].toInt() and 0xff) shl 8) or + (bytes[offset + 1].toInt() and 0xff) + + private fun writeU16(bytes: ByteArray, offset: Int, value: Int) { + bytes[offset] = (value ushr 8).toByte() + bytes[offset + 1] = value.toByte() + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2Wire.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2Wire.kt new file mode 100644 index 0000000..e4d6085 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2Wire.kt @@ -0,0 +1,151 @@ +package com.shilapi.xcertplay.iap2.wire + +import java.io.ByteArrayOutputStream +import java.io.IOException + +/** An iAP2 CSM body parameter: `u16 length | u16 parameterId | payload`. */ +class Iap2Parameter(id: Int, payload: ByteArray) { + val id: Int + private val bytes: ByteArray + + init { + require(id in 0..0xffff) { "CSM parameter id must fit in u16" } + require(payload.size <= Iap2CsmFramer.MAX_PARAM_BYTES - HEADER_BYTES) { + "CSM parameter payload exceeds ${Iap2CsmFramer.MAX_PARAM_BYTES - HEADER_BYTES} bytes" + } + this.id = id + bytes = payload.copyOf() + } + + /** A defensive copy of the parameter payload, excluding its header. */ + val payload: ByteArray get() = bytes.copyOf() + + internal fun payloadView(): ByteArray = bytes + + override fun equals(other: Any?): Boolean = + other is Iap2Parameter && id == other.id && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = 31 * id + bytes.contentHashCode() + + override fun toString(): String = + "Iap2Parameter(id=0x${id.toString(16).padStart(4, '0')}, payload=${bytes.size} bytes)" + + companion object { + const val HEADER_BYTES = 4 + } +} + +/** + * An ordered CSM parameter collection. + * + * A list is intentional: CSM permits repeated parameter IDs and preserves wire order. Callers + * should use [first] or [all] rather than converting the body to a map. + */ +class Iap2ParameterList private constructor( + private val parameters: List, +) { + val size: Int get() = parameters.size + + fun isEmpty(): Boolean = parameters.isEmpty() + + fun asList(): List = parameters.toList() + + operator fun get(index: Int): Iap2Parameter = parameters[index] + + fun has(id: Int): Boolean = parameters.any { it.id == id } + + fun first(id: Int): Iap2Parameter? = parameters.firstOrNull { it.id == id } + + fun require(id: Int, description: String = "parameter 0x${id.toString(16).padStart(4, '0')}"): Iap2Parameter = + first(id) ?: throw Iap2ProtocolException("Missing required $description") + + fun all(id: Int): List = parameters.filter { it.id == id } + + fun encode(): ByteArray { + val encoded = ByteArrayOutputStream() + for (parameter in parameters) { + val next = Iap2CsmFramer.encodeParam(parameter.id, parameter.payloadView()) + require(next.size <= MAX_BODY_BYTES - encoded.size()) { + "Encoded CSM parameters exceed the $MAX_BODY_BYTES-byte message body limit" + } + encoded.write(next) + } + return encoded.toByteArray() + } + + override fun equals(other: Any?): Boolean = + other is Iap2ParameterList && parameters == other.parameters + + override fun hashCode(): Int = parameters.hashCode() + + companion object { + private const val MAX_BODY_BYTES = + Iap2CsmFramer.MAX_FRAME_BYTES - Iap2CsmFramer.HEADER_BYTES + + val EMPTY: Iap2ParameterList = Iap2ParameterList(emptyList()) + + fun of(parameters: Iterable): Iap2ParameterList = + Iap2ParameterList(parameters.toList()) + + fun of(vararg parameters: Iap2Parameter): Iap2ParameterList = + of(parameters.asList()) + + /** Parses a complete body and rejects truncated headers, invalid lengths, and trailing bytes. */ + fun parse(encoded: ByteArray): Iap2ParameterList { + val parameters = ArrayList() + var offset = 0 + while (offset < encoded.size) { + if (encoded.size - offset < Iap2Parameter.HEADER_BYTES) { + throw Iap2ProtocolException("Truncated CSM parameter header") + } + val length = Iap2WireCodec.readU16(encoded, offset) + if (length < Iap2Parameter.HEADER_BYTES || length > encoded.size - offset) { + throw Iap2ProtocolException("Invalid CSM parameter length $length") + } + parameters += Iap2Parameter( + id = Iap2WireCodec.readU16(encoded, offset + 2), + payload = encoded.copyOfRange( + offset + Iap2Parameter.HEADER_BYTES, + offset + length, + ), + ) + offset += length + } + return Iap2ParameterList(parameters) + } + } +} + +/** One decoded iAP2 Control Session Message (CSM). */ +class Iap2Frame(messageId: Int, payload: ByteArray) { + val messageId: Int + private val body: ByteArray + + init { + require(messageId in 0..0xffff) { "CSM message id must fit in u16" } + require(payload.size <= Iap2CsmFramer.MAX_BODY_BYTES) { + "CSM payload exceeds ${Iap2CsmFramer.MAX_BODY_BYTES} bytes" + } + this.messageId = messageId + body = payload.copyOf() + } + + /** A defensive copy of the body, excluding the CSM header. */ + val payload: ByteArray get() = body.copyOf() + + fun body(): Iap2ParameterList = Iap2ParameterList.parse(body) + + /** A defensive copy of this frame's complete six-byte-header CSM encoding. */ + fun encodedFrame(): ByteArray = Iap2CsmFramer.encodeFrame(messageId, body) + + override fun equals(other: Any?): Boolean = + other is Iap2Frame && messageId == other.messageId && body.contentEquals(other.body) + + override fun hashCode(): Int = 31 * messageId + body.contentHashCode() + + override fun toString(): String = + "Iap2Frame(messageId=0x${messageId.toString(16).padStart(4, '0')}, payload=${body.size} bytes)" +} + +/** A malformed, incomplete, or otherwise invalid iAP2 CSM value. */ +open class Iap2ProtocolException(message: String, cause: Throwable? = null) : IOException(message, cause) diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2WireCodec.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2WireCodec.kt new file mode 100644 index 0000000..f3e01ad --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/wire/Iap2WireCodec.kt @@ -0,0 +1,154 @@ +package com.shilapi.xcertplay.iap2.wire + +import java.io.ByteArrayOutputStream + +/** + * Big-endian scalar and string codecs shared by every iAP2 endpoint body. + * + * The codec intentionally contains no endpoint IDs or business defaults. Optional values are + * represented by nullable arguments at the builder layer, so an omitted value never becomes a + * zero-filled wire parameter. + */ +object Iap2WireCodec { + fun void(): ByteArray = ByteArray(0) + + fun u8(value: Int): ByteArray { + require(value in 0..0xff) { "u8 value must be in 0..255" } + return byteArrayOf(value.toByte()) + } + + fun i8(value: Int): ByteArray { + require(value in -0x80..0x7f) { "i8 value must be in -128..127" } + return byteArrayOf(value.toByte()) + } + + fun bool(value: Boolean): ByteArray = u8(if (value) 1 else 0) + + fun u16(value: Int): ByteArray { + require(value in 0..0xffff) { "u16 value must be in 0..65535" } + return byteArrayOf((value ushr 8).toByte(), value.toByte()) + } + + fun i16(value: Int): ByteArray { + require(value in -0x8000..0x7fff) { "i16 value must be in -32768..32767" } + return u16(value and 0xffff) + } + + fun u32(value: Long): ByteArray { + require(value in 0..0xffff_ffffL) { "u32 value must be in 0..4294967295" } + return byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) + } + + fun u32(value: Int): ByteArray = u32(value.toLong()) + + fun i32(value: Int): ByteArray = u32(value.toLong() and 0xffff_ffffL) + + fun u64(value: Long): ByteArray { + require(value >= 0) { "u64 value must be non-negative in this API" } + return byteArrayOf( + (value ushr 56).toByte(), + (value ushr 48).toByte(), + (value ushr 40).toByte(), + (value ushr 32).toByte(), + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) + } + + fun bytes(value: ByteArray): ByteArray = value.copyOf() + + fun string(value: String): ByteArray { + require('\u0000' !in value) { "NUL-terminated strings must not contain U+0000" } + return value.encodeToByteArray() + byteArrayOf(0) + } + + fun strings(values: Iterable): ByteArray { + val output = ByteArrayOutputStream() + for (value in values) output.write(string(value)) + return output.toByteArray() + } + + fun u16List(values: Iterable): ByteArray { + val materialized = values.toList() + val output = ByteArray(materialized.size * 2) + materialized.forEachIndexed { index, value -> + require(value in 0..0xffff) { "u16[] value must be in 0..65535" } + output[index * 2] = (value ushr 8).toByte() + output[index * 2 + 1] = value.toByte() + } + return output + } + + fun readU8(bytes: ByteArray, offset: Int = 0): Int = u8(bytes[offset]) + + fun readI8(bytes: ByteArray, offset: Int = 0): Int = bytes[offset].toInt() + + fun readBool(bytes: ByteArray, offset: Int = 0, nonzeroIsTrue: Boolean = true): Boolean { + val value = readU8(bytes, offset) + return if (nonzeroIsTrue) value != 0 else value == 1 + } + + fun readU16(bytes: ByteArray, offset: Int = 0): Int = + (u8(bytes[offset]) shl 8) or u8(bytes[offset + 1]) + + fun readI16(bytes: ByteArray, offset: Int = 0): Int { + val value = readU16(bytes, offset) + return if (value and 0x8000 != 0) value or -0x1_0000 else value + } + + fun readU32(bytes: ByteArray, offset: Int = 0): Long = + (u8(bytes[offset]).toLong() shl 24) or + (u8(bytes[offset + 1]).toLong() shl 16) or + (u8(bytes[offset + 2]).toLong() shl 8) or + u8(bytes[offset + 3]).toLong() + + fun readI32(bytes: ByteArray, offset: Int = 0): Int = readU32(bytes, offset).toInt() + + fun readU64(bytes: ByteArray, offset: Int = 0): Long { + var value = 0L + repeat(8) { index -> + value = (value shl 8) or u8(bytes[offset + index]).toLong() + } + return value + } + + fun readBytes(payload: ByteArray): ByteArray = payload.copyOf() + + fun readString(payload: ByteArray): String { + if (payload.isEmpty() || payload.last() != 0.toByte()) { + throw Iap2ProtocolException("CSM string is not NUL terminated") + } + return payload.copyOf(payload.size - 1).decodeToString() + } + + fun readStrings(payload: ByteArray): List { + if (payload.isEmpty()) return emptyList() + if (payload.last() != 0.toByte()) { + throw Iap2ProtocolException("CSM string[] is not NUL terminated") + } + val values = ArrayList() + var start = 0 + for (index in payload.indices) { + if (payload[index] != 0.toByte()) continue + values += payload.copyOfRange(start, index).decodeToString() + start = index + 1 + } + return values + } + + fun readU16List(payload: ByteArray): List { + if (payload.size % 2 != 0) { + throw Iap2ProtocolException("CSM u16[] payload has odd length ${payload.size}") + } + return List(payload.size / 2) { index -> readU16(payload, index * 2) } + } + + private fun u8(byte: Byte): Int = byte.toInt() and 0xff +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/location/AndroidCarPlayLocationProvider.kt b/shared/src/main/java/com/shilapi/xcertplay/location/AndroidCarPlayLocationProvider.kt new file mode 100644 index 0000000..fa81a78 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/location/AndroidCarPlayLocationProvider.kt @@ -0,0 +1,146 @@ +package com.shilapi.xcertplay.location + +import android.annotation.SuppressLint +import android.content.Context +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Bundle +import android.os.Looper +import android.util.Log +import com.shilapi.xcertplay.transport.CarPlayLocationFix +import com.shilapi.xcertplay.transport.Iap2LocationProvider +import com.shilapi.xcertplay.transport.NmeaLocationEncoder +import java.util.concurrent.ConcurrentHashMap + +/** + * Foreground Android location source for iAP2 LocationInformation. + * + * The freshest usable fix from GPS, fused, or network location is sent. + */ +class AndroidCarPlayLocationProvider( + context: Context, + private val preferredLocationAgeMillis: Long = DEFAULT_PREFERRED_LOCATION_AGE_MILLIS, +) : Iap2LocationProvider { + private val locationManager = + context.applicationContext.getSystemService(LocationManager::class.java) + private val stateLock = Any() + private val preferredProviders = buildList { + add(LocationManager.GPS_PROVIDER) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add(LocationManager.FUSED_PROVIDER) + } + add(LocationManager.NETWORK_PROVIDER) + }.filter { it in locationManager.allProviders } + private val latestFixes = ConcurrentHashMap() + private val listener = object : LocationListener { + override fun onLocationChanged(location: Location) { + val provider = location.provider ?: return + if (provider in preferredProviders) { + latestFixes[provider] = location + } + } + + override fun onProviderEnabled(provider: String) = Unit + override fun onProviderDisabled(provider: String) = Unit + override fun onStatusChanged(provider: String, status: Int, extras: Bundle?) = Unit + } + + private var started = false + + @SuppressLint("MissingPermission") + override fun start(): Boolean = synchronized(stateLock) { + if (started) { + true + } else { + seedLastKnownLocations() + var subscribedProviders = 0 + for (provider in preferredProviders) { + try { + locationManager.requestLocationUpdates( + provider, + UPDATE_INTERVAL_MILLIS, + MIN_DISTANCE_METERS, + listener, + Looper.getMainLooper(), + ) + subscribedProviders++ + } catch (error: Exception) { + Log.w(TAG, "Could not subscribe to Android location provider $provider", error) + } + } + started = subscribedProviders > 0 + if (!started) { + Log.w(TAG, "No Android location provider could be started") + } + started + } + } + + @SuppressLint("MissingPermission") + private fun seedLastKnownLocations() { + for (provider in preferredProviders) { + val location = try { + locationManager.getLastKnownLocation(provider) + } catch (error: Exception) { + Log.w(TAG, "Could not read last known location from $provider", error) + null + } ?: continue + latestFixes[provider] = location + Log.i( + TAG, + "Seeded $provider location ageMs=${(System.currentTimeMillis() - location.time).coerceAtLeast(0)}", + ) + } + } + + override fun stop() { + val shouldUnregister = synchronized(stateLock) { + val wasStarted = started + started = false + wasStarted + } + if (!shouldUnregister) return + try { + locationManager.removeUpdates(listener) + } catch (_: Exception) { + // Already unregistered. + } + } + + override fun latestNmea(): String? { + val fix = latestFix() ?: return null + return try { + NmeaLocationEncoder.encode( + CarPlayLocationFix( + latitudeDegrees = fix.latitude, + longitudeDegrees = fix.longitude, + altitudeMeters = fix.altitude.takeIf { fix.hasAltitude() }?.toDouble(), + bearingDegrees = fix.bearing.takeIf { fix.hasBearing() }?.toDouble(), + speedMetersPerSecond = fix.speed.takeIf { fix.hasSpeed() }?.toDouble(), + accuracyMeters = fix.accuracy.takeIf { fix.hasAccuracy() }?.toDouble(), + timestampMillis = fix.time, + ), + ) + } catch (error: Throwable) { + Log.w(TAG, "Could not encode Android location as NMEA", error) + null + } + } + + private fun latestFix(): Location? { + val now = System.currentTimeMillis() + return preferredProviders.asSequence() + .mapNotNull(latestFixes::get) + .filter { now - it.time <= preferredLocationAgeMillis } + .maxByOrNull { it.time } + } + + companion object { + private const val TAG = "CarPlayLocation" + private const val UPDATE_INTERVAL_MILLIS = 1_000L + private const val MIN_DISTANCE_METERS = 0f + private const val DEFAULT_PREFERRED_LOCATION_AGE_MILLIS = 30_000L + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt new file mode 100644 index 0000000..ef3864d --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/AndroidMediaSink.kt @@ -0,0 +1,913 @@ +package com.shilapi.xcertplay.media + +import android.media.AudioAttributes +import android.media.AudioFormat as AndroidAudioFormat +import android.media.AudioTrack +import android.media.MediaCodec +import android.media.MediaCodecList +import android.media.MediaFormat +import android.os.Build +import android.util.Log +import android.view.Surface +import com.shilapi.xcertplay.airplay.AudioCodecKind +import com.shilapi.xcertplay.airplay.AudioFormat +import com.shilapi.xcertplay.airplay.MediaSink +import com.shilapi.xcertplay.airplay.MicrophoneConfig +import com.shilapi.xcertplay.airplay.VideoCodec +import com.shilapi.xcertplay.airplay.toHexString +import java.io.Closeable +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Android rendering backend for the CarPlay media engine. Video frames are + * decoded with MediaCodec onto a Surface; audio streams are decoded to PCM and + * played through AudioTrack. Call [close] when the session tears down. + */ +class AndroidMediaSink( + surface: Surface? = null, + private val videoWidth: Int = 1280, + private val videoHeight: Int = 720, + private val preferSoftwareHevcDecoder: Boolean = false, + private val advancedAudioChannelMapping: Boolean = false, + onScreenStreamActiveChanged: ((Int, Boolean) -> Unit)? = null, +) : MediaSink { + private val screenStateLock = Any() + private val activeScreenTypes = mutableSetOf() + private val defaultSurface = surface + @Volatile private var screenStreamActiveChanged = onScreenStreamActiveChanged + private val surfaces = ConcurrentHashMap() + private val videoDecoders = ConcurrentHashMap() + private val audioRenderers = ConcurrentHashMap() + private val microphoneUplinks = ConcurrentHashMap() + private val pendingVideoCodec = ConcurrentHashMap() + private val videoRecoveryHandlers = ConcurrentHashMap Unit>() + private val videoDiagnosticHandlers = ConcurrentHashMap Unit>() + private val recoveryPending = AtomicBoolean(false) + private val recoveryExecutor = Executors.newSingleThreadExecutor { task -> + Thread(task, "carplay-video-recovery").apply { isDaemon = true } + } + + override fun setVideoRecoveryHandler(type: Int, handler: () -> Unit) { + videoRecoveryHandlers[type] = handler + } + + override fun setVideoDiagnosticHandler(type: Int, handler: (String) -> Unit) { + videoDiagnosticHandlers[type] = handler + } + + private fun requestVideoRecovery(type: Int) { + if (!recoveryPending.compareAndSet(false, true)) return + try { + recoveryExecutor.execute { + try { videoRecoveryHandlers[type]?.invoke() } + catch (error: Exception) { Log.w("xcertplay-usb", "Video keyframe request failed", error) } + finally { recoveryPending.set(false) } + } + } catch (_: java.util.concurrent.RejectedExecutionException) { recoveryPending.set(false) } + } + + fun setSurface(type: Int, surface: Surface) { + surfaces[type] = surface + videoDecoders[type]?.setSurface(surface) + } + + fun clearSurface(type: Int, surface: Surface) { + if (surfaces.remove(type, surface)) videoDecoders[type]?.setSurface(null) + } + + fun setScreenStreamActiveChangedListener(listener: ((Int, Boolean) -> Unit)?) { + synchronized(screenStateLock) { + screenStreamActiveChanged = listener + activeScreenTypes.forEach { listener?.invoke(it, true) } + } + } + + override fun onVideoCodec(type: Int, codec: VideoCodec) { + pendingVideoCodec[type] = codec + } + + override fun onVideoConfig(type: Int, codecData: ByteArray) { + val codec = pendingVideoCodec[type] ?: VideoCodec.H264 + videoDecoder(type).configure(codec, codecData) + } + + override fun onVideoFrame(type: Int, naluBytes: ByteArray) { + videoDecoder(type).submit(naluBytes) + } + + override fun onScreenStreamActive(type: Int, active: Boolean) { + if (!active) { + videoRecoveryHandlers.remove(type) + videoDiagnosticHandlers.remove(type) + videoDecoders.remove(type)?.close() + pendingVideoCodec.remove(type) + } + synchronized(screenStateLock) { + if (active) activeScreenTypes.add(type) else activeScreenTypes.remove(type) + screenStreamActiveChanged?.invoke(type, active) + } + } + + override fun onAudioStarted(type: Int, format: AudioFormat, firstSample: Int) { + audioRenderer(type, format).start() + } + + override fun onAudioRtp(type: Int, format: AudioFormat, rtp: ByteArray, sample: Int) { + audioRenderer(type, format).submit(rtp, sample) + } + + override fun onAudioStopped(type: Int) { + audioRenderers.remove(type)?.close() + } + + override fun onMicrophoneStarted(type: Int, config: MicrophoneConfig) { + val uplink = microphoneUplinks.computeIfAbsent(type) { MicrophoneUplink(config) } + if (!uplink.start()) microphoneUplinks.remove(type, uplink) + } + + override fun onMicrophoneStopped(type: Int) { + microphoneUplinks.remove(type)?.close() + } + + fun close() { + synchronized(screenStateLock) { + activeScreenTypes.forEach { screenStreamActiveChanged?.invoke(it, false) } + activeScreenTypes.clear() + screenStreamActiveChanged = null + } + videoDecoders.values.forEach(VideoDecoder::close) + videoDecoders.clear() + videoRecoveryHandlers.clear() + videoDiagnosticHandlers.clear() + recoveryExecutor.shutdownNow() + audioRenderers.values.forEach(AudioRenderer::close) + audioRenderers.clear() + microphoneUplinks.values.forEach(MicrophoneUplink::close) + microphoneUplinks.clear() + } + + private fun videoDecoder(type: Int): VideoDecoder = + videoDecoders.computeIfAbsent(type) { + VideoDecoder( + surfaces[type] ?: defaultSurface, + videoWidth, + videoHeight, + preferSoftwareHevcDecoder, + requestKeyFrame = { requestVideoRecovery(type) }, + report = { videoDiagnosticHandlers[type]?.invoke(it) }, + ) + } + + @Synchronized + private fun audioRenderer(type: Int, format: AudioFormat): AudioRenderer { + val existing = audioRenderers[type] + if (existing?.format == format) return existing + existing?.close() + return AudioRenderer(format, advancedAudioChannelMapping).also { audioRenderers[type] = it } + } +} + +/** Serial MediaCodec video decoder: one worker owns configure and frame feeding. */ +private class VideoDecoder( + surface: Surface?, + private val width: Int, + private val height: Int, + private val preferSoftwareHevcDecoder: Boolean, + private val requestKeyFrame: () -> Unit, + private val report: (String) -> Unit, +) : Closeable { + private val queue = VideoDecodeQueue() + @Volatile private var running = true + @Volatile private var decoder: MediaCodec? = null + private var outputSurface: Surface? = surface + private var lastConfig: VideoJob.Config? = null + private var renderedFrameLogged = false + private var submittedFrameLogged = false + private var duplicateConfigLogged = false + private val referenceChain = VideoReferenceChain() + private var lastKeyFrameRequestNs = 0L + private val thread = Thread(::run, "carplay-video").apply { isDaemon = true; start() } + + fun configure(codec: VideoCodec, codecData: ByteArray) { + queue.offer(VideoJob.Config(codec, codecData)) + } + + fun submit(nalus: ByteArray) { + queue.offer(VideoJob.Frame(nalus)) + } + + fun setSurface(surface: Surface?) { + queue.offer(VideoJob.SurfaceChanged(surface)) + } + + override fun close() { + running = false + thread.interrupt() + } + + private fun run() { + try { + while (running) { + val job = queue.poll(5) + try { + when (job) { + is VideoJob.Config -> configureDecoder(job) + is VideoJob.Frame -> { + if (System.nanoTime() - job.receivedNs > MAX_FRAME_AGE_NS) { + queue.discardFrames() + recover("video backlog exceeded 250 ms") + } else feed(job.nalus) + } + is VideoJob.SurfaceChanged -> changeSurface(job.surface) + is VideoJob.Resync -> recover("video queue overflow") + null -> Unit + } + decoder?.let(::drainOutput) + if (referenceChain.needsKeyFrame && lastConfig != null && outputSurface != null) requestKeyFrameIfDue() + } catch (error: Exception) { + if (running) Log.e(TAG, "video decoder job failed: ${job?.javaClass?.simpleName}", error) + if (running) report("decoder error ${error.javaClass.simpleName}; waiting for keyframe") + releaseDecoder() + referenceChain.reset() + requestKeyFrameIfDue() + } + } + } catch (_: InterruptedException) { + // Worker shut down. + } finally { + releaseDecoder() + } + } + + private fun configureDecoder(config: VideoJob.Config) { + val previous = lastConfig + if ( + decoder != null && + previous?.codec == config.codec && + previous.codecData.contentEquals(config.codecData) + ) { + if (!duplicateConfigLogged) { + duplicateConfigLogged = true + Log.i(TAG, "video decoder config unchanged; keeping existing decoder") + } + return + } + lastConfig = config + duplicateConfigLogged = false + releaseDecoder() + referenceChain.reset() + val surface = outputSurface ?: return + val codec = config.codec + val codecData = config.codecData + val mime = if (codec == VideoCodec.H265) MediaFormat.MIMETYPE_VIDEO_HEVC + else MediaFormat.MIMETYPE_VIDEO_AVC + val format = MediaFormat.createVideoFormat(mime, width, height).apply { + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_INPUT_SIZE) + setInteger(MediaFormat.KEY_PRIORITY, 0) + } + if (codec == VideoCodec.H265) { + val csd = MediaCodecSupport.hevcCodecSpecificData(codecData) + if (csd.isNotEmpty()) format.setByteBuffer("csd-0", ByteBuffer.wrap(csd)) + } else { + val (sps, pps) = MediaCodecSupport.avcParameterSets(codecData) + if (sps.isNotEmpty()) format.setByteBuffer("csd-0", ByteBuffer.wrap(START_CODE + sps)) + if (pps.isNotEmpty()) format.setByteBuffer("csd-1", ByteBuffer.wrap(START_CODE + pps)) + } + var candidate: MediaCodec? = null + val next = try { + createDecoder(mime).also { + candidate = it + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + it.codecInfo.getCapabilitiesForType(mime).isFeatureSupported("low-latency")) { + format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1) + } + it.configure(format, surface, null, 0) + it.start() + } + } catch (error: Exception) { + runCatching { candidate?.release() } + Log.e(TAG, "video decoder configure failed mime=$mime size=${width}x$height", error) + report("decoder configuration failed mime=$mime size=${width}x$height error=${error.javaClass.simpleName}") + null + } + decoder = next + renderedFrameLogged = false + submittedFrameLogged = false + if (next != null) { + report("decoder=${next.name} mime=$mime size=${width}x$height") + Log.i( + TAG, + "video decoder configured name=${next.name} mime=$mime size=${width}x$height", + ) + } + } + + private fun createDecoder(mime: String): MediaCodec { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + mime == MediaFormat.MIMETYPE_VIDEO_HEVC && + preferSoftwareHevcDecoder + ) { + val software = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.firstOrNull { + !it.isEncoder && it.isSoftwareOnly && mime in it.supportedTypes + } + if (software != null) { + try { + return MediaCodec.createByCodecName(software.name) + } catch (error: Exception) { + Log.w(TAG, "software HEVC decoder unavailable name=${software.name}", error) + } + } + } + return MediaCodec.createDecoderByType(mime) + } + + private fun changeSurface(surface: Surface?) { + if (outputSurface === surface) return + outputSurface = surface + if (surface == null) { + releaseDecoder() + Log.i(TAG, "video decoder detached from surface") + return + } + val codec = decoder + if (codec != null) { + try { + codec.setOutputSurface(surface) + Log.i(TAG, "video decoder output surface updated") + return + } catch (error: Exception) { + Log.w(TAG, "video decoder output surface update failed; reconfiguring", error) + } + } + releaseDecoder() + lastConfig?.let(::configureDecoder) + } + + private fun feed(nalus: ByteArray) { + val annexB = MediaCodecSupport.toAnnexB(nalus) + val config = lastConfig ?: return + if (outputSurface == null) return + if (annexB.isEmpty()) { recover("invalid video access unit"); return } + if (!referenceChain.accepts(annexB, config.codec)) { + requestKeyFrameIfDue() + return + } + if (decoder == null) configureDecoder(config) + val codec = decoder ?: return + if (!submittedFrameLogged) { + submittedFrameLogged = true + Log.i( + TAG, + "video decoder first input avcc=${nalus.size} annexB=${annexB.size} " + + "head=${annexB.take(16).joinToString("") { "%02x".format(it.toInt() and 0xff) }}", + ) + } + val index = VideoInputPump.acquire( + running = { running }, drain = { drainOutput(codec) }, + dequeue = { codec.dequeueInputBuffer(INPUT_TIMEOUT_US) }, + ) + if (index < 0) { recover("video decoder input stalled"); return } + val input = checkNotNull(codec.getInputBuffer(index)) { "Decoder input buffer unavailable" } + input.clear() + if (annexB.size <= input.remaining()) { + input.put(annexB) + codec.queueInputBuffer(index, 0, annexB.size, System.nanoTime() / 1000, 0) + referenceChain.onQueued() + } else { + recover("video frame exceeded codec input capacity") + return + } + drainOutput(codec) + } + + private fun recover(reason: String) { + Log.w(TAG, "Video recovery: $reason; waiting for keyframe") + report("recovery: $reason; waiting for keyframe") + releaseDecoder() + referenceChain.reset() + requestKeyFrameIfDue() + } + + private fun requestKeyFrameIfDue() { + val now = System.nanoTime() + if (lastKeyFrameRequestNs != 0L && now - lastKeyFrameRequestNs < 1_000_000_000L) return + lastKeyFrameRequestNs = now + requestKeyFrame() + } + + private fun drainOutput(codec: MediaCodec) { + val info = MediaCodec.BufferInfo() + while (running) { + val index = codec.dequeueOutputBuffer(info, 0) + when { + index == MediaCodec.INFO_TRY_AGAIN_LATER -> return + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> logOutputFormat(codec.outputFormat) + index >= 0 -> { + val render = outputSurface != null + codec.releaseOutputBuffer(index, render) + if (render && !renderedFrameLogged) { + renderedFrameLogged = true + report("first frame rendered") + Log.i(TAG, "video decoder rendered first frame bytes=${info.size}") + } + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return + } + else -> return + } + } + } + + private fun logOutputFormat(format: MediaFormat) { + report("output format requested=${width}x${height} " + + "coded=${format.intOrNull(MediaFormat.KEY_WIDTH)}x${format.intOrNull(MediaFormat.KEY_HEIGHT)} " + + "crop=${format.intOrNull("crop-left")},${format.intOrNull("crop-top")}," + + "${format.intOrNull("crop-right")},${format.intOrNull("crop-bottom")} " + + "stride=${format.intOrNull(MediaFormat.KEY_STRIDE)} slice=${format.intOrNull(MediaFormat.KEY_SLICE_HEIGHT)} " + + "color=${format.intOrNull(MediaFormat.KEY_COLOR_STANDARD)}/${format.intOrNull(MediaFormat.KEY_COLOR_RANGE)}/${format.intOrNull(MediaFormat.KEY_COLOR_TRANSFER)}") + Log.i( + TAG, + "video decoder output format " + + "size=${format.intOrNull(MediaFormat.KEY_WIDTH)}x" + + "${format.intOrNull(MediaFormat.KEY_HEIGHT)} " + + "stride=${format.intOrNull(MediaFormat.KEY_STRIDE)} " + + "slice=${format.intOrNull(MediaFormat.KEY_SLICE_HEIGHT)} " + + "standard=${format.intOrNull(MediaFormat.KEY_COLOR_STANDARD)} " + + "range=${format.intOrNull(MediaFormat.KEY_COLOR_RANGE)} " + + "transfer=${format.intOrNull(MediaFormat.KEY_COLOR_TRANSFER)}", + ) + } + + @Synchronized + private fun releaseDecoder() { + val codec = decoder + decoder = null + if (codec != null) { + try { + codec.stop() + } catch (_: Exception) { + // Best effort. + } + try { + codec.release() + } catch (_: Exception) { + // Best effort. + } + } + } + + private companion object { + const val TAG = "xcertplay-usb" + const val MAX_INPUT_SIZE = 8 * 1024 * 1024 + const val INPUT_TIMEOUT_US = 10_000L + const val MAX_FRAME_AGE_NS = 250_000_000L + val START_CODE = byteArrayOf(0x00, 0x00, 0x00, 0x01) + } +} + +private fun MediaFormat.intOrNull(key: String): Int? = + if (!containsKey(key)) { + null + } else { + try { + getInteger(key) + } catch (_: Exception) { + null + } + } + +/** Decodes AAC-LC/Opus to PCM and plays it, or plays wired LPCM directly. */ +private class AudioRenderer( + val format: AudioFormat, + private val advancedAudioChannelMapping: Boolean, +) : Closeable { + private data class AudioPacket(val rtp: ByteArray, val sample: Int) + + private val queue = LinkedBlockingQueue(MAX_QUEUED_PACKETS) + @Volatile private var running = true + @Volatile private var started = false + private var codec: MediaCodec? = null + private var track: AudioTrack? = null + private var pcm = ByteArray(64 * 1024) + private var playbackStarted = false + private var prebufferBytes = 0 + private var startThresholdBytes = 0 + private var fadeApplied = false + private var droppedPacketsLogged = false + private var firstAacPayloadLogged = false + private var firstOpusShortPacketLogged = false + private var firstInputQueuedLogged = false + private var inputQueued = 0 + private var inputDropped = 0 + private var outputBuffers = 0 + private var firstPcmLogged = false + private val thread = Thread(::run, "carplay-audio").apply { isDaemon = true } + + fun start() { + if (started) return + started = true + thread.start() + } + + fun submit(rtp: ByteArray, sample: Int) { + if (!started || !queue.offer(AudioPacket(rtp, sample))) { + if (started && !droppedPacketsLogged) { + droppedPacketsLogged = true + Log.w(TAG, "audio queue full; dropping newest packets to bound latency") + } + } + } + + override fun close() { + running = false + thread.interrupt() + } + + private fun run() { + try { + when (format.codec) { + AudioCodecKind.AAC_LC -> configureCodec(MediaFormat.MIMETYPE_AUDIO_AAC) + AudioCodecKind.OPUS -> configureCodec(MediaFormat.MIMETYPE_AUDIO_OPUS) + AudioCodecKind.LPCM -> Unit + } + createTrack() + while (running) handle(queue.take()) + } catch (_: InterruptedException) { + // Worker shut down. + } catch (error: Exception) { + if (running) Log.e(TAG, "audio renderer worker failed", error) + } finally { + release() + } + } + + private fun configureCodec(mime: String) { + val mediaFormat = MediaFormat().apply { + setString(MediaFormat.KEY_MIME, mime) + setInteger(MediaFormat.KEY_SAMPLE_RATE, format.sampleRate) + setInteger(MediaFormat.KEY_CHANNEL_COUNT, format.channels) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 64 * 1024) + if (mime == MediaFormat.MIMETYPE_AUDIO_AAC) { + setInteger(MediaFormat.KEY_IS_ADTS, 1) + setByteBuffer("csd-0", ByteBuffer.wrap(aacAudioSpecificConfig())) + } else { + setByteBuffer("csd-0", ByteBuffer.wrap(opusHead())) + setByteBuffer("csd-1", ByteBuffer.wrap(opusCodecDelay())) + setByteBuffer("csd-2", ByteBuffer.wrap(opusSeekPreRoll())) + } + } + if (mime == MediaFormat.MIMETYPE_AUDIO_AAC) { + Log.i( + TAG, + "audio AAC config rate=${format.sampleRate} channels=${format.channels} " + + "csd0=${aacAudioSpecificConfig().toHexString()}", + ) + } + codec = try { + MediaCodec.createDecoderByType(mime).also { + it.configure(mediaFormat, null, null, 0) + it.start() + Log.i(TAG, "audio decoder configured mime=$mime name=${it.name}") + } + } catch (error: Exception) { + Log.e(TAG, "audio decoder configuration failed mime=$mime", error) + null + } + } + + private fun createTrack() { + val encoding = AndroidAudioFormat.ENCODING_PCM_16BIT + val channelMask = if (format.channels >= 2) AndroidAudioFormat.CHANNEL_OUT_STEREO + else AndroidAudioFormat.CHANNEL_OUT_MONO + val minBuffer = AudioTrack.getMinBufferSize(format.sampleRate, channelMask, encoding) + if (minBuffer <= 0) { + Log.e(TAG, "AudioTrack buffer size unavailable rate=${format.sampleRate} channels=${format.channels}") + return + } + val bufferBytes = maxOf(minBuffer * 4, MIN_TRACK_BUFFER_BYTES) + startThresholdBytes = if (format.audioType == "telephony" || format.audioType == "speechrecognition") { + maxOf(minBuffer, MIN_START_BUFFER_BYTES) + } else { + maxOf(minBuffer, MIN_START_BUFFER_BYTES) + } + track = AudioTrack.Builder() + .setAudioAttributes(audioAttributes()) + .setAudioFormat( + AndroidAudioFormat.Builder() + .setEncoding(encoding) + .setSampleRate(format.sampleRate) + .setChannelMask(channelMask) + .build(), + ) + .setBufferSizeInBytes(bufferBytes) + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + Log.i( + TAG, + "audio track prepared type=${format.payloadType} audioType=${format.audioType} " + + "codec=${format.codec} " + + "rate=${format.sampleRate} channels=${format.channels}", + ) + } + + private fun aacAudioSpecificConfig(): ByteArray { + val frequencyIndex = MediaCodecSupport.aacFrequencyIndex(format.sampleRate) + val value = (AAC_OBJECT_TYPE_LC shl 11) or + (frequencyIndex shl 7) or + (format.channels.coerceIn(1, 7) shl 3) + return byteArrayOf((value ushr 8).toByte(), value.toByte()) + } + + private fun audioAttributes(): AudioAttributes { + val mode = if (advancedAudioChannelMapping) { + AudioChannelMappingMode.AUTOMOTIVE_BUS + } else { + AudioChannelMappingMode.MOBILE_COMPATIBLE + } + val selection = AudioChannelMapper.map( + audioType = format.audioType, + payloadType = format.payloadType, + mode = mode, + ) + val usage = usageFor(selection.channel) + val contentType = contentTypeFor(selection.contentType) + return AudioAttributes.Builder() + .setUsage(usage) + .setContentType(contentType) + .build() + .also { + Log.i( + TAG, + "audio route type=${format.payloadType} audioType=${format.audioType} " + + "mode=$mode channel=${selection.channel} " + + "usage=$usage contentType=$contentType", + ) + } + } + + private fun usageFor(channel: AudioChannel): Int = when (channel) { + AudioChannel.MEDIA -> AudioAttributes.USAGE_MEDIA + AudioChannel.PHONE -> AudioAttributes.USAGE_VOICE_COMMUNICATION + AudioChannel.ASSISTANT -> AudioAttributes.USAGE_ASSISTANT + AudioChannel.NAVIGATION -> AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE + } + + private fun contentTypeFor(contentType: AudioContentType): Int = when (contentType) { + AudioContentType.MUSIC -> AudioAttributes.CONTENT_TYPE_MUSIC + AudioContentType.SPEECH -> AudioAttributes.CONTENT_TYPE_SPEECH + } + + /** Minimal OpusHead CSD for the mono 48 kHz stream CarPlay negotiates. */ + private fun opusHead(): ByteArray { + val head = ByteArray(19) + "OpusHead".toByteArray(Charsets.US_ASCII).copyInto(head, 0) + head[8] = 1 + head[9] = format.channels.toByte() + head[10] = 0x38 + head[11] = 0x01 + head[12] = format.sampleRate.toByte() + head[13] = (format.sampleRate ushr 8).toByte() + head[14] = (format.sampleRate ushr 16).toByte() + head[15] = (format.sampleRate ushr 24).toByte() + return head + } + + private fun opusCodecDelay(): ByteArray = + java.nio.ByteBuffer.allocate(8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .putLong(OPUS_CODEC_DELAY_NANOS) + .array() + + private fun opusSeekPreRoll(): ByteArray = + java.nio.ByteBuffer.allocate(8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .putLong(OPUS_SEEK_PRE_ROLL_NANOS) + .array() + + private fun handle(packet: AudioPacket) { + val rtp = packet.rtp + val timestampUs = sampleTimestampUs(packet.sample) + when (format.codec) { + AudioCodecKind.LPCM -> writePcm(byteSwapS16(rtp.copyOfRange(12, rtp.size))) + AudioCodecKind.AAC_LC -> { + val accessUnit = rtp.copyOfRange(12, rtp.size) + if (accessUnit.isNotEmpty()) { + if (!firstAacPayloadLogged) { + firstAacPayloadLogged = true + Log.i( + TAG, + "audio AAC access unit bytes=${accessUnit.size} " + + "head=${accessUnit.copyOf(minOf(accessUnit.size, 16)).toHexString()}", + ) + } + feedCodec( + MediaCodecSupport.adtsFrame(accessUnit, format.sampleRate, format.channels), + timestampUs, + ) + } + } + AudioCodecKind.OPUS -> { + val accessUnit = rtp.copyOfRange(12, rtp.size) + if (accessUnit.size < MIN_OPUS_PACKET_BYTES) { + if (!firstOpusShortPacketLogged) { + firstOpusShortPacketLogged = true + Log.i( + TAG, + "audio Opus skipping short packet bytes=${accessUnit.size} " + + "head=${accessUnit.toHexString()}", + ) + } + return + } + feedCodec(accessUnit, timestampUs) + } + } + } + + private fun sampleTimestampUs(sample: Int): Long = + (sample.toLong() and 0xffff_ffffL) * 1_000_000L / format.sampleRate + + private fun feedCodec(payload: ByteArray, presentationTimeUs: Long) { + val codec = codec ?: return + val index = codec.dequeueInputBuffer(INPUT_TIMEOUT_US) + if (index < 0) { + inputDropped++ + if (inputDropped == 1) { + Log.w( + TAG, + "audio decoder input unavailable codec=${format.codec} " + + "queued=$inputQueued dropped=$inputDropped", + ) + } + return + } + val input = codec.getInputBuffer(index) ?: return + input.clear() + if (payload.size <= input.remaining()) { + input.put(payload) + codec.queueInputBuffer(index, 0, payload.size, presentationTimeUs, 0) + inputQueued++ + if (!firstInputQueuedLogged) { + firstInputQueuedLogged = true + Log.i( + TAG, + "audio decoder first input codec=${format.codec} bytes=${payload.size} " + + "head=${payload.copyOf(minOf(payload.size, 16)).toHexString()}", + ) + } + } else { + codec.queueInputBuffer(index, 0, 0, 0, 0) + inputDropped++ + } + drainCodec(codec) + } + + private fun drainCodec(codec: MediaCodec) { + val info = MediaCodec.BufferInfo() + while (running) { + val index = codec.dequeueOutputBuffer(info, 0) + when { + index == MediaCodec.INFO_TRY_AGAIN_LATER -> return + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> Unit + index >= 0 -> { + val size = info.size + if (size > 0) { + outputBuffers++ + if (outputBuffers == 1 || outputBuffers % DECODED_BUFFER_LOG_INTERVAL == 0) { + Log.i( + TAG, + "audio decoder output codec=${format.codec} " + + "buffers=$outputBuffers bytes=$size " + + "queued=$inputQueued dropped=$inputDropped", + ) + } + } + if (size > 0) { + val output = codec.getOutputBuffer(index) + if (output != null) { + if (size > pcm.size) pcm = ByteArray(size) + output.position(info.offset) + output.limit(info.offset + size) + output.get(pcm, 0, size) + writePcm(pcm, 0, size) + } + } + codec.releaseOutputBuffer(index, false) + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return + } + else -> return + } + } + } + + private fun writePcm(data: ByteArray, offset: Int = 0, length: Int = data.size) { + val track = track ?: return + if (!firstPcmLogged && length > 0) { + firstPcmLogged = true + val end = minOf(data.size, offset + minOf(length, 16)) + Log.i( + TAG, + "audio first PCM type=${format.payloadType} bytes=$length " + + "head=${data.copyOfRange(offset, end).toHexString()}", + ) + } + if (!fadeApplied) { + applyFadeIn(data, offset, length) + fadeApplied = true + } + var written = 0 + while (written < length && running) { + val writeLength = if (playbackStarted) { + length - written + } else { + minOf(length - written, PREBUFFER_WRITE_CHUNK_BYTES) + } + val count = track.write(data, offset + written, writeLength, AudioTrack.WRITE_BLOCKING) + if (count <= 0) break + written += count + if (!playbackStarted) { + prebufferBytes += count + if (prebufferBytes >= startThresholdBytes) { + track.play() + playbackStarted = true + Log.i(TAG, "audio playback started type=${format.payloadType}") + } + } + } + } + + private fun applyFadeIn(data: ByteArray, offset: Int, length: Int) { + val samples = (length - length % 2) / 2 + val fadeSamples = minOf(samples, maxOf(1, format.sampleRate / 100)) + for (index in 0 until fadeSamples) { + val position = offset + index * 2 + val sample = (data[position].toInt() and 0xff) or (data[position + 1].toInt() shl 8) + val scaled = (sample.toLong() * (index + 1) / fadeSamples).toInt() + data[position] = scaled.toByte() + data[position + 1] = (scaled shr 8).toByte() + } + } + + private fun byteSwapS16(source: ByteArray): ByteArray { + for (index in 0 until source.size - 1 step 2) { + val tmp = source[index] + source[index] = source[index + 1] + source[index + 1] = tmp + } + return source + } + + @Synchronized + private fun release() { + val codec = codec + this.codec = null + if (codec != null) { + try { + codec.stop() + } catch (_: Exception) { + // Best effort. + } + try { + codec.release() + } catch (_: Exception) { + // Best effort. + } + } + val track = track + this.track = null + if (track != null) { + try { + track.pause() + } catch (_: Exception) { + // Best effort. + } + try { + track.flush() + } catch (_: Exception) { + // Best effort. + } + try { + track.release() + } catch (_: Exception) { + // Best effort. + } + } + } + + private companion object { + const val TAG = "xcertplay-usb" + const val AAC_OBJECT_TYPE_LC = 2 + const val MIN_OPUS_PACKET_BYTES = 4 + const val OPUS_CODEC_DELAY_NANOS = 6_500_000L + const val OPUS_SEEK_PRE_ROLL_NANOS = 80_000_000L + const val INPUT_TIMEOUT_US = 10_000L + const val MAX_QUEUED_PACKETS = 64 + const val MIN_TRACK_BUFFER_BYTES = 16 * 1024 + const val MIN_START_BUFFER_BYTES = 4 * 1024 + const val PREBUFFER_WRITE_CHUNK_BYTES = 2 * 1024 + const val DECODED_BUFFER_LOG_INTERVAL = 50 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt b/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt new file mode 100644 index 0000000..896daa4 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/AudioChannelMapping.kt @@ -0,0 +1,78 @@ +package com.shilapi.xcertplay.media + +internal enum class AudioChannelMappingMode { + MOBILE_COMPATIBLE, + AUTOMOTIVE_BUS, +} + +internal enum class AudioChannel { + MEDIA, + PHONE, + ASSISTANT, + NAVIGATION, +} + +internal enum class AudioContentType { + MUSIC, + SPEECH, +} + +internal data class AudioChannelSelection( + val channel: AudioChannel, + val contentType: AudioContentType, +) + +/** + * Maps CarPlay stream metadata to Android AudioAttributes values. + * + * AAOS routes AudioTrack instances by usage, so the automotive mode keeps generic media streams + * on the media bus instead of reusing mobile's navigation fallback for default/compatibility. + */ +internal object AudioChannelMapper { + const val STREAM_TYPE_MAIN_HIGH_AUDIO = 102 + + fun map( + audioType: String, + payloadType: Int, + mode: AudioChannelMappingMode, + ): AudioChannelSelection { + val normalized = audioType.lowercase() + return when (mode) { + AudioChannelMappingMode.MOBILE_COMPATIBLE -> mapMobileCompatible(normalized, payloadType) + AudioChannelMappingMode.AUTOMOTIVE_BUS -> mapAutomotiveBus(normalized, payloadType) + } + } + + private fun mapMobileCompatible( + audioType: String, + payloadType: Int, + ): AudioChannelSelection = when (audioType) { + "telephony" -> AudioChannelSelection(AudioChannel.PHONE, AudioContentType.SPEECH) + "speechrecognition" -> + AudioChannelSelection(AudioChannel.ASSISTANT, AudioContentType.SPEECH) + "media" -> AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC) + "default", "alert", "compatibility" -> + AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH) + else -> mainHighAudioOrNavigation(payloadType) + } + + private fun mapAutomotiveBus( + audioType: String, + payloadType: Int, + ): AudioChannelSelection = when (audioType) { + "telephony" -> AudioChannelSelection(AudioChannel.PHONE, AudioContentType.SPEECH) + "speechrecognition" -> + AudioChannelSelection(AudioChannel.ASSISTANT, AudioContentType.SPEECH) + "media", "default", "compatibility" -> + AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC) + "alert" -> AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH) + else -> mainHighAudioOrNavigation(payloadType) + } + + private fun mainHighAudioOrNavigation(payloadType: Int): AudioChannelSelection = + if (payloadType == STREAM_TYPE_MAIN_HIGH_AUDIO) { + AudioChannelSelection(AudioChannel.MEDIA, AudioContentType.MUSIC) + } else { + AudioChannelSelection(AudioChannel.NAVIGATION, AudioContentType.SPEECH) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayTouchMapper.kt b/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayTouchMapper.kt new file mode 100644 index 0000000..8148c2a --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayTouchMapper.kt @@ -0,0 +1,30 @@ +package com.shilapi.xcertplay.media + +import android.view.MotionEvent +import com.shilapi.xcertplay.airplay.AirPlayContact + +/** Converts Android MotionEvents into normalized CarPlay touch contacts. */ +object CarPlayTouchMapper { + private const val MAX_CONTACTS = 2 + + fun contacts(event: MotionEvent, viewWidth: Int, viewHeight: Int): List { + val width = viewWidth.coerceAtLeast(1) + val height = viewHeight.coerceAtLeast(1) + val action = event.actionMasked + val liftedIndex = if (action == MotionEvent.ACTION_POINTER_UP) event.actionIndex else -1 + val allUp = action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL + val count = minOf(MAX_CONTACTS, event.pointerCount) + val contacts = ArrayList(count) + for (index in 0 until count) { + contacts.add( + AirPlayContact( + id = index, + x = (event.getX(index).toDouble() / width).coerceIn(0.0, 1.0), + y = (event.getY(index).toDouble() / height).coerceIn(0.0, 1.0), + down = !allUp && index != liftedIndex, + ), + ) + } + return contacts + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/MediaCodecSupport.kt b/shared/src/main/java/com/shilapi/xcertplay/media/MediaCodecSupport.kt new file mode 100644 index 0000000..56eb22d --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/MediaCodecSupport.kt @@ -0,0 +1,193 @@ +package com.shilapi.xcertplay.media + +import com.shilapi.xcertplay.airplay.VideoCodec +import java.io.ByteArrayOutputStream + +/** + * Pure byte helpers that convert the CarPlay screen/audio payloads into the + * records Android MediaCodec and AudioTrack expect. Kept free of Android types + * so they stay testable on the JVM. + */ +object MediaCodecSupport { + private val START_CODE = byteArrayOf(0x00, 0x00, 0x00, 0x01) + + /** Splits an AVCDecoderConfigurationRecord into raw first SPS and first PPS. */ + fun avcParameterSets(codecData: ByteArray): Pair { + if (codecData.size < 7) return emptySet() + var cursor = 6 + val sps = readParameterSets(codecData, cursor, codecData[5].toInt() and 0x1f) + cursor += sps.sumOf { it.size + 2 } + if (cursor >= codecData.size) return emptySet() + val pps = readParameterSets(codecData, cursor + 1, codecData[cursor].toInt() and 0xff) + return (sps.firstOrNull() ?: ByteArray(0)) to (pps.firstOrNull() ?: ByteArray(0)) + } + + /** + * Converts an HEVCDecoderConfigurationRecord (hvcC) into Annex B VPS/SPS/PPS CSD. + * + * Android video decoders expect the initialization data as NAL units with start codes, + * not as the raw ISO-BMFF hvcC record. + */ + fun hevcCodecSpecificData(codecData: ByteArray): ByteArray { + if (codecData.size < HEVC_FIXED_RECORD_SIZE || codecData[0].toInt() != 1) { + return ByteArray(0) + } + + var cursor = HEVC_ARRAY_COUNT_OFFSET + val arrayCount = codecData[cursor++].toInt() and 0xff + val output = ByteArrayOutputStream() + repeat(arrayCount) { + if (cursor >= codecData.size) return ByteArray(0) + cursor += 1 // array_completeness, reserved, and nal_unit_type + if (cursor + 2 > codecData.size) return ByteArray(0) + val nalUnitCount = readU16Be(codecData, cursor) + cursor += 2 + repeat(nalUnitCount) { + if (cursor + 2 > codecData.size) return ByteArray(0) + val nalUnitLength = readU16Be(codecData, cursor) + cursor += 2 + if (cursor + nalUnitLength > codecData.size) return ByteArray(0) + output.write(START_CODE) + output.write(codecData, cursor, nalUnitLength) + cursor += nalUnitLength + } + } + return output.toByteArray() + } + + /** Converts CarPlay's length-prefixed NAL units into an Annex B byte stream. */ + fun toAnnexB(lengthPrefixed: ByteArray): ByteArray { + if (lengthPrefixed.size >= 4 && + lengthPrefixed[0] == 0.toByte() && + lengthPrefixed[1] == 0.toByte() && + lengthPrefixed[2] == 0.toByte() && + lengthPrefixed[3] == 1.toByte() + ) { + return lengthPrefixed + } + + var inputOffset = 0 + var outputSize = 0 + while (inputOffset + 4 <= lengthPrefixed.size) { + val length = readU32Be(lengthPrefixed, inputOffset) + inputOffset += 4 + if (length <= 0 || length > lengthPrefixed.size - inputOffset) return ByteArray(0) + if (length > Int.MAX_VALUE - outputSize - START_CODE.size) return ByteArray(0) + outputSize += START_CODE.size + length + inputOffset += length + } + if (outputSize == 0 || inputOffset != lengthPrefixed.size) return ByteArray(0) + + val output = ByteArray(outputSize) + var offset = 0 + var outputOffset = 0 + while (offset + 4 <= lengthPrefixed.size) { + val length = readU32Be(lengthPrefixed, offset) + offset += 4 + if (length <= 0 || offset + length > lengthPrefixed.size) break + START_CODE.copyInto(output, outputOffset) + outputOffset += START_CODE.size + lengthPrefixed.copyInto(output, outputOffset, offset, offset + length) + outputOffset += length + offset += length + } + return output + } + + /** IDR (AVC) or IRAP (HEVC) starts an independently decodable reference chain. */ + fun isRandomAccess(annexB: ByteArray, codec: VideoCodec): Boolean { + var cursor = 0 + while (cursor + 3 < annexB.size) { + val prefix = when { + annexB[cursor] != 0.toByte() || annexB[cursor + 1] != 0.toByte() -> 0 + annexB[cursor + 2] == 1.toByte() -> 3 + annexB[cursor + 2] == 0.toByte() && annexB[cursor + 3] == 1.toByte() -> 4 + else -> 0 + } + if (prefix > 0 && cursor + prefix < annexB.size) { + val header = annexB[cursor + prefix].toInt() and 255 + if (codec == VideoCodec.H264 && header and 31 == 5) return true + if (codec == VideoCodec.H265 && cursor + prefix + 1 < annexB.size && + (header shr 1) and 63 in 16..21) return true + cursor += prefix + } else cursor++ + } + return false + } + + /** Wraps one raw AAC-LC access unit in an MPEG-4 ADTS frame. */ + fun adtsFrame(accessUnit: ByteArray, sampleRate: Int, channels: Int): ByteArray { + val frequencyIndex = aacFrequencyIndex(sampleRate) + val channelConfig = channels.coerceIn(1, 7) + val frameLength = accessUnit.size + 7 + val header = ByteArray(7) + header[0] = 0xff.toByte() + header[1] = 0xf1.toByte() + header[2] = ((1 shl 6) or (frequencyIndex shl 2) or (channelConfig ushr 2)).toByte() + header[3] = (((channelConfig and 0x3) shl 6) or (frameLength ushr 11)).toByte() + header[4] = ((frameLength ushr 3) and 0xff).toByte() + header[5] = (((frameLength and 0x7) shl 5) or 0x1f).toByte() + header[6] = 0xfc.toByte() + return header + accessUnit + } + + /** Extracts one RFC 3640 AAC access unit from an RTP payload. */ + fun aacAccessUnit(rtpPayload: ByteArray): ByteArray { + if (rtpPayload.size < 4) return ByteArray(0) + val headerBits = readU16Be(rtpPayload, 0) + if (headerBits < 16 || headerBits % 16 != 0) return ByteArray(0) + val headerBytes = headerBits / 8 + if (2 + headerBytes > rtpPayload.size) return ByteArray(0) + val auSize = (readU16Be(rtpPayload, 2) shr 3) and 0x1fff + val start = 2 + headerBytes + val end = minOf(start + auSize, rtpPayload.size) + return if (end <= start) ByteArray(0) else rtpPayload.copyOfRange(start, end) + } + + /** MPEG-4 sampling frequency index used by both ADTS and AudioSpecificConfig. */ + fun aacFrequencyIndex(sampleRate: Int): Int = when (sampleRate) { + 96_000 -> 0 + 88_200 -> 1 + 64_000 -> 2 + 48_000 -> 3 + 44_100 -> 4 + 32_000 -> 5 + 24_000 -> 6 + 22_050 -> 7 + 16_000 -> 8 + 12_000 -> 9 + 11_025 -> 10 + 8_000 -> 11 + 7_350 -> 12 + else -> 3 + } + + private fun emptySet(): Pair = ByteArray(0) to ByteArray(0) + + private fun readParameterSets(source: ByteArray, offset: Int, count: Int): List { + val sets = ArrayList(count) + var cursor = offset + var index = 0 + while (index < count && cursor + 2 <= source.size) { + index++ + val length = readU16Be(source, cursor) + cursor += 2 + if (cursor + length > source.size) break + sets.add(source.copyOfRange(cursor, cursor + length)) + cursor += length + } + return sets + } + + private fun readU16Be(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 8) or (source[offset + 1].toInt() and 0xff) + + private fun readU32Be(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 24) or + ((source[offset + 1].toInt() and 0xff) shl 16) or + ((source[offset + 2].toInt() and 0xff) shl 8) or + (source[offset + 3].toInt() and 0xff) + + private const val HEVC_FIXED_RECORD_SIZE = 23 + private const val HEVC_ARRAY_COUNT_OFFSET = 22 +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt b/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt new file mode 100644 index 0000000..bb91c6d --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/MicrophoneUplink.kt @@ -0,0 +1,262 @@ +package com.shilapi.xcertplay.media + +import android.media.AudioFormat as AndroidAudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import com.shilapi.xcertplay.airplay.AudioCodecKind +import com.shilapi.xcertplay.airplay.MicrophoneConfig +import com.shilapi.xcertplay.airplay.MicrophoneCounters +import com.shilapi.xcertplay.airplay.MicrophonePacketizer +import com.shilapi.xcertplay.airplay.toHexString +import java.io.Closeable +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Captures one PCM microphone stream and sends it back to the phone as sealed CarPlay RTP. + * + * The recorder runs only while the matching audio stream is active, so callers start this after + * the first downlink audio packet and close it on stream teardown. + */ +internal class MicrophoneUplink(private val config: MicrophoneConfig) : Closeable { + private val running = AtomicBoolean(false) + private val firstPacketLogged = AtomicBoolean(false) + @Volatile private var recorder: AudioRecord? = null + @Volatile private var socket: DatagramSocket? = null + @Volatile private var opusEncoder: OpusEncoder? = null + private var thread: Thread? = null + + fun start(): Boolean { + if (!running.compareAndSet(false, true)) return true + + val channelMask = if (config.channels >= 2) { + AndroidAudioFormat.CHANNEL_IN_STEREO + } else { + AndroidAudioFormat.CHANNEL_IN_MONO + } + val minBuffer = AudioRecord.getMinBufferSize( + config.sampleRate, + channelMask, + AndroidAudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuffer <= 0) { + Log.w(TAG, "microphone unavailable rate=${config.sampleRate} channels=${config.channels}") + running.set(false) + return false + } + + val source = when (config.audioType) { + "telephony" -> MediaRecorder.AudioSource.VOICE_COMMUNICATION + "speechrecognition" -> MediaRecorder.AudioSource.VOICE_RECOGNITION + else -> MediaRecorder.AudioSource.MIC + } + val nextEncoder = if (config.codec == AudioCodecKind.OPUS) { + OpusEncoder(config.bitrate ?: 48_000).takeIf { it.available } + } else { + null + } + if (config.codec == AudioCodecKind.OPUS && nextEncoder == null) { + Log.w(TAG, "microphone Opus encoder is unavailable") + running.set(false) + return false + } + val bufferSize = maxOf(minBuffer * 2, config.frameBytes * 4) + val nextRecorder = try { + AudioRecord.Builder() + .setAudioSource(source) + .setAudioFormat( + AndroidAudioFormat.Builder() + .setEncoding(AndroidAudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(config.sampleRate) + .setChannelMask(channelMask) + .build(), + ) + .setBufferSizeInBytes(bufferSize) + .build() + } catch (error: Exception) { + Log.e(TAG, "microphone recorder creation failed", error) + nextEncoder?.close() + running.set(false) + return false + } + if (nextRecorder.state != AudioRecord.STATE_INITIALIZED) { + Log.w(TAG, "microphone recorder failed to initialize") + nextRecorder.release() + nextEncoder?.close() + running.set(false) + return false + } + + val nextSocket = try { + DatagramSocket(null).apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName("::"), 0)) + } + } catch (error: Exception) { + Log.e(TAG, "microphone socket creation failed", error) + nextRecorder.release() + nextEncoder?.close() + running.set(false) + return false + } + + recorder = nextRecorder + socket = nextSocket + opusEncoder = nextEncoder + return try { + nextRecorder.startRecording() + thread = Thread({ capture(nextRecorder, nextSocket) }, "carplay-mic").apply { + isDaemon = true + start() + } + Log.i( + TAG, + "microphone uplink started type=${config.audioType} " + + "rate=${config.sampleRate} channels=${config.channels} " + + "frameMs=${config.frameMillis} port=${config.port}", + ) + true + } catch (error: Exception) { + Log.e(TAG, "microphone recording failed", error) + release() + false + } + } + + private fun capture(recorder: AudioRecord, socket: DatagramSocket) { + val frame = ByteArray(config.frameBytes) + val readBuffer = ByteArray(maxOf(frame.size, MIN_READ_BYTES)) + val counters = MicrophoneCounters() + var filled = 0 + try { + while (running.get()) { + val count = recorder.read(readBuffer, 0, readBuffer.size, AudioRecord.READ_BLOCKING) + if (count < 0) { + if (running.get()) Log.e(TAG, "microphone read failed code=$count") + return + } + if (count == 0) { + continue + } + var offset = 0 + while (offset < count && running.get()) { + val copied = minOf(frame.size - filled, count - offset) + readBuffer.copyInto(frame, filled, offset, offset + copied) + filled += copied + offset += copied + if (filled == frame.size) { + sendFrame(socket, counters, frame) + filled = 0 + } + } + } + } catch (error: Exception) { + if (running.get()) Log.e(TAG, "microphone capture failed", error) + } finally { + running.set(false) + release() + } + } + + private fun sendFrame(socket: DatagramSocket, counters: MicrophoneCounters, frame: ByteArray) { + val bodies = if (config.codec == AudioCodecKind.OPUS) { + opusEncoder?.encode(frame).orEmpty() + } else { + listOf(MicrophonePacketizer.toWirePcm(frame)) + } + bodies.forEach { body -> + sendPacket( + socket = socket, + counters = counters, + body = body, + samples = config.samplesPerPacket, + ) + } + } + + private fun sendPacket( + socket: DatagramSocket, + counters: MicrophoneCounters, + body: ByteArray, + samples: Int, + ) { + val packet = MicrophonePacketizer.sealPacket( + key = config.key, + payloadType = config.payloadType, + counters = counters, + body = body, + samples = samples, + ) + try { + socket.send(DatagramPacket(packet, packet.size, config.host, config.port)) + if (firstPacketLogged.compareAndSet(false, true)) { + Log.i( + TAG, + "microphone first packet bytes=${packet.size} body=${body.size} " + + "head=${packet.copyOf(minOf(packet.size, 16)).toHexString()} " + + "port=${config.port}", + ) + } + } catch (error: Exception) { + if (running.get()) throw error + } + } + + override fun close() { + if (!running.compareAndSet(true, false)) { + release() + return + } + try { + recorder?.stop() + } catch (_: Exception) { + // Best effort; release below is authoritative. + } + try { + socket?.close() + } catch (_: Exception) { + // Best effort. + } + thread?.let { worker -> + try { + worker.join(CLOSE_JOIN_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + if (worker.isAlive) worker.interrupt() + } + release() + } + + @Synchronized + private fun release() { + running.set(false) + val currentRecorder = recorder + recorder = null + try { + currentRecorder?.release() + } catch (_: Exception) { + // Best effort. + } + val currentSocket = socket + socket = null + try { + currentSocket?.close() + } catch (_: Exception) { + // Best effort. + } + val currentEncoder = opusEncoder + opusEncoder = null + currentEncoder?.close() + } + + private companion object { + const val TAG = "xcertplay-usb" + const val MIN_READ_BYTES = 2_048 + const val CLOSE_JOIN_MILLIS = 500L + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/OpusEncoder.kt b/shared/src/main/java/com/shilapi/xcertplay/media/OpusEncoder.kt new file mode 100644 index 0000000..43b42a3 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/OpusEncoder.kt @@ -0,0 +1,141 @@ +package com.shilapi.xcertplay.media + +import android.media.MediaCodec +import android.media.MediaFormat +import android.util.Log +import java.io.Closeable + +/** + * Encodes 20 ms chunks of 48 kHz mono PCM into raw Opus access units for the CarPlay + * microphone uplink. + */ +internal class OpusEncoder(bitrate: Int) : Closeable { + private val codec: MediaCodec? = try { + val format = MediaFormat.createAudioFormat( + MediaFormat.MIMETYPE_AUDIO_OPUS, + SAMPLE_RATE, + CHANNELS, + ).apply { + setInteger(MediaFormat.KEY_BIT_RATE, bitrate) + setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_INPUT_BYTES) + } + MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also { + it.configure( + format, + null, + null, + MediaCodec.CONFIGURE_FLAG_ENCODE, + ) + it.start() + Log.i(TAG, "Opus microphone encoder started bitrate=$bitrate") + } + } catch (error: Exception) { + Log.w(TAG, "Opus microphone encoder unavailable", error) + null + } + private val bufferInfo = MediaCodec.BufferInfo() + private var presentationTimeUs = 0L + private var closed = false + private var outputPackets = 0 + + val available: Boolean get() = codec != null && !closed + + /** + * Queues one 20 ms PCM frame and returns all Opus access units made available by the codec. + */ + fun encode(pcm: ByteArray): List { + val codec = codec ?: return emptyList() + if (closed) return emptyList() + val inputIndex = try { + codec.dequeueInputBuffer(INPUT_TIMEOUT_US) + } catch (error: Exception) { + Log.w(TAG, "Opus microphone input dequeue failed", error) + return emptyList() + } + if (inputIndex >= 0) { + val input = codec.getInputBuffer(inputIndex) + if (input == null || pcm.size > input.remaining()) { + codec.queueInputBuffer(inputIndex, 0, 0, presentationTimeUs, 0) + } else { + input.clear() + input.put(pcm) + codec.queueInputBuffer( + inputIndex, + 0, + pcm.size, + presentationTimeUs, + 0, + ) + presentationTimeUs += INPUT_DURATION_US + } + } + return drain() + } + + private fun drain(): List { + val codec = codec ?: return emptyList() + val output = ArrayList() + while (!closed) { + val index = try { + codec.dequeueOutputBuffer(bufferInfo, 0) + } catch (error: Exception) { + Log.w(TAG, "Opus microphone output dequeue failed", error) + return output + } + when { + index == MediaCodec.INFO_TRY_AGAIN_LATER -> return output + index == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> continue + index >= 0 -> { + if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) { + codec.releaseOutputBuffer(index, false) + continue + } + val buffer = codec.getOutputBuffer(index) + if (buffer != null && bufferInfo.size > 0) { + val bytes = ByteArray(bufferInfo.size) + buffer.position(bufferInfo.offset) + buffer.limit(bufferInfo.offset + bufferInfo.size) + buffer.get(bytes) + output.add(bytes) + outputPackets++ + if (outputPackets <= FIRST_PACKET_LOG_COUNT) { + Log.i( + TAG, + "Opus microphone packet=$outputPackets bytes=${bytes.size} " + + "head=${bytes.copyOf(minOf(bytes.size, 16)).toHexString()}", + ) + } + } + codec.releaseOutputBuffer(index, false) + } + } + } + return output + } + + override fun close() { + if (closed) return + closed = true + val codec = codec ?: return + try { + codec.stop() + } catch (_: Exception) { + // Best effort. + } + try { + codec.release() + } catch (_: Exception) { + // Best effort. + } + } + + private companion object { + const val TAG = "xcertplay-usb" + const val SAMPLE_RATE = 48_000 + const val CHANNELS = 1 + const val INPUT_TIMEOUT_US = 10_000L + const val INPUT_DURATION_US = 20_000L + const val MAX_INPUT_BYTES = 4_096 + const val FIRST_PACKET_LOG_COUNT = 3 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt b/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt new file mode 100644 index 0000000..5168cf3 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/media/VideoDecodeQueue.kt @@ -0,0 +1,70 @@ +package com.shilapi.xcertplay.media + +import android.view.Surface +import com.shilapi.xcertplay.airplay.VideoCodec +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit + +internal sealed interface VideoJob { + data class Config(val codec: VideoCodec, val codecData: ByteArray) : VideoJob + data class Frame(val nalus: ByteArray, val receivedNs: Long = System.nanoTime()) : VideoJob + data class SurfaceChanged(val surface: Surface?) : VideoJob + data object Resync : VideoJob +} + +/** Do not resume dependent pictures after losing a reference frame. */ +internal class VideoReferenceChain { + var needsKeyFrame = true + private set + fun reset() { needsKeyFrame = true } + fun accepts(bytes: ByteArray, codec: VideoCodec): Boolean = + !needsKeyFrame || MediaCodecSupport.isRandomAccess(bytes, codec) + fun onQueued() { needsKeyFrame = false } +} + +/** Limit latency and memory without ever dropping a reference frame silently. */ +internal class VideoDecodeQueue( + private val maxFrames: Int = 8, + private val maxBytes: Int = 8 * 1024 * 1024, +) { + private val jobs = LinkedBlockingQueue() + + @Synchronized fun offer(job: VideoJob) { + if (job is VideoJob.Frame) { + val frames = jobs.filterIsInstance() + if (frames.size >= maxFrames || frames.sumOf { it.nalus.size.toLong() } + job.nalus.size > maxBytes) { + discardFrames() + jobs.offer(VideoJob.Resync) + } + // A single oversized frame is also a lost reference chain. + if (job.nalus.size > maxBytes) return + } + jobs.offer(job) + } + + @Synchronized fun discardFrames() { + jobs.removeIf { it is VideoJob.Frame || it is VideoJob.Resync } + } + + fun poll(timeoutMillis: Long): VideoJob? = jobs.poll(timeoutMillis, TimeUnit.MILLISECONDS) +} + +/** Drain output while waiting for input: full output buffers can otherwise starve input forever. */ +internal object VideoInputPump { + fun acquire( + running: () -> Boolean, + drain: () -> Unit, + dequeue: () -> Int, + nanoTime: () -> Long = System::nanoTime, + timeoutNs: Long = TimeUnit.MILLISECONDS.toNanos(500), + ): Int { + val start = nanoTime() + while (running()) { + drain() + val index = dequeue() + if (index >= 0) return index + if (nanoTime() - start >= timeoutNs) break + } + return -1 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/Iap2MfiAuthenticationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/Iap2MfiAuthenticationClient.kt new file mode 100644 index 0000000..f8f1333 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/Iap2MfiAuthenticationClient.kt @@ -0,0 +1,119 @@ +package com.shilapi.xcertplay.mfi + +import com.shilapi.xcertplay.iap2.message.Iap2AuthenticationMessages +import com.shilapi.xcertplay.iap2.session.Iap2Session +import com.shilapi.xcertplay.iap2.wire.Iap2Frame + +/** Runs LIVI's minimal iAP2 CSM MFi exchange without taking ownership of [Iap2Session]. */ +class Iap2MfiAuthenticationClient( + private val authentication: MfiAuthenticator, + private val maximumCertificateLength: Int = MfiAuthenticationClient.DEFAULT_MAXIMUM_CERTIFICATE_OUTPUT_LENGTH, +) { + init { + require(maximumCertificateLength in 1..MAX_CSM_CERTIFICATE_BYTES) { + "maximumCertificateLength must be in 1..$MAX_CSM_CERTIFICATE_BYTES" + } + } + + /** Blocks until the phone confirms AA05, or throws a typed authentication failure. */ + fun run( + session: Iap2Session, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + onProgress: (String) -> Unit = {}, + ) { + require(timeoutMillis in 1..MAX_TIMEOUT_MILLIS) { + "timeoutMillis must be in 1..$MAX_TIMEOUT_MILLIS" + } + val deadlineNanos = deadlineAfter(timeoutMillis) + val certificate = authentication.readCertificate(maximumCertificateLength) + val certificateFrame = when (authentication.certificateType) { + MfiCertificateType.MFI -> Iap2AuthenticationMessages.accessoryCertificate(certificate) + MfiCertificateType.BAA -> Iap2AuthenticationMessages.accessoryCertificateBody(certificate) + } + val certificatePayloadBytes = certificateFrame.payload.size + onProgress( + "mfi type=${authentication.certificateType} " + + "certificate loaded bytes=${certificate.size} " + + "payload bytes=$certificatePayloadBytes", + ) + while (true) { + val remaining = remainingMillis(deadlineNanos) + if (remaining == 0L) throw Iap2MfiAuthenticationException("Timed out waiting for iAP2 MFi authentication") + val frame = session.recv(remaining) + ?: throw Iap2MfiAuthenticationException("Timed out waiting for iAP2 MFi authentication") + when (frame.messageId) { + REQUEST_CERTIFICATE -> { + onProgress("iap2 mfi rx=0xaa00 request-certificate") + send(session, certificateFrame, deadlineNanos) + onProgress("iap2 mfi tx=0xaa01 certificate bytes=$certificatePayloadBytes") + } + REQUEST_CHALLENGE -> { + val challenge = requireParameterZero(frame) + if (challenge.size !in 1..MAXIMUM_CHALLENGE_BYTES) { + throw Iap2MfiAuthenticationException("Invalid iAP2 MFi challenge length ${challenge.size}") + } + onProgress("iap2 mfi rx=0xaa02 challenge bytes=${challenge.size}") + val signature = authentication.signChallenge(challenge) + send(session, Iap2AuthenticationMessages.response(signature), deadlineNanos) + onProgress("iap2 mfi tx=0xaa03 signature bytes=${signature.size}") + } + AUTHENTICATION_SUCCEEDED -> { + onProgress("iap2 mfi rx=0xaa05 authentication-succeeded") + return + } + AUTHENTICATION_FAILED -> throw Iap2MfiAuthenticationException("iPhone sent AuthenticationFailed") + else -> throw Iap2MfiAuthenticationException( + "Unexpected iAP2 MFi message 0x${frame.messageId.toString(16).padStart(4, '0')}", + ) + } + } + } + + private fun send( + session: Iap2Session, + frame: Iap2Frame, + deadlineNanos: Long, + ) { + val remaining = remainingMillis(deadlineNanos) + if (remaining == 0L) throw Iap2MfiAuthenticationException("Timed out sending iAP2 MFi authentication reply") + session.send(frame, remaining) + } + + private fun requireParameterZero(frame: Iap2Frame): ByteArray { + return try { + Iap2AuthenticationMessages.challenge(frame) + } catch (failure: Exception) { + throw Iap2MfiAuthenticationException("iAP2 MFi challenge is missing parameter 0", failure) + } + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val now = System.nanoTime() + val delta = timeoutMillis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun remainingMillis(deadlineNanos: Long): Long { + val remaining = deadlineNanos - System.nanoTime() + if (remaining <= 0) return 0 + return ((remaining + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND) + .coerceAtMost(MAX_TIMEOUT_MILLIS) + } + + companion object { + private const val REQUEST_CERTIFICATE = 0xaa00 + private const val CERTIFICATE = 0xaa01 + private const val REQUEST_CHALLENGE = 0xaa02 + private const val RESPONSE = 0xaa03 + private const val AUTHENTICATION_FAILED = 0xaa04 + private const val AUTHENTICATION_SUCCEEDED = 0xaa05 + private const val MAXIMUM_CHALLENGE_BYTES = 128 + private const val MAX_CSM_CERTIFICATE_BYTES = 65_525 + private const val DEFAULT_TIMEOUT_MILLIS = 30_000L + private const val MAX_TIMEOUT_MILLIS = 5 * 60 * 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + } +} + +/** A malformed, rejected, unexpected, or timed-out iAP2 MFi authentication exchange. */ +class Iap2MfiAuthenticationException(message: String, cause: Throwable? = null) : MfiException(message, cause) diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/LocalMfiAuthenticationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/LocalMfiAuthenticationClient.kt new file mode 100644 index 0000000..ebb4a08 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/LocalMfiAuthenticationClient.kt @@ -0,0 +1,105 @@ +package com.shilapi.xcertplay.mfi + +import org.bouncycastle.asn1.ASN1Integer +import org.bouncycastle.asn1.ASN1Sequence +import java.io.File +import java.security.KeyFactory +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.interfaces.ECPublicKey +import java.security.spec.PKCS8EncodedKeySpec + +/** + * Experimental, local P-256 provider. Identity provisioning is owned by the host application. + * A valid signature here proves key consistency, not that an iPhone trusts the identity. + */ +class LocalMfiAuthenticationClient private constructor( + private val privateKey: PrivateKey, + private val certificate: ByteArray, + private val onSignature: (Int) -> Unit, +) : MfiAuthenticator { + override fun protocolMajor(): Int = 3 + + override fun readCertificate(maximumOutputLength: Int): ByteArray { + require(maximumOutputLength > 0 && certificate.size <= maximumOutputLength) { + "Local certificate exceeds the requested output limit" + } + return certificate.copyOf() + } + + override fun signChallenge(challenge: ByteArray): ByteArray { + require(challenge.size == 32) { "Local MFi v3 expects a 32-byte digest" } + // The iAP2/AirPlay caller supplies a digest already. Do not hash it a second time. + val signer = Signature.getInstance("NONEwithECDSA") + signer.initSign(privateKey) + signer.update(challenge) + val result = derToRaw(signer.sign()) + onSignature(challenge.size) + return result + } + + companion object { + /** Presence of this private app directory selects offline-only mode, even if incomplete. */ + const val DIRECTORY = "offline-mfi" + private const val MAX_FILE_BYTES = 16 * 1024 + + fun load(directory: File, onSignature: (Int) -> Unit = {}): LocalMfiAuthenticationClient { + require(directory.isDirectory) { "Offline MFi directory is missing or invalid" } + val encodedKey = readBounded(File(directory, "identity.pk8")) + val privateKey = try { + KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(encodedKey)) + } finally { + encodedKey.fill(0) + } + val certificate = readBounded(File(directory, "certificate.p7b")) + val certificates = CertificateFactory.getInstance("X.509") + .generateCertificates(certificate.inputStream()) + require(certificates.size == 1) { "Expected one accessory certificate" } + val publicKey = certificates.single().publicKey as? ECPublicKey + ?: error("Expected an EC accessory certificate") + require(publicKey.params.order.toString(16) == + "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551") { + "Expected a P-256 accessory certificate" + } + val challenge = ByteArray(32).also(SecureRandom()::nextBytes) + val signer = Signature.getInstance("NONEwithECDSA") + signer.initSign(privateKey) + signer.update(challenge) + val signature = signer.sign() + val verifier = Signature.getInstance("NONEwithECDSA") + verifier.initVerify(publicKey) + verifier.update(challenge) + require(verifier.verify(signature)) { "Local private key does not match certificate" } + return LocalMfiAuthenticationClient(privateKey, certificate, onSignature) + } + + private fun readBounded(file: File): ByteArray = file.inputStream().use { stream -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(1024) + while (true) { + val count = stream.read(buffer) + if (count < 0) break + require(output.size() + count <= MAX_FILE_BYTES) { "Local identity file is too large" } + output.write(buffer, 0, count) + } + output.toByteArray().also { require(it.isNotEmpty()) { "Local identity file is empty" } } + } + + internal fun derToRaw(der: ByteArray): ByteArray { + val sequence = ASN1Sequence.getInstance(der) + require(sequence.size() == 2) { "Invalid ECDSA signature" } + val result = ByteArray(64) + for (index in 0..1) { + val value = ASN1Integer.getInstance(sequence.getObjectAt(index)).value + require(value.signum() > 0 && value.bitLength() <= 256) { "Invalid ECDSA integer" } + val bytes = value.toByteArray() + val skip = if (bytes.size == 33 && bytes[0] == 0.toByte()) 1 else 0 + val length = bytes.size - skip + bytes.copyInto(result, index * 32 + 32 - length, skip) + } + return result + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/LocalMfiProbe.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/LocalMfiProbe.kt new file mode 100644 index 0000000..245703d --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/LocalMfiProbe.kt @@ -0,0 +1,41 @@ +package com.shilapi.xcertplay.mfi + +import java.io.File +import java.math.BigInteger +import java.security.SecureRandom +import java.security.Signature +import java.security.cert.CertificateFactory +import org.bouncycastle.asn1.ASN1Integer +import org.bouncycastle.asn1.DERSequence + +/** ADB/app_process probe: exercises Android's local crypto without starting a CarPlay session. */ +object LocalMfiProbe { + @JvmStatic fun main(arguments: Array) { + require(arguments.size == 1) { "Expected private identity directory" } + var signatureCount = 0 + repeat(2) { startup -> + val client = LocalMfiAuthenticationClient.load(File(arguments.single())) { signatureCount++ } + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificates(client.readCertificate().inputStream()).single() + repeat(3) { trial -> + val digest = ByteArray(32).also(SecureRandom()::nextBytes) + val raw = client.signChallenge(digest) + check(raw.size == 64) + val der = DERSequence(arrayOf( + ASN1Integer(BigInteger(1, raw.copyOfRange(0, 32))), + ASN1Integer(BigInteger(1, raw.copyOfRange(32, 64))), + )).encoded + val verifier = Signature.getInstance("NONEwithECDSA") + verifier.initVerify(certificate.publicKey) + verifier.update(digest) + check(verifier.verify(der)) { "Signature verification failed" } + digest[0] = (digest[0].toInt() xor 1).toByte() + verifier.initVerify(certificate.publicKey) + verifier.update(digest) + check(!verifier.verify(der)) { "Changed challenge was incorrectly accepted" } + println("offline-probe startup=${startup + 1} trial=${trial + 1} signatureBytes=64 verifies=true changedChallengeRejected=true") + } + } + println("offline-probe PASS localSignatures=$signatureCount remoteRequests=0 (provider has no network operations)") + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiAuthenticationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiAuthenticationClient.kt new file mode 100644 index 0000000..0b9db61 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiAuthenticationClient.kt @@ -0,0 +1,263 @@ +package com.shilapi.xcertplay.mfi + +import com.shilapi.xcertplay.transport.I2cTransport +import com.shilapi.xcertplay.transport.I2cTransportException + +enum class MfiCertificateType { + MFI, + BAA, +} + +class BaaCertificatePair(leaf: ByteArray, intermediate: ByteArray) { + val leaf: ByteArray = leaf.copyOf() + val intermediate: ByteArray = intermediate.copyOf() + + init { + require(this.leaf.isNotEmpty()) { "BAA leaf certificate must not be empty" } + require(this.intermediate.isNotEmpty()) { "BAA intermediate certificate must not be empty" } + } +} + +/** Common certificate/signing contract implemented by local coprocessors and remote services. */ +interface MfiAuthenticator { + val certificateType: MfiCertificateType + get() = MfiCertificateType.MFI + + fun protocolMajor(): Int + + fun readCertificate( + maximumOutputLength: Int = MfiAuthenticationClient.DEFAULT_MAXIMUM_CERTIFICATE_OUTPUT_LENGTH, + ): ByteArray + + fun signChallenge(challenge: ByteArray): ByteArray + + fun baaCertificates(): BaaCertificatePair = + throw MfiInvalidDataException("Authenticator does not provide BAA certificates") +} + +/** + * Blocking register client for one MFi authentication coprocessor. + * + * It follows the LIVI I2C sequence: a register read is a write-only register selection (with a + * STOP), followed by a separate read-only transaction. Call it from a worker thread. + */ +class MfiAuthenticationClient( + private val transport: I2cTransport, + val address7Bit: Int, +) : MfiAuthenticator { + override val certificateType: MfiCertificateType = MfiCertificateType.MFI + + init { + require(address7Bit in 0x00..0x7f) { "address7Bit must be in 0x00..0x7f" } + } + + /** Returns the raw value advertised by register 0x02; no protocol-major policy is imposed. */ + override fun protocolMajor(): Int = synchronized(COPROCESSOR_LOCK) { + readByte(PROTOCOL_MAJOR_REGISTER) + } + + /** Reads the certificate length from 0x30 and its body through 128-byte register windows. */ + override fun readCertificate( + maximumOutputLength: Int, + ): ByteArray = synchronized(COPROCESSOR_LOCK) { + require(maximumOutputLength in 1..MAX_REGISTER_READ_BYTES) { + "maximumOutputLength must be in 1..$MAX_REGISTER_READ_BYTES" + } + val length = readUnsignedBigEndianShort(CERTIFICATE_LENGTH_REGISTER) + if (length !in 1..maximumOutputLength) { + throw MfiInvalidDataException( + "Certificate length $length is outside 1..$maximumOutputLength", + ) + } + val certificate = ByteArray(length) + var offset = 0 + var register = CERTIFICATE_DATA_REGISTER + while (offset < certificate.size) { + val count = minOf(CERTIFICATE_REGISTER_WINDOW_BYTES, certificate.size - offset) + readRegister(register, count).copyInto(certificate, offset) + offset += count + register += 1 + } + certificate + } + + /** + * Writes a 1..128 byte challenge and returns the dynamically sized signature response. + * + * The protocol-major register intentionally does not participate in this sequence: LIVI's + * implementation uses the same registers for every observed major version. + */ + override fun signChallenge(challenge: ByteArray): ByteArray = synchronized(COPROCESSOR_LOCK) { + if (challenge.size !in MINIMUM_CHALLENGE_BYTES..MAXIMUM_CHALLENGE_BYTES) { + throw MfiInvalidDataException( + "challenge must be $MINIMUM_CHALLENGE_BYTES..$MAXIMUM_CHALLENGE_BYTES bytes", + ) + } + val input = challenge.copyOf() + writeUnsignedBigEndianShort(CHALLENGE_LENGTH_REGISTER, input.size) + writeRegister(CHALLENGE_DATA_REGISTER, input) + writeRegister(AUTH_CONTROL_STATUS_REGISTER, byteArrayOf(AUTH_START_COMMAND.toByte())) + + sleepMillis(INITIAL_AUTH_DELAY_MILLIS) + val deadlineNanos = deadlineAfter(AUTH_TIMEOUT_MILLIS) + while (true) { + try { + if (readByte(AUTH_CONTROL_STATUS_REGISTER) == AUTH_SUCCESS_STATUS) break + } catch (_: I2cTransportException) { + // A polling read can be transiently unavailable while the chip is computing. + } + if (System.nanoTime() >= deadlineNanos) { + throw MfiAuthenticationFailedException(bestEffortErrorCode()) + } + sleepMillis(AUTH_POLL_MILLIS) + } + + val length = readUnsignedBigEndianShort(RESPONSE_LENGTH_REGISTER) + if (length !in 1..MAX_REGISTER_READ_BYTES) { + throw MfiInvalidDataException("Invalid signature length $length") + } + readRegister(RESPONSE_DATA_REGISTER, length) + } + + private fun bestEffortErrorCode(): Int? = try { + selectRegister(ERROR_REGISTER) + val result = transport.transaction(address7Bit, ByteArray(0), 1) + if (result.size == 1) result[0].toInt() and BYTE_MASK else null + } catch (_: I2cTransportException) { + null + } + + private fun readUnsignedBigEndianShort(register: Int): Int { + val bytes = readRegister(register, 2) + return ((bytes[0].toInt() and BYTE_MASK) shl 8) or (bytes[1].toInt() and BYTE_MASK) + } + + private fun writeUnsignedBigEndianShort(register: Int, value: Int) { + require(value in 0..0xffff) { "value must be in 0..65535" } + writeRegister(register, byteArrayOf((value ushr 8).toByte(), value.toByte())) + } + + private fun readByte(register: Int): Int = readRegister(register, 1)[0].toInt() and BYTE_MASK + + private fun readRegister(register: Int, length: Int): ByteArray { + require(length in 1..MAX_REGISTER_READ_BYTES) { "read length must be in 1..$MAX_REGISTER_READ_BYTES" } + return retryIo("read at 0x${register.toString(16).padStart(2, '0')}") { + // The coprocessor can be asleep when the register-select write arrives. CH341 does + // not surface that I2C NACK separately, so retry the complete select/read pair; a + // retry of only the pure-read half can otherwise keep reading the old register. + selectRegisterOnce(register) + val result = transport.transaction(address7Bit, ByteArray(0), length) + if (result.size != length) { + throw MfiInvalidDataException( + "Register 0x${register.toString(16)} returned ${result.size} bytes; expected $length", + ) + } + result.copyOf() + } + } + + private fun selectRegister(register: Int) { + retryIo("register select 0x${register.toString(16).padStart(2, '0')}") { + selectRegisterOnce(register) + } + } + + private fun selectRegisterOnce(register: Int) { + val result = transport.transaction(address7Bit, byteArrayOf(register.toByte()), 0) + if (result.isNotEmpty()) { + throw MfiInvalidDataException("Register select 0x${register.toString(16)} returned data") + } + } + + private fun writeRegister(register: Int, data: ByteArray) { + val request = ByteArray(data.size + 1) + request[0] = register.toByte() + data.copyInto(request, destinationOffset = 1) + retryIo("write at 0x${register.toString(16).padStart(2, '0')}") { + val result = transport.transaction(address7Bit, request, 0) + if (result.isNotEmpty()) { + throw MfiInvalidDataException("Write to register 0x${register.toString(16)} returned data") + } + } + } + + private fun retryIo(operation: String, action: () -> T): T { + val deadlineNanos = deadlineAfter(IO_RETRY_TIMEOUT_MILLIS) + var lastFailure: I2cTransportException? = null + while (true) { + try { + return action() + } catch (failure: I2cTransportException) { + lastFailure = failure + } + if (System.nanoTime() >= deadlineNanos) throw checkNotNull(lastFailure) { operation } + sleepMicros(IO_RETRY_DELAY_MICROS) + } + } + + private fun deadlineAfter(millis: Long): Long { + val now = System.nanoTime() + val delta = millis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun sleepMicros(micros: Long) { + try { + Thread.sleep(micros / MICROS_PER_MILLISECOND, ((micros % MICROS_PER_MILLISECOND) * NANOS_PER_MICROSECOND).toInt()) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw MfiAuthenticationFailedException(null, interrupted) + } + } + + private fun sleepMillis(millis: Long) { + try { + Thread.sleep(millis) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw MfiAuthenticationFailedException(null, interrupted) + } + } + + companion object { + private val COPROCESSOR_LOCK = Any() + const val DEFAULT_MAXIMUM_CERTIFICATE_OUTPUT_LENGTH = 65_525 + private const val MAX_REGISTER_READ_BYTES = 65_525 + private const val BYTE_MASK = 0xff + private const val PROTOCOL_MAJOR_REGISTER = 0x02 + private const val ERROR_REGISTER = 0x05 + private const val AUTH_CONTROL_STATUS_REGISTER = 0x10 + private const val RESPONSE_LENGTH_REGISTER = 0x11 + private const val RESPONSE_DATA_REGISTER = 0x12 + private const val CHALLENGE_LENGTH_REGISTER = 0x20 + private const val CHALLENGE_DATA_REGISTER = 0x21 + private const val CERTIFICATE_LENGTH_REGISTER = 0x30 + private const val CERTIFICATE_DATA_REGISTER = 0x31 + private const val CERTIFICATE_REGISTER_WINDOW_BYTES = 128 + private const val AUTH_START_COMMAND = 0x01 + private const val AUTH_SUCCESS_STATUS = 0x10 + private const val MINIMUM_CHALLENGE_BYTES = 1 + private const val MAXIMUM_CHALLENGE_BYTES = 128 + private const val IO_RETRY_TIMEOUT_MILLIS = 2_000L + private const val IO_RETRY_DELAY_MICROS = 20_000L + private const val INITIAL_AUTH_DELAY_MILLIS = 10L + private const val AUTH_POLL_MILLIS = 10L + private const val AUTH_TIMEOUT_MILLIS = 3_000L + private const val MICROS_PER_MILLISECOND = 1_000L + private const val NANOS_PER_MICROSECOND = 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + } +} + +/** MFi-layer failures that are distinct from [I2cTransportException]. */ +sealed class MfiException(message: String, cause: Throwable? = null) : Exception(message, cause) + +class MfiInvalidDataException(message: String, cause: Throwable? = null) : MfiException(message, cause) + +/** The chip did not complete an authentication request; [errorCode] is best-effort only. */ +class MfiAuthenticationFailedException(val errorCode: Int?, cause: Throwable? = null) : + MfiException( + if (errorCode == null) "MFi authentication failed (error code unavailable)" + else "MFi authentication failed with error 0x${errorCode.toString(16)}", + cause, + ) diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiDeviceScanner.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiDeviceScanner.kt new file mode 100644 index 0000000..9d97262 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiDeviceScanner.kt @@ -0,0 +1,155 @@ +package com.shilapi.xcertplay.mfi + +import com.shilapi.xcertplay.transport.I2cTransport +import com.shilapi.xcertplay.transport.I2cTransportException + +/** + * Finds the first responding MFi authentication coprocessor. + * + * LIVI probes `0x10`, then `0x11`, by selecting device-version register `0x00` with STOP and + * issuing a separate pure read. It retries that candidate cycle for at most two seconds and + * returns immediately on the first valid response. Call this blocking scanner off the main thread. + */ +class MfiDeviceScanner( + private val transport: I2cTransport, + candidateAddresses: List = DEFAULT_CANDIDATE_ADDRESSES, + private val probeTimeoutMillis: Long = DEFAULT_PROBE_TIMEOUT_MILLIS, +) { + private val candidateAddresses = candidateAddresses.toList() + + init { + require(probeTimeoutMillis > 0) { "probeTimeoutMillis must be positive" } + } + + fun scan(): MfiDiscoveryResult { + val failures = LinkedHashMap() + val validCandidates = candidateAddresses.filter { address7Bit -> + if (address7Bit in MIN_ADDRESS_7_BIT..MAX_ADDRESS_7_BIT) { + true + } else { + failures[address7Bit] = MfiDiscoveryError.InvalidCandidateAddress(address7Bit) + false + } + } + if (validCandidates.isEmpty()) return MfiDiscoveryResult(null, failures.toFailures()) + + val deadlineNanos = deadlineAfter(probeTimeoutMillis) + while (true) { + for (address7Bit in validCandidates) { + when (val result = readDeviceVersion(address7Bit)) { + is DeviceVersionRead.Success -> { + return MfiDiscoveryResult( + MfiDiscoveredChip( + address7Bit = address7Bit, + writeAddress8Bit = address7Bit shl 1, + readAddress8Bit = (address7Bit shl 1) or READ_BIT, + deviceVersion = result.value, + ), + failures.toFailures(), + ) + } + is DeviceVersionRead.Failure -> failures[address7Bit] = result.error + } + } + if (System.nanoTime() >= deadlineNanos) return MfiDiscoveryResult(null, failures.toFailures()) + if (!sleepMicros(RETRY_DELAY_MICROS)) { + return MfiDiscoveryResult(null, failures.toFailures(), interrupted = true) + } + } + } + + private fun readDeviceVersion(address7Bit: Int): DeviceVersionRead = try { + val selectResponse = transport.transaction(address7Bit, byteArrayOf(DEVICE_VERSION_REGISTER.toByte()), 0) + if (selectResponse.isNotEmpty()) { + DeviceVersionRead.Failure(MfiDiscoveryError.UnexpectedResponseLength(0, selectResponse.size)) + } else { + val value = transport.transaction(address7Bit, ByteArray(0), 1) + if (value.size != 1) { + DeviceVersionRead.Failure(MfiDiscoveryError.UnexpectedResponseLength(1, value.size)) + } else { + val deviceVersion = value[0].toInt() and BYTE_MASK + if (deviceVersion == 0x00 || deviceVersion == 0xff) { + DeviceVersionRead.Failure(MfiDiscoveryError.InvalidDeviceVersion(deviceVersion)) + } else { + DeviceVersionRead.Success(deviceVersion) + } + } + } + } catch (error: I2cTransportException) { + DeviceVersionRead.Failure(MfiDiscoveryError.Transport(error)) + } + + private fun deadlineAfter(millis: Long): Long { + val now = System.nanoTime() + val delta = millis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun sleepMicros(micros: Long): Boolean = try { + Thread.sleep(micros / MICROS_PER_MILLISECOND, ((micros % MICROS_PER_MILLISECOND) * NANOS_PER_MICROSECOND).toInt()) + true + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + + private fun LinkedHashMap.toFailures(): List = + entries.map { (address7Bit, error) -> MfiDiscoveryFailure(address7Bit, error) } + + private sealed class DeviceVersionRead { + data class Success(val value: Int) : DeviceVersionRead() + + data class Failure(val error: MfiDiscoveryError) : DeviceVersionRead() + } + + companion object { + /** Documented MFi candidates in LIVI probe order. */ + val DEFAULT_CANDIDATE_ADDRESSES = listOf(0x10, 0x11) + + private const val MIN_ADDRESS_7_BIT = 0x00 + private const val MAX_ADDRESS_7_BIT = 0x7f + private const val DEVICE_VERSION_REGISTER = 0x00 + private const val READ_BIT = 0x01 + private const val BYTE_MASK = 0xff + private const val DEFAULT_PROBE_TIMEOUT_MILLIS = 2_000L + // A first NACK can merely wake a sleeping authentication coprocessor. + private const val RETRY_DELAY_MICROS = 20_000L + private const val MICROS_PER_MILLISECOND = 1_000L + private const val NANOS_PER_MICROSECOND = 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + } +} + +/** Structured outcome of a first-match MFi device probe. */ +data class MfiDiscoveryResult( + val chip: MfiDiscoveredChip?, + val failures: List, + val interrupted: Boolean = false, +) + +/** The first candidate that returned a one-byte device version. */ +data class MfiDiscoveredChip( + val address7Bit: Int, + val writeAddress8Bit: Int, + val readAddress8Bit: Int, + val deviceVersion: Int, +) + +/** The most recent failure retained for a probe candidate. */ +data class MfiDiscoveryFailure( + val address7Bit: Int, + val error: MfiDiscoveryError, +) + +/** Failure categories retained for caller-side diagnostics without a logging dependency. */ +sealed class MfiDiscoveryError { + data class InvalidCandidateAddress(val actual: Int) : MfiDiscoveryError() + + data class Transport(val cause: I2cTransportException) : MfiDiscoveryError() + + data class UnexpectedResponseLength(val expected: Int, val actual: Int) : MfiDiscoveryError() + + /** CH341 returns all-zero/all-one bus data when no usable coprocessor answered. */ + data class InvalidDeviceVersion(val actual: Int) : MfiDiscoveryError() + +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiSelfCheck.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiSelfCheck.kt new file mode 100644 index 0000000..2fea5db --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/MfiSelfCheck.kt @@ -0,0 +1,55 @@ +package com.shilapi.xcertplay.mfi + +import com.shilapi.xcertplay.transport.I2cTransport +import com.shilapi.xcertplay.transport.I2cTransportException + +/** Transport-independent MFi presence check for the documented address candidates. */ +class MfiSelfCheck( + private val transport: I2cTransport, +) { + fun run(): MfiSelfCheckResult { + val discovery = MfiDeviceScanner(transport).scan() + val chip = discovery.chip ?: return MfiSelfCheckResult(discovery, null) + return MfiSelfCheckResult( + discovery = discovery, + chip = MfiSelfCheckChip( + address7Bit = chip.address7Bit, + deviceVersion = chip.deviceVersion, + protocolMajor = try { + MfiProtocolMajorResult.Value( + chip.address7Bit, + MfiAuthenticationClient(transport, chip.address7Bit).protocolMajor(), + ) + } catch (error: MfiException) { + MfiProtocolMajorResult.MfiFailure(chip.address7Bit, error) + } catch (error: I2cTransportException) { + MfiProtocolMajorResult.TransportFailure(chip.address7Bit, error) + }, + ), + ) + } +} + +data class MfiSelfCheckResult( + val discovery: MfiDiscoveryResult, + val chip: MfiSelfCheckChip?, +) + +data class MfiSelfCheckChip( + val address7Bit: Int, + val deviceVersion: Int, + val protocolMajor: MfiProtocolMajorResult, +) + +sealed class MfiProtocolMajorResult { + abstract val address7Bit: Int + + data class Value(override val address7Bit: Int, val major: Int) : MfiProtocolMajorResult() + + data class MfiFailure(override val address7Bit: Int, val error: MfiException) : MfiProtocolMajorResult() + + data class TransportFailure( + override val address7Bit: Int, + val error: I2cTransportException, + ) : MfiProtocolMajorResult() +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/mfi/RemoteMfiAuthenticationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/mfi/RemoteMfiAuthenticationClient.kt new file mode 100644 index 0000000..8ca6dd4 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/mfi/RemoteMfiAuthenticationClient.kt @@ -0,0 +1,356 @@ +package com.shilapi.xcertplay.mfi + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID +import com.shilapi.xcertplay.iap2.message.Iap2AuthenticationMessages + +/** Blocking HTTP implementation of the MFI certificate and challenge-signing operations. */ +class RemoteMfiAuthenticationClient( + serverAddress: String, + token: String? = null, + private val connectTimeoutMillis: Int = DEFAULT_CONNECT_TIMEOUT_MILLIS, + private val readTimeoutMillis: Int = DEFAULT_READ_TIMEOUT_MILLIS, + private val maximumAttempts: Int = DEFAULT_MAXIMUM_ATTEMPTS, +) : MfiAuthenticator { + private data class CertificateInfo( + val type: MfiCertificateType, + val protocolMajor: Int, + val wireCertificate: ByteArray, + val iap2Certificate: ByteArray, + val baaCertificates: BaaCertificatePair?, + ) + + private data class HttpResponse( + val statusCode: Int, + val body: String, + ) + + private val baseAddress = serverAddress.trim().trimEnd('/') + private val bearerToken = token?.takeIf { it.isNotEmpty() } + private val operationLock = Any() + private var certificateInfo: CertificateInfo? = null + + init { + require(baseAddress.isNotEmpty()) { "Remote MFI server address must not be blank" } + require(baseAddress.startsWith("http://") || baseAddress.startsWith("https://")) { + "Remote MFI server address must use http:// or https://" + } + require(connectTimeoutMillis > 0) { "connectTimeoutMillis must be positive" } + require(readTimeoutMillis > 0) { "readTimeoutMillis must be positive" } + require(maximumAttempts > 0) { "maximumAttempts must be positive" } + } + + /** Resets the remote authentication session and invalidates the cached certificate response. */ + fun reset(): Unit = synchronized(operationLock) { + certificateInfo = null + request( + method = "POST", + path = RESET_PATH, + requestBody = "{}", + retry = false, + ) + Unit + } + + override fun protocolMajor(): Int = synchronized(operationLock) { + loadCertificateInfo().protocolMajor + } + + override val certificateType: MfiCertificateType + get() = synchronized(operationLock) { + loadCertificateInfo().type + } + + override fun readCertificate(maximumOutputLength: Int): ByteArray = synchronized(operationLock) { + require(maximumOutputLength in 1..MAXIMUM_RESPONSE_BYTES) { + "maximumOutputLength must be in 1..$MAXIMUM_RESPONSE_BYTES" + } + val certificate = loadCertificateInfo().iap2Certificate + if (certificate.size !in 1..maximumOutputLength) { + throw MfiInvalidDataException( + "Remote certificate length ${certificate.size} is outside 1..$maximumOutputLength", + ) + } + certificate.copyOf() + } + + override fun baaCertificates(): BaaCertificatePair = synchronized(operationLock) { + loadCertificateInfo().baaCertificates + ?: throw MfiInvalidDataException("Remote MFI service is not using BAA certificates") + } + + override fun signChallenge(challenge: ByteArray): ByteArray = synchronized(operationLock) { + if (challenge.size !in MINIMUM_CHALLENGE_BYTES..MAXIMUM_CHALLENGE_BYTES) { + throw MfiInvalidDataException( + "challenge must be $MINIMUM_CHALLENGE_BYTES..$MAXIMUM_CHALLENGE_BYTES bytes", + ) + } + val requestId = UUID.randomUUID().toString() + val encodedChallenge = Base64.getEncoder().encodeToString(challenge.copyOf()) + val response = request( + method = "POST", + path = SIGN_PATH, + requestBody = "{\"challenge\":\"$encodedChallenge\",\"requestId\":\"$requestId\"}", + retry = true, + ) + val signature = decodeBase64(RemoteMfiJson.string(response, "signature"), "signature") + if (signature.isEmpty() || signature.size > MAXIMUM_RESPONSE_BYTES) { + throw MfiInvalidDataException("Invalid remote signature length ${signature.size}") + } + signature + } + + private fun loadCertificateInfo(): CertificateInfo { + certificateInfo?.let { return it } + val response = request( + method = "GET", + path = CERTIFICATE_PATH, + requestBody = null, + retry = true, + ) + val protocolMajor = RemoteMfiJson.integer(response, "protocolMajor") + if (protocolMajor !in 0..0xff) { + throw MfiInvalidDataException("Invalid remote MFI protocol major $protocolMajor") + } + val typeText = RemoteMfiJson.optionalString(response, "type") ?: "mfi" + val type = when (typeText.lowercase()) { + "mfi" -> MfiCertificateType.MFI + "baa" -> MfiCertificateType.BAA + else -> throw MfiInvalidDataException("Unsupported remote MFI certificate type '$typeText'") + } + val certificate = decodeBase64( + RemoteMfiJson.string(response, "certificate"), + "certificate", + ) + if (certificate.isEmpty() || certificate.size > MAXIMUM_RESPONSE_BYTES) { + throw MfiInvalidDataException("Invalid remote certificate length ${certificate.size}") + } + val expectedDigest = decodeSha256(RemoteMfiJson.string(response, "certificateSha256")) + val actualDigest = MessageDigest.getInstance("SHA-256").digest(certificate) + if (!MessageDigest.isEqual(expectedDigest, actualDigest)) { + throw MfiInvalidDataException("Remote certificate SHA-256 does not match certificateSha256") + } + val baaCertificates = if (type == MfiCertificateType.BAA) { + parseBaaCertificatePackage(certificate) + } else { + null + } + val iap2Certificate = if (baaCertificates != null) { + Iap2AuthenticationMessages.baaCertificatePackage( + baaCertificates.leaf, + baaCertificates.intermediate, + ).payload + } else { + certificate.copyOf() + } + if (iap2Certificate.size > MAXIMUM_RESPONSE_BYTES) { + throw MfiInvalidDataException("Remote iAP2 certificate payload is too large") + } + return CertificateInfo( + type = type, + protocolMajor = protocolMajor, + wireCertificate = certificate.copyOf(), + iap2Certificate = iap2Certificate, + baaCertificates = baaCertificates, + ).also { certificateInfo = it } + } + + private fun parseBaaCertificatePackage(encoded: ByteArray): BaaCertificatePair { + if (encoded.size < BAA_PACKAGE_HEADER_BYTES) { + throw MfiInvalidDataException("BAA certificate package is too short") + } + val leafLength = readU32(encoded, 0) + val intermediateLength = readU32(encoded, 4) + if (leafLength <= 0 || intermediateLength <= 0 || + BAA_PACKAGE_HEADER_BYTES + leafLength + intermediateLength != encoded.size) { + throw MfiInvalidDataException("BAA certificate package has invalid lengths") + } + val leafEnd = BAA_PACKAGE_HEADER_BYTES + leafLength + return BaaCertificatePair( + leaf = encoded.copyOfRange(BAA_PACKAGE_HEADER_BYTES, leafEnd), + intermediate = encoded.copyOfRange(leafEnd, encoded.size), + ) + } + + private fun readU32(bytes: ByteArray, offset: Int): Int = + ((bytes[offset].toInt() and 0xff) shl 24) or + ((bytes[offset + 1].toInt() and 0xff) shl 16) or + ((bytes[offset + 2].toInt() and 0xff) shl 8) or + (bytes[offset + 3].toInt() and 0xff) + + private fun request( + method: String, + path: String, + requestBody: String?, + retry: Boolean, + ): String { + var lastFailure: Throwable? = null + val attempts = if (retry) maximumAttempts else 1 + repeat(attempts) { attempt -> + try { + val response = execute(method, path, requestBody) + if (response.statusCode in 200..299) return response.body + val detail = RemoteMfiJson.optionalString(response.body, "detail") + ?.takeIf { it.isNotBlank() } + ?: "HTTP ${response.statusCode}" + val failure = RemoteMfiHttpException(response.statusCode, detail) + if (attempt + 1 >= attempts || !isRetryable(response.statusCode)) throw failure + lastFailure = failure + } catch (failure: IOException) { + lastFailure = failure + if (attempt + 1 >= attempts) { + throw RemoteMfiException("Remote MFI request failed: ${failure.message}", failure) + } + } + } + throw RemoteMfiException("Remote MFI request failed", lastFailure) + } + + private fun execute(method: String, path: String, requestBody: String?): HttpResponse { + val connection = URL(baseAddress + path).openConnection() as HttpURLConnection + try { + connection.requestMethod = method + connection.connectTimeout = connectTimeoutMillis + connection.readTimeout = readTimeoutMillis + connection.instanceFollowRedirects = false + connection.useCaches = false + connection.setRequestProperty("Accept", JSON_CONTENT_TYPE) + bearerToken?.let { connection.setRequestProperty("Authorization", "Bearer $it") } + if (requestBody != null) { + val bodyBytes = requestBody.toByteArray(StandardCharsets.UTF_8) + connection.doOutput = true + connection.setRequestProperty("Content-Type", JSON_CONTENT_TYPE) + connection.setFixedLengthStreamingMode(bodyBytes.size) + connection.outputStream.use { it.write(bodyBytes) } + } + val statusCode = connection.responseCode + val stream = if (statusCode in 200..299) connection.inputStream else connection.errorStream + val responseBody = stream?.use(::readUtf8Limited).orEmpty() + return HttpResponse(statusCode, responseBody) + } finally { + connection.disconnect() + } + } + + private fun readUtf8Limited(input: java.io.InputStream): String { + val output = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + var total = 0 + while (true) { + val count = input.read(buffer) + if (count < 0) break + total += count + if (total > MAXIMUM_HTTP_BODY_BYTES) { + throw IOException("Remote MFI response exceeds $MAXIMUM_HTTP_BODY_BYTES bytes") + } + output.write(buffer, 0, count) + } + return output.toString(StandardCharsets.UTF_8.name()) + } + + private fun decodeBase64(encoded: String, field: String): ByteArray = try { + Base64.getDecoder().decode(encoded) + } catch (failure: IllegalArgumentException) { + throw MfiInvalidDataException("Remote $field is not valid base64", failure) + } + + private fun decodeSha256(hex: String): ByteArray { + if (hex.length != SHA256_HEX_LENGTH || hex.any { it.digitToIntOrNull(16) == null }) { + throw MfiInvalidDataException("certificateSha256 must contain 64 hexadecimal characters") + } + return ByteArray(SHA256_BYTES) { index -> + hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() + } + } + + private fun isRetryable(statusCode: Int): Boolean = + statusCode == HttpURLConnection.HTTP_CLIENT_TIMEOUT || + statusCode == 429 || + statusCode in 500..599 + + companion object { + private const val CERTIFICATE_PATH = "/mfi/certificate" + private const val SIGN_PATH = "/mfi/sign" + private const val RESET_PATH = "/mfi/reset" + private const val JSON_CONTENT_TYPE = "application/json; charset=utf-8" + private const val DEFAULT_CONNECT_TIMEOUT_MILLIS = 5_000 + private const val DEFAULT_READ_TIMEOUT_MILLIS = 10_000 + private const val DEFAULT_MAXIMUM_ATTEMPTS = 2 + private const val MINIMUM_CHALLENGE_BYTES = 1 + private const val MAXIMUM_CHALLENGE_BYTES = 128 + private const val MAXIMUM_RESPONSE_BYTES = 65_525 + private const val MAXIMUM_HTTP_BODY_BYTES = 2 * 1024 * 1024 + private const val SHA256_BYTES = 32 + private const val SHA256_HEX_LENGTH = SHA256_BYTES * 2 + private const val BAA_PACKAGE_HEADER_BYTES = 8 + } +} + +class RemoteMfiException(message: String, cause: Throwable? = null) : MfiException(message, cause) + +class RemoteMfiHttpException( + val statusCode: Int, + val detail: String, +) : MfiException("Remote MFI request failed with HTTP $statusCode: $detail") + +/** Minimal flat-object JSON reader for the remote MFI wire format. */ +internal object RemoteMfiJson { + fun string(json: String, name: String): String = optionalString(json, name) + ?: throw MfiInvalidDataException("Remote MFI response is missing '$name'") + + fun optionalString(json: String, name: String): String? { + val match = stringPattern(name).find(json) ?: return null + return decodeJsonString(match.groupValues[1]) + } + + fun integer(json: String, name: String): Int { + val match = integerPattern(name).find(json) + ?: throw MfiInvalidDataException("Remote MFI response is missing '$name'") + return match.groupValues[1].toIntOrNull() + ?: throw MfiInvalidDataException("Remote MFI '$name' is not a valid integer") + } + + private fun stringPattern(name: String): Regex = + Regex("\\\"${Regex.escape(name)}\\\"\\s*:\\s*\\\"((?:\\\\.|[^\\\"\\\\])*)\\\"") + + private fun integerPattern(name: String): Regex = + Regex("\\\"${Regex.escape(name)}\\\"\\s*:\\s*(-?\\d+)") + + private fun decodeJsonString(encoded: String): String { + val result = StringBuilder(encoded.length) + var index = 0 + while (index < encoded.length) { + val character = encoded[index++] + if (character != '\\') { + result.append(character) + continue + } + if (index >= encoded.length) throw MfiInvalidDataException("Invalid JSON string escape") + when (val escaped = encoded[index++]) { + '\"', '\\', '/' -> result.append(escaped) + 'b' -> result.append('\b') + 'f' -> result.append('\u000c') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'u' -> { + if (index + 4 > encoded.length) { + throw MfiInvalidDataException("Invalid JSON unicode escape") + } + val value = encoded.substring(index, index + 4).toIntOrNull(16) + ?: throw MfiInvalidDataException("Invalid JSON unicode escape") + result.append(value.toChar()) + index += 4 + } + else -> throw MfiInvalidDataException("Unsupported JSON string escape \\$escaped") + } + } + return result.toString() + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt new file mode 100644 index 0000000..1831b2a --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayBonjour.kt @@ -0,0 +1,555 @@ +package com.shilapi.xcertplay.network + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.net.wifi.WifiManager +import android.os.Build +import android.util.Log +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import java.io.BufferedReader +import java.io.Closeable +import java.io.IOException +import java.io.InputStreamReader +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import javax.jmdns.JmDNS +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceInfo +import javax.jmdns.ServiceListener + +data class CarPlayBonjourEndpoint( + val serviceName: String, + val host: String, + val port: Int, + val bluetoothId: String?, +) + +sealed interface CarPlayBonjourEvent { + data class Resolved(val endpoint: CarPlayBonjourEndpoint) : CarPlayBonjourEvent + + data class Probed( + val endpoint: CarPlayBonjourEndpoint, + val attempts: Int, + val statusLine: String?, + val error: IOException?, + ) : CarPlayBonjourEvent +} + +/** Pure protocol values shared by the Android runtime and JVM tests. */ +object CarPlayBonjourProtocol { + fun airPlayTxtRecords( + config: AirPlayConfig, + identity: AirPlayIdentity, + ): Map = linkedMapOf( + "deviceid" to config.deviceId, + "features" to "0x44540380,0x61", + "flags" to "0x4", + "model" to config.model, + "srcvers" to config.sourceVersion, + "protovers" to "1.1", + "pi" to identity.pairingId, + "pk" to identity.publicKeyHex, + ) + + fun connectProbeRequest( + host: String, + port: Int, + sourceVersion: String, + deviceId: String, + ): String { + val unbracketedHost = host.removeSurrounding("[", "]").substringBefore('%') + require(unbracketedHost.isNotBlank()) { "host must not be blank" } + require(port in 1..65535) { "port must be in 1..65535" } + require(sourceVersion.isNotEmpty()) { "sourceVersion must not be empty" } + require('\r' !in sourceVersion && '\n' !in sourceVersion) { + "sourceVersion must not contain a line break" + } + require('\r' !in unbracketedHost && '\n' !in unbracketedHost) { + "host must not contain a line break" + } + val receiverDeviceId = deviceId.replace(":", "") + require(receiverDeviceId.isNotEmpty()) { "deviceId must contain a hexadecimal value" } + require('\r' !in receiverDeviceId && '\n' !in receiverDeviceId) { + "deviceId must not contain a line break" + } + val hostHeader = if (':' in unbracketedHost) { + "[$unbracketedHost]:$port" + } else { + "$unbracketedHost:$port" + } + return "GET /ctrl-int/1/connect HTTP/1.1\r\n" + + "Host: $hostHeader\r\n" + + "User-Agent: AirPlay/$sourceVersion\r\n" + + "AirPlay-Receiver-Device-ID: $receiverDeviceId\r\n" + + "Connection: close\r\n" + + "\r\n" + } +} + +/** + * Publishes the accessory AirPlay service and discovers the iPhone's CarPlay control service. + * + * The NSD callbacks only enqueue work. Resolution, probing, and [onEvent] all run on the worker + * started by [start], so a blocking consumer callback never runs on the caller or main thread. + */ +class CarPlayBonjour( + context: Context, + private val config: AirPlayConfig, + private val identity: AirPlayIdentity, + private val advertisedHost: String? = null, + private val useInterfaceMdns: Boolean = false, + private val onEvent: (CarPlayBonjourEvent) -> Unit = {}, +) : Closeable { + private val nsdManager = (context.applicationContext ?: context) + .getSystemService(Context.NSD_SERVICE) as NsdManager + private val services = LinkedBlockingQueue() + private val interfaceServices = LinkedBlockingQueue>() + private val seenServices = ConcurrentHashMap.newKeySet() + private val lifecycleLock = Any() + private val localAdvertisedAddress = advertisedHostAddress() + private val multicastLock = (context.applicationContext ?: context) + .getSystemService(WifiManager::class.java) + .createMulticastLock("carplay-bonjour").apply { setReferenceCounted(false) } + + private var started = false + @Volatile + private var closed = false + private var registrationRequested = false + private var discoveryRequested = false + @Volatile + private var worker: Thread? = null + @Volatile + private var activeSocket: Socket? = null + private var interfaceMdns: JmDNS? = null + + private val interfaceListener = object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + if (!closed) event.dns.requestServiceInfo(event.type, event.name, true) + } + + override fun serviceRemoved(event: ServiceEvent) { + seenServices.remove(event.name) + } + + override fun serviceResolved(event: ServiceEvent) { + if (closed) return + val info = event.info + // Keep the HTTP probe in the same address family as its bound source. + val address = info.inetAddresses.firstOrNull { + (it is Inet4Address) == (localAdvertisedAddress is Inet4Address) + }?.let(::applyLocalScope) ?: return + if (info.port !in 1..65535 || !seenServices.add(event.name)) return + val endpoint = CarPlayBonjourEndpoint( + event.name, address.hostAddress ?: return, info.port, + info.getPropertyString("id"), + ) + interfaceServices.offer(endpoint to address) + } + } + + private val registrationListener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(serviceInfo: NsdServiceInfo) = Unit + + override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.w(TAG, "AirPlay NSD registration failed code=$errorCode") + } + + override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) = Unit + + override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + Log.w(TAG, "AirPlay NSD unregistration failed code=$errorCode") + } + } + + private val discoveryListener = object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.w(TAG, "CarPlay control discovery failed code=$errorCode") + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.w(TAG, "CarPlay control discovery stop failed code=$errorCode") + } + + override fun onDiscoveryStarted(serviceType: String) = Unit + + override fun onDiscoveryStopped(serviceType: String) = Unit + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + if (closed) return + val name = serviceInfo.serviceName ?: return + val type = serviceInfo.serviceType ?: CARPLAY_CONTROL_SERVICE_TYPE + val key = "$type|$name" + if (!seenServices.add(key)) return + services.offer(serviceInfo) + } + + override fun onServiceLost(serviceInfo: NsdServiceInfo) { + val name = serviceInfo.serviceName ?: return + val type = serviceInfo.serviceType ?: CARPLAY_CONTROL_SERVICE_TYPE + seenServices.remove("$type|$name") + } + } + + /** Starts publication and discovery. Calling this more than once is harmless. */ + fun start() { + synchronized(lifecycleLock) { + check(!closed) { "CarPlayBonjour is closed" } + if (started) return + started = true + try { + multicastLock.acquire() + if (useInterfaceMdns) { + val address = requireNotNull(localAdvertisedAddress) { + "Interface mDNS requires a local advertised address" + } + val dns = JmDNS.create(address, "carplay-${config.deviceId.replace(":", "")}") + interfaceMdns = dns + dns.addServiceListener("$CARPLAY_CONTROL_SERVICE_TYPE.local.", interfaceListener) + dns.registerService(ServiceInfo.create( + "$AIRPLAY_SERVICE_TYPE.local.", config.deviceName, config.port, + 0, 0, CarPlayBonjourProtocol.airPlayTxtRecords(config, identity), + )) + } else { + registerAirPlay() + registrationRequested = true + nsdManager.discoverServices( + CARPLAY_CONTROL_SERVICE_TYPE, + NsdManager.PROTOCOL_DNS_SD, + discoveryListener, + ) + discoveryRequested = true + } + worker = Thread(::runWorker, WORKER_NAME).apply { + isDaemon = true + start() + } + } catch (error: Exception) { + closed = true + if (registrationRequested) { + registrationRequested = false + runCatching { nsdManager.unregisterService(registrationListener) } + } + if (discoveryRequested) { + discoveryRequested = false + runCatching { nsdManager.stopServiceDiscovery(discoveryListener) } + } + worker?.interrupt() + worker = null + runCatching { interfaceMdns?.close() } + interfaceMdns = null + if (multicastLock.isHeld) multicastLock.release() + throw error + } + } + } + + override fun close() { + val workerToJoin: Thread? + val dnsToClose: JmDNS? + synchronized(lifecycleLock) { + if (closed) return + closed = true + if (registrationRequested) { + registrationRequested = false + runCatching { nsdManager.unregisterService(registrationListener) } + } + if (discoveryRequested) { + discoveryRequested = false + runCatching { nsdManager.stopServiceDiscovery(discoveryListener) } + } + activeSocket?.let { socket -> runCatching { socket.close() } } + activeSocket = null + services.clear() + interfaceServices.clear() + dnsToClose = interfaceMdns + interfaceMdns = null + workerToJoin = worker + worker = null + workerToJoin?.interrupt() + if (multicastLock.isHeld) multicastLock.release() + } + runCatching { dnsToClose?.close() } + workerToJoin?.let(::joinWorker) + } + + @Suppress("DEPRECATION") + private fun registerAirPlay() { + val serviceInfo = NsdServiceInfo().apply { + serviceName = config.deviceName + serviceType = AIRPLAY_SERVICE_TYPE + port = config.port + CarPlayBonjourProtocol.airPlayTxtRecords(config, identity).forEach { (key, value) -> + setAttribute(key, value) + } + localAdvertisedAddress?.let(::setHost) + } + nsdManager.registerService( + serviceInfo, + NsdManager.PROTOCOL_DNS_SD, + registrationListener, + ) + } + + private fun advertisedHostAddress(): InetAddress? { + val value = advertisedHost?.trim()?.takeIf { it.isNotEmpty() } ?: return null + val address = try { + InetAddress.getByName(value.removeSurrounding("[", "]")) + } catch (error: Exception) { + throw IllegalArgumentException("Invalid advertised host: $value", error) + } + require(!address.isLoopbackAddress) { + "advertisedHost must not be a loopback address" + } + require(address !is Inet6Address || address.isLinkLocalAddress) { + "advertisedHost must be link-local IPv6 or IPv4" + } + return address + } + + private fun runWorker() { + while (!closed) { + if (useInterfaceMdns) { + try { + val (endpoint, address) = interfaceServices.poll( + WORKER_POLL_MILLIS, TimeUnit.MILLISECONDS, + ) ?: continue + emit(CarPlayBonjourEvent.Resolved(endpoint)) + probe(endpoint, address)?.let(::emit) + } catch (_: InterruptedException) { + return + } catch (error: Exception) { + if (!closed) Log.w(TAG, "Interface CarPlay service handling failed", error) + } + continue + } + val service = try { + services.poll(WORKER_POLL_MILLIS, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + return + } ?: continue + if (closed) return + try { + handleService(service) + } catch (_: InterruptedException) { + return + } catch (error: Exception) { + if (!closed) Log.w(TAG, "CarPlay control service handling failed", error) + } + } + } + + private fun handleService(service: NsdServiceInfo) { + val resolved = resolveWithRetry(service) ?: return + val address = preferredAddress(resolved) ?: return + val port = resolved.port + if (port !in 1..65535) return + val serviceName = resolved.serviceName ?: service.serviceName ?: return + val host = address.hostAddress ?: return + val bluetoothId = resolved.attributes + ?.get("id") + ?.let(::decodeTxtValue) + ?.takeIf { it.isNotBlank() } + val endpoint = CarPlayBonjourEndpoint( + serviceName = serviceName, + host = host, + port = port, + bluetoothId = bluetoothId, + ) + emit(CarPlayBonjourEvent.Resolved(endpoint)) + probe(endpoint, address)?.let(::emit) + } + + @Suppress("DEPRECATION") + private fun resolveWithRetry(service: NsdServiceInfo): NsdServiceInfo? { + repeat(RESOLVE_ATTEMPTS) { attempt -> + if (closed) return null + val latch = CountDownLatch(1) + val resolved = AtomicReference() + val failure = AtomicInteger(FAILURE_NONE) + val listener = object : NsdManager.ResolveListener { + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + resolved.set(serviceInfo) + latch.countDown() + } + + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + failure.set(errorCode) + latch.countDown() + } + } + val submitted = try { + synchronized(lifecycleLock) { + if (closed) { + false + } else { + nsdManager.resolveService(service, listener) + true + } + } + } catch (error: RuntimeException) { + Log.w(TAG, "CarPlay control service resolution failed", error) + false + } + if (!submitted) return null + val completed = try { + latch.await(RESOLVE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + throw error + } + if (!completed) { + Log.w(TAG, "CarPlay control service resolution timed out") + return null + } + resolved.get()?.let { return it } + if (failure.get() != NsdManager.FAILURE_ALREADY_ACTIVE) { + Log.w(TAG, "CarPlay control service resolution failed code=${failure.get()}") + return null + } + if (!closed && attempt + 1 < RESOLVE_ATTEMPTS) { + Thread.sleep(RESOLVE_RETRY_DELAY_MILLIS) + } + } + return null + } + + @Suppress("DEPRECATION") + private fun preferredAddress(serviceInfo: NsdServiceInfo): InetAddress? { + val addresses = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + serviceInfo.hostAddresses.orEmpty() + } else { + listOfNotNull(serviceInfo.host) + } + return addresses.firstOrNull { it is Inet6Address && it.isLinkLocalAddress } + ?.let(::applyLocalScope) + ?: addresses.firstOrNull { it is Inet4Address } + ?: addresses.firstOrNull { it is Inet6Address } + ?: addresses.firstOrNull() + } + + private fun applyLocalScope(address: InetAddress): InetAddress { + val scope = (localAdvertisedAddress as? Inet6Address)?.scopeId ?: return address + if (address !is Inet6Address || address.scopeId != 0) return address + return try { + Inet6Address.getByAddress(null, address.address, scope) + } catch (_: Exception) { + address + } + } + + private fun probe( + endpoint: CarPlayBonjourEndpoint, + address: InetAddress, + ): CarPlayBonjourEvent.Probed? { + var lastError: IOException? = null + repeat(MAX_PROBE_ATTEMPTS) { attempt -> + if (closed) return null + try { + val statusLine = probeOnce(address, endpoint.port) + return CarPlayBonjourEvent.Probed( + endpoint = endpoint, + attempts = attempt + 1, + statusLine = statusLine, + error = null, + ) + } catch (error: IOException) { + lastError = error + } catch (error: RuntimeException) { + lastError = IOException("AirPlay control probe failed", error) + } + if (closed) return null + if (attempt + 1 < MAX_PROBE_ATTEMPTS) { + Thread.sleep(PROBE_RETRY_DELAY_MILLIS) + } + } + return CarPlayBonjourEvent.Probed( + endpoint = endpoint, + attempts = MAX_PROBE_ATTEMPTS, + statusLine = null, + error = lastError, + ) + } + + private fun probeOnce(address: InetAddress, port: Int): String { + val socket = Socket() + synchronized(lifecycleLock) { + check(!closed) { "CarPlayBonjour is closed" } + activeSocket = socket + } + try { + localAdvertisedAddress?.let { socket.bind(InetSocketAddress(it, 0)) } + socket.connect(InetSocketAddress(address, port), CONNECT_TIMEOUT_MILLIS) + socket.soTimeout = READ_TIMEOUT_MILLIS + val host = address.hostAddress + ?: throw IOException("AirPlay control service has no host address") + val request = CarPlayBonjourProtocol.connectProbeRequest( + host = host, + port = port, + sourceVersion = config.sourceVersion, + deviceId = config.deviceId, + ) + val output = socket.getOutputStream() + output.write(request.toByteArray(StandardCharsets.US_ASCII)) + output.flush() + val reader = BufferedReader( + InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII), + ) + return reader.readLine() + ?: throw IOException("AirPlay control probe returned no status line") + } finally { + synchronized(lifecycleLock) { + if (activeSocket === socket) activeSocket = null + } + runCatching { socket.close() } + } + } + + private fun emit(event: CarPlayBonjourEvent) { + if (closed) return + try { + onEvent(event) + } catch (error: RuntimeException) { + Log.w(TAG, "CarPlay Bonjour event callback failed", error) + } + } + + private fun decodeTxtValue(value: ByteArray): String = + String(value, StandardCharsets.UTF_8).trimEnd('\u0000') + + private fun joinWorker(worker: Thread) { + if (worker === Thread.currentThread()) return + try { + worker.join(JOIN_TIMEOUT_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + private companion object { + const val TAG = "xcertplay-bonjour" + const val WORKER_NAME = "carplay-bonjour" + const val AIRPLAY_SERVICE_TYPE = "_airplay._tcp" + const val CARPLAY_CONTROL_SERVICE_TYPE = "_carplay-ctrl._tcp" + const val WORKER_POLL_MILLIS = 500L + const val RESOLVE_ATTEMPTS = 3 + const val RESOLVE_TIMEOUT_MILLIS = 10_000L + const val RESOLVE_RETRY_DELAY_MILLIS = 250L + const val MAX_PROBE_ATTEMPTS = 7 + const val PROBE_RETRY_DELAY_MILLIS = 1_500L + const val CONNECT_TIMEOUT_MILLIS = 3_000 + const val READ_TIMEOUT_MILLIS = 3_000 + const val JOIN_TIMEOUT_MILLIS = 2_000L + const val FAILURE_NONE = -1 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt new file mode 100644 index 0000000..0213000 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/CarPlayVpnService.kt @@ -0,0 +1,293 @@ +package com.shilapi.xcertplay.network + +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Binder +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.util.Log +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.AirPlayMediaHandler +import com.shilapi.xcertplay.airplay.AirPlaySession +import com.shilapi.xcertplay.airplay.AirPlaySessionListener +import com.shilapi.xcertplay.airplay.PairingStore +import com.shilapi.xcertplay.mfi.MfiAuthenticator +import com.shilapi.xcertplay.transport.NcmUsbBridge +import java.io.IOException +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Hosts the AirPlay TCP listener for both NCM/VPN and local-only Wi-Fi transports. + * + * The wired path also owns the Android VPN tunnel and NCM IPv6 bridge. VPN consent is requested + * with [prepare] before binding. + */ +class CarPlayVpnService : VpnService() { + inner class LocalBinder : Binder() { + val service: CarPlayVpnService get() = this@CarPlayVpnService + } + + sealed class AttachResult { + data object Started : AttachResult() + data object AlreadyStarted : AttachResult() + data class Failed(val message: String) : AttachResult() + } + + private data class AirPlayAttachment( + val address: InetAddress, + val config: AirPlayConfig, + val identity: AirPlayIdentity, + val pairings: PairingStore, + val mfi: MfiAuthenticator?, + val listener: AirPlaySessionListener, + val media: AirPlayMediaHandler, + ) + + private val binder = LocalBinder() + private val active = AtomicBoolean(false) + private val sessionsLock = Any() + private val sessions = mutableSetOf() + @Volatile private var attachment: AirPlayAttachment? = null + private var serverSocket: ServerSocket? = null + private var bridge: Ipv6NcmBridge? = null + private var tun: ParcelFileDescriptor? = null + private var attachGeneration = 0 + + override fun onBind(intent: Intent?): IBinder = binder + + @Synchronized + fun attach( + ncm: NcmUsbBridge, + linkLocal: String, + hostMac: ByteArray, + config: AirPlayConfig, + identity: AirPlayIdentity, + pairings: PairingStore, + mfi: MfiAuthenticator?, + listener: AirPlaySessionListener, + media: AirPlayMediaHandler, + ): AttachResult { + if (active.get()) { + Log.i(TAG, "replacing stale NCM/VPN attachment") + releaseLocked() + } + active.set(true) + val generation = ++attachGeneration + return try { + val address = InetAddress.getByName(linkLocal) + if (address !is Inet6Address || !address.isLinkLocalAddress) { + throw IllegalArgumentException("linkLocal must be a link-local IPv6 literal") + } + require(hostMac.size == 6) { "hostMac must be 6 bytes" } + + val tunFd = Builder() + .addAddress(linkLocal, LINK_PREFIX) + .addRoute(LINK_LOCAL_ROUTE, LINK_PREFIX) + .setSession(SESSION_NAME) + .setMtu(TUN_MTU) + .setBlocking(true) + .establish() + ?: throw IOException("VpnService.establish returned null") + tun = tunFd + + val ipv6Bridge = Ipv6NcmBridge(ncm, tunFd, hostMac) { error -> + onTransportError(generation, listener, error) + } + ipv6Bridge.start() + bridge = ipv6Bridge + + startAirPlayServer( + generation, + AirPlayAttachment(address, config, identity, pairings, mfi, listener, media), + ) + AttachResult.Started + } catch (error: Exception) { + releaseLocked() + AttachResult.Failed(error.message ?: error.javaClass.simpleName) + } + } + + /** + * Starts the AirPlay listener on the local-only Wi-Fi AP address without establishing a VPN or + * NCM bridge. + */ + @Synchronized + fun attachWireless( + bindAddress: InetAddress, + config: AirPlayConfig, + identity: AirPlayIdentity, + pairings: PairingStore, + mfi: MfiAuthenticator?, + listener: AirPlaySessionListener, + media: AirPlayMediaHandler, + ): AttachResult { + if (active.get()) { + Log.i(TAG, "replacing stale local-only Wi-Fi attachment") + releaseLocked() + } + active.set(true) + val generation = ++attachGeneration + return try { + startAirPlayServer( + generation, + AirPlayAttachment(bindAddress, config, identity, pairings, mfi, listener, media), + ) + AttachResult.Started + } catch (error: Exception) { + releaseLocked() + AttachResult.Failed(error.message ?: error.javaClass.simpleName) + } + } + + /** Releases the active AirPlay listener and whichever VPN/NCM transport resources are active. */ + @Synchronized + fun detach() { + releaseLocked() + } + + fun isAttached(): Boolean = active.get() && attachment != null + + override fun onDestroy() { + detach() + super.onDestroy() + } + + private fun startAirPlayServer( + generation: Int, + replacement: AirPlayAttachment, + ) { + val server = ServerSocket() + server.bind(InetSocketAddress(replacement.address, replacement.config.port)) + attachment = replacement + serverSocket = server + Thread( + { acceptLoop(generation, server) }, + "airplay-accept", + ).apply { + isDaemon = true + start() + } + } + + private fun acceptLoop( + generation: Int, + server: ServerSocket, + ) { + try { + while (active.get()) { + val socket: Socket = server.accept() + Log.i(TAG, "airplay connection accepted from ${socket.remoteSocketAddress}") + socket.tcpNoDelay = true + socket.keepAlive = true + socket.setSoLinger(true, 0) + val session = synchronized(this) { + if (!active.get()) { + socket.close() + return + } + val current = attachment + if (current == null) { + socket.close() + return + } + AirPlaySession( + socket = socket, + config = current.config, + identity = current.identity, + pairings = current.pairings, + mfi = current.mfi, + listener = object : AirPlaySessionListener by current.listener { + override fun onSessionEnded(session: AirPlaySession) { + removeSession(session) + current.listener.onSessionEnded(session) + } + }, + media = current.media, + ).also(::addSession) + } + session.start() + } + } catch (error: IOException) { + if (active.get()) { + attachment?.listener?.let { onTransportError(generation, it, error) } + } + } + } + + private fun addSession(session: AirPlaySession) { + synchronized(sessionsLock) { sessions.add(session) } + } + + private fun removeSession(session: AirPlaySession?) { + if (session == null) return + synchronized(sessionsLock) { sessions.remove(session) } + } + + private fun closeSessionsLocked() { + synchronized(sessionsLock) { + sessions.toList().forEach { session -> + try { + session.close() + } catch (error: Exception) { + Log.w(TAG, "AirPlay session replacement failed", error) + } + } + sessions.clear() + } + } + + private fun onTransportError( + generation: Int, + listener: AirPlaySessionListener, + error: Throwable, + ) { + val message = error.message ?: error.javaClass.simpleName + Log.e(TAG, "CarPlay transport stopped: $message", error) + Thread( + { + synchronized(this) { + if (generation != attachGeneration) return@Thread + releaseLocked() + } + listener.onTransportError(message) + stopSelf() + }, + "airplay-teardown", + ).apply { + isDaemon = true + start() + } + } + + /** Caller must hold this service's monitor. Closes only resources active for this attachment. */ + private fun releaseLocked() { + attachGeneration += 1 + active.set(false) + attachment = null + serverSocket?.close() + serverSocket = null + closeSessionsLocked() + bridge?.close() + bridge = null + tun?.close() + tun = null + } + + companion object { + private const val TAG = "xcertplay-usb" + private const val LINK_PREFIX = 64 + private const val LINK_LOCAL_ROUTE = "fe80::" + private const val SESSION_NAME = "xcertplay CarPlay" + private const val TUN_MTU = 1500 + + /** Returns the VPN consent intent, or null when consent is already granted. */ + fun prepare(context: Context): Intent? = VpnService.prepare(context) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/Ipv6NcmBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/network/Ipv6NcmBridge.kt new file mode 100644 index 0000000..05e1833 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/Ipv6NcmBridge.kt @@ -0,0 +1,181 @@ +package com.shilapi.xcertplay.network + +import android.os.ParcelFileDescriptor +import android.util.Log +import com.shilapi.xcertplay.transport.EthernetIpv6Codec +import com.shilapi.xcertplay.transport.NcmUsbBridge +import java.io.Closeable +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.net.InetAddress +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.LockSupport + +/** + * Moves IPv6 packets between an Android VpnService tun and the iPhone NCM Ethernet link. + * + * NCM carries Ethernet frames while the tun is a layer-3 device, so this bridge strips and + * restores the Ethernet II header. Link-local neighbor discovery stays in the Android kernel, + * mirroring LIVI's reliance on the host kernel for NDP on its TAP interface. + */ +class Ipv6NcmBridge( + private val ncm: NcmUsbBridge, + private val tun: ParcelFileDescriptor, + private val hostMac: ByteArray, + private val onError: (Throwable) -> Unit, +) : Closeable { + init { + require(hostMac.size == EthernetIpv6Codec.MAC_BYTES) { "hostMac must be 6 bytes" } + } + + @Volatile + private var peerMac: ByteArray? = null + private var loggedInbound = false + private var loggedOutbound = false + private var loggedWaitingForPeer = false + private var inboundLogBudget = 16 + private var outboundLogBudget = 24 + private val running = AtomicBoolean(false) + private lateinit var ncmToTunThread: Thread + private lateinit var tunToNcmThread: Thread + + fun start() { + check(running.compareAndSet(false, true)) { "bridge is already started" } + ncmToTunThread = Thread(::runNcmToTun, "ncm-ipv6-in").apply { + isDaemon = true + start() + } + tunToNcmThread = Thread(::runTunToNcm, "ncm-ipv6-out").apply { + isDaemon = true + start() + } + } + + override fun close() { + if (!running.compareAndSet(true, false)) return + ncm.close() + tun.close() + join(ncmToTunThread) + join(tunToNcmThread) + } + + private fun runNcmToTun() { + val output = FileOutputStream(tun.fileDescriptor) + try { + while (running.get()) { + val frame = ncm.recv(READ_TIMEOUT_MILLIS) ?: continue + val ipv6 = EthernetIpv6Codec.parseIpv6View(frame) ?: continue + peerMac = ipv6.sourceMac + if (!loggedInbound) { + loggedInbound = true + Log.i( + TAG, + "ncm first inbound ipv6 bytes=${ipv6.payloadLength} " + + "peer=${ipv6.sourceMac.macString()}", + ) + } + if (inboundLogBudget > 0) { + inboundLogBudget-- + Log.i(TAG, "ncm inbound ${frame.summary(ipv6.payloadOffset)}") + } + output.write(frame, ipv6.payloadOffset, ipv6.payloadLength) + } + } catch (error: IOException) { + if (running.get()) onError(error) + } catch (error: RuntimeException) { + if (running.get()) onError(error) + } + } + + private fun runTunToNcm() { + val input = FileInputStream(tun.fileDescriptor) + val buffer = ByteArray(TUN_READ_BYTES) + try { + while (running.get()) { + val length = input.read(buffer) + if (length == -1) { + if (running.get()) onError(IOException("NCM IPv6 tunnel closed")) + return + } + // Android's TUN fd may transiently report a zero-byte read while its network is + // being registered. It is neither EOF (-1) nor an IPv6 packet. + if (length == 0) { + LockSupport.parkNanos(ZERO_READ_BACKOFF_NANOS) + continue + } + val tunPacket = buffer.copyOf(length) + val ipv6 = EthernetIpv6Codec.addNeighborAdvertisementTargetMac(tunPacket, hostMac) + if (ipv6.size != tunPacket.size) { + Log.i(TAG, "ncm added target-link-layer option to neighbor advertisement") + } + if (outboundLogBudget > 0) { + outboundLogBudget-- + Log.i(TAG, "ncm outbound ${ipv6.summary(0)}") + } + val multicastMac = EthernetIpv6Codec.multicastDestinationMac(ipv6) + val mac = multicastMac ?: peerMac + if (mac == null) { + if (!loggedWaitingForPeer) { + loggedWaitingForPeer = true + Log.i(TAG, "ncm deferred outbound unicast bytes=$length until peer MAC is learned") + } + continue + } + if (!loggedOutbound) { + loggedOutbound = true + Log.i( + TAG, + "ncm first outbound ipv6 bytes=$length destination=${mac.macString()} multicast=${multicastMac != null}", + ) + } + val frame = EthernetIpv6Codec.build(hostMac, mac, ipv6) + ncm.send(frame, WRITE_TIMEOUT_MILLIS) + } + } catch (error: IOException) { + if (running.get()) onError(error) + } catch (error: RuntimeException) { + if (running.get()) onError(error) + } + } + + private fun join(thread: Thread) { + if (thread === Thread.currentThread()) return + try { + thread.join(JOIN_TIMEOUT_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + if (thread.isAlive) thread.interrupt() + } + + private fun ByteArray.macString(): String = + joinToString(":") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private fun ByteArray.summary(offset: Int): String { + val payloadBytes = size - offset + if (payloadBytes < 40) return "truncated bytes=$payloadBytes" + val source = InetAddress.getByAddress(copyOfRange(offset + 8, offset + 24)).hostAddress + val destination = InetAddress.getByAddress(copyOfRange(offset + 24, offset + 40)).hostAddress + val nextHeader = this[offset + 6].toInt() and 0xff + val detail = when { + nextHeader == 6 && payloadBytes >= 44 -> " tcp=${u16(offset + 40)}->${u16(offset + 42)}" + nextHeader == 17 && payloadBytes >= 44 -> " udp=${u16(offset + 40)}->${u16(offset + 42)}" + nextHeader == 58 && payloadBytes >= 41 -> " icmp6=${this[offset + 40].toInt() and 0xff}" + else -> "" + } + return "bytes=$payloadBytes src=$source dst=$destination next=$nextHeader$detail" + } + + private fun ByteArray.u16(offset: Int): Int = + ((this[offset].toInt() and 0xff) shl 8) or (this[offset + 1].toInt() and 0xff) + + private companion object { + const val TAG = "xcertplay-usb" + const val READ_TIMEOUT_MILLIS = 1_000L + const val WRITE_TIMEOUT_MILLIS = 2_000 + const val TUN_READ_BYTES = 4_096 + const val ZERO_READ_BACKOFF_NANOS = 1_000_000L + const val JOIN_TIMEOUT_MILLIS = 2_000L + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/LocalOnlyHotspotManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/LocalOnlyHotspotManager.kt new file mode 100644 index 0000000..201054e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/LocalOnlyHotspotManager.kt @@ -0,0 +1,688 @@ +package com.shilapi.xcertplay.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.MacAddress +import android.net.wifi.SoftApConfiguration +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper +import androidx.annotation.RequiresApi +import com.shilapi.xcertplay.transport.Iap2WirelessSecurity +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException +import java.net.UnknownHostException +import java.util.Collections +import java.util.concurrent.TimeUnit + +/** + * Owns one Android LocalOnlyHotspot reservation and reports its live configuration. + * + * [start] must run on a worker thread because it blocks until the system callback arrives and + * the AP interface is usable. The reservation and multicast lock stay owned by this instance + * until [close]. + */ +class LocalOnlyHotspotManager(context: Context) : WirelessHotspotManager { + private val connectivityManager = + context.applicationContext.getSystemService(ConnectivityManager::class.java) + private val wifiManager = context.applicationContext.getSystemService(WifiManager::class.java) + ?: throw IllegalStateException("WifiManager is unavailable") + private val stateLock = Object() + + private var startAttempt: StartAttempt? = null + private var reservation: WifiManager.LocalOnlyHotspotReservation? = null + private var multicastLock: WifiManager.MulticastLock? = null + private var callbackThread: HandlerThread? = null + private var stopped = false + private var closed = false + + /** + * Starts a LocalOnlyHotspot and waits up to [timeoutMillis] for the live configuration and AP + * interface. The returned credentials are not retained by this manager. + */ + override fun start(timeoutMillis: Long): WirelessHotspotInfo { + check(Looper.myLooper() != Looper.getMainLooper()) { + "LocalOnlyHotspotManager.start must not run on the main thread" + } + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + + val attempt = StartAttempt() + synchronized(stateLock) { + check(!closed) { "LocalOnlyHotspotManager is closed" } + check(startAttempt == null && reservation == null) { + "A LocalOnlyHotspot is already starting or active" + } + startAttempt = attempt + } + + val thread = HandlerThread("xcertplay-local-only-hotspot").apply { start() } + attempt.thread = thread + var acquiredMulticastLock: WifiManager.MulticastLock? = null + val deadlineNanos = deadlineAfter(timeoutMillis) + val preStartInterfaces = networkInterfaceNames() + + try { + ensureStartActive(attempt) + wifiManager.startLocalOnlyHotspot( + createCallback(attempt), + Handler(thread.looper), + ) + + val activeReservation = awaitStart(attempt, deadlineNanos, timeoutMillis) + acquiredMulticastLock = acquireMulticastLock(attempt) + val configuration = readConfiguration(activeReservation) + val apInterface = awaitApInterface( + bssid = configuration.bssidBytes, + preStartInterfaces = preStartInterfaces, + attempt = attempt, + deadlineNanos = deadlineNanos, + ) + + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + reservation = activeReservation + multicastLock = acquiredMulticastLock + callbackThread = thread + startAttempt = null + stopped = false + acquiredMulticastLock = null + } + + return WirelessHotspotInfo( + ssid = configuration.ssid, + passphrase = configuration.passphrase, + security = configuration.security, + channel = configuration.channel, + frequencyMHz = null, + bssid = apInterface?.bssid ?: configuration.bssid, + interfaceName = apInterface?.name, + hostAddress = apInterface?.hostAddress, + bandLabel = configuration.bandLabel, + backend = WirelessHotspotBackend.LOCAL_ONLY_HOTSPOT, + ) + } catch (failure: Exception) { + cleanupFailedStart(attempt, acquiredMulticastLock) + throw failure + } + } + + override fun close() { + val activeReservation: WifiManager.LocalOnlyHotspotReservation? + val activeMulticastLock: WifiManager.MulticastLock? + val activeCallbackThread: HandlerThread? + synchronized(stateLock) { + if (closed) return + closed = true + startAttempt?.stopped = true + stateLock.notifyAll() + activeReservation = reservation + activeMulticastLock = multicastLock + activeCallbackThread = callbackThread + reservation = null + multicastLock = null + callbackThread = null + } + + releaseMulticastLock(activeMulticastLock) + activeReservation?.close() + activeCallbackThread?.quitSafely() + } + + private fun createCallback(attempt: StartAttempt): WifiManager.LocalOnlyHotspotCallback = + object : WifiManager.LocalOnlyHotspotCallback() { + override fun onStarted( + reservation: WifiManager.LocalOnlyHotspotReservation, + ) { + val closeReservation = synchronized(stateLock) { + if ( + closed || + startAttempt !== attempt || + attempt.stopped || + attempt.reservation != null + ) { + true + } else { + attempt.reservation = reservation + stateLock.notifyAll() + false + } + } + if (closeReservation) reservation.close() + } + + override fun onFailed(reason: Int) { + synchronized(stateLock) { + if (startAttempt === attempt && attempt.failure == null) { + attempt.failure = IOException( + "LocalOnlyHotspot failed: ${failureReason(reason)}", + ) + stateLock.notifyAll() + } + } + } + + override fun onStopped() { + var lockToRelease: WifiManager.MulticastLock? = null + synchronized(stateLock) { + if (startAttempt === attempt) attempt.stopped = true + if (reservation === attempt.reservation) { + stopped = true + lockToRelease = multicastLock + multicastLock = null + } + stateLock.notifyAll() + } + releaseMulticastLock(lockToRelease) + } + } + + private fun awaitStart( + attempt: StartAttempt, + deadlineNanos: Long, + timeoutMillis: Long, + ): WifiManager.LocalOnlyHotspotReservation = synchronized(stateLock) { + while (true) { + if (closed) throw IOException("LocalOnlyHotspot manager closed while starting") + attempt.failure?.let { throw it } + if (attempt.stopped) { + throw IOException("LocalOnlyHotspot stopped before startup completed") + } + attempt.reservation?.let { return@synchronized it } + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) { + throw IOException( + "Timed out after ${timeoutMillis}ms waiting for LocalOnlyHotspot", + ) + } + waitNanos(remainingNanos) + } + error("unreachable") + } + + private fun acquireMulticastLock(attempt: StartAttempt): WifiManager.MulticastLock { + ensureStartActive(attempt) + val lock = wifiManager.createMulticastLock(MULTICAST_LOCK_TAG) + lock.setReferenceCounted(false) + try { + lock.acquire() + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + } + return lock + } catch (failure: Exception) { + releaseMulticastLock(lock) + throw failure + } + } + + private fun readConfiguration( + reservation: WifiManager.LocalOnlyHotspotReservation, + ): HotspotConfiguration { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + readSoftApConfiguration(reservation.softApConfiguration) + } else { + @Suppress("DEPRECATION") + val configuration = reservation.wifiConfiguration + ?: throw IOException("LocalOnlyHotspot did not provide a Wi-Fi configuration") + readWifiConfiguration(configuration) + } + } + + @Suppress("DEPRECATION") + @RequiresApi(Build.VERSION_CODES.R) + private fun readSoftApConfiguration(configuration: SoftApConfiguration): HotspotConfiguration { + val ssid = validateSsid(configuration.ssid) + val security = mapSoftApSecurity(configuration.securityType) + val passphrase = validatePassphrase(security, configuration.passphrase) + val channel = if (Build.VERSION.SDK_INT >= 36) { + readConfiguredChannel(configuration) + } else { + readLegacySoftApChannel(configuration) + } + val bssid = configuration.bssid + + return HotspotConfiguration( + ssid = ssid, + passphrase = passphrase, + security = security, + channel = channel.first, + bssid = bssid?.toString(), + bssidBytes = bssid?.toByteArray(), + bandLabel = channel.second, + ) + } + + @Suppress("DEPRECATION") + private fun readWifiConfiguration(configuration: WifiConfiguration): HotspotConfiguration { + val ssid = validateSsid(configuration.SSID) + val security = mapWifiConfigurationSecurity(configuration) + val passphrase = validatePassphrase(security, unquote(configuration.preSharedKey)) + val bssid = configuration.BSSID?.let { + try { + MacAddress.fromString(it) + } catch (failure: IllegalArgumentException) { + throw IOException("LocalOnlyHotspot reported an invalid BSSID: $it", failure) + } + } + val channel = readWifiConfigurationChannel(configuration) + + return HotspotConfiguration( + ssid = ssid, + passphrase = passphrase, + security = security, + channel = channel, + bssid = bssid?.toString(), + bssidBytes = bssid?.toByteArray(), + bandLabel = readWifiConfigurationBandLabel(configuration, channel), + ) + } + + @RequiresApi(36) + private fun readConfiguredChannel(configuration: SoftApConfiguration): Pair { + val channels = configuration.channels + if (channels.size() != 1) { + throw IOException("Expected one LocalOnlyHotspot channel, got ${channels.size()}") + } + val band = channels.keyAt(0) + val channel = requireChannel( + channels.valueAt(0), + "SoftApConfiguration.channels", + allowAuto = true, + ) + return channel to softApBandLabel(band) + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun readLegacySoftApChannel(configuration: SoftApConfiguration): Pair { + val channel = try { + val getter = SoftApConfiguration::class.java.getMethod("getChannel") + val value = getter.invoke(configuration) as? Number + ?: throw IOException("SoftApConfiguration.getChannel returned no channel") + requireChannel( + value.toInt(), + "SoftApConfiguration.getChannel", + allowAuto = true, + ) + } catch (failure: ReflectiveOperationException) { + throw IOException( + "Android ${Build.VERSION.RELEASE} does not expose SoftApConfiguration.getChannel", + failure, + ) + } + return channel to readLegacySoftApBandLabel(configuration, channel) + } + + private fun readWifiConfigurationChannel(configuration: WifiConfiguration): Int { + val channel = try { + WifiConfiguration::class.java.getField("apChannel").getInt(configuration) + } catch (failure: ReflectiveOperationException) { + throw IOException( + "Android ${Build.VERSION.RELEASE} does not expose WifiConfiguration.apChannel", + failure, + ) + } + return requireChannel(channel, "WifiConfiguration.apChannel", allowAuto = true) + } + + private fun readWifiConfigurationBandLabel( + configuration: WifiConfiguration, + channel: Int, + ): String { + val band = try { + (WifiConfiguration::class.java.getField("apBand").get(configuration) as? Number) + ?.toInt() + } catch (_: ReflectiveOperationException) { + null + } + return band?.let(::softApBandLabel) ?: legacyBandLabel(channel) + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun readLegacySoftApBandLabel( + configuration: SoftApConfiguration, + channel: Int, + ): String { + val band = try { + (SoftApConfiguration::class.java.getMethod("getBand").invoke(configuration) as? Number) + ?.toInt() + } catch (_: ReflectiveOperationException) { + null + } + return band?.let(::softApBandLabel) ?: legacyBandLabel(channel) + } + + private fun awaitApInterface( + bssid: ByteArray?, + preStartInterfaces: Set, + attempt: StartAttempt, + deadlineNanos: Long, + ): ApInterface? { + var matchedInterfaceName: String? = null + while (true) { + ensureStartActive(attempt) + val networkInterface = findInterface(bssid, preStartInterfaces) + if (networkInterface != null) { + matchedInterfaceName = networkInterface.name + networkInterface.hotspotAddress()?.let { hostAddress -> + val interfaceBssid = networkInterface.hardwareAddress?.toMacAddressString() + ?: (hostAddress as? Inet6Address)?.toEui64MacAddress() + if (bssid == null && interfaceBssid == null) { + return@let + } + return ApInterface( + name = networkInterface.name, + hostAddress = hostAddress, + bssid = interfaceBssid, + ) + } + } + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) { + return matchedInterfaceName?.let { + ApInterface(it, null, null) + } + } + try { + TimeUnit.NANOSECONDS.sleep(minOf(remainingNanos, INTERFACE_POLL_NANOS)) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("Interrupted while waiting for the LocalOnlyHotspot interface", interrupted) + } + } + } + + private fun findInterface( + bssid: ByteArray?, + preStartInterfaces: Set, + ): NetworkInterface? { + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return null + val candidates = Collections.list(interfaces).filter { networkInterface -> + try { + networkInterface.isUp && !networkInterface.isLoopback + } catch (_: SocketException) { + false + } + } + if (bssid != null) { + return candidates.firstOrNull { + try { + it.hardwareAddress?.contentEquals(bssid) == true + } catch (_: SocketException) { + false + } + } + } + candidates.firstOrNull { it.name !in preStartInterfaces && it.hotspotAddress() != null } + ?.let { return it } + + val primaryInterface = connectivityManager?.activeNetwork + ?.let { connectivityManager.getLinkProperties(it)?.interfaceName } + val nonPrimary = candidates.filter { it.name != primaryInterface } + return nonPrimary.firstOrNull { it.hasPrivate192Address() } + ?: nonPrimary.firstOrNull { it.hasSiteLocalAddress() } + ?: nonPrimary.firstOrNull { it.hasLinkLocalAddress() } + ?: nonPrimary.firstOrNull { it.hotspotAddress() != null } + } + + private fun networkInterfaceNames(): Set = + NetworkInterface.getNetworkInterfaces() + ?.let { + Collections.list(it) + .filter { networkInterface -> + try { + networkInterface.isUp + } catch (_: SocketException) { + false + } + } + .mapTo(linkedSetOf()) { n -> n.name } + } + .orEmpty() + + private fun NetworkInterface.hasPrivate192Address(): Boolean = + Collections.list(inetAddresses).any { + it is Inet4Address && it.address.size == 4 && + (it.address[0].toInt() and 0xff) == 192 && + (it.address[1].toInt() and 0xff) == 168 + } + + private fun NetworkInterface.hasSiteLocalAddress(): Boolean = + Collections.list(inetAddresses).any { it is Inet4Address && it.isSiteLocalAddress } + + private fun NetworkInterface.hasLinkLocalAddress(): Boolean = + Collections.list(inetAddresses).any { it is Inet6Address && it.isLinkLocalAddress } + + private fun NetworkInterface.hotspotAddress(): InetAddress? { + var ipv4: InetAddress? = null + for (address in Collections.list(inetAddresses)) { + if (address is Inet6Address && address.isLinkLocalAddress) { + if (address.scopeId == index) return address + try { + return Inet6Address.getByAddress(null, address.address, this) + } catch (_: UnknownHostException) { + continue + } + } + if (address is Inet4Address && !address.isLoopbackAddress && ipv4 == null) { + ipv4 = address + } + } + return ipv4 + } + + private fun ensureStartActive(attempt: StartAttempt) { + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + } + } + + private fun ensureStartActiveLocked(attempt: StartAttempt) { + if (closed) throw IOException("LocalOnlyHotspot manager closed while starting") + if (startAttempt !== attempt) throw IOException("LocalOnlyHotspot startup was cancelled") + if (attempt.stopped) throw IOException("LocalOnlyHotspot stopped while starting") + } + + private fun cleanupFailedStart( + attempt: StartAttempt, + multicastLock: WifiManager.MulticastLock?, + ) { + val failedReservation: WifiManager.LocalOnlyHotspotReservation? + val failedThread: HandlerThread? + synchronized(stateLock) { + if (startAttempt === attempt) startAttempt = null + attempt.stopped = true + stateLock.notifyAll() + failedReservation = attempt.reservation + failedThread = attempt.thread + } + releaseMulticastLock(multicastLock) + failedReservation?.close() + failedThread?.quitSafely() + } + + private fun waitNanos(nanos: Long) { + val millis = nanos / NANOS_PER_MILLISECOND + val remainder = (nanos % NANOS_PER_MILLISECOND).toInt() + try { + stateLock.wait(millis, remainder) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("Interrupted while waiting for LocalOnlyHotspot", interrupted) + } + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val now = System.nanoTime() + val delta = timeoutMillis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun validateSsid(value: String?): String { + val ssid = unquote(value) + if (ssid.isNullOrEmpty() || ssid == WifiManager.UNKNOWN_SSID) { + throw IOException("LocalOnlyHotspot did not report a usable SSID") + } + if ('\u0000' in ssid) throw IOException("LocalOnlyHotspot SSID contains U+0000") + return ssid + } + + private fun validatePassphrase( + security: Iap2WirelessSecurity, + value: String?, + ): String { + val passphrase = value.orEmpty() + if (security != Iap2WirelessSecurity.NONE && passphrase.isEmpty()) { + throw IOException("LocalOnlyHotspot did not report a passphrase for secured Wi-Fi") + } + if ('\u0000' in passphrase) { + throw IOException("LocalOnlyHotspot passphrase contains U+0000") + } + return passphrase + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun mapSoftApSecurity(securityType: Int): Iap2WirelessSecurity = when (securityType) { + SoftApConfiguration.SECURITY_TYPE_OPEN -> Iap2WirelessSecurity.NONE + SoftApConfiguration.SECURITY_TYPE_WPA2_PSK -> Iap2WirelessSecurity.WPA_WPA2 + SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> + Iap2WirelessSecurity.WPA3_TRANSITION + SoftApConfiguration.SECURITY_TYPE_WPA3_SAE -> Iap2WirelessSecurity.WPA3_ONLY + SoftApConfiguration.SECURITY_TYPE_WPA3_OWE, + SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION, + -> throw IOException("Unsupported LocalOnlyHotspot security type: OWE") + else -> throw IOException( + "Unsupported LocalOnlyHotspot security type: $securityType", + ) + } + + private fun mapWifiConfigurationSecurity( + configuration: WifiConfiguration, + ): Iap2WirelessSecurity { + val keyManagement = configuration.allowedKeyManagement + ?: throw IOException("LocalOnlyHotspot did not report its key management") + val open = keyManagement.get(WifiConfiguration.KeyMgmt.NONE) + val wpa2 = keyManagement.get(WifiConfiguration.KeyMgmt.WPA2_PSK) + val sae = keyManagement.get(WifiConfiguration.KeyMgmt.SAE) + val owe = keyManagement.get(WifiConfiguration.KeyMgmt.OWE) + + return when { + owe -> throw IOException("Unsupported LocalOnlyHotspot security type: OWE") + open && !wpa2 && !sae -> Iap2WirelessSecurity.NONE + wpa2 && sae -> Iap2WirelessSecurity.WPA3_TRANSITION + wpa2 -> Iap2WirelessSecurity.WPA_WPA2 + sae -> Iap2WirelessSecurity.WPA3_ONLY + else -> throw IOException( + "Unsupported LocalOnlyHotspot key management: $keyManagement", + ) + } + } + + private fun requireChannel(channel: Int, source: String): Int { + return requireChannel(channel, source, allowAuto = false) + } + + private fun requireChannel(channel: Int, source: String, allowAuto: Boolean): Int { + if (channel !in 0..0xff || (!allowAuto && channel == 0)) { + throw IOException("$source returned invalid channel: $channel") + } + return channel + } + + private fun ByteArray.toMacAddressString(): String = + joinToString(":") { "%02x".format(it.toInt() and 0xff) } + + private fun Inet6Address.toEui64MacAddress(): String? { + val bytes = address + if (!isLinkLocalAddress || bytes.size != 16 || bytes[11] != 0xff.toByte() || + bytes[12] != 0xfe.toByte() + ) { + return null + } + return byteArrayOf( + (bytes[8].toInt() xor 0x02).toByte(), + bytes[9], + bytes[10], + bytes[13], + bytes[14], + bytes[15], + ).toMacAddressString() + } + + private fun softApBandLabel(band: Int): String = when (band) { + SoftApConfiguration.BAND_2GHZ -> "2.4 GHz" + SoftApConfiguration.BAND_5GHZ -> "5 GHz" + SoftApConfiguration.BAND_6GHZ -> "6 GHz" + SoftApConfiguration.BAND_60GHZ -> "60 GHz" + else -> "Unknown band ($band)" + } + + private fun legacyBandLabel(channel: Int): String = when (channel) { + in 1..14 -> "2.4 GHz" + in 32..177 -> "5 GHz" + else -> "Unknown band" + } + + private fun unquote(value: String?): String? { + if (value == null) return null + return if (value.length >= 2 && value.first() == '"' && value.last() == '"') { + value.substring(1, value.length - 1) + } else { + value + } + } + + private fun failureReason(reason: Int): String = when (reason) { + WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL -> "no channel available" + WifiManager.LocalOnlyHotspotCallback.ERROR_GENERIC -> "generic error" + WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE -> "incompatible Wi-Fi mode" + WifiManager.LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED -> "tethering disallowed" + else -> "reason $reason" + } + + private fun releaseMulticastLock(lock: WifiManager.MulticastLock?) { + if (lock == null) return + try { + if (lock.isHeld) lock.release() + } catch (_: RuntimeException) { + // close() is best-effort; the hotspot reservation remains the authoritative owner. + } + } + + private class StartAttempt { + var thread: HandlerThread? = null + var reservation: WifiManager.LocalOnlyHotspotReservation? = null + var failure: IOException? = null + var stopped = false + } + + private class HotspotConfiguration( + val ssid: String, + val passphrase: String, + val security: Iap2WirelessSecurity, + val channel: Int, + val bssid: String?, + val bssidBytes: ByteArray?, + val bandLabel: String, + ) + + private class ApInterface( + val name: String, + val hostAddress: InetAddress?, + val bssid: String?, + ) + + private companion object { + const val MULTICAST_LOCK_TAG = "xcertplay-local-only-hotspot-mdns" + const val NANOS_PER_MILLISECOND = 1_000_000L + val INTERFACE_POLL_NANOS: Long = TimeUnit.MILLISECONDS.toNanos(100) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt new file mode 100644 index 0000000..4fb7160 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/ManualHotspotManager.kt @@ -0,0 +1,470 @@ +package com.shilapi.xcertplay.network + +import android.annotation.SuppressLint +import android.content.Context +import android.net.ConnectivityManager +import android.net.wifi.SoftApConfiguration +import android.net.wifi.WifiConfiguration +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Looper +import android.util.Log +import com.shilapi.xcertplay.orchestration.ManualHotspotBand +import com.shilapi.xcertplay.orchestration.ManualHotspotSecurity +import com.shilapi.xcertplay.transport.Iap2WirelessSecurity +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException +import java.net.UnknownHostException +import java.util.Collections +import java.util.concurrent.TimeUnit + +/** + * Attaches to a hotspot that is already running on this device. + * + * The hotspot remains owned by the system. This manager only locates its interface and reads the + * channel/security data that the public Android APIs expose. Some vendors hide the current SoftAP + * configuration, in which case the caller-supplied credentials remain authoritative and the iAP2 + * channel is reported as zero ("auto"). + */ +class ManualHotspotManager( + context: Context, + ssid: String, + passphrase: String, + band: ManualHotspotBand, + channel: Int, + security: ManualHotspotSecurity, +) : WirelessHotspotManager { + private val appContext = context.applicationContext + private val connectivityManager = + appContext.getSystemService(ConnectivityManager::class.java) + private val wifiManager = appContext.getSystemService(WifiManager::class.java) + ?: throw IllegalStateException("WifiManager is unavailable") + private val expectedSsid = ssid + private val passphrase = passphrase + private val expectedBand = band + private val expectedChannel = channel + private val expectedSecurity = security.toIap2Security() + + @Volatile + private var closed = false + + init { + require(expectedSsid.isNotBlank()) { "ssid must not be blank" } + require('\u0000' !in expectedSsid) { "ssid must not contain U+0000" } + require('\u0000' !in passphrase) { "passphrase must not contain U+0000" } + require(passphrase.isEmpty() || passphrase.length in 8..63) { + "passphrase must be empty or between 8 and 63 characters" + } + require(channel in 0..196) { "channel must be 0 or in 1..196" } + require(expectedSecurity == Iap2WirelessSecurity.NONE || passphrase.isNotEmpty()) { + "passphrase is required for secured manual hotspots" + } + require(expectedSecurity != Iap2WirelessSecurity.NONE || passphrase.isEmpty()) { + "passphrase must be empty for open manual hotspots" + } + } + + override fun start(timeoutMillis: Long): WirelessHotspotInfo { + check(Looper.myLooper() != Looper.getMainLooper()) { + "ManualHotspotManager.start must not run on the main thread" + } + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + + val deadlineNanos = deadlineAfter(timeoutMillis) + val apConfiguration = readApConfiguration() + if (apConfiguration != null && apConfiguration.ssid != expectedSsid) { + throw IOException( + "Manual hotspot SSID does not match the active local AP configuration: " + + "'${apConfiguration.ssid}'", + ) + } + validateApConfiguration(apConfiguration) + + var lastReason = "local hotspot interface was not found" + while (true) { + check(!closed) { "ManualHotspotManager is closed" } + val localInterface = findLocalHotspotInterface() + if (localInterface != null) { + val connectionFrequency = frequencyFromConnectionInfo() + val scanFrequency = frequencyFromScanResult(localInterface) + val channel = observedManualHotspotChannel( + apChannel = apConfiguration?.channel ?: 0, + connectionFrequencyMHz = connectionFrequency, + scanFrequencyMHz = scanFrequency, + apFrequencyMHz = apConfiguration?.frequencyMHz, + ) + val frequencyMHz = when { + apConfiguration?.frequencyMHz != null -> apConfiguration.frequencyMHz + connectionFrequency != null -> connectionFrequency + scanFrequency != null -> scanFrequency + else -> null + } + val security = apConfiguration?.security ?: expectedSecurity + if (security != Iap2WirelessSecurity.NONE && passphrase.isEmpty()) { + throw IOException("Manual hotspot is secured but no passphrase was provided") + } + + if (channel == 0) { + Log.w( + TAG, + "Could not read the active hotspot channel from Android public APIs; " + + "reporting iAP2 channel 0 (auto) instead of configured channel " + + "$expectedChannel", + ) + } + val observedBandLabel = wifiBandLabel(apConfiguration?.band) + return WirelessHotspotInfo( + ssid = expectedSsid, + passphrase = passphrase, + security = security, + channel = channel, + frequencyMHz = frequencyMHz, + bssid = localInterface.hardwareAddress, + interfaceName = localInterface.name, + hostAddress = localInterface.hostAddress, + bandLabel = when (expectedBand) { + ManualHotspotBand.GHZ_2_4 -> "2.4 GHz" + ManualHotspotBand.GHZ_5 -> "5 GHz" + ManualHotspotBand.AUTO -> + frequencyMHz?.let(::bandLabel) ?: observedBandLabel ?: "Auto" + }, + backend = WirelessHotspotBackend.MANUAL_HOTSPOT, + ) + } + + val remainingNanos = remainingNanos(deadlineNanos) + if (remainingNanos <= 0) { + throw IOException( + "Timed out after ${timeoutMillis}ms waiting for the manual hotspot: " + + lastReason, + ) + } + sleep(minOf(remainingNanos, INTERFACE_POLL_NANOS)) + } + } + + override fun close() { + closed = true + } + + private fun validateApConfiguration(configuration: ManualApConfiguration?) { + configuration ?: return + if (expectedChannel > 0 && configuration.channel > 0 && + configuration.channel != expectedChannel + ) { + throw IOException( + "Manual hotspot channel ${configuration.channel} does not match configured " + + "channel $expectedChannel", + ) + } + val actualBand = when (configuration.band) { + 1 -> ManualHotspotBand.GHZ_2_4 + 2 -> ManualHotspotBand.GHZ_5 + else -> null + } + if (actualBand != null && expectedBand != ManualHotspotBand.AUTO && + actualBand != expectedBand + ) { + throw IOException( + "Manual hotspot band ${wifiBandLabel(configuration.band)} does not match " + + "configured band ${wifiBandLabel(if (expectedBand == ManualHotspotBand.GHZ_2_4) 1 else 2)}", + ) + } + if (configuration.security != expectedSecurity) { + throw IOException( + "Manual hotspot security ${configuration.security} does not match configured " + + "security $expectedSecurity", + ) + } + val frequency = configuration.frequencyMHz ?: return + when (expectedBand) { + ManualHotspotBand.GHZ_2_4 -> if (frequency !in 2_400..2_500) { + throw IOException("Manual hotspot is not running on 2.4 GHz") + } + ManualHotspotBand.GHZ_5 -> if (frequency !in 5_150..5_895) { + throw IOException("Manual hotspot is not running on 5 GHz") + } + ManualHotspotBand.AUTO -> Unit + } + } + + private fun findLocalHotspotInterface(): LocalHotspotInterface? { + val interfaces = try { + NetworkInterface.getNetworkInterfaces() + } catch (_: SocketException) { + null + } ?: return null + val primaryInterface = connectivityManager?.activeNetwork + ?.let { connectivityManager.getLinkProperties(it)?.interfaceName } + return Collections.list(interfaces) + .asSequence() + .filter { isUsableInterface(it, primaryInterface) } + .mapNotNull { networkInterface -> + networkInterface.hotspotAddress()?.let { address -> + LocalHotspotInterface( + name = networkInterface.name, + hostAddress = address, + hardwareAddress = networkInterface.hardwareAddress?.toMacAddressString(), + score = interfaceScore(networkInterface.name, address), + ) + } + } + .maxByOrNull(LocalHotspotInterface::score) + } + + private fun isUsableInterface( + networkInterface: NetworkInterface, + primaryInterface: String?, + ): Boolean = try { + networkInterface.name != primaryInterface && + !networkInterface.isLoopback && + networkInterface.isUp && + EXCLUDED_INTERFACE_PREFIXES.none { networkInterface.name.startsWith(it) } + } catch (_: SocketException) { + false + } + + private fun interfaceScore(name: String, address: InetAddress): Int { + var score = when { + name.startsWith("ap") || name.contains("softap", ignoreCase = true) -> 100 + name.startsWith("p2p") -> 80 + name.startsWith("wlan") -> 70 + else -> 0 + } + if (address is Inet4Address) { + val bytes = address.address + when { + bytes[0] == 192.toByte() && bytes[1] == 168.toByte() -> score += 30 + address.isSiteLocalAddress -> score += 20 + } + } + if (address is Inet6Address && address.isLinkLocalAddress) score += 15 + return score + } + + private fun NetworkInterface.hotspotAddress(): InetAddress? { + var ipv6: Inet6Address? = null + for (address in Collections.list(inetAddresses)) { + if (address is Inet4Address && !address.isLoopbackAddress && + !address.isLinkLocalAddress + ) { + return address + } + if (address is Inet6Address && address.isLinkLocalAddress) { + if (ipv6 == null) { + ipv6 = if (address.scopeId == index) { + address + } else { + try { + Inet6Address.getByAddress(null, address.address, this) + } catch (_: UnknownHostException) { + null + } + } + } + } + } + return ipv6 + } + + private fun frequencyFromConnectionInfo(): Int? { + val connectionInfo = try { + wifiManager.connectionInfo + } catch (_: SecurityException) { + null + } ?: return null + if (unquote(connectionInfo.ssid) != expectedSsid) return null + return connectionInfo.frequency.takeIf { it > 0 } + } + + private fun frequencyFromScanResult(localInterface: LocalHotspotInterface): Int? { + val localBssid = localInterface.hardwareAddress ?: return null + val scanResults = try { + wifiManager.scanResults + } catch (_: SecurityException) { + return null + } + return scanResults.firstOrNull { result -> + result.SSID == expectedSsid && + result.BSSID.equals(localBssid, ignoreCase = true) && + result.frequency > 0 + }?.frequency + } + + @SuppressLint("PrivateApi") + private fun readApConfiguration(): ManualApConfiguration? = + readSoftApConfiguration() ?: readLegacyApConfiguration() + + @SuppressLint("PrivateApi") + private fun readSoftApConfiguration(): ManualApConfiguration? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null + return try { + val method = WifiManager::class.java.getMethod("getSoftApConfiguration") + val configuration = method.invoke(wifiManager) as? SoftApConfiguration + ?: return null + val ssid = configuration.ssid ?: return null + val bandAndChannel = when { + Build.VERSION.SDK_INT >= 36 -> { + val channels = configuration.channels + if (channels.size() == 0) null else channels.keyAt(0) to channels.valueAt(0) + } + else -> { + val band = ( + SoftApConfiguration::class.java + .getMethod("getBand") + .invoke(configuration) as? Number + )?.toInt() + val channel = SoftApConfiguration::class.java + .getMethod("getChannel") + .invoke(configuration) as? Number + if (band == null || channel == null) null else band to channel.toInt() + } + } + val band = bandAndChannel?.first + val channel = bandAndChannel?.second ?: 0 + ManualApConfiguration( + ssid = ssid, + band = band, + channel = channel, + frequencyMHz = wifiChannelToFrequencyMhz(channel, band), + security = mapSoftApSecurity(configuration.securityType), + ) + } catch (_: Throwable) { + null + } + } + + @SuppressLint("PrivateApi") + private fun readLegacyApConfiguration(): ManualApConfiguration? { + return try { + val method = WifiManager::class.java.getMethod("getWifiApConfiguration") + val configuration = method.invoke(wifiManager) as? WifiConfiguration + ?: return null + val ssid = unquote(configuration.SSID) ?: return null + val channel = try { + WifiConfiguration::class.java.getField("apChannel").getInt(configuration) + } catch (_: ReflectiveOperationException) { + 0 + } + val band = try { + WifiConfiguration::class.java.getField("apBand").getInt(configuration) + } catch (_: ReflectiveOperationException) { + null + } + ManualApConfiguration( + ssid = ssid, + band = band, + channel = channel, + frequencyMHz = wifiChannelToFrequencyMhz(channel, band), + security = mapWifiConfigurationSecurity(configuration), + ) + } catch (_: Throwable) { + null + } + } + + private fun mapSoftApSecurity(securityType: Int): Iap2WirelessSecurity = when (securityType) { + SoftApConfiguration.SECURITY_TYPE_OPEN -> Iap2WirelessSecurity.NONE + SoftApConfiguration.SECURITY_TYPE_WPA2_PSK -> Iap2WirelessSecurity.WPA_WPA2 + SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> + Iap2WirelessSecurity.WPA3_TRANSITION + SoftApConfiguration.SECURITY_TYPE_WPA3_SAE -> Iap2WirelessSecurity.WPA3_ONLY + else -> Iap2WirelessSecurity.WPA_WPA2 + } + + private fun mapWifiConfigurationSecurity( + configuration: WifiConfiguration, + ): Iap2WirelessSecurity { + val keyManagement = configuration.allowedKeyManagement ?: return Iap2WirelessSecurity.NONE + val open = keyManagement.get(WifiConfiguration.KeyMgmt.NONE) + val wpa2 = keyManagement.get(WifiConfiguration.KeyMgmt.WPA2_PSK) + val sae = keyManagement.get(WifiConfiguration.KeyMgmt.SAE) + return when { + open && !wpa2 && !sae -> Iap2WirelessSecurity.NONE + wpa2 && sae -> Iap2WirelessSecurity.WPA3_TRANSITION + wpa2 -> Iap2WirelessSecurity.WPA_WPA2 + sae -> Iap2WirelessSecurity.WPA3_ONLY + else -> Iap2WirelessSecurity.WPA_WPA2 + } + } + + private fun bandLabel(frequencyMHz: Int): String = when (frequencyMHz) { + in 2400..2500 -> "2.4 GHz" + in 5150..5895 -> "5 GHz" + in 5925..7125 -> "6 GHz" + else -> "Unknown band" + } + + private fun unquote(value: String?): String? { + if (value == null) return null + return if (value.length >= 2 && value.first() == '"' && value.last() == '"') { + value.substring(1, value.length - 1) + } else { + value + } + } + + private fun ByteArray.toMacAddressString(): String = + joinToString(":") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private fun sleep(nanos: Long) { + try { + TimeUnit.NANOSECONDS.sleep(nanos) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("Interrupted while waiting for the manual hotspot", interrupted) + } + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val now = System.nanoTime() + val delta = timeoutMillis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun remainingNanos(deadlineNanos: Long): Long = + (deadlineNanos - System.nanoTime()).coerceAtLeast(0L) + + private class ManualApConfiguration( + val ssid: String, + val band: Int?, + val channel: Int, + val frequencyMHz: Int?, + val security: Iap2WirelessSecurity, + ) + + private class LocalHotspotInterface( + val name: String, + val hostAddress: InetAddress, + val hardwareAddress: String?, + val score: Int, + ) + + private companion object { + const val TAG = "xcertplay-usb" + const val NANOS_PER_MILLISECOND = 1_000_000L + val INTERFACE_POLL_NANOS: Long = TimeUnit.MILLISECONDS.toNanos(250) + val EXCLUDED_INTERFACE_PREFIXES = listOf( + "lo", + "dummy", + "rmnet", + "r_rmnet", + "tun", + "ppp", + "sit", + "ip6", + "bond", + ) + } +} + +private fun ManualHotspotSecurity.toIap2Security(): Iap2WirelessSecurity = when (this) { + ManualHotspotSecurity.OPEN -> Iap2WirelessSecurity.NONE + ManualHotspotSecurity.WPA2 -> Iap2WirelessSecurity.WPA_WPA2 + ManualHotspotSecurity.WPA3_TRANSITION -> Iap2WirelessSecurity.WPA3_TRANSITION + ManualHotspotSecurity.WPA3 -> Iap2WirelessSecurity.WPA3_ONLY +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/P2pConfigurationMemory.kt b/shared/src/main/java/com/shilapi/xcertplay/network/P2pConfigurationMemory.kt new file mode 100644 index 0000000..0fd0b04 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/P2pConfigurationMemory.kt @@ -0,0 +1,37 @@ +package com.shilapi.xcertplay.network + +import android.content.Context +import java.util.UUID + +/** Only radio settings are stored: never network credentials, phone identifiers or addresses. */ +internal class P2pConfigurationMemory(context: Context) { + private val prefs = context.getSharedPreferences("carplay_wifi_p2p_success", Context.MODE_PRIVATE) + data class Record(val encoded: String, val request: P2pCreationRequest, val stationMHz: Int?) + + fun read(): Record? = runCatching { + val encoded = prefs.getString("confirmed", null) ?: return null + val fields = encoded.split('|') + if (fields.size != 5) return null + val requested = fields[1].toInt() + val request = when (fields[0]) { + "system" -> if (requested == 0) P2pCreationRequest(P2pCreationMode.SYSTEM_DEFAULT) else return null + "frequency" -> P2pStartupRecovery.rememberedFrequency(requested) ?: return null + else -> return null + } + if (wifiFrequencyMhzToChannel(fields[2].toInt()) == null) return null + UUID.fromString(fields[4]) + Record(encoded, request, fields[3].toInt().takeIf { it > 0 }) + }.getOrNull() + + fun remember(request: P2pCreationRequest, actualMHz: Int, stationMHz: Int?): Boolean { + val kind = if (request.mode == P2pCreationMode.SYSTEM_DEFAULT) "system" else "frequency" + val encoded = "$kind|${request.frequencyMHz ?: 0}|$actualMHz|${stationMHz ?: 0}|${UUID.randomUUID()}" + return synchronized(prefs) { prefs.edit().putString("confirmed", encoded).commit() } + } + + /** A late close from an older manager must not erase a newer successful session. */ + fun forget(record: Record): Boolean = runCatching { synchronized(prefs) { + if (prefs.getString("confirmed", null) != record.encoded) false + else prefs.edit().remove("confirmed").commit() + } }.getOrDefault(false) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/P2pOwnership.kt b/shared/src/main/java/com/shilapi/xcertplay/network/P2pOwnership.kt new file mode 100644 index 0000000..3c0e60e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/P2pOwnership.kt @@ -0,0 +1,20 @@ +package com.shilapi.xcertplay.network + +import java.io.IOException +import java.security.MessageDigest + +/** Reinstall-stable namespace; never treats a generic DIRECT-* name as ours. */ +internal object P2pOwnership { + fun prefix(packageName: String, scopedId: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest("$packageName:$scopedId".toByteArray(Charsets.UTF_8)) + return "DIRECT-dp" + digest.take(6).joinToString("") { "%02x".format(it.toInt() and 255) } + "-" + } + + fun canReclaim(isOwner: Boolean, ssid: String?, recorded: String?, prefix: String): Boolean = + isOwner && !ssid.isNullOrBlank() && + (ssid == recorded || (ssid.startsWith(prefix) && + ssid.removePrefix(prefix).matches(Regex("[A-Za-z0-9]{4}")))) +} + +class P2pResetRequiredException : IOException("An existing Wi-Fi Direct connection needs a reset") diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/P2pStartupRecovery.kt b/shared/src/main/java/com/shilapi/xcertplay/network/P2pStartupRecovery.kt new file mode 100644 index 0000000..22dfcef --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/P2pStartupRecovery.kt @@ -0,0 +1,75 @@ +package com.shilapi.xcertplay.network + +import android.net.wifi.p2p.WifiP2pManager +import java.io.IOException + +internal enum class P2pCreationMode { ALIGNED_5_GHZ, ALIGNED_2_GHZ, FIXED_5_GHZ, FIXED_2_GHZ, SYSTEM_DEFAULT } + +internal data class P2pCreationRequest(val mode: P2pCreationMode, val frequencyMHz: Int? = null) + +internal class P2pCreateRejected(val reason: Int, message: String) : IOException(message) + +/** Only an explicit rejection permits another request; a timeout may still create a group. */ +internal object P2pStartupRecovery { + fun rememberedFrequency(frequency: Int): P2pCreationRequest? = when { + frequency in 2412..2462 && (frequency - 2412) % 5 == 0 -> + P2pCreationRequest(P2pCreationMode.FIXED_2_GHZ, frequency) + frequency in listOf(5180, 5200, 5220, 5240, 5745, 5765, 5785, 5805, 5825) -> + P2pCreationRequest(P2pCreationMode.FIXED_5_GHZ, frequency) + else -> null + } + + /** A band-only request still needs channel selection, which some BYD drivers cannot do. */ + fun plan(stationFrequency: Int?, preferred: P2pCreationRequest? = null): List = buildList { + val aligned24 = stationFrequency != null && stationFrequency in 2412..2462 && + (stationFrequency - 2412) % 5 == 0 + val aligned5 = stationFrequency in listOf(5180, 5200, 5220, 5240, 5745, 5765, 5785, 5805, 5825) + val frequencies = mutableSetOf() + fun channel(mode: P2pCreationMode, frequency: Int) { + if (frequencies.add(frequency)) add(P2pCreationRequest(mode, frequency)) + } + if (preferred?.mode == P2pCreationMode.SYSTEM_DEFAULT && preferred.frequencyMHz == null) add(preferred) + else preferred?.frequencyMHz?.let(::rememberedFrequency)?.let { channel(it.mode, it.frequencyMHz!!) } + if (aligned24) channel(P2pCreationMode.ALIGNED_2_GHZ, requireNotNull(stationFrequency)) + if (aligned5) channel(P2pCreationMode.ALIGNED_5_GHZ, requireNotNull(stationFrequency)) + fun twoGhz() = listOf(2437, 2412, 2462).forEach { channel(P2pCreationMode.FIXED_2_GHZ, it) } + fun fiveGhz() = listOf(5180, 5745).forEach { channel(P2pCreationMode.FIXED_5_GHZ, it) } + // Keep a shared radio on its existing station channel when possible. Otherwise prefer + // explicit non-DFS 5 GHz, then channels 6/1/11. The platform enforces regulatory limits. + if (aligned24) { twoGhz(); fiveGhz() } else { fiveGhz(); twoGhz() } + // Some vendors only implement the default-configuration API. Use it last, after every + // explicit-frequency option has been rejected, never before the 2.4 GHz attempts. + if (none { it.mode == P2pCreationMode.SYSTEM_DEFAULT }) add(P2pCreationRequest(P2pCreationMode.SYSTEM_DEFAULT)) + } + + fun create( + stationFrequency: Int?, + beforeRetry: () -> Unit, + preferred: P2pCreationRequest? = null, + request: (P2pCreationRequest) -> Unit, + ): P2pCreationRequest { + val modes = plan(stationFrequency, preferred) + var retriedBusy = false + for ((index, mode) in modes.withIndex()) { + while (true) { + try { + request(mode) + return mode + } catch (failure: P2pCreateRejected) { + when { + failure.reason == WifiP2pManager.BUSY && !retriedBusy -> { + retriedBusy = true + beforeRetry() + } + failure.reason == WifiP2pManager.ERROR && index < modes.lastIndex -> { + beforeRetry() + break + } + else -> throw failure + } + } + } + } + error("No Wi-Fi Direct creation mode attempted") + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt new file mode 100644 index 0000000..3b753d9 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/WifiP2pGroupManager.kt @@ -0,0 +1,691 @@ +package com.shilapi.xcertplay.network + +import android.Manifest +import android.app.AppOpsManager +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import android.net.wifi.SupplicantState +import android.net.wifi.WifiManager +import android.net.wifi.p2p.WifiP2pConfig +import android.net.wifi.p2p.WifiP2pGroup +import android.net.wifi.p2p.WifiP2pInfo +import android.net.wifi.p2p.WifiP2pManager +import android.os.Build +import android.os.HandlerThread +import android.os.Looper +import android.provider.Settings +import android.util.Log +import androidx.annotation.RequiresApi +import com.shilapi.xcertplay.transport.Iap2WirelessSecurity +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.SocketException +import java.net.UnknownHostException +import java.security.SecureRandom +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Creates a temporary Wi-Fi Direct group owner, preferring 5 GHz, that can also be joined as a legacy AP. + * + * The group is deliberately not persistent. [close] removes it and releases the callback thread. + */ +class WifiP2pGroupManager( + context: Context, + private val diagnostic: (String) -> Unit = {}, +) : WirelessHotspotManager { + private val appContext = context.applicationContext + private val p2pManager = appContext.getSystemService(WifiP2pManager::class.java) + ?: throw IllegalStateException("WifiP2pManager is unavailable") + private val stateLock = Object() + private val random = SecureRandom() + private val configurationMemory = P2pConfigurationMemory(appContext) + private var rememberedAttempt: P2pConfigurationMemory.Record? = null + private var pendingSuccess: (() -> Boolean)? = null + private var carPlayConfirmed = false + private val ownership = appContext.getSharedPreferences("carplay_wifi_p2p", Context.MODE_PRIVATE) + private val ssidPrefix by lazy { + // ANDROID_ID is scoped to the signing key, user and device on our supported OS versions. + val id = runCatching { Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID) } + .getOrNull()?.takeIf { it.isNotBlank() } + ?: ownership.getString("fallback_id", null) + ?: java.util.UUID.randomUUID().toString().also { + check(ownership.edit().putString("fallback_id", it).commit()) + } + P2pOwnership.prefix(appContext.packageName, id) + } + + private var channel: WifiP2pManager.Channel? = null + private var callbackThread: HandlerThread? = null + private var created = false + private var closed = false + private var startAttempt: StartAttempt? = null + + override fun start(timeoutMillis: Long): WirelessHotspotInfo { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw IOException("Wi-Fi P2P credentials require Android 10 (API 29) or newer") + } + check(Looper.myLooper() != Looper.getMainLooper()) { + "WifiP2pGroupManager.start must not run on the main thread" + } + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + + val attempt = StartAttempt() + synchronized(stateLock) { + check(!closed) { "WifiP2pGroupManager is closed" } + check(startAttempt == null && !created) { + "A Wi-Fi P2P group is already starting or active" + } + startAttempt = attempt + } + + val thread = HandlerThread("xcertplay-wifi-p2p").apply { start() } + attempt.thread = thread + val deadlineNanos = deadlineAfter(timeoutMillis) + val credentials = randomCredentials() + + try { + val station = readStation() + val stationFrequency = station.alignmentFrequency + checkPrerequisites(station) + val remembered = configurationMemory.read() + val preferred = remembered?.takeIf { it.stationMHz == stationFrequency } + diagnostic(when { + preferred != null -> "Wi-Fi P2P remembered first mode=${preferred.request.mode} frequencyMHz=${preferred.request.frequencyMHz ?: "auto"}" + remembered != null -> "Wi-Fi P2P remembered skipped=station_channel_changed" + else -> "Wi-Fi P2P remembered unavailable" + }) + val p2pChannel = p2pManager.initialize( + appContext, + thread.looper, + createChannelListener(attempt), + ) + attempt.channel = p2pChannel + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + channel = p2pChannel + callbackThread = thread + } + + logP2pState(attempt, p2pChannel) + // Preferences disappear on reinstall, but the scoped namespace survives. + val existing = requestGroupInfo(attempt, p2pChannel, REQUEST_POLL_NANOS, requireResponse = true) + diagnostic("Wi-Fi P2P existingGroup=${existing != null}") + if (existing != null) { + if (!P2pOwnership.canReclaim(existing.isGroupOwner, existing.networkName, + ownership.getString("owned_ssid", null), ssidPrefix)) { + throw P2pResetRequiredException() + } + diagnostic("Wi-Fi P2P reclaiming retained owned group") + removeGroupBlocking(p2pChannel) + val removalDeadline = minOf(deadlineNanos, deadlineAfter(REMOVE_GROUP_TIMEOUT_MILLIS)) + while (requestGroupInfo(attempt, p2pChannel, REQUEST_POLL_NANOS, requireResponse = true) != null) { + if (remainingNanos(removalDeadline) == 0L) throw IOException("Wi-Fi Direct reset did not finish") + synchronized(stateLock) { waitNanos(TimeUnit.MILLISECONDS.toNanos(100)) } + } + } + + val creation = P2pStartupRecovery.create( + stationFrequency = stationFrequency, + preferred = preferred?.request, + beforeRetry = { + ensureStartActive(attempt) + // Do not cancel discovery, toggle Wi-Fi, or remove a newly observed group. + // A competing app may have acquired the radio since our rejected request. + synchronized(stateLock) { waitNanos(TimeUnit.MILLISECONDS.toNanos(500)) } + ensureStartActive(attempt) + checkPrerequisites(readStation()) + if (requestGroupInfo(attempt, p2pChannel, REQUEST_POLL_NANOS, requireResponse = true) != null) { + throw P2pResetRequiredException() + } + }, + request = { selection -> + ensureStartActive(attempt) + if (remainingNanos(deadlineNanos) == 0L) throw IOException("Wi-Fi Direct startup timed out") + val config = if (selection.mode == P2pCreationMode.SYSTEM_DEFAULT) null else { + val builder = WifiP2pConfig.Builder() + .setNetworkName(credentials.ssid) + .setPassphrase(credentials.passphrase) + builder.setGroupOperatingFrequency(requireNotNull(selection.frequencyMHz)) + builder.build() + } + if (config != null && !ownership.edit().putString("owned_ssid", credentials.ssid).commit()) { + throw IOException("Could not record Wi-Fi P2P group ownership") + } + val request = CreateRequest() + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + attempt.request = request + } + diagnostic("Wi-Fi P2P create mode=${selection.mode} frequencyMHz=${selection.frequencyMHz ?: "auto"}") + // The API 29 overload with null config uses system-generated credentials + // without requesting a persistent group, unlike the older two-argument API. + val usingRemembered = preferred?.request == selection + if (usingRemembered) synchronized(stateLock) { rememberedAttempt = preferred } + try { + p2pManager.createGroup(p2pChannel, config, createActionListener(attempt, request)) + awaitGroupCreated(attempt, request, deadlineNanos, timeoutMillis) + } catch (failure: P2pCreateRejected) { + if (usingRemembered && failure.reason == WifiP2pManager.ERROR && preferred != null) { + if (configurationMemory.forget(preferred)) diagnostic("Wi-Fi P2P remembered cleared=create_rejected") + } + throw failure + } + }, + ) + val group = awaitUsableGroup( + attempt = attempt, + channel = p2pChannel, + credentials = if (creation.mode == P2pCreationMode.SYSTEM_DEFAULT) null else credentials, + deadlineNanos = deadlineNanos, + timeoutMillis = timeoutMillis, + ) + if (!ownership.edit().putString("owned_ssid", group.ssid).commit()) { + throw IOException("Could not record Wi-Fi P2P group ownership") + } + diagnostic("Wi-Fi P2P ready mode=${creation.mode} band=${group.bandLabel} channel=${group.channel} frequencyMHz=${group.frequencyMHz}") + diagnostic("Wi-Fi P2P channel requestedMHz=${creation.frequencyMHz ?: "auto"} actualMHz=${group.frequencyMHz} matched=${creation.frequencyMHz?.let { it == group.frequencyMHz } ?: "system_selected"}") + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + created = true + startAttempt = null + pendingSuccess = { + configurationMemory.remember(creation, requireNotNull(group.frequencyMHz), stationFrequency) + } + } + return group + } catch (failure: Exception) { + diagnostic("Wi-Fi P2P startup stopped type=${failure.javaClass.simpleName}") + cleanupFailedStart(attempt) + throw failure + } + } + + override fun onCarPlayConfirmed() = synchronized(stateLock) { + if (closed || !created || carPlayConfirmed) return@synchronized + val save = pendingSuccess ?: return@synchronized + // Storage failure must not interrupt a working CarPlay session. + carPlayConfirmed = true + val saved = runCatching { save() }.getOrDefault(false) + diagnostic("Wi-Fi P2P remembered saved=$saved proof=authenticated_first_frame") + } + + override fun close() { + val attempt: StartAttempt? + val activeChannel: WifiP2pManager.Channel? + val activeThread: HandlerThread? + val removeGroup: Boolean + synchronized(stateLock) { + if (closed) return + closed = true + if (!carPlayConfirmed) rememberedAttempt?.let { + if (configurationMemory.forget(it)) diagnostic("Wi-Fi P2P remembered cleared=session_unconfirmed") + } + pendingSuccess = null + attempt = startAttempt + attempt?.stopped = true + stateLock.notifyAll() + activeChannel = channel ?: attempt?.channel + activeThread = callbackThread ?: attempt?.thread + removeGroup = created || attempt?.createSucceeded == true + channel = null + callbackThread = null + startAttempt = null + } + + if (removeGroup && activeChannel != null) { + removeGroupBlocking(activeChannel) + } + activeChannel?.close() + activeThread?.quitSafely() + } + + private fun createChannelListener( + attempt: StartAttempt, + ): WifiP2pManager.ChannelListener = object : WifiP2pManager.ChannelListener { + override fun onChannelDisconnected() { + failAttempt(attempt, IOException("Wi-Fi P2P channel disconnected")) + } + } + + private fun createActionListener( + attempt: StartAttempt, + request: CreateRequest, + ): WifiP2pManager.ActionListener = object : WifiP2pManager.ActionListener { + override fun onSuccess() { + val activeChannel = attempt.channel + val removeDetachedGroup = synchronized(stateLock) { + if (request.completed) return + request.completed = true + attempt.createSucceeded = true + if (startAttempt === attempt && attempt.request === request && !attempt.stopped && !closed) { + created = true + stateLock.notifyAll() + false + } else true + } + if (removeDetachedGroup && activeChannel != null) { + removeGroup(activeChannel, waitForCallback = false) + } + } + + override fun onFailure(reason: Int) { + synchronized(stateLock) { + if (request.completed || attempt.request !== request) return + request.completed = true + request.failure = P2pCreateRejected(reason, + "Wi-Fi P2P createGroup failed: ${failureReason(reason)} (code=$reason)") + stateLock.notifyAll() + } + diagnostic("Wi-Fi P2P create rejected code=$reason reason=${failureReason(reason)}") + } + } + + private fun failAttempt(attempt: StartAttempt, failure: IOException) { + synchronized(stateLock) { + if (startAttempt === attempt && !attempt.stopped && !closed) { + if (attempt.failure == null) attempt.failure = failure + stateLock.notifyAll() + } + } + } + + private fun awaitGroupCreated( + attempt: StartAttempt, + request: CreateRequest, + deadlineNanos: Long, + timeoutMillis: Long, + ) { + synchronized(stateLock) { + while (true) { + ensureStartActiveLocked(attempt) + attempt.failure?.let { throw it } + request.failure?.let { throw it } + if (attempt.createSucceeded) return + + val remainingNanos = remainingNanos(deadlineNanos) + if (remainingNanos <= 0) { + throw IOException( + "Timed out after ${timeoutMillis}ms waiting for Wi-Fi P2P group creation", + ) + } + waitNanos(remainingNanos) + } + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun awaitUsableGroup( + attempt: StartAttempt, + channel: WifiP2pManager.Channel, + credentials: Credentials?, + deadlineNanos: Long, + timeoutMillis: Long, + ): WirelessHotspotInfo { + var lastReason = "group information was not available" + while (true) { + ensureStartActive(attempt) + val remainingNanos = remainingNanos(deadlineNanos) + if (remainingNanos <= 0) { + throw IOException( + "Timed out after ${timeoutMillis}ms waiting for a usable Wi-Fi P2P group: " + + lastReason, + ) + } + + val group = requestGroupInfo( + attempt = attempt, + channel = channel, + timeoutNanos = minOf(remainingNanos, REQUEST_POLL_NANOS), + ) + if (group == null) continue + if (!group.isGroupOwner) { + throw IOException("Wi-Fi P2P device became a group client instead of owner") + } + + val networkName = group.networkName?.takeIf { it.isNotBlank() } + if (credentials != null && networkName != null && networkName != credentials.ssid) { + throw IOException("Wi-Fi Direct returned an unexpected group") + } + val passphrase = group.passphrase?.takeIf { it.isNotBlank() } + ?: credentials?.passphrase + val interfaceName = group.getInterface()?.takeIf { it.isNotBlank() } + val frequencyMHz = group.frequency + val channelNumber = wifiFrequencyMhzToChannel(frequencyMHz) + if ( + networkName == null || passphrase == null || + interfaceName == null || + frequencyMHz <= 0 || + channelNumber == null + ) { + lastReason = "incomplete group details frequencyMHz=$frequencyMHz" + continue + } + val band = when { + is5Ghz(frequencyMHz) -> "5 GHz" + frequencyMHz in 2412..2484 -> "2.4 GHz" + else -> throw IOException("Wi-Fi P2P returned an unsupported band at ${frequencyMHz}MHz") + } + + val hostAddress = awaitInterfaceAddress(attempt, interfaceName, deadlineNanos) + ?: requestConnectionAddress( + attempt = attempt, + channel = channel, + timeoutNanos = minOf(remainingNanos(deadlineNanos), REQUEST_POLL_NANOS), + ) + if (hostAddress == null) { + lastReason = "interface $interfaceName has no usable IPv6 or IPv4 address" + continue + } + + return WirelessHotspotInfo( + ssid = networkName, + passphrase = passphrase, + security = groupSecurity(group), + channel = channelNumber, + frequencyMHz = frequencyMHz, + bssid = interfaceHardwareAddress(interfaceName) + ?: group.owner?.deviceAddress?.takeIf { it.isNotBlank() }, + interfaceName = interfaceName, + hostAddress = hostAddress, + bandLabel = band, + backend = WirelessHotspotBackend.WIFI_P2P, + ) + } + } + + private fun requestGroupInfo( + attempt: StartAttempt, + channel: WifiP2pManager.Channel, + timeoutNanos: Long, + requireResponse: Boolean = false, + ): WifiP2pGroup? { + val result = AtomicReference() + val latch = CountDownLatch(1) + p2pManager.requestGroupInfo(channel) { + result.set(it) + latch.countDown() + } + if (!await(latch, timeoutNanos)) { + if (requireResponse) throw IOException("Wi-Fi Direct did not respond") + return null + } + ensureStartActive(attempt) + return result.get() + } + + private fun requestConnectionAddress( + attempt: StartAttempt, + channel: WifiP2pManager.Channel, + timeoutNanos: Long, + ): InetAddress? { + val result = AtomicReference() + val latch = CountDownLatch(1) + p2pManager.requestConnectionInfo(channel) { + result.set(it) + latch.countDown() + } + if (!await(latch, timeoutNanos)) return null + ensureStartActive(attempt) + val info = result.get() ?: return null + if (!info.groupFormed) return null + return info.groupOwnerAddress?.takeUnless(InetAddress::isAnyLocalAddress) + } + + private fun await(latch: CountDownLatch, timeoutNanos: Long): Boolean = try { + val waitNanos = timeoutNanos.coerceAtLeast(1L) + latch.await(waitNanos, TimeUnit.NANOSECONDS) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("Interrupted while waiting for Wi-Fi P2P", interrupted) + } + + private fun interfaceAddress(interfaceName: String): InetAddress? { + val networkInterface = networkInterface(interfaceName) ?: return null + var ipv4: InetAddress? = null + for (address in Collections.list(networkInterface.inetAddresses)) { + if (address is Inet6Address && address.isLinkLocalAddress) { + if (address.scopeId == networkInterface.index) return address + try { + return Inet6Address.getByAddress(null, address.address, networkInterface) + } catch (_: UnknownHostException) { + continue + } + } + if (address is Inet4Address && !address.isLoopbackAddress && ipv4 == null) { + ipv4 = address + } + } + return ipv4 + } + + private fun awaitInterfaceAddress( + attempt: StartAttempt, + interfaceName: String, + startupDeadlineNanos: Long, + ): InetAddress? { + // Group creation precedes IPv6 link-local configuration on some head units. + // Give IPv6 a bounded chance to appear before falling back to IPv4. + val addressDeadline = minOf(startupDeadlineNanos, deadlineAfter(2_000)) + while (true) { + ensureStartActive(attempt) + val address = interfaceAddress(interfaceName) + if (address is Inet6Address || remainingNanos(addressDeadline) <= 0) return address + Thread.sleep(100) + } + } + + private data class Station(val state: SupplicantState?, val reportedFrequency: Int?) { + // A vendor may retain the last frequency after disconnecting. Only align to an + // established Wi-Fi association; internet validation is deliberately irrelevant. + val alignmentFrequency: Int? + get() = reportedFrequency?.takeIf { state == SupplicantState.COMPLETED && it > 0 } + } + + @Suppress("DEPRECATION") + private fun readStation(): Station = runCatching { + val info = appContext.getSystemService(WifiManager::class.java)?.connectionInfo + Station(info?.supplicantState, info?.frequency?.takeIf { it > 0 }) + }.getOrDefault(Station(null, null)) + + private fun checkPrerequisites(station: Station) { + val wifi = appContext.getSystemService(WifiManager::class.java) + val fiveGhzSupported = runCatching { wifi?.is5GHzBandSupported }.getOrNull() + val wifiEnabled = runCatching { wifi?.isWifiEnabled }.getOrNull() + val locationEnabled = runCatching { + appContext.getSystemService(LocationManager::class.java)?.isLocationEnabled + }.getOrNull() + val required = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES + else Manifest.permission.ACCESS_FINE_LOCATION + val granted = appContext.checkSelfPermission(required) == PackageManager.PERMISSION_GRANTED + val locationAccessMode = if (Build.VERSION.SDK_INT in 29..32) runCatching { + appContext.getSystemService(AppOpsManager::class.java)?.unsafeCheckOpNoThrow( + AppOpsManager.OPSTR_FINE_LOCATION, android.os.Process.myUid(), appContext.packageName) + }.getOrNull() else null + diagnostic("Wi-Fi P2P preflight wifiEnabled=$wifiEnabled locationEnabled=$locationEnabled permissionGranted=$granted locationAccessMode=${locationAccessMode ?: "unknown"} stationMHz=${station.alignmentFrequency ?: "unknown"} stationState=${station.state ?: "unknown"} reportedStationMHz=${station.reportedFrequency ?: "unknown"} fiveGhzSupported=${fiveGhzSupported ?: "unknown"}") + if (!granted) throw IOException(if (Build.VERSION.SDK_INT >= 33) + "Allow Nearby devices for DiPlay in the head unit's app permissions" + else "Allow precise Location for DiPlay in the head unit's app permissions") + if (wifiEnabled == false) throw IOException("Turn on Wi-Fi in the head unit's settings, then reconnect") + // Location mode is diagnostic only: AOSP createGroup does not require it to be on. + // Do not block firmware where Wi-Fi Direct works with Location services disabled. + } + + @RequiresApi(Build.VERSION_CODES.Q) + private fun logP2pState(attempt: StartAttempt, channel: WifiP2pManager.Channel) { + val result = AtomicReference() + val latch = CountDownLatch(1) + p2pManager.requestP2pState(channel) { state -> result.set(state); latch.countDown() } + await(latch, REQUEST_POLL_NANOS) + ensureStartActive(attempt) + diagnostic("Wi-Fi P2P frameworkState=${result.get() ?: "unknown"}") + } + + private fun interfaceHardwareAddress(interfaceName: String): String? = + networkInterface(interfaceName) + ?.hardwareAddress + ?.takeIf { it.isNotEmpty() } + ?.joinToString(":") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private fun networkInterface(interfaceName: String): NetworkInterface? = try { + NetworkInterface.getByName(interfaceName) + } catch (_: SocketException) { + null + } + + private fun groupSecurity(group: WifiP2pGroup): Iap2WirelessSecurity { + if (Build.VERSION.SDK_INT < 36) return Iap2WirelessSecurity.WPA_WPA2 + return when (group.securityType) { + WifiP2pGroup.SECURITY_TYPE_WPA2_PSK -> Iap2WirelessSecurity.WPA_WPA2 + WifiP2pGroup.SECURITY_TYPE_WPA3_COMPATIBILITY -> + Iap2WirelessSecurity.WPA3_TRANSITION + WifiP2pGroup.SECURITY_TYPE_WPA3_SAE -> Iap2WirelessSecurity.WPA3_ONLY + else -> throw IOException( + "Unsupported Wi-Fi P2P security type: ${group.securityType}", + ) + } + } + + private fun randomCredentials(): Credentials = Credentials( + ssid = "$ssidPrefix${randomToken(4)}", + passphrase = randomToken(16), + ) + + private fun randomToken(length: Int): String = + buildString(length) { + repeat(length) { + append(TOKEN_ALPHABET[random.nextInt(TOKEN_ALPHABET.length)]) + } + } + + private fun ensureStartActive(attempt: StartAttempt) { + synchronized(stateLock) { + ensureStartActiveLocked(attempt) + } + } + + private fun ensureStartActiveLocked(attempt: StartAttempt) { + if (closed) throw IOException("WifiP2pGroupManager closed while starting") + if (startAttempt !== attempt) throw IOException("Wi-Fi P2P startup was cancelled") + if (attempt.stopped) throw IOException("Wi-Fi P2P group stopped before startup completed") + attempt.failure?.let { throw it } + } + + private fun cleanupFailedStart(attempt: StartAttempt) { + val failedChannel: WifiP2pManager.Channel? + val failedThread: HandlerThread? + val removeGroup: Boolean + synchronized(stateLock) { + if (startAttempt === attempt) startAttempt = null + attempt.stopped = true + stateLock.notifyAll() + failedChannel = attempt.channel + failedThread = attempt.thread + removeGroup = attempt.createSucceeded + created = false + if (channel === failedChannel) channel = null + if (callbackThread === failedThread) callbackThread = null + } + if (removeGroup && failedChannel != null) { + removeGroupBlocking(failedChannel) + } + failedChannel?.close() + failedThread?.quitSafely() + } + + private fun removeGroupBlocking(channel: WifiP2pManager.Channel) { + removeGroup(channel, waitForCallback = true) + } + + private fun removeGroup(channel: WifiP2pManager.Channel, waitForCallback: Boolean) { + val latch = CountDownLatch(1) + try { + p2pManager.removeGroup( + channel, + object : WifiP2pManager.ActionListener { + override fun onSuccess() { + latch.countDown() + } + + override fun onFailure(reason: Int) { + diagnostic("Wi-Fi P2P remove rejected code=$reason reason=${failureReason(reason)}") + latch.countDown() + } + }, + ) + } catch (failure: RuntimeException) { + Log.w(TAG, "Wi-Fi P2P removeGroup could not be issued", failure) + latch.countDown() + } + if (!waitForCallback) return + try { + latch.await(REMOVE_GROUP_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + private fun waitNanos(nanos: Long) { + val millis = nanos / NANOS_PER_MILLISECOND + val remainder = (nanos % NANOS_PER_MILLISECOND).toInt() + try { + stateLock.wait(millis, remainder) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw IOException("Interrupted while waiting for Wi-Fi P2P", interrupted) + } + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val now = System.nanoTime() + val delta = timeoutMillis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun remainingNanos(deadlineNanos: Long): Long = + (deadlineNanos - System.nanoTime()).coerceAtLeast(0L) + + private fun failureReason(reason: Int): String = when (reason) { + WifiP2pManager.P2P_UNSUPPORTED -> "Wi-Fi P2P is unsupported" + WifiP2pManager.BUSY -> "Wi-Fi P2P is busy" + WifiP2pManager.ERROR -> "generic error" + WifiP2pManager.NO_PERMISSION -> "permission denied" + else -> "reason $reason" + } + + private fun is5Ghz(frequencyMHz: Int): Boolean = frequencyMHz in 5150..5895 + + private class StartAttempt { + var channel: WifiP2pManager.Channel? = null + var thread: HandlerThread? = null + var createSucceeded = false + var request: CreateRequest? = null + var failure: IOException? = null + var stopped = false + } + + private class CreateRequest { + var completed = false + var failure: P2pCreateRejected? = null + } + + private class Credentials( + val ssid: String, + val passphrase: String, + ) + + private companion object { + const val TAG = "xcertplay-usb" + const val NANOS_PER_MILLISECOND = 1_000_000L + const val REMOVE_GROUP_TIMEOUT_MILLIS = 2_000L + val REQUEST_POLL_NANOS: Long = TimeUnit.MILLISECONDS.toNanos(500) + const val TOKEN_ALPHABET = + "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789" + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt b/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt new file mode 100644 index 0000000..1a75aea --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/network/WirelessHotspotManager.kt @@ -0,0 +1,95 @@ +package com.shilapi.xcertplay.network + +import com.shilapi.xcertplay.transport.Iap2WirelessSecurity +import java.io.Closeable +import java.net.InetAddress + +enum class WirelessHotspotBackend(val label: String) { + WIFI_P2P("Wi-Fi P2P"), + LOCAL_ONLY_HOTSPOT("LocalOnlyHotspot"), + MANUAL_HOTSPOT("Manual hotspot"), +} + +/** The live Wi-Fi credentials and interface details for one wireless CarPlay hotspot. */ +class WirelessHotspotInfo( + val ssid: String, + val passphrase: String, + val security: Iap2WirelessSecurity, + val channel: Int, + val frequencyMHz: Int?, + val bssid: String?, + val interfaceName: String?, + val hostAddress: InetAddress?, + val bandLabel: String, + val backend: WirelessHotspotBackend, +) { + override fun toString(): String = + "WirelessHotspotInfo(backend=${backend.label}, ssid='$ssid', " + + "passphrase=, security=$security, channel=$channel, " + + "frequencyMHz=$frequencyMHz, bssid='$bssid', interfaceName=$interfaceName, " + + "hostAddress=$hostAddress, bandLabel='$bandLabel')" +} + +/** Owns one Android Wi-Fi group and all resources needed to keep it alive. */ +interface WirelessHotspotManager : Closeable { + /** + * Starts a hotspot and waits up to [timeoutMillis] for its live configuration and AP + * interface. Implementations must not be called on the main thread. + */ + fun start(timeoutMillis: Long): WirelessHotspotInfo + + /** The authenticated wireless session has rendered CarPlay; AP creation alone is insufficient. */ + fun onCarPlayConfirmed() {} +} + +/** + * Converts IEEE 802.11 frequency in MHz to its channel number. + * + * Android's [android.net.wifi.p2p.WifiP2pGroup.getFrequency] reports MHz, while the iAP2 + * wireless CarPlay payload expects a channel number. + */ +internal fun wifiFrequencyMhzToChannel(frequencyMHz: Int): Int? = when { + frequencyMHz in 2412..2472 && (frequencyMHz - 2407) % 5 == 0 -> + (frequencyMHz - 2407) / 5 + frequencyMHz == 2484 -> 14 + frequencyMHz in 5160..5895 && (frequencyMHz - 5000) % 5 == 0 -> + (frequencyMHz - 5000) / 5 + frequencyMHz in 5955..7115 && (frequencyMHz - 5950) % 5 == 0 -> + (frequencyMHz - 5950) / 5 + else -> null +} + +/** Converts an IEEE 802.11 channel number to MHz when its band is known. */ +internal fun wifiChannelToFrequencyMhz(channel: Int, band: Int? = null): Int? = when { + channel == 0 -> null + band == 1 && channel in 1..13 -> 2407 + channel * 5 + band == 1 && channel == 14 -> 2484 + band == 2 && channel in 32..177 -> 5000 + channel * 5 + band == 3 && channel in 1..233 -> 5950 + channel * 5 + else -> null +} + +/** Maps Android's [android.net.wifi.SoftApConfiguration] band constants to a user-facing label. */ +internal fun wifiBandLabel(band: Int?): String? = when (band) { + 1 -> "2.4 GHz" + 2 -> "5 GHz" + 3 -> "6 GHz" + else -> null +} + +/** + * Returns only a channel observed from Android. An unknown channel must stay unknown instead of + * echoing the user's desired channel back to the iPhone in 0x5703. + */ +internal fun observedManualHotspotChannel( + apChannel: Int, + connectionFrequencyMHz: Int?, + scanFrequencyMHz: Int?, + apFrequencyMHz: Int?, +): Int { + if (apChannel > 0) return apChannel + connectionFrequencyMHz?.let(::wifiFrequencyMhzToChannel)?.let { return it } + scanFrequencyMHz?.let(::wifiFrequencyMhzToChannel)?.let { return it } + apFrequencyMHz?.let(::wifiFrequencyMhzToChannel)?.let { return it } + return 0 +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt new file mode 100644 index 0000000..6c560d9 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt @@ -0,0 +1,1965 @@ +package com.shilapi.xcertplay.orchestration + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothA2dp +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothHeadset +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothSocket +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.provider.Settings +import android.util.Log +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayContact +import com.shilapi.xcertplay.airplay.AirPlayDeviceInfo +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import com.shilapi.xcertplay.airplay.AirPlayMediaHandler +import com.shilapi.xcertplay.airplay.AirPlaySession +import com.shilapi.xcertplay.airplay.AirPlaySessionListener +import com.shilapi.xcertplay.airplay.PairingStore +import com.shilapi.xcertplay.iap2.session.Iap2Session +import com.shilapi.xcertplay.mfi.Iap2MfiAuthenticationClient +import com.shilapi.xcertplay.mfi.MfiAuthenticationClient +import com.shilapi.xcertplay.mfi.RemoteMfiAuthenticationClient +import com.shilapi.xcertplay.mfi.LocalMfiAuthenticationClient +import com.shilapi.xcertplay.network.CarPlayBonjour +import com.shilapi.xcertplay.network.CarPlayVpnService +import com.shilapi.xcertplay.network.LocalOnlyHotspotManager +import com.shilapi.xcertplay.network.ManualHotspotManager +import com.shilapi.xcertplay.network.WifiP2pGroupManager +import com.shilapi.xcertplay.network.WirelessHotspotInfo +import com.shilapi.xcertplay.network.WirelessHotspotBackend +import com.shilapi.xcertplay.network.WirelessHotspotManager +import com.shilapi.xcertplay.transport.BlockingDuplexByteStream +import com.shilapi.xcertplay.transport.BluetoothRfcommDuplexStream +import com.shilapi.xcertplay.transport.Ch341DeviceMatcher +import com.shilapi.xcertplay.transport.Ch341I2cTransport +import com.shilapi.xcertplay.transport.Ch341UsbHost +import com.shilapi.xcertplay.transport.Ch341UsbSession +import com.shilapi.xcertplay.transport.Iap2IdentificationConfig +import com.shilapi.xcertplay.transport.Iap2LocationProvider +import com.shilapi.xcertplay.transport.Iap2UsbMuxHost +import com.shilapi.xcertplay.transport.Iap2UsbSession +import com.shilapi.xcertplay.transport.Iap2WiredCarPlayEndpoint +import com.shilapi.xcertplay.transport.Iap2WiredControlClient +import com.shilapi.xcertplay.transport.Iap2WiredControlTerminal +import com.shilapi.xcertplay.transport.Iap2WirelessCarPlayEndpoint +import com.shilapi.xcertplay.transport.Iap2WirelessControlClient +import com.shilapi.xcertplay.transport.Iap2WirelessControlTerminal +import com.shilapi.xcertplay.transport.Iap2WirelessIdentification +import com.shilapi.xcertplay.transport.I2cTransport +import com.shilapi.xcertplay.transport.I2cTransportException +import com.shilapi.xcertplay.transport.IphoneCarPlayConfiguration +import com.shilapi.xcertplay.transport.IphoneUsbException +import com.shilapi.xcertplay.transport.IphoneUsbHost +import com.shilapi.xcertplay.transport.IphoneUsbMatcher +import com.shilapi.xcertplay.transport.LinuxI2cTransport +import com.shilapi.xcertplay.transport.LockdownCarKitClient +import com.shilapi.xcertplay.transport.LockdownPairingClient +import com.shilapi.xcertplay.transport.LockdownPairRecord +import com.shilapi.xcertplay.transport.NcmFunctionDiscovery +import com.shilapi.xcertplay.transport.NcmUsbBridge +import java.io.Closeable +import java.io.IOException +import java.net.InetAddress +import java.net.Inet6Address +import java.util.Locale +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +sealed class CarPlayStatus { + data object DiscoveringMfi : CarPlayStatus() + data object WaitingForMfi : CarPlayStatus() + data object RequestingMfiPermission : CarPlayStatus() + data object MfiReady : CarPlayStatus() + data object StartingHotspot : CarPlayStatus() + data class HotspotReady( + val ssid: String, + val band: String, + val channel: Int, + val bssid: String, + val address: String, + val backend: String, + ) : CarPlayStatus() + data object WaitingForPairedIphone : CarPlayStatus() + data object ConnectingBluetooth : CarPlayStatus() + data object RunningWireless : CarPlayStatus() + data object WirelessActive : CarPlayStatus() + data object DiscoveringIphone : CarPlayStatus() + data object WaitingForIphone : CarPlayStatus() + data object RequestingIphonePermission : CarPlayStatus() + data object WaitingForReenumeration : CarPlayStatus() + data object SelectingConfiguration : CarPlayStatus() + data object OpeningDataPaths : CarPlayStatus() + data object Pairing : CarPlayStatus() + data object ConnectingControl : CarPlayStatus() + data object AttachingNetwork : CarPlayStatus() + data object RunningControl : CarPlayStatus() + data object ControlEnded : CarPlayStatus() + data class Failed(val message: String, val wifiResetRequired: Boolean = false) : CarPlayStatus() +} + +internal fun isWirelessHandoffInProgress( + handoffRequested: Boolean, + tunnelActive: Boolean, + sessionActive: Boolean, +): Boolean = handoffRequested || tunnelActive || sessionActive + +/** + * Wires the complete wired or wireless CarPlay path: MFi coprocessor discovery, iPhone bring-up, + * iAP2 control, transport setup, and the AirPlay media/input sessions. + * + * All blocking USB/I2C work runs on one worker executor. Status callbacks are delivered on the + * main thread. This class is the integration seam only and is not evidence of hardware operation. + */ +class CarPlayController( + context: Context, + private val config: CarPlayRuntimeConfig, + private val airPlayConfig: AirPlayConfig, + private val identity: AirPlayIdentity, + private val pairings: PairingStore, + listener: AirPlaySessionListener, + private val media: AirPlayMediaHandler, + reportStatus: (CarPlayStatus) -> Unit, + private val loadPairRecord: () -> LockdownPairRecord? = { null }, + private val savePairRecord: (LockdownPairRecord) -> Unit = {}, + private val clearPairRecord: () -> Unit = {}, + private val locationProvider: Iap2LocationProvider? = null, +) : Closeable { + init { + require(!config.locationReportingEnabled || locationProvider != null) { + "A location provider is required when location reporting is enabled" + } + } + + private enum class Phase { IDLE, MFI, WIRELESS, IPHONE, REENUMERATION, DATAPATHS, CONTROL } + + private val appContext = context.applicationContext + private val usbManager = context.getSystemService(UsbManager::class.java) + private val bluetoothAdapter = + appContext.getSystemService(BluetoothManager::class.java)?.adapter + private val iphoneHost = IphoneUsbHost( + appContext, + usbManager, + if (config.iphoneDevices.isNotEmpty()) { + IphoneUsbMatcher(config.iphoneDevices) + } else { + IphoneUsbMatcher.appleVendor() + }, + ) + private val executor: ExecutorService = Executors.newSingleThreadExecutor() + private val touchExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val tunnelExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private val mainHandler = Handler(Looper.getMainLooper()) + private val hostId = UUID.randomUUID().toString().uppercase(Locale.US) + private val systemBuid = UUID.randomUUID().toString().uppercase(Locale.US) + private val lifecycleLock = Any() + @Volatile private var uiListener: AirPlaySessionListener? = listener + @Volatile private var uiStatusReporter: ((CarPlayStatus) -> Unit)? = reportStatus + private val permissionGrant = AtomicBoolean(false) + private val availabilityPollGeneration = AtomicInteger(0) + private var permissionPollGeneration = 0 + private var reenumerationAttempts = 0 + private var lastReportedStatus: CarPlayStatus? = null + private var mfiResetLogged = false + + @Volatile private var closed = false + @Volatile private var phase = Phase.IDLE + @Volatile private var ch341Host: Ch341UsbHost? = null + @Volatile private var mfiSession: MfiSession? = null + @Volatile private var mux: Iap2UsbMuxHost? = null + @Volatile private var csm: Iap2Session? = null + @Volatile private var activeSession: AirPlaySession? = null + @Volatile private var hotspot: WirelessHotspotManager? = null + @Volatile private var bonjour: CarPlayBonjour? = null + @Volatile private var bluetoothSocket: BluetoothSocket? = null + @Volatile private var bluetoothStream: BluetoothRfcommDuplexStream? = null + @Volatile private var wirelessTunnelChannel: Iap2Session? = null + @Volatile private var wirelessIdentification: Iap2IdentificationConfig? = null + @Volatile private var wirelessAirPlayEndpoint: Iap2WirelessCarPlayEndpoint? = null + @Volatile private var vpnService: CarPlayVpnService? = null + @Volatile private var vpnBound = false + private val wirelessHandoffRequested = AtomicBoolean(false) + private val wirelessTunnelReady = AtomicBoolean(false) + private val wirelessActiveReported = AtomicBoolean(false) + private val wirelessGeneration = AtomicInteger(0) + private val wirelessConnectionProof = WirelessConnectionProof() + + private var permissionCloseable: Closeable? = null + private var attachCloseable: Closeable? = null + private var ch341PermissionCloseable: Closeable? = null + private var vpnLatch = CountDownLatch(1) + private val teardownComplete = CountDownLatch(1) + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, binder: IBinder) { + vpnService = (binder as CarPlayVpnService.LocalBinder).service + vpnLatch.countDown() + } + + override fun onServiceDisconnected(name: ComponentName) { + vpnService = null + fail(IphoneUsbException.DeviceUnavailable("CarPlay VPN service disconnected")) + } + } + + private val sessionListener = object : AirPlaySessionListener { + override fun onSessionActive(session: AirPlaySession) { + activeSession = session + debugLog( + "AirPlay session active controller=${session.controllerId ?: "unknown"} " + + "peer=${session.host}", + ) + uiListener?.onSessionActive(session) + } + + override fun onSessionEnded(session: AirPlaySession) { + if (activeSession === session) activeSession = null + debugLog("AirPlay session ended peer=${session.host}") + uiListener?.onSessionEnded(session) + } + + override fun onTransportError(message: String) { + debugLog("AirPlay transport error: $message") + uiListener?.onTransportError(message) + } + + override fun onDeviceInfo(session: AirPlaySession, info: AirPlayDeviceInfo) { + debugLog( + "AirPlay device info name=${info.name} deviceId=${info.deviceId} " + + "wifiMac=${info.wifiMac} model=${info.model}", + ) + uiListener?.onDeviceInfo(session, info) + } + + override fun onHostUiRequested(session: AirPlaySession) { + uiListener?.onHostUiRequested(session) + } + + override fun onCommand(session: AirPlaySession, type: String, params: Map) { + debugLog( + "AirPlay command type=$type params=${params.keys.sorted().joinToString(",")}", + ) + if ( + config.transport == CarPlayTransport.WIRELESS && + !closed && + activeSession === session && + isBluetoothHandoffCommand(type) && + wirelessHandoffRequested.compareAndSet(false, true) + ) { + debugLog( + "wireless CarPlay Bluetooth handoff requested; " + + "waiting for tunnel iAP2 readiness", + ) + armWirelessHandoffWatchdog(wirelessGeneration.get()) + maybeCompleteWirelessHandoff() + } + uiListener?.onCommand(session, type, params) + } + + override fun onDebugLog(message: String) { + debugLog(message) + } + } + + fun attachUi( + listener: AirPlaySessionListener, + reportStatus: (CarPlayStatus) -> Unit, + ) { + uiListener = listener + uiStatusReporter = reportStatus + mainHandler.post { + if (uiListener === listener) lastReportedStatus?.let(reportStatus) + } + } + + fun isClosed(): Boolean = closed + + fun hasActiveAirPlayAttachment(): Boolean = synchronized(lifecycleLock) { + !closed && vpnService?.isAttached() == true + } + + fun start() { + synchronized(this) { + if (closed) return + } + if (config.transport == CarPlayTransport.WIRED) { + permissionCloseable = iphoneHost.registerPermissionReceiver(::onIphonePermission) + attachCloseable = iphoneHost.registerAttachReceiver(::onIphoneAttached) + } + startMfi() + } + + /** Reopens the CH341/MFi path without restarting the app. */ + fun reconnectMfi() = synchronized(lifecycleLock) { + if (closed) return + closeMfiSession() + startMfi() + } + + /** Re-runs iPhone discovery/bring-up using the already-open MFi session. */ + fun reconnectIphone() = synchronized(lifecycleLock) { + if (closed) return + if (mfiSession == null) { + startMfi() + } else if (config.transport == CarPlayTransport.WIRELESS) { + restartWireless() + } else { + startIphone() + } + } + + fun sendTouch(contacts: List): Boolean { + if (closed) return false + val session = activeSession ?: return false + return try { + touchExecutor.execute { session.sendTouch(contacts) } + true + } catch (_: Exception) { + false + } + } + + override fun close() { + synchronized(this) { + if (closed) return + closed = true + } + closeReceivers() + availabilityPollGeneration.incrementAndGet() + wirelessGeneration.incrementAndGet() + permissionPollGeneration += 1 + touchExecutor.shutdownNow() + tunnelExecutor.shutdownNow() + val service = vpnService + unbindVpn() + Thread( + { + try { + if (config.transport == CarPlayTransport.WIRELESS) { + closeBestEffort("wireless stack") { closeWirelessStack(service) } + } else { + closeBestEffort("CSM") { csm?.close() } + csm = null + } + closeBestEffort("USBMUX") { mux?.close() } + mux = null + if (config.transport == CarPlayTransport.WIRED) { + closeBestEffort("VPN/NCM") { service?.detach() } + } + closeBestEffort("MFi") { mfiSession?.close() } + mfiSession = null + wirelessIdentification = null + wirelessAirPlayEndpoint = null + closeBestEffort("location provider") { locationProvider?.close() } + } finally { + executor.shutdownNow() + try { + executor.awaitTermination(EXECUTOR_CLOSE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + teardownComplete.countDown() + } + }, + "xcertplay-controller-teardown", + ).apply { + isDaemon = true + start() + } + } + + /** Waits for USB, iAP2, MFi and VPN teardown; intended for a non-main lifecycle thread. */ + fun awaitClosed(timeoutMillis: Long): Boolean { + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + return try { + teardownComplete.await(timeoutMillis, TimeUnit.MILLISECONDS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + false + } + } + + private fun startMfi() { + availabilityPollGeneration.incrementAndGet() + phase = Phase.MFI + onStatus(CarPlayStatus.DiscoveringMfi) + val offlineDirectory = java.io.File(appContext.noBackupFilesDir, LocalMfiAuthenticationClient.DIRECTORY) + if (offlineDirectory.exists()) { + openLocalMfi(offlineDirectory) + return + } + when (config.mfiTarget) { + MfiTarget.LOCAL -> openLocalMfi(offlineDirectory) + MfiTarget.USB_CH341 -> { + debugLog("mfi discovery backend=CH341 devices=${config.ch341Devices}") + val host = ch341Host ?: Ch341UsbHost( + appContext, + usbManager, + Ch341DeviceMatcher(config.ch341Devices), + ).also { + ch341Host = it + ch341PermissionCloseable = it.registerPermissionReceiver(::onCh341Permission) + } + checkCh341Mfi(host) + } + MfiTarget.I2C -> { + debugLog("mfi discovery backend=Linux I2C path=${config.linuxI2cPath}") + openLinuxMfi() + } + MfiTarget.REMOTE -> { + debugLog("mfi discovery backend=Remote server=${config.remoteMfiServer.orEmpty()}") + openRemoteMfi() + } + } + } + + private fun openLocalMfi(directory: java.io.File) { + debugLog("mfi discovery backend=LocalOffline remoteFallback=disabled") + executor.execute { + try { + val signatures = AtomicInteger(0) + val client = LocalMfiAuthenticationClient.load(directory) { size -> + debugLog("mfi local signature count=${signatures.incrementAndGet()} digestBytes=$size") + } + if (closed || phase != Phase.MFI) return@execute + mfiSession = MfiSession(client, null) + debugLog("mfi local offline ready protocolMajor=${client.protocolMajor()} certificateBytes=${client.readCertificate().size}") + onStatus(CarPlayStatus.MfiReady) + startPhone() + } catch (error: Throwable) { + // A broken local identity must fail closed rather than silently use the helper. + fail(error) + } + } + } + + private fun openRemoteMfi() { + executor.execute { + try { + val client = RemoteMfiAuthenticationClient( + serverAddress = checkNotNull(config.remoteMfiServer), + token = config.remoteMfiToken, + ) + client.reset() + val protocolMajor = client.protocolMajor() + if (closed || phase != Phase.MFI) return@execute + mfiSession = MfiSession(client, null) + debugLog( + "mfi remote service ready server=${config.remoteMfiServer} " + + "protocolMajor=$protocolMajor", + ) + onStatus(CarPlayStatus.MfiReady) + startPhone() + } catch (error: Throwable) { + fail(error) + } + } + } + + private fun checkCh341Mfi(host: Ch341UsbHost) { + if (closed || phase != Phase.MFI) return + val device = host.discover().firstOrNull() + if (device == null) { + waitForMfi() + } else { + availabilityPollGeneration.incrementAndGet() + requestCh341Permission(device) + } + } + + private fun openLinuxMfi() { + executor.execute { + try { + val transport = LinuxI2cTransport.open(config.linuxI2cPath!!) + try { + mfiSession = MfiSession(MfiRuntime.scan(transport), transport) + debugLog("mfi Linux I2C coprocessor ready path=${config.linuxI2cPath}") + onStatus(CarPlayStatus.MfiReady) + startPhone() + } catch (error: Throwable) { + transport.close() + throw error + } + } catch (error: MfiCoprocessorNotFoundException) { + debugLog("mfi Linux discovery failed: ${error.message}") + waitForMfi() + } catch (error: Throwable) { + fail(error) + } + } + } + + private fun requestCh341Permission(device: UsbDevice) { + try { + when (val request = ch341Host!!.requestPermission(device)) { + is Ch341UsbHost.PermissionRequest.AlreadyGranted -> { + permissionGrant.set(false) + onCh341Permission(Ch341UsbHost.PermissionResult.Granted(request.device)) + } + is Ch341UsbHost.PermissionRequest.Requested -> { + permissionGrant.set(false) + onStatus(CarPlayStatus.RequestingMfiPermission) + pollCh341Permission(device) + } + } + } catch (error: Throwable) { + fail(error) + } + } + + private fun onCh341Permission(result: Ch341UsbHost.PermissionResult) { + if (closed || phase != Phase.MFI) return + when (result) { + is Ch341UsbHost.PermissionResult.Granted -> { + // The system broadcast and CarUsbHandler's direct grant can both observe success. + if (!permissionGrant.compareAndSet(false, true)) return + permissionPollGeneration++ + openCh341(result.device) + } + is Ch341UsbHost.PermissionResult.Denied -> { + permissionGrant.set(true) + onStatus(CarPlayStatus.Failed("CH341 USB permission was denied")) + } + } + } + + /** Some car systems grant USB access through CarUsbHandler without delivering a broadcast. */ + private fun pollCh341Permission(device: UsbDevice) { + val generation = ++permissionPollGeneration + val deadlineNanos = System.nanoTime() + PERMISSION_POLL_TIMEOUT_MILLIS * 1_000_000L + val check = object : Runnable { + override fun run() { + if (closed || phase != Phase.MFI || generation != permissionPollGeneration) return + if (usbManager.hasPermission(device)) { + onCh341Permission(Ch341UsbHost.PermissionResult.Granted(device)) + return + } + if (System.nanoTime() >= deadlineNanos) { + if (permissionGrant.compareAndSet(false, true)) { + onStatus( + CarPlayStatus.Failed( + "MFi USB permission was not granted; reconnect the CH341 to retry", + ), + ) + } + return + } + mainHandler.postDelayed(this, PERMISSION_POLL_INTERVAL_MILLIS) + } + } + mainHandler.postDelayed(check, PERMISSION_POLL_INTERVAL_MILLIS) + } + + private fun openCh341(device: UsbDevice) { + ch341Host!!.openAsync(device, executor) { result -> + when (result) { + is Ch341UsbHost.OpenResult.Connected -> { + val session: Ch341UsbSession = result.session + if (closed || phase != Phase.MFI) { + session.close() + return@openAsync + } + try { + val transport = Ch341I2cTransport(session) + config.ch341MfiResetGpio?.let { gpio -> + transport.pulseActiveLowReset(gpio) + if (!mfiResetLogged) { + mfiResetLogged = true + debugLog("mfi reset pulse gpio=D$gpio mode=low/high-z") + } + } + val client = MfiRuntime.scan(transport) + val probes = mfiCandidateAddresses + .associateWith { address -> probeMfiCandidate(transport, address) } + for ((address, probe) in probes) { + debugLog("mfi probe address=0x${address.toString(16)} ${probe.describe()}") + } + val selected = preferCertificateBearingAddress(client, transport, probes) + debugLog( + "mfi coprocessor address=0x${selected.address7Bit.toString(16)} " + + "protocolMajor=${selected.protocolMajor()}", + ) + mfiSession = MfiSession(selected, session) + debugLog("mfi CH341 session ready") + onStatus(CarPlayStatus.MfiReady) + startPhone() + } catch (error: MfiCoprocessorNotFoundException) { + debugLog("mfi CH341 discovery failed: ${error.message}") + Log.w(IphoneCarPlayConfiguration.TAG, error.message ?: "MFi discovery failed") + session.close() + waitForMfi() + } catch (error: Throwable) { + session.close() + fail(error) + } + } + is Ch341UsbHost.OpenResult.Failed -> when (result.error) { + is I2cTransportException.DeviceUnavailable -> waitForMfi() + else -> fail(result.error) + } + } + } + } + + private val mfiCandidateAddresses = listOf(0x10, 0x11) + private val maxMfiCertificateBytes = 1280 + + private data class MfiCandidateProbe( + val deviceVersion: Int?, + val firmwareVersion: Int?, + val protocolMajor: Int?, + val accessoryCertificateLength: Int?, + val appleCertificateLength: Int?, + val failure: String?, + ) { + val hasAccessoryCertificate: Boolean + get() = (accessoryCertificateLength ?: 0) in 1..1280 + + fun describe(): String { + if (failure != null) return failure + return "deviceVersion=" + hex(deviceVersion) + + " firmwareVersion=" + hex(firmwareVersion) + + " protocolMajor=" + hex(protocolMajor) + + " accessoryCertificateLength=" + accessoryCertificateLength + + " appleCertificateLength=" + appleCertificateLength + } + + private fun hex(value: Int?): String = + if (value == null) "?" else "0x" + value.toString(16).padStart(2, '0') + } + + private fun probeMfiCandidate(transport: I2cTransport, address7Bit: Int): MfiCandidateProbe = try { + MfiCandidateProbe( + deviceVersion = readMfiRegister(transport, address7Bit, 0x00, 1), + firmwareVersion = readMfiRegister(transport, address7Bit, 0x01, 1), + protocolMajor = readMfiRegister(transport, address7Bit, 0x02, 1), + accessoryCertificateLength = readMfiRegister(transport, address7Bit, 0x30, 2), + appleCertificateLength = readMfiRegister(transport, address7Bit, 0x50, 2), + failure = null, + ) + } catch (error: Throwable) { + MfiCandidateProbe( + deviceVersion = null, + firmwareVersion = null, + protocolMajor = null, + accessoryCertificateLength = null, + appleCertificateLength = null, + failure = "failed: " + error.javaClass.simpleName + ": " + error.message, + ) + } + + private fun readMfiRegister( + transport: I2cTransport, + address7Bit: Int, + register: Int, + length: Int, + ): Int { + transport.transaction(address7Bit, byteArrayOf(register.toByte()), 0) + var value = 0 + for (byte in transport.transaction(address7Bit, ByteArray(0), length)) { + value = (value shl 8) or (byte.toInt() and 0xff) + } + return value + } + + private fun preferCertificateBearingAddress( + client: MfiAuthenticationClient, + transport: I2cTransport, + probes: Map, + ): MfiAuthenticationClient { + if (probes[client.address7Bit]?.hasAccessoryCertificate == true) return client + val alternative = probes.entries.firstOrNull { (address, probe) -> + address != client.address7Bit && probe.hasAccessoryCertificate + } ?: return client + debugLog( + "mfi address override: 0x" + client.address7Bit.toString(16) + + " has no accessory certificate; using 0x" + alternative.key.toString(16), + ) + return MfiAuthenticationClient(transport, alternative.key) + } + + private fun waitForMfi() { + if (closed || phase != Phase.MFI) return + onStatus(CarPlayStatus.WaitingForMfi) + scheduleAvailabilityPoll(Phase.MFI) { + when (config.mfiTarget) { + MfiTarget.USB_CH341 -> ch341Host?.let(::checkCh341Mfi) + MfiTarget.I2C -> openLinuxMfi() + MfiTarget.REMOTE, MfiTarget.LOCAL -> Unit + } + } + } + + private fun startPhone() { + if (config.locationReportingEnabled) { + val started = try { + locationProvider?.start() == true + } catch (error: Throwable) { + Log.w( + IphoneCarPlayConfiguration.TAG, + "Could not prewarm the Android location provider", + error, + ) + false + } + debugLog("location provider prewarmed=$started") + } + if (config.transport == CarPlayTransport.WIRELESS) { + startWireless() + } else { + startIphone() + } + } + + private fun startWireless() { + availabilityPollGeneration.incrementAndGet() + phase = Phase.WIRELESS + wirelessHandoffRequested.set(false) + wirelessTunnelReady.set(false) + wirelessActiveReported.set(false) + onStatus(CarPlayStatus.StartingHotspot) + val generation = wirelessGeneration.incrementAndGet() + executor.execute { + runWireless(generation) + } + } + + private fun restartWireless() { + wirelessGeneration.incrementAndGet() + Thread( + { + closeWirelessStack() + if (!closed) startWireless() + }, + "xcertplay-wireless-restart", + ).apply { + isDaemon = true + start() + } + } + + private fun runWireless(generation: Int) { + try { + debugLog("wireless bring-up generation=$generation starting") + closeWirelessStack() + if ( + closed || + phase != Phase.WIRELESS || + generation != wirelessGeneration.get() + ) { + return + } + + val mfi = mfiSession?.client + ?: throw IOException("MFi coprocessor client is unavailable") + val hotspotInfo = startWirelessHotspot(generation) + if (isStaleWirelessRun(generation)) { + closeWirelessStack() + return + } + val startedHotspot = hotspot + wirelessConnectionProof.begin(generation) { + if (!isStaleWirelessRun(generation)) startedHotspot?.onCarPlayConfirmed() + } + val hostAddress = hotspotInfo.hostAddress + ?: throw IOException( + "Wireless hotspot did not provide a usable host address", + ) + if ( + hostAddress is Inet6Address && + (!hostAddress.isLinkLocalAddress || hostAddress.scopeId == 0) + ) { + throw IOException( + "Wireless hotspot link-local IPv6 address is not scoped", + ) + } + val hostAddressText = hostAddressText(hostAddress) + val deviceIdentifier = hotspotInfo.bssid + ?.takeUnless { it.equals(ADAPTER_ADDRESS_PLACEHOLDER, ignoreCase = true) } + ?: airPlayConfig.deviceId + debugLog( + "wireless hotspot backend=${hotspotInfo.backend.label} " + + "iface=${hotspotInfo.interfaceName ?: "unknown"} " + + "host=$hostAddressText " + + "band=${hotspotInfo.bandLabel} channel=${hotspotInfo.channel} " + + "frequency=${hotspotInfo.frequencyMHz?.toString() ?: "unknown"}MHz", + ) + onStatus( + CarPlayStatus.HotspotReady( + ssid = hotspotInfo.ssid, + band = hotspotInfo.bandLabel, + channel = hotspotInfo.channel, + bssid = deviceIdentifier, + address = hostAddressText, + backend = hotspotInfo.backend.label, + ), + ) + onStatus(CarPlayStatus.WaitingForPairedIphone) + + val adapter = bluetoothAdapter + ?: throw IOException("Bluetooth adapter is unavailable") + if (!adapter.isEnabled) throw IOException("Bluetooth is not enabled") + val device = selectWirelessBluetoothDevice(adapter) + val hostBluetoothMac = accessoryBluetoothMac(adapter) + debugLog( + "wireless selected Bluetooth target name=${device.name ?: "unknown"} " + + "address=${device.address} localBt=$hostBluetoothMac", + ) + val wirelessAirPlayConfig = airPlayConfig.copy( + deviceId = deviceIdentifier, + btMac = hostBluetoothMac, + ) + + onStatus(CarPlayStatus.AttachingNetwork) + val service = awaitVpnService() + ?: throw IOException("Could not bind the CarPlay AirPlay service") + when ( + val result = service.attachWireless( + bindAddress = hostAddress, + config = wirelessAirPlayConfig, + identity = identity, + pairings = pairings, + mfi = mfi, + listener = wirelessSessionListener(generation), + media = media, + ) + ) { + CarPlayVpnService.AttachResult.Started -> Unit + CarPlayVpnService.AttachResult.AlreadyStarted -> + throw IOException("Wireless AirPlay transport is already attached") + is CarPlayVpnService.AttachResult.Failed -> + throw IOException(result.message) + } + debugLog( + "wireless AirPlay listener attached bind=$hostAddressText " + + "port=${airPlayConfig.port}", + ) + if (isStaleWirelessRun(generation)) { + closeWirelessStack() + return + } + + val bonjourClient = CarPlayBonjour( + context = appContext, + config = wirelessAirPlayConfig, + identity = identity, + advertisedHost = hostAddress.hostAddress, + useInterfaceMdns = hotspotInfo.backend == WirelessHotspotBackend.WIFI_P2P, + onEvent = { event -> debugLog("wireless bonjour: $event") }, + ) + bonjour = bonjourClient + bonjourClient.start() + debugLog("wireless Bonjour services started") + if (isStaleWirelessRun(generation)) { + closeWirelessStack() + return + } + + onStatus(CarPlayStatus.ConnectingBluetooth) + debugLog( + "wireless RFCOMM connecting address=${device.address} " + + "uuid=$IAP2_IPHONE_UUID", + ) + val socket = device + .createRfcommSocketToServiceRecord(UUID.fromString(IAP2_IPHONE_UUID)) + .also { bluetoothSocket = it } + connectBluetoothSocket(socket, device.address) + debugLog("wireless RFCOMM connected address=${device.address}") + if (isStaleWirelessRun(generation)) { + closeWirelessStack() + return + } + val stream = BluetoothRfcommDuplexStream(socket).also { bluetoothStream = it } + val channel = Iap2Session.openWireless( + stream, + traceContext = "wireless-rfcomm", + onTrace = ::debugLog, + ).also { csm = it } + debugLog("wireless iAP2 CSM channel opened over RFCOMM") + if (isStaleWirelessRun(generation)) { + closeWirelessStack() + return + } + val identification = config.identification.copy( + wireless = Iap2WirelessIdentification(hostBluetoothMac, hotspotInfo.ssid), + ) + val endpoint = Iap2WirelessCarPlayEndpoint( + ssid = hotspotInfo.ssid, + passphrase = hotspotInfo.passphrase, + channel = hotspotInfo.channel, + security = hotspotInfo.security, + ipAddresses = listOf(hostAddressText), + airPlayPort = airPlayConfig.port, + deviceIdentifier = deviceIdentifier, + publicKey = identity.publicKeyHex, + sourceVersion = airPlayConfig.sourceVersion, + ) + wirelessIdentification = identification + wirelessAirPlayEndpoint = endpoint + media.setIapTunnelHandler(::startWirelessTunnelControl) + + onStatus(CarPlayStatus.RunningWireless) + debugLog("wireless Bluetooth iAP2 control starting") + val result = Iap2WirelessControlClient( + session = channel, + mfi = Iap2MfiAuthenticationClient(mfi), + ).run( + identification = identification, + endpoint = endpoint, + timeoutMillis = controlLoopTimeoutMillis(), + locationProvider = locationProvider, + onProgress = ::debugLog, + ) + if (isStaleWirelessRun(generation)) { + closeWirelessStack() + return + } + when (result.terminal) { + Iap2WirelessControlTerminal.CHANNEL_CLOSED -> { + debugLog( + "wireless RFCOMM EOF: iap2State=${result.stage} " + + "wirelessCarPlayAvailable=${result.wirelessCarPlayAvailableSeen} " + + "transportIdentifier=${result.transportNotificationSeen} " + + "carPlayStartSessions=${result.carPlayStartSessionsSent} " + + "postTransportConfigs=${result.postTransportWiFiConfigurationsSent} " + + "handoffRequested=${wirelessHandoffRequested.get()} " + + "tunnelReady=${wirelessTunnelReady.get()} " + + "wirelessActive=${wirelessActiveReported.get()}", + ) + if (!wirelessActiveReported.get()) { + val handoffInProgress = isWirelessHandoffInProgress( + handoffRequested = wirelessHandoffRequested.get(), + tunnelActive = wirelessTunnelChannel != null, + sessionActive = activeSession != null, + ) + if (!handoffInProgress) { + throw IOException( + "Wireless CarPlay control channel closed before tunnel iAP2 ready", + ) + } + debugLog( + "wireless Bluetooth bootstrap closed during handoff; " + + "keeping the Wi-Fi AirPlay tunnel alive", + ) + } + } + Iap2WirelessControlTerminal.TIMED_OUT -> + if (!wirelessActiveReported.get()) { + onStatus(CarPlayStatus.ControlEnded) + } + } + } catch (error: Throwable) { + if (closed || generation != wirelessGeneration.get()) { + return + } + if (wirelessActiveReported.get() && error !is Error) { + debugLog("wireless RFCOMM control ended after tunnel handoff: ${error.message}") + } else { + debugLog("wireless bring-up failed", error) + closeWirelessStack() + if (error is Error) throw error + fail(error) + } + } + } + + private fun startWirelessTunnelControl(stream: BlockingDuplexByteStream): Boolean { + if (closed || config.transport != CarPlayTransport.WIRELESS) return false + val identification = wirelessIdentification ?: return false + val endpoint = wirelessAirPlayEndpoint ?: return false + val mfi = mfiSession?.client ?: return false + debugLog("wireless type-130 tunnel data stream accepted") + val channel = try { + Iap2Session.openTunnel( + stream, + traceContext = "wireless-tunnel", + onTrace = ::debugLog, + ) + } catch (error: Throwable) { + debugLog("Could not open the tunneled iAP2 link", error) + return false + } + wirelessTunnelChannel = channel + val generation = wirelessGeneration.get() + debugLog("wireless iAP2 tunnel control starting") + return try { + tunnelExecutor.execute { + try { + val result = Iap2WirelessControlClient( + session = channel, + mfi = Iap2MfiAuthenticationClient(mfi), + ).run( + identification = identification, + endpoint = endpoint, + timeoutMillis = Iap2WirelessControlClient.NO_TIMEOUT_MILLIS, + locationProvider = locationProvider, + onReady = { + onWirelessTunnelReady(generation) + }, + onProgress = { message -> debugLog("iAP tunnel $message") }, + ) + if (closed || generation != wirelessGeneration.get()) return@execute + when (result.terminal) { + Iap2WirelessControlTerminal.TIMED_OUT -> + onStatus(CarPlayStatus.ControlEnded) + Iap2WirelessControlTerminal.CHANNEL_CLOSED -> + onStatus(CarPlayStatus.Failed("Wireless iAP2 tunnel closed")) + } + } catch (error: Throwable) { + if (!closed && generation == wirelessGeneration.get()) { + debugLog("tunneled iAP2 control failed", error) + onStatus( + CarPlayStatus.Failed( + error.message ?: error.javaClass.simpleName, + ), + ) + } + } finally { + if (wirelessTunnelChannel === channel) wirelessTunnelChannel = null + } + } + true + } catch (error: Throwable) { + if (wirelessTunnelChannel === channel) wirelessTunnelChannel = null + closeBestEffort("tunneled iAP2 link") { channel.close() } + debugLog("iAP2 tunnel executor rejected the link", error) + false + } + } + + private fun wirelessSessionListener(generation: Int): AirPlaySessionListener = + object : AirPlaySessionListener by sessionListener { + override fun onSessionActive(session: AirPlaySession) { + if (isStaleWirelessRun(generation)) return + wirelessConnectionProof.activate(generation, session) + sessionListener.onSessionActive(session) + } + + override fun onSessionEnded(session: AirPlaySession) { + if (isStaleWirelessRun(generation)) return + wirelessConnectionProof.end(generation, session) + sessionListener.onSessionEnded(session) + } + + override fun onVideoFrameRendered(session: AirPlaySession) { + if (isStaleWirelessRun(generation) || activeSession !== session) return + wirelessConnectionProof.rendered(generation, session) + } + } + + private fun onWirelessTunnelReady(generation: Int) { + if ( + closed || + phase != Phase.WIRELESS || + generation != wirelessGeneration.get() + ) { + return + } + wirelessTunnelReady.set(true) + wirelessConnectionProof.authenticated(generation) + debugLog( + "wireless iAP2 tunnel ready; " + + "handoffRequested=${wirelessHandoffRequested.get()}", + ) + maybeCompleteWirelessHandoff() + } + + private fun maybeCompleteWirelessHandoff() { + if (!wirelessHandoffRequested.get() || !wirelessTunnelReady.get()) return + if (!wirelessActiveReported.compareAndSet(false, true)) return + val generation = wirelessGeneration.get() + Thread( + { + if ( + closed || + phase != Phase.WIRELESS || + generation != wirelessGeneration.get() + ) { + return@Thread + } + debugLog("wireless handoff ready; closing Bluetooth bootstrap transport") + closeBluetoothBootstrapTransport() + onStatus(CarPlayStatus.WirelessActive) + }, + "xcertplay-wireless-handoff", + ).apply { + isDaemon = true + start() + } + } + + private fun armWirelessHandoffWatchdog(generation: Int) { + mainHandler.postDelayed( + { + if ( + closed || + phase != Phase.WIRELESS || + generation != wirelessGeneration.get() || + !wirelessHandoffRequested.get() || + wirelessActiveReported.get() + ) { + return@postDelayed + } + debugLog("wireless handoff timed out waiting for tunnel iAP2 readiness") + Thread( + { + if ( + closed || + phase != Phase.WIRELESS || + generation != wirelessGeneration.get() || + wirelessActiveReported.get() + ) { + return@Thread + } + closeWirelessStack() + fail(IOException("Wireless CarPlay handoff timed out waiting for tunnel iAP2")) + }, + "xcertplay-wireless-handoff-timeout", + ).apply { + isDaemon = true + start() + } + }, + WIRELESS_HANDOFF_TIMEOUT_MILLIS, + ) + } + + private fun closeBluetoothBootstrapTransport() { + val activeCsm = csm + csm = null + if (activeCsm != null) closeBestEffort("wireless CSM") { activeCsm.close() } + + val activeStream = bluetoothStream + bluetoothStream = null + if (activeStream != null) closeBestEffort("wireless RFCOMM stream") { activeStream.close() } + + val activeSocket = bluetoothSocket + bluetoothSocket = null + if (activeSocket != null) closeBestEffort("wireless Bluetooth socket") { activeSocket.close() } + } + + private fun startIphone() { + availabilityPollGeneration.incrementAndGet() + phase = Phase.IPHONE + reenumerationAttempts = 0 + onStatus(CarPlayStatus.DiscoveringIphone) + checkIphoneAvailability() + } + + private fun checkIphoneAvailability() { + if (closed || phase != Phase.IPHONE) return + val device = iphoneHost.discover().firstOrNull() + if (device == null) { + onStatus(CarPlayStatus.WaitingForIphone) + scheduleAvailabilityPoll(Phase.IPHONE, ::checkIphoneAvailability) + } else { + debugLog( + "wired iPhone discovered vid=0x${device.vendorId.toString(16)} " + + "pid=0x${device.productId.toString(16)}", + ) + availabilityPollGeneration.incrementAndGet() + requestIphonePermission(device) + } + } + + private fun requestIphonePermission(device: UsbDevice) { + mainHandler.post { doRequestIphonePermission(device) } + } + + private fun doRequestIphonePermission(device: UsbDevice) { + if (closed) return + try { + when (val request = iphoneHost.requestPermission(device)) { + is IphoneUsbHost.PermissionRequest.AlreadyGranted -> { + debugLog("wired iPhone USB permission already granted") + permissionGrant.set(false) + onIphonePermission(IphoneUsbHost.PermissionResult.Granted(request.device)) + } + is IphoneUsbHost.PermissionRequest.Requested -> { + debugLog("wired iPhone USB permission requested") + permissionGrant.set(false) + onStatus(CarPlayStatus.RequestingIphonePermission) + pollIphonePermission(device) + } + } + } catch (error: Throwable) { + fail(error) + } + } + + private fun onIphonePermission(result: IphoneUsbHost.PermissionResult) { + when (result) { + is IphoneUsbHost.PermissionResult.Granted -> { + // The system broadcast and the polling fallback can both observe the grant. + if (!permissionGrant.compareAndSet(false, true)) return + debugLog("wired iPhone USB permission granted") + permissionPollGeneration++ + when (phase) { + Phase.REENUMERATION, Phase.IPHONE -> { + if (IphoneCarPlayConfiguration.find(result.device) != null) { + openDataPaths(result.device) + } else if (reenumerationAttempts < MAXIMUM_REENUMERATION_ATTEMPTS) { + beginReenumeration(result.device) + } else { + fail( + IphoneUsbException.Protocol( + "iPhone did not expose a complete CarPlay USB configuration", + ), + ) + } + } + else -> Unit + } + } + is IphoneUsbHost.PermissionResult.Denied -> { + permissionGrant.set(true) + onStatus(CarPlayStatus.Failed("iPhone USB permission was denied")) + } + } + } + + /** Some Android builds grant the dialog without delivering the permission broadcast. */ + private fun pollIphonePermission(device: UsbDevice) { + val generation = ++permissionPollGeneration + val deadlineNanos = System.nanoTime() + PERMISSION_POLL_TIMEOUT_MILLIS * 1_000_000L + val check = object : Runnable { + override fun run() { + if (closed || generation != permissionPollGeneration) return + if (usbManager.hasPermission(device)) { + onIphonePermission(IphoneUsbHost.PermissionResult.Granted(device)) + return + } + if (System.nanoTime() >= deadlineNanos) { + if (permissionGrant.compareAndSet(false, true)) { + onStatus( + CarPlayStatus.Failed( + "iPhone USB permission was not granted; tap Reconnect iPhone to retry", + ), + ) + } + return + } + mainHandler.postDelayed(this, PERMISSION_POLL_INTERVAL_MILLIS) + } + } + mainHandler.postDelayed(check, PERMISSION_POLL_INTERVAL_MILLIS) + } + + private fun beginReenumeration(device: UsbDevice) { + phase = Phase.REENUMERATION + reenumerationAttempts += 1 + onStatus(CarPlayStatus.SelectingConfiguration) + iphoneHost.requestCarPlayReenumerationAsync(device, executor) { transition -> + when (transition) { + IphoneUsbHost.TransitionResult.ReenumerationRequested -> + onStatus(CarPlayStatus.WaitingForReenumeration) + is IphoneUsbHost.TransitionResult.Failed -> fail(transition.error) + } + } + } + + private fun onIphoneAttached(device: UsbDevice) { + when (phase) { + Phase.REENUMERATION, Phase.IPHONE -> { + availabilityPollGeneration.incrementAndGet() + requestIphonePermission(device) + } + else -> Unit + } + } + + private fun scheduleAvailabilityPoll(phase: Phase, check: () -> Unit) { + val generation = availabilityPollGeneration.get() + mainHandler.postDelayed( + { + if ( + !closed && + this.phase == phase && + generation == availabilityPollGeneration.get() + ) { + check() + } + }, + DEVICE_AVAILABILITY_POLL_INTERVAL_MILLIS, + ) + } + + private fun openDataPaths(device: UsbDevice) { + phase = Phase.DATAPATHS + debugLog("wired opening iPhone USB data paths") + onStatus(CarPlayStatus.SelectingConfiguration) + onStatus(CarPlayStatus.OpeningDataPaths) + iphoneHost.openIap2UsbSessionAsync(device, executor) { result -> + when (result) { + is IphoneUsbHost.Iap2SessionResult.Connected -> { + try { + val ncm = openNcm(device) + runStack(result.session, ncm) + } catch (error: Throwable) { + result.session.close() + fail(error) + } + } + is IphoneUsbHost.Iap2SessionResult.Failed -> fail(result.error) + } + } + } + + private fun openNcm(device: UsbDevice): NcmUsbBridge { + val configuration = IphoneCarPlayConfiguration.find(device) + ?: throw IphoneUsbException.Protocol( + "iPhone exposes no CarPlay configuration for NCM", + ) + val function = NcmFunctionDiscovery.find(configuration) + ?: throw IphoneUsbException.Protocol("iPhone configuration does not expose an NCM function") + debugLog( + "ncm config=${configuration.id} control=${function.control.id}/${function.control.alternateSetting}" + + " data=${function.data.id}/${function.data.alternateSetting}" + + " status=${function.statusIn?.address?.let { "0x${it.toString(16)}" } ?: "none"}" + + " in=0x${function.bulkIn.address.toString(16)} out=0x${function.bulkOut.address.toString(16)}", + ) + val connection = usbManager.openDevice(device) + ?: throw IphoneUsbException.DeviceUnavailable("Could not open the iPhone NCM connection") + return NcmUsbBridge.open(connection, function) + } + + private fun runStack(usbSession: Iap2UsbSession, ncm: NcmUsbBridge) { + phase = Phase.CONTROL + var ncmOwnedLocally = true + try { + if (closed) return + val mux = Iap2UsbMuxHost.open(usbSession) + this.mux = mux + debugLog("wired USBMUX host opened") + onStatus(CarPlayStatus.Pairing) + val pairingClient = LockdownPairingClient(mux) + val savedPairRecord = loadPairRecord() + var pairRecord = savedPairRecord ?: pairNewRecord(pairingClient) + debugLog( + if (savedPairRecord != null) { + "wired using saved Lockdown pair record" + } else { + "wired created a new Lockdown pair record" + }, + ) + onStatus(CarPlayStatus.ConnectingControl) + val carKitClient = LockdownCarKitClient(mux) + // Temporary lab capture, limited to accessory/authentication messages and two minutes. + try { + val relay = carKitClient.openService(pairRecord, config.label, "com.apple.syslog_relay") + Thread({ + try { + relay.use { + val deadline = System.nanoTime() + 120_000_000_000L + val pending = StringBuilder() + val relevant = Regex(" (accessoryd|ACCCarPlayService|iap2d|CarPlay)([\\[(])", RegexOption.IGNORE_CASE) + while (!closed && System.nanoTime() < deadline) { + val bytes = relay.recv(8192, 1000) ?: continue + if (bytes.isEmpty()) break + pending.append(bytes.toString(Charsets.UTF_8).replace('\u0000', '\n')) + while (true) { + val end = pending.indexOf("\n") + if (end < 0) break + val line = pending.substring(0, end) + pending.delete(0, end + 1) + if (relevant.containsMatchIn(line)) debugLog("PHONE ${line.take(2000)}") + } + if (pending.length > 65536) pending.clear() + } + } + debugLog("phone authentication diagnostic capture ended") + } catch (error: Exception) { + debugLog("phone authentication diagnostic capture ended: ${error.javaClass.simpleName}") + } + }, "carplay-lab-phone-diagnostics").apply { isDaemon = true; start() } + debugLog("phone authentication diagnostic capture started") + } catch (error: Exception) { + debugLog("phone authentication diagnostics unavailable: ${error.message}") + } + val carkit = try { + carKitClient.open(pairRecord, config.label) + } catch (error: Throwable) { + if (!isInvalidPairRecord(error)) throw error + debugLog("saved Lockdown pair record rejected; pairing again") + clearPairRecord() + pairRecord = pairNewRecord(pairingClient) + carKitClient.open(pairRecord, config.label) + } + debugLog("wired com.apple.carkit.service stream opened") + // Lab transport diagnostics: packet headers only, never certificate or challenge data. + fun wireSummary(bytes: ByteArray): String { + if (bytes.size < 9 || bytes[0].toInt() and 0xff != 0xff || + bytes[1].toInt() and 0xff != 0x5a) return "bytes=${bytes.size}" + fun value(index: Int) = bytes[index].toInt() and 0xff + return "bytes=${bytes.size} length=${(value(2) shl 8) or value(3)} " + + "flags=${value(4)} seq=${value(5)} ack=${value(6)} session=${value(7)}" + } + val tracedCarkit = object : com.shilapi.xcertplay.transport.BlockingDuplexByteStream { + override fun send(data: ByteArray) { + debugLog("wired link TX begin ${wireSummary(data)}") + // Bound each TLS write while diagnosing the stalled certificate transfer. + for (offset in data.indices step 256) { + carkit.send(data.copyOfRange(offset, minOf(offset + 256, data.size))) + } + debugLog("wired link TX completed bytes=${data.size}") + } + override fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? = + carkit.recv(maxBytes, timeoutMillis)?.also { + debugLog("wired link RX ${wireSummary(it)}") + } + override fun close() = carkit.close() + } + val csm = Iap2Session.open( + tracedCarkit, + traceContext = "wired", + onTrace = ::debugLog, + ) + this.csm = csm + debugLog("wired iAP2 CSM channel opened") + + val ncmHostMac = ncm.hostMac ?: config.hostMac + debugLog("ncm using hostMac=${ncmHostMac.macString()}") + if (!attachVpn(ncm, ncmHostMac)) { + throw IphoneUsbException.DeviceUnavailable("Could not attach the NCM/VPN AirPlay transport") + } + ncmOwnedLocally = false + debugLog("wired NCM/VPN AirPlay transport attached") + if (closed) { + vpnService?.detach() + return + } + + val mfi = mfiSession?.client + ?: throw IphoneUsbException.DeviceUnavailable("MFi coprocessor client is unavailable") + val endpoint = Iap2WiredCarPlayEndpoint( + ipv6Addresses = listOf(config.linkLocal), + airPlayPort = airPlayConfig.port, + publicKey = identity.publicKeyHex, + sourceVersion = airPlayConfig.sourceVersion, + deviceIdentifier = ncmHostMac.macString(), + ) + onStatus(CarPlayStatus.RunningControl) + debugLog("wired iAP2 control starting") + val result = Iap2WiredControlClient(csm, Iap2MfiAuthenticationClient(mfi)).run( + identification = config.identification, + endpoint = endpoint, + availableCurrentMilliAmps = config.availableCurrentMilliAmps, + timeoutMillis = controlLoopTimeoutMillis(), + locationProvider = locationProvider, + onIncoming = { }, + onProgress = { message -> debugLog("wired $message") }, + ) + onStatus( + when (result.terminal) { + Iap2WiredControlTerminal.TIMED_OUT -> CarPlayStatus.ControlEnded + Iap2WiredControlTerminal.CHANNEL_CLOSED -> + CarPlayStatus.Failed("CarPlay control channel closed") + }, + ) + } catch (error: Throwable) { + debugLog("wired bring-up failed", error) + if (!ncmOwnedLocally) vpnService?.detach() + fail(error) + } finally { + if (ncmOwnedLocally) ncm.close() + } + } + + private fun pairNewRecord(client: LockdownPairingClient): LockdownPairRecord = + client.pair( + label = config.label, + hostId = hostId, + systemBuid = systemBuid, + totalTimeoutMillis = PAIR_TIMEOUT_MILLIS, + isCancelled = { closed }, + ).pairRecord.also(savePairRecord) + + private fun isInvalidPairRecord(error: Throwable): Boolean { + var cause: Throwable? = error + while (cause != null) { + if (cause.message?.contains("InvalidPairRecord", ignoreCase = true) == true) return true + cause = cause.cause + } + return false + } + + private fun isBluetoothHandoffCommand(type: String): Boolean = + type.equals("disableBluetooth", ignoreCase = true) || + type.equals("disable-bluetooth", ignoreCase = true) + + private fun startWirelessHotspot(generation: Int): WirelessHotspotInfo { + val hotspotMode = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + config.wirelessHotspotMode == WirelessHotspotMode.WIFI_P2P + ) { + WirelessHotspotMode.LOCAL_ONLY_HOTSPOT + } else { + config.wirelessHotspotMode + } + val manager: WirelessHotspotManager = when (hotspotMode) { + WirelessHotspotMode.WIFI_P2P -> WifiP2pGroupManager(appContext, ::debugLog) + WirelessHotspotMode.LOCAL_ONLY_HOTSPOT -> LocalOnlyHotspotManager(appContext) + WirelessHotspotMode.MANUAL -> ManualHotspotManager( + context = appContext, + ssid = config.manualHotspotSsid + ?: throw IOException("Manual hotspot SSID is not configured"), + passphrase = config.manualHotspotPassphrase.orEmpty(), + band = config.manualHotspotBand, + channel = config.manualHotspotChannel, + security = config.manualHotspotSecurity, + ) + } + hotspot = manager + val timeoutMillis = if (hotspotMode == WirelessHotspotMode.WIFI_P2P) { + WIFI_P2P_START_TIMEOUT_MILLIS + } else { + HOTSPOT_START_TIMEOUT_MILLIS + } + return try { + manager.start(timeoutMillis) + } catch (failure: Exception) { + if (hotspot === manager) hotspot = null + closeBestEffort(hotspotMode.name) { manager.close() } + if (isStaleWirelessRun(generation)) throw failure + throw IOException( + "Could not establish ${hotspotMode.name} hotspot: " + + (failure.message ?: failure.javaClass.simpleName), + failure, + ) + } + } + + private fun isStaleWirelessRun(generation: Int): Boolean = + closed || phase != Phase.WIRELESS || generation != wirelessGeneration.get() + + private fun selectWirelessBluetoothDevice(adapter: BluetoothAdapter): BluetoothDevice { + val bonded = adapter.bondedDevices.orEmpty() + config.wirelessBluetoothDeviceAddress?.let { selected -> + return bonded.firstOrNull { it.address.equals(selected, ignoreCase = true) } + ?: throw IOException("The selected iPhone is no longer paired. Choose it again in DiPlay.") + } + val iPhones = bonded.filter { device -> + device.name?.contains("iPhone", ignoreCase = true) == true + } + val directlyConnectedIPhones = iPhones.filter(::isBluetoothDeviceConnected) + Log.i( + IphoneCarPlayConfiguration.TAG, + "wireless Bluetooth bondedIPhones=${iPhones.size} " + + "directlyConnected=${directlyConnectedIPhones.size}", + ) + val connectedIPhones = if (directlyConnectedIPhones.isNotEmpty()) { + directlyConnectedIPhones + } else { + val connectedAddresses = connectedBluetoothDevices(adapter).mapTo(mutableSetOf()) { + it.address + } + iPhones.filter { it.address in connectedAddresses } + } + if (connectedIPhones.size == 1) return connectedIPhones.single() + if (connectedIPhones.size > 1) { + throw IOException( + "Multiple connected iPhones found: " + + connectedIPhones.joinToString { "${it.name ?: "iPhone"} (${it.address})" }, + ) + } + if (iPhones.size == 1) return iPhones.single() + if (iPhones.size > 1) { + throw IOException( + "Multiple bonded iPhones found and none is currently connected; " + + "connect one iPhone and retry", + ) + } + if (bonded.size == 1) return bonded.single() + throw IOException( + "No unambiguous bonded iPhone found; pair one iPhone and retry", + ) + } + + private fun connectBluetoothSocket(socket: BluetoothSocket, address: String) { + val result = AtomicReference() + val connected = CountDownLatch(1) + Thread( + { + try { + socket.connect() + } catch (error: Throwable) { + result.set(error) + } finally { + connected.countDown() + } + }, + "wireless-rfcomm-connect", + ).apply { + isDaemon = true + start() + } + val completed = try { + connected.await(RFCOMM_CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + } catch (error: InterruptedException) { + Thread.currentThread().interrupt() + runCatching { socket.close() } + throw IOException("Interrupted while connecting RFCOMM to $address", error) + } + if (!completed) { + debugLog( + "wireless RFCOMM connect timed out after " + + "${RFCOMM_CONNECT_TIMEOUT_MILLIS}ms address=$address", + ) + runCatching { socket.close() } + throw IOException( + "Timed out after ${RFCOMM_CONNECT_TIMEOUT_MILLIS}ms connecting RFCOMM to $address", + ) + } + when (val failure = result.get()) { + null -> Unit + is IOException -> throw failure + else -> throw IOException("Could not connect RFCOMM to $address", failure) + } + } + + private fun closeWirelessStack(service: CarPlayVpnService? = vpnService) { + wirelessConnectionProof.clear() + media.setIapTunnelHandler(null) + val activeTunnel = wirelessTunnelChannel + wirelessTunnelChannel = null + if (activeTunnel != null) closeBestEffort("tunneled iAP2 link") { activeTunnel.close() } + + closeBluetoothBootstrapTransport() + + val activeBonjour = bonjour + bonjour = null + if (activeBonjour != null) closeBestEffort("Bonjour") { activeBonjour.close() } + + val activeHotspot = hotspot + hotspot = null + if (activeHotspot != null) closeBestEffort("wireless hotspot") { activeHotspot.close() } + wirelessIdentification = null + wirelessAirPlayEndpoint = null + wirelessHandoffRequested.set(false) + wirelessTunnelReady.set(false) + wirelessActiveReported.set(false) + + if (service != null) closeBestEffort("AirPlay service") { service.detach() } + } + + private fun isBluetoothDeviceConnected(device: BluetoothDevice): Boolean = try { + val method = BluetoothDevice::class.java.getMethod("isConnected") + method.invoke(device) as? Boolean == true + } catch (error: ReflectiveOperationException) { + false + } catch (error: RuntimeException) { + Log.w(IphoneCarPlayConfiguration.TAG, "Could not read Bluetooth connection state", error) + false + } + + private fun connectedBluetoothDevices(adapter: BluetoothAdapter): Set = + buildSet { + addAll(connectedBluetoothDevices(adapter, BluetoothProfile.HEADSET, BluetoothHeadset::class.java)) + addAll(connectedBluetoothDevices(adapter, BluetoothProfile.A2DP, BluetoothA2dp::class.java)) + } + + private fun connectedBluetoothDevices( + adapter: BluetoothAdapter, + profile: Int, + profileClass: Class, + ): Set { + val latch = CountDownLatch(1) + val devices = java.util.Collections.synchronizedSet(mutableSetOf()) + val listener = object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profileId: Int, proxy: BluetoothProfile) { + try { + if (profileClass.isInstance(proxy)) { + devices.addAll(proxy.connectedDevices.orEmpty()) + } + } catch (error: SecurityException) { + Log.w(IphoneCarPlayConfiguration.TAG, "Could not read connected Bluetooth devices", error) + } finally { + adapter.closeProfileProxy(profileId, proxy) + latch.countDown() + } + } + + override fun onServiceDisconnected(profileId: Int) { + latch.countDown() + } + } + if (!adapter.getProfileProxy(appContext, listener, profile)) return emptySet() + if (!latch.await(3, TimeUnit.SECONDS)) { + Log.w(IphoneCarPlayConfiguration.TAG, "Timed out reading Bluetooth profile $profile") + } + return synchronized(devices) { devices.toSet() } + } + + @Suppress("DEPRECATION") + private fun accessoryBluetoothMac(adapter: BluetoothAdapter): String { + val address = try { + adapter.address + } catch (_: SecurityException) { + null + } + val settingsAddress = try { + Settings.Secure.getString(appContext.contentResolver, "bluetooth_address") + } catch (_: SecurityException) { + null + } + return listOfNotNull(address, settingsAddress) + .firstOrNull { + BLUETOOTH_ADDRESS.matches(it) && + !it.equals(ADAPTER_ADDRESS_PLACEHOLDER, ignoreCase = true) + } + ?: airPlayConfig.btMac + } + + private fun hostAddressText(address: InetAddress): String { + val text = address.hostAddress?.substringBefore('%') + if (text.isNullOrBlank()) { + throw IOException("LocalOnlyHotspot host address is unavailable") + } + return text + } + + private fun closeBestEffort(name: String, close: () -> Unit) { + try { + close() + } catch (error: Throwable) { + debugLog("$name teardown failed", error) + } + } + + private fun controlLoopTimeoutMillis(): Long = when { + config.transport == CarPlayTransport.WIRED -> Iap2WiredControlClient.NO_TIMEOUT_MILLIS + config.locationReportingEnabled -> LOCATION_CONTROL_LOOP_TIMEOUT_MILLIS + else -> CONTROL_LOOP_TIMEOUT_MILLIS + } + + private fun attachVpn(ncm: NcmUsbBridge, hostMac: ByteArray): Boolean { + onStatus(CarPlayStatus.AttachingNetwork) + val service = awaitVpnService() ?: run { + debugLog("wired VPN service bind failed") + ncm.close() + return false + } + debugLog("wired VPN service bound; attaching NCM transport") + val result = try { + service.attach( + ncm = ncm, + linkLocal = config.linkLocal, + hostMac = hostMac, + config = airPlayConfig, + identity = identity, + pairings = pairings, + mfi = mfiSession?.client, + listener = sessionListener, + media = media, + ) + } catch (error: Throwable) { + ncm.close() + onStatus(CarPlayStatus.Failed(error.message ?: error.javaClass.simpleName)) + return false + } + return when (result) { + CarPlayVpnService.AttachResult.Started -> { + debugLog("wired VPN/NCM transport attach result=started") + true + } + CarPlayVpnService.AttachResult.AlreadyStarted -> { + debugLog("wired VPN/NCM transport attach result=already-started") + ncm.close() + false + } + is CarPlayVpnService.AttachResult.Failed -> { + debugLog("wired VPN/NCM transport attach result=failed ${result.message}") + ncm.close() + onStatus(CarPlayStatus.Failed(result.message)) + false + } + } + } + + private fun ByteArray.macString(): String = + joinToString(":") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private fun awaitVpnService(): CarPlayVpnService? { + vpnService?.let { return it } + bindVpn() + return try { + if (vpnLatch.await(VPN_CONNECT_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) vpnService else null + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + null + } + } + + private fun bindVpn() { + if (vpnBound) return + vpnBound = true + try { + val intent = Intent(appContext, CarPlayVpnService::class.java) + if (!appContext.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)) { + vpnBound = false + vpnLatch.countDown() + } + } catch (_: Throwable) { + vpnBound = false + vpnLatch.countDown() + } + } + + private fun unbindVpn() { + if (!vpnBound) return + vpnBound = false + try { + appContext.unbindService(serviceConnection) + } catch (_: Exception) { + // The service may have already been unbound. + } + vpnService = null + } + + private fun closeReceivers() { + listOfNotNull(permissionCloseable, attachCloseable, ch341PermissionCloseable).forEach { + try { + it.close() + } catch (_: Exception) { + // Receiver is already unregistered. + } + } + permissionCloseable = null + attachCloseable = null + ch341PermissionCloseable = null + } + + private fun closeMfiSession() { + val session = mfiSession + mfiSession = null + if (session != null) { + executor.execute { + try { + session.close() + } catch (_: Exception) { + // Best effort. + } + } + } + } + + private fun fail(error: Throwable) { + if (closed) return + onStatus(CarPlayStatus.Failed(error.message ?: error.javaClass.simpleName, + generateSequence(error) { it.cause }.any { it is com.shilapi.xcertplay.network.P2pResetRequiredException })) + } + + private fun debugLog(message: String) { + Log.i(IphoneCarPlayConfiguration.TAG, message) + try { + uiListener?.onDebugLog(message) + } catch (error: Exception) { + Log.w(IphoneCarPlayConfiguration.TAG, "debug log callback failed", error) + } + } + + private fun debugLog(message: String, error: Throwable) { + Log.w(IphoneCarPlayConfiguration.TAG, message, error) + try { + uiListener?.onDebugLog( + "$message: ${error.message ?: error.javaClass.simpleName}", + ) + } catch (callbackError: Exception) { + Log.w(IphoneCarPlayConfiguration.TAG, "debug log callback failed", callbackError) + } + } + + private fun onStatus(status: CarPlayStatus) { + if (closed) return + mainHandler.post { + if (!closed && status != lastReportedStatus) { + lastReportedStatus = status + uiListener?.onDebugLog(status.debugLogMessage()) + uiStatusReporter?.invoke(status) + } + } + } + + private fun CarPlayStatus.debugLogMessage(): String = when (this) { + CarPlayStatus.DiscoveringMfi -> + "STEP mfi/start: preparing the configured MFi authentication provider" + CarPlayStatus.WaitingForMfi -> + "STEP mfi/wait: MFi coprocessor not present; polling" + CarPlayStatus.RequestingMfiPermission -> + "STEP mfi/permission: requesting CH341 USB access" + CarPlayStatus.MfiReady -> + "STEP mfi/ready: MFi authentication provider is ready" + CarPlayStatus.StartingHotspot -> + "STEP wifi/ap: starting the wireless CarPlay access point" + is CarPlayStatus.HotspotReady -> + "STEP wifi/ap-ready: backend=$backend ssid=$ssid band=$band " + + "channel=$channel bssid=$bssid address=$address" + CarPlayStatus.WaitingForPairedIphone -> + "STEP bt/select: waiting for a paired or connected iPhone" + CarPlayStatus.ConnectingBluetooth -> + "STEP bt/rfcomm: connecting to the iPhone iAP2 RFCOMM service" + CarPlayStatus.RunningWireless -> + "STEP iap2/wireless: Bluetooth control loop running" + CarPlayStatus.WirelessActive -> + "STEP handoff/complete: tunnel iAP2 ready; Bluetooth bootstrap released" + CarPlayStatus.DiscoveringIphone -> + "STEP usb/discover: searching for an iPhone USB device" + CarPlayStatus.WaitingForIphone -> + "STEP usb/wait: iPhone USB device not present; polling" + CarPlayStatus.RequestingIphonePermission -> + "STEP usb/permission: requesting USB access to the iPhone" + CarPlayStatus.WaitingForReenumeration -> + "STEP usb/reenum: waiting for the CarPlay USB configuration" + CarPlayStatus.SelectingConfiguration -> + "STEP usb/config: selecting the iPhone CarPlay configuration" + CarPlayStatus.OpeningDataPaths -> + "STEP usb/data: opening iAP2 and NCM USB data paths" + CarPlayStatus.Pairing -> + "STEP lockdown/pair: loading or creating the pairing record" + CarPlayStatus.ConnectingControl -> + "STEP lockdown/carkit: opening com.apple.carkit.service" + CarPlayStatus.AttachingNetwork -> + "STEP network/attach: attaching the AirPlay network transport" + CarPlayStatus.RunningControl -> + "STEP iap2/wired: wired iAP2 control loop running" + CarPlayStatus.ControlEnded -> + "STEP control/end: the control window ended" + is CarPlayStatus.Failed -> + "ERROR $message" + } + + companion object { + private const val IAP2_IPHONE_UUID = "00000000-deca-fade-deca-deafdecacafe" + private const val HOTSPOT_START_TIMEOUT_MILLIS = 60_000L + private const val WIFI_P2P_START_TIMEOUT_MILLIS = 20_000L + private const val PAIR_TIMEOUT_MILLIS = 5 * 60_000L + private const val VPN_CONNECT_TIMEOUT_MILLIS = 10_000L + private const val CONTROL_LOOP_TIMEOUT_MILLIS = 5 * 60_000L + private const val LOCATION_CONTROL_LOOP_TIMEOUT_MILLIS = 24 * 60 * 60 * 1_000L + private const val PERMISSION_POLL_INTERVAL_MILLIS = 500L + private const val PERMISSION_POLL_TIMEOUT_MILLIS = 120_000L + private const val DEVICE_AVAILABILITY_POLL_INTERVAL_MILLIS = 2_000L + private const val WIRELESS_HANDOFF_TIMEOUT_MILLIS = 45_000L + private const val RFCOMM_CONNECT_TIMEOUT_MILLIS = 15_000L + private const val MAXIMUM_REENUMERATION_ATTEMPTS = 2 + private const val EXECUTOR_CLOSE_TIMEOUT_MILLIS = 2_000L + private const val ADAPTER_ADDRESS_PLACEHOLDER = "02:00:00:00:00:00" + private val BLUETOOTH_ADDRESS = Regex("^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$") + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayRuntimeConfig.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayRuntimeConfig.kt new file mode 100644 index 0000000..5ee0cc9 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayRuntimeConfig.kt @@ -0,0 +1,153 @@ +package com.shilapi.xcertplay.orchestration + +import com.shilapi.xcertplay.transport.Iap2IdentificationConfig +import com.shilapi.xcertplay.transport.UsbDeviceId +import java.net.Inet6Address +import java.net.InetAddress + +enum class CarPlayTransport { + WIRED, + WIRELESS, +} + +enum class MfiTarget { + LOCAL, + USB_CH341, + I2C, + REMOTE, +} + +enum class WirelessHotspotMode { + WIFI_P2P, + LOCAL_ONLY_HOTSPOT, + MANUAL, +} + +enum class ManualHotspotBand { + AUTO, + GHZ_2_4, + GHZ_5, +} + +enum class ManualHotspotSecurity { + OPEN, + WPA2, + WPA3_TRANSITION, + WPA3, +} + +/** + * Deployment-owned constants for one head unit. There are deliberately no built-in Apple or + * CH341 product IDs: the physical devices attached to the target must be identified first. + */ +class CarPlayRuntimeConfig( + val iphoneDevices: List = emptyList(), + val mfiTarget: MfiTarget = MfiTarget.USB_CH341, + val ch341Devices: List = emptyList(), + val ch341MfiResetGpio: Int? = null, + val linuxI2cPath: String? = null, + val remoteMfiServer: String? = null, + val remoteMfiToken: String? = null, + val hostMac: ByteArray = DEFAULT_HOST_MAC, + val linkLocal: String = "fe80::2", + val identification: Iap2IdentificationConfig, + val availableCurrentMilliAmps: Int = 2400, + val label: String = "xcertplay", + val hostName: String = "xcertplay", + val transport: CarPlayTransport = CarPlayTransport.WIRED, + val wirelessHotspotMode: WirelessHotspotMode = WirelessHotspotMode.WIFI_P2P, + val manualHotspotSsid: String? = null, + val manualHotspotPassphrase: String? = null, + val manualHotspotBand: ManualHotspotBand = ManualHotspotBand.AUTO, + val manualHotspotChannel: Int = 0, + val manualHotspotSecurity: ManualHotspotSecurity = ManualHotspotSecurity.WPA2, + val wirelessBluetoothDeviceAddress: String? = null, + val locationReportingEnabled: Boolean = false, +) { + init { + require(iphoneDevices.all { it.vendorId == APPLE_VENDOR_ID }) { + "iPhone USB identities must use Apple vendor ID 0x${APPLE_VENDOR_ID.toString(16)}" + } + require(hostMac.size == 6) { "hostMac must be 6 bytes" } + require(isLinkLocalIpv6(linkLocal)) { "linkLocal must be a link-local IPv6 literal" } + require(availableCurrentMilliAmps in 0..0xffff) { + "availableCurrentMilliAmps must be in 0..65535" + } + require(label.isNotBlank()) { "label must not be blank" } + require(hostName.isNotBlank()) { "hostName must not be blank" } + require(mfiTarget != MfiTarget.USB_CH341 || ch341Devices.isNotEmpty()) { + "CH341 devices must be configured for the USB/CH341 MFi target" + } + require(mfiTarget != MfiTarget.I2C || !linuxI2cPath.isNullOrBlank()) { + "A Linux I2C path must be configured for the I2C MFi target" + } + require(mfiTarget != MfiTarget.REMOTE || !remoteMfiServer.isNullOrBlank()) { + "A server address must be configured for the remote MFi target" + } + require(ch341MfiResetGpio == null || ch341MfiResetGpio in 0..5) { + "CH341 MFi reset GPIO must be D0..D5" + } + require(remoteMfiServer?.contains('\u0000') != true) { + "Remote MFi server must not contain U+0000" + } + require(remoteMfiToken?.contains('\u0000') != true) { + "Remote MFi token must not contain U+0000" + } + if (wirelessHotspotMode == WirelessHotspotMode.MANUAL) { + val ssid = manualHotspotSsid + require(!ssid.isNullOrBlank()) { + "manualHotspotSsid is required in manual hotspot mode" + } + require('\u0000' !in ssid) { + "manualHotspotSsid must not contain U+0000" + } + val passphrase = manualHotspotPassphrase.orEmpty() + require('\u0000' !in passphrase) { + "manualHotspotPassphrase must not contain U+0000" + } + require(passphrase.isEmpty() || passphrase.length in 8..63) { + "manualHotspotPassphrase must be empty or between 8 and 63 characters" + } + require(manualHotspotChannel in 0..196) { + "manualHotspotChannel must be 0 or in 1..196" + } + require( + manualHotspotSecurity == ManualHotspotSecurity.OPEN || + passphrase.length in 8..63, + ) { + "A passphrase between 8 and 63 characters is required for secured manual hotspots" + } + require( + manualHotspotChannel == 0 || + isManualHotspotChannelCompatible(manualHotspotBand, manualHotspotChannel), + ) { + "manualHotspotChannel is not valid for the selected manual hotspot band" + } + require( + manualHotspotSecurity == ManualHotspotSecurity.OPEN || passphrase.isNotEmpty(), + ) { + "manualHotspotPassphrase is required for secured manual hotspots" + } + } + } + + companion object { + const val APPLE_VENDOR_ID = 0x05ac + val DEFAULT_HOST_MAC = byteArrayOf(0x02, 0x00, 0x00, 0x00, 0x00, 0x02) + private fun isLinkLocalIpv6(value: String): Boolean { + if (value.contains('%') || '\u0000' in value || !value.contains(':')) return false + return try { + val address = InetAddress.getByName(value) + address is Inet6Address && address.isLinkLocalAddress + } catch (_: Exception) { + false + } + } + } +} + +fun isManualHotspotChannelCompatible(band: ManualHotspotBand, channel: Int): Boolean = when (band) { + ManualHotspotBand.AUTO -> channel in 1..196 + ManualHotspotBand.GHZ_2_4 -> channel in 1..14 + ManualHotspotBand.GHZ_5 -> channel in 32..177 +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/MfiRuntime.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/MfiRuntime.kt new file mode 100644 index 0000000..3616dcf --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/MfiRuntime.kt @@ -0,0 +1,41 @@ +package com.shilapi.xcertplay.orchestration + +import com.shilapi.xcertplay.mfi.MfiAuthenticationClient +import com.shilapi.xcertplay.mfi.MfiAuthenticator +import com.shilapi.xcertplay.mfi.MfiDeviceScanner +import com.shilapi.xcertplay.transport.I2cTransport +import java.io.Closeable +import java.io.IOException + +/** Signals the normal, retryable state where the MFi address probe found no chip. */ +internal class MfiCoprocessorNotFoundException( + message: String = "No MFi authentication coprocessor responded to the device probe", +) : IOException(message) + +/** An opened MFi coprocessor client plus the handle that releases its backing transport. */ +class MfiSession( + val client: MfiAuthenticator, + private val closeable: Closeable?, +) : Closeable { + override fun close() { + closeable?.close() + } +} + +/** Runs the documented address probe and wraps the first responding MFi coprocessor. */ +object MfiRuntime { + fun scan(transport: I2cTransport): MfiAuthenticationClient { + val discovery = MfiDeviceScanner( + transport = transport, + probeTimeoutMillis = MFI_STARTUP_PROBE_TIMEOUT_MILLIS, + ).scan() + val chip = discovery.chip + ?: throw MfiCoprocessorNotFoundException( + "No MFi authentication coprocessor responded: " + + discovery.failures.joinToString { "0x${it.address7Bit.toString(16)}=${it.error}" }, + ) + return MfiAuthenticationClient(transport, chip.address7Bit) + } + + private const val MFI_STARTUP_PROBE_TIMEOUT_MILLIS = 15_000L +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/WirelessConnectionProof.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/WirelessConnectionProof.kt new file mode 100644 index 0000000..51db23f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/WirelessConnectionProof.kt @@ -0,0 +1,57 @@ +package com.shilapi.xcertplay.orchestration + +/** Confirms one current session only after authenticated tunnel readiness AND a rendered frame. */ +internal class WirelessConnectionProof { + private var generation = -1 + private var session: S? = null + private var authenticated = false + private var rendered = false + private var confirmation: (() -> Unit)? = null + + @Synchronized fun begin(generation: Int, confirmation: () -> Unit) { + clear() + this.generation = generation + this.confirmation = confirmation + } + + @Synchronized fun activate(generation: Int, session: S) { + if (this.generation != generation || this.session === session) return + this.session = session + authenticated = false + rendered = false + } + + @Synchronized fun authenticated(generation: Int) { + if (this.generation != generation || session == null) return + authenticated = true + confirmIfReady() + } + + @Synchronized fun rendered(generation: Int, session: S) { + if (this.generation != generation || this.session !== session) return + rendered = true + confirmIfReady() + } + + @Synchronized fun end(generation: Int, session: S) { + if (this.generation != generation || this.session !== session) return + this.session = null + authenticated = false + rendered = false + } + + @Synchronized fun clear() { + generation = -1 + session = null + authenticated = false + rendered = false + confirmation = null + } + + private fun confirmIfReady() { + if (!authenticated || !rendered) return + val callback = confirmation ?: return + confirmation = null + callback() + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppScreen.kt b/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppScreen.kt new file mode 100644 index 0000000..a027c3e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppScreen.kt @@ -0,0 +1,16 @@ +package com.shilapi.xcertplay.shared + +import androidx.car.app.CarContext +import androidx.car.app.Screen +import androidx.car.app.model.Action +import androidx.car.app.model.MessageTemplate +import androidx.car.app.model.Template + +class MyCarAppScreen(carContext: CarContext) : Screen(carContext) { + override fun onGetTemplate(): Template { + return MessageTemplate.Builder("Hardware transport is not configured. Board I2C needs a /dev/i2c-N path and OS/SELinux permission; CH341 needs deployed VID/PID configuration.") + .setHeaderAction(Action.APP_ICON) + .setTitle("xcertplay hardware status") + .build() + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppService.kt b/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppService.kt new file mode 100644 index 0000000..c3b1fbc --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppService.kt @@ -0,0 +1,15 @@ +package com.shilapi.xcertplay.shared + +import androidx.car.app.CarAppService +import androidx.car.app.Session +import androidx.car.app.validation.HostValidator + +class MyCarAppService : CarAppService() { + override fun createHostValidator(): HostValidator { + return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR + } + + override fun onCreateSession(): Session { + return MyCarAppSession() + } +} \ No newline at end of file diff --git a/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppSession.kt b/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppSession.kt new file mode 100644 index 0000000..45c2301 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/shared/MyCarAppSession.kt @@ -0,0 +1,11 @@ +package com.shilapi.xcertplay.shared + +import android.content.Intent +import androidx.car.app.Screen +import androidx.car.app.Session + +class MyCarAppSession : Session() { + override fun onCreateScreen(intent: Intent): Screen { + return MyCarAppScreen(carContext) + } +} \ No newline at end of file diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/BlockingDuplexByteStream.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/BlockingDuplexByteStream.kt new file mode 100644 index 0000000..4ee2064 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/BlockingDuplexByteStream.kt @@ -0,0 +1,18 @@ +package com.shilapi.xcertplay.transport + +import java.io.Closeable + +/** + * A blocking, ordered, full-duplex byte stream. + * + * [recv] returns `null` only when its timeout expires and an empty array after the peer has + * ended its output. Implementations may split or coalesce writes arbitrarily. Calls belong on a + * worker thread, never Android's main thread. + */ +interface BlockingDuplexByteStream : Closeable { + fun send(data: ByteArray) + + fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? + + override fun close() +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/BluetoothRfcommDuplexStream.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/BluetoothRfcommDuplexStream.kt new file mode 100644 index 0000000..664cdce --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/BluetoothRfcommDuplexStream.kt @@ -0,0 +1,219 @@ +package com.shilapi.xcertplay.transport + +import android.bluetooth.BluetoothSocket +import java.io.IOException +import java.util.ArrayDeque +import kotlin.math.min + +/** + * A bounded [BlockingDuplexByteStream] over an already-open RFCOMM socket. + * + * Android's RFCOMM input has no per-read timeout, so one daemon reader performs the blocking + * reads. [close] closes the owned socket, which unblocks that reader. + */ +class BluetoothRfcommDuplexStream( + private val socket: BluetoothSocket, +) : BlockingDuplexByteStream { + private val lock = Object() + private val sendLock = Object() + private val input = socket.inputStream + private val output = socket.outputStream + private val pending = ArrayDeque() + private var pendingBytes = 0 + private var peerEnded = false + private var closed = false + private var socketCloseStarted = false + private var failure: IOException? = null + + private val reader = Thread(::readLoop, "xcertplay-bluetooth-rfcomm-reader").apply { + isDaemon = true + } + + init { + reader.start() + } + + override fun send(data: ByteArray) { + synchronized(sendLock) { + synchronized(lock) { + failure?.let { throw it } + if (closed) throw IOException("Bluetooth RFCOMM stream is closed") + } + try { + output.write(data) + output.flush() + } catch (io: IOException) { + fail(io) + throw io + } + } + } + + override fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? { + require(maxBytes > 0) { "maxBytes must be positive" } + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + + val deadlineNanos = deadlineAfter(timeoutMillis) + synchronized(lock) { + while (true) { + takePendingLocked(maxBytes)?.let { return it } + failure?.let { throw it } + if (peerEnded || closed) return EMPTY + + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) return null + try { + lock.wait( + remainingNanos / NANOS_PER_MILLISECOND, + (remainingNanos % NANOS_PER_MILLISECOND).toInt(), + ) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return null + } + } + } + } + + /** Closes the owned socket and waits briefly for the reader to leave its blocking read. */ + override fun close() { + val firstClose = synchronized(lock) { + if (closed) { + false + } else { + closed = true + lock.notifyAll() + true + } + } + if (!firstClose) return + + var closeFailure = closeSocketOnce() + if (Thread.currentThread() !== reader) { + try { + reader.join(CLOSE_JOIN_MILLIS) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + closeFailure = combine( + closeFailure, + IOException("Interrupted while closing the Bluetooth RFCOMM reader", interrupted), + ) + } + if (reader.isAlive) { + closeFailure = combine( + closeFailure, + IOException("Bluetooth RFCOMM reader did not stop after close"), + ) + } + } + if (closeFailure != null) throw closeFailure + } + + private fun readLoop() { + var readFailure: IOException? = null + try { + while (true) { + val readSize = synchronized(lock) { + while (!closed && failure == null && pendingBytes >= MAX_PENDING_BYTES) { + lock.wait() + } + if (closed || failure != null) return + min(READ_CHUNK_BYTES, MAX_PENDING_BYTES - pendingBytes) + } + + val buffer = ByteArray(readSize) + when (val count = input.read(buffer)) { + -1 -> { + synchronized(lock) { + peerEnded = true + lock.notifyAll() + } + return + } + + 0 -> Unit + else -> synchronized(lock) { + if (closed) return + pending.addLast(if (count == buffer.size) buffer else buffer.copyOf(count)) + pendingBytes += count + lock.notifyAll() + } + } + } + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + if (!isClosed()) { + readFailure = IOException("Bluetooth RFCOMM reader was interrupted", interrupted) + } + } catch (io: IOException) { + if (!isClosed()) readFailure = io + } catch (failure: Throwable) { + readFailure = IOException("Bluetooth RFCOMM reader failed", failure) + if (failure is Error) throw failure + } finally { + readFailure?.let(::fail) + val closeFailure = closeSocketOnce() + if (readFailure == null && closeFailure != null && !isClosed() && !endedCleanly()) { + fail(closeFailure) + } + } + } + + private fun takePendingLocked(maxBytes: Int): ByteArray? { + val chunk = pending.pollFirst() ?: return null + pendingBytes -= chunk.size + if (chunk.size <= maxBytes) return chunk + + val head = chunk.copyOf(maxBytes) + val tail = chunk.copyOfRange(maxBytes, chunk.size) + pending.addFirst(tail) + pendingBytes += tail.size + return head + } + + private fun fail(io: IOException) { + synchronized(lock) { + if (failure == null) failure = io + lock.notifyAll() + } + closeSocketOnce() + } + + private fun closeSocketOnce(): IOException? { + synchronized(lock) { + if (socketCloseStarted) return null + socketCloseStarted = true + } + return try { + socket.close() + null + } catch (failure: Throwable) { + if (failure is Error) throw failure + IOException("Could not close the Bluetooth RFCOMM socket", failure) + } + } + + private fun isClosed(): Boolean = synchronized(lock) { closed } + + private fun endedCleanly(): Boolean = synchronized(lock) { closed || peerEnded } + + private fun deadlineAfter(timeoutMillis: Long): Long { + val now = System.nanoTime() + val delta = timeoutMillis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun combine(first: IOException?, second: IOException): IOException { + if (first == null) return second + if (first !== second) first.addSuppressed(second) + return first + } + + private companion object { + private const val READ_CHUNK_BYTES = 8_192 + private const val MAX_PENDING_BYTES = 65_536 + private const val CLOSE_JOIN_MILLIS = 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + private val EMPTY = ByteArray(0) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341DeviceMatcher.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341DeviceMatcher.kt new file mode 100644 index 0000000..4815a28 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341DeviceMatcher.kt @@ -0,0 +1,15 @@ +package com.shilapi.xcertplay.transport + +/** A USB identity explicitly allowed by the product or deployment configuration. */ +data class UsbDeviceId(val vendorId: Int, val productId: Int) + +/** + * Matches only configured CH341 USB identities. There is intentionally no built-in VID/PID: + * deployed CH341 hardware must be identified on the target unit first. + */ +class Ch341DeviceMatcher(allowedDevices: Collection) { + private val allowedDevices = allowedDevices.toSet() + + fun matches(vendorId: Int, productId: Int): Boolean = + UsbDeviceId(vendorId, productId) in allowedDevices +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341I2cStreamEncoder.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341I2cStreamEncoder.kt new file mode 100644 index 0000000..576b0af --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341I2cStreamEncoder.kt @@ -0,0 +1,182 @@ +package com.shilapi.xcertplay.transport + +/** CH341 I2C clock selections documented by the project's CH341 interface baseline. */ +enum class Ch341I2cSpeed(internal val command: Int, internal val bitsPerSecond: Int) { + KHZ_20(0x60, 20_000), + KHZ_100(0x61, 100_000), + KHZ_400(0x62, 400_000), + KHZ_750(0x63, 750_000), +} + +/** + * Encodes a complete CH341 I2C transaction. + * + * Every byte written to the bus is emitted as its own bare `0x80` command. A bare `0x80` is the + * only form the controller answers with an ACK/NACK status byte, which is what lets + * [Ch341I2cTransport] see a NACKed register select; a batched `0x80|n` write is answered with no + * status bytes at all. The number of written bytes is unchanged, so the transport's + * "one status byte per written byte" model stays exact. + * + * A transaction may contain several 32-byte CH341 stream segments. Intermediate segments end + * with `00` and padding, but deliberately omit STOP so the I2C transaction continues in the next + * segment. The final segment contains STOP followed by `00`. + */ +object Ch341I2cStreamEncoder { + const val MAX_STREAM_PACKET_BYTES = 32 + /** Matches the bounded I2C message size accepted by [LinuxI2cTransport]. */ + const val MAX_TRANSACTION_DATA_BYTES = 0xffff + const val MAX_READ_BYTES = MAX_TRANSACTION_DATA_BYTES + + fun configuration(speed: Ch341I2cSpeed): ByteArray = byteArrayOf( + STREAM_START.toByte(), + speed.command.toByte(), + STREAM_END.toByte(), + ) + + fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray { + validateRequest(address7Bit, writeData.size, readLength) + legacyTransaction(address7Bit, writeData, readLength)?.let { return it } + + val stream = SegmentedStream() + if (writeData.isNotEmpty()) { + stream.startAndWrite(addressByte(address7Bit, read = false), writeData) + } + if (readLength > 0) { + stream.startAndWrite(addressByte(address7Bit, read = true), byteArrayOf()) + stream.read(readLength) + } + return stream.finish() + } + + private fun legacyTransaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray? { + if (readLength > MAX_READ_BLOCK_BYTES || writeData.size + 1 > MAX_WRITE_COMMAND_BYTES) return null + + val bytes = ArrayList(MAX_STREAM_PACKET_BYTES) + fun add(value: Int) { + bytes += value.toByte() + } + fun write(address: Byte, data: ByteArray) { + // One bare `0x80` per byte: the controller answers that form with ACK/NACK status. + add(WRITE) + add(address.toInt() and 0xff) + data.forEach { + add(WRITE) + add(it.toInt() and 0xff) + } + } + + add(STREAM_START) + if (writeData.isNotEmpty()) { + add(START) + write(addressByte(address7Bit, read = false), writeData) + } + if (readLength > 0) { + add(START) + write(addressByte(address7Bit, read = true), byteArrayOf()) + if (readLength > 1) add(READ + readLength - 1) + add(READ) + } + add(STOP) + add(STREAM_END) + return bytes.takeIf { it.size <= MAX_STREAM_PACKET_BYTES }?.toByteArray() + } + + private fun validateRequest(address7Bit: Int, writeLength: Int, readLength: Int) { + if (address7Bit !in 0..0x7f) { + throw I2cTransportException.InvalidRequest("I2C address must be a 7-bit value") + } + if (readLength < 0) { + throw I2cTransportException.InvalidRequest("Read length must not be negative") + } + if (writeLength == 0 && readLength == 0) { + throw I2cTransportException.InvalidRequest("I2C transaction must read or write data") + } + if (writeLength > MAX_TRANSACTION_DATA_BYTES || readLength > MAX_TRANSACTION_DATA_BYTES) { + throw I2cTransportException.InvalidRequest( + "CH341 transactions support at most $MAX_TRANSACTION_DATA_BYTES bytes per read or write", + ) + } + } + + private fun addressByte(address7Bit: Int, read: Boolean): Byte = + ((address7Bit shl 1) or if (read) 1 else 0).toByte() + + private class SegmentedStream { + private val result = ArrayList() + private var segment = ArrayList(MAX_STREAM_PACKET_BYTES) + + init { + segment += STREAM_START.toByte() + } + + fun startAndWrite(address: Byte, data: ByteArray) { + if (segment.size + START_AND_ADDRESS_BYTES + STREAM_END_BYTES > MAX_STREAM_PACKET_BYTES) { + finishIntermediate() + } + segment += START.toByte() + write(address, data) + } + + fun write(address: Byte, data: ByteArray) { + if (data.isEmpty()) { + // Address-only write: the read-address setup of a segmented read. Emit it as ONE + // batched `0x80|1` command, which suppresses the ACK/NACK status byte. This is the + // form the hardware-proven reference implementation uses: mfi3.py's seg_read_packets + // emits `[START, OUT | 1, address]` and then requires the accumulated reply to total + // exactly the requested data length, with no status byte in front of it. Emitting a + // bare `0x80` here instead adds a status byte and shifts the whole answer. + command(byteArrayOf((WRITE + 1).toByte(), address)) + return + } + // Writes that carry data keep one bare `0x80` per byte so that + // [Ch341I2cTransport] can still check each byte's ACK/NACK status. + command(byteArrayOf(WRITE.toByte(), address)) + data.forEach { value -> + command(byteArrayOf(WRITE.toByte(), value)) + } + } + + fun read(length: Int) { + var remaining = length + while (remaining > MAX_READ_BLOCK_BYTES) { + command(byteArrayOf((READ + MAX_READ_BLOCK_BYTES).toByte())) + finishIntermediate() + remaining -= MAX_READ_BLOCK_BYTES + } + if (remaining > 1) command(byteArrayOf((READ + remaining - 1).toByte())) + command(byteArrayOf(READ.toByte())) + } + + fun finish(): ByteArray { + if (segment.size + STOP_AND_END_BYTES > MAX_STREAM_PACKET_BYTES) finishIntermediate() + segment += STOP.toByte() + segment += STREAM_END.toByte() + result += segment + return result.toByteArray() + } + + private fun command(bytes: ByteArray) { + if (segment.size + bytes.size + STREAM_END_BYTES > MAX_STREAM_PACKET_BYTES) finishIntermediate() + segment += bytes.toList() + } + + private fun finishIntermediate() { + segment += STREAM_END.toByte() + while (segment.size < MAX_STREAM_PACKET_BYTES) segment += 0 + result += segment + segment = arrayListOf(STREAM_START.toByte()) + } + } + + private const val STREAM_START = 0xaa + private const val START = 0x74 + private const val WRITE = 0x80 + private const val READ = 0xc0 + private const val STOP = 0x75 + private const val STREAM_END = 0x00 + private const val STREAM_END_BYTES = 1 + private const val STOP_AND_END_BYTES = 2 + private const val START_AND_ADDRESS_BYTES = 3 + private const val MAX_WRITE_COMMAND_BYTES = 0x3f + private const val MAX_READ_BLOCK_BYTES = 32 +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341I2cTransport.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341I2cTransport.kt new file mode 100644 index 0000000..33f46be --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341I2cTransport.kt @@ -0,0 +1,234 @@ +package com.shilapi.xcertplay.transport + +import java.io.ByteArrayOutputStream + +/** + * Blocking CH341 implementation of [I2cTransport]. One transport owns one USB session and + * serializes its configure/write/read sequence so stream packets cannot interleave. + */ +class Ch341I2cTransport( + private val session: Ch341UsbSession, + private val speed: Ch341I2cSpeed = Ch341I2cSpeed.KHZ_100, + private val timeoutMillis: Int = DEFAULT_TIMEOUT_MILLIS, +) : I2cTransport { + private val lock = Any() + + init { + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + } + + override fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray { + val stream = Ch341I2cStreamEncoder.transaction(address7Bit, writeData, readLength) + val transactionTimeoutMillis = maxOf(timeoutMillis, minimumTransferTimeoutMillis(writeData.size, readLength)) + return synchronized(lock) { + configure() + // The 2.0C revision NACKs the read address when a STOP is followed by a START any + // sooner than about 3 ms, and the CH341 then answers the read request with 0xFF + // padding. Hold the bus idle across the STOP/START boundary of a register read. + if (writeData.isEmpty() && readLength > 0) { + sleepBusIdle(SELECT_TO_READ_GAP_MILLIS) + } + val response = ByteArrayOutputStream(readLength) + var offset = 0 + while (offset < stream.size) { + val end = minOf(offset + Ch341I2cStreamEncoder.MAX_STREAM_PACKET_BYTES, stream.size) + val segment = stream.copyOfRange(offset, end) + session.bulkWrite(segment, transactionTimeoutMillis) + val (writtenBytes, dataBytes) = instructionLengths(segment) + if (writtenBytes + dataBytes > 0) { + // The worst case is one ACK/NACK status byte per byte written, but the controller + // may answer a multi-byte OUT with fewer or with none. Writes always precede the + // read commands, so the answer is split from its tail instead of trusting a fixed + // status count. A write-only packet is allowed to go unanswered entirely. + val received = session.bulkReadAtMost( + maxLength = writtenBytes + dataBytes, + firstPacketMillis = if (dataBytes > 0) { + transactionTimeoutMillis + } else { + minOf(RESPONSE_QUIET_MILLIS, transactionTimeoutMillis) + }, + quietMillis = RESPONSE_QUIET_MILLIS, + allowEmpty = dataBytes == 0, + ) + if (received.size < dataBytes) { + throw I2cTransportException.DeviceUnavailable( + "CH341 stream packet answered ${received.size} bytes; " + + "expected $dataBytes data bytes", + ) + } + val dataStart = received.size - dataBytes + checkStatusBytes(received, dataStart) + if (dataBytes > 0) { + response.write(received, dataStart, dataBytes) + } + } + offset = end + } + val bytes = response.toByteArray() + if (bytes.size != readLength) { + throw I2cTransportException.Protocol( + "CH341 transaction returned ${bytes.size} bytes; expected $readLength", + ) + } + bytes + } + } + + /** + * Pulses one D0..D5 output low, then leaves the pin actively driven instead of floating. + * + * The 2.0C reset notes require RST to be held low or actively driven high for the whole reset + * window, so the release-to-input form left the address selection undefined on any board + * without a pull-up on RST. The low period is timed on the host because the CH341 UIO delay + * command cannot span the MFi reset window; the level itself sits in the CH341 output latch, so + * USB scheduling cannot shorten it. + * + * [driveHigh] leaves RST driven high, which selects coprocessor address 0x11. Do not tie RST + * straight to GND while driving high -- drive into the target's own pull-up, or pass + * [driveHigh] = false to release the pin and let that pull-up set the idle level. + */ + fun pulseActiveLowReset( + gpio: Int, + lowMillis: Long = MFI_RESET_LOW_MILLIS, + driveHigh: Boolean = true, + ) { + require(gpio in 0..5) { "CH341 output GPIO must be D0..D5" } + require(lowMillis > 0) { "reset pulse must be positive" } + val pinMask = 1 shl gpio + val driveLowStream = byteArrayOf( + UIO_STREAM.toByte(), + UIO_OUT.toByte(), // preload every output latch low while pins are inputs + (UIO_DIR or pinMask).toByte(), // D becomes an output driving low + UIO_END.toByte(), + ) + val releaseStream = byteArrayOf( + UIO_STREAM.toByte(), + if (driveHigh) (UIO_OUT or pinMask).toByte() else UIO_OUT.toByte(), + if (driveHigh) (UIO_DIR or pinMask).toByte() else UIO_DIR.toByte(), + UIO_END.toByte(), + ) + synchronized(lock) { + session.bulkWrite(driveLowStream, timeoutMillis) + sleepBusIdle(lowMillis) + session.bulkWrite(releaseStream, timeoutMillis) + sleepBusIdle(MFI_RESET_STARTUP_MILLIS) + } + } + + /** + * Splits one CH341 stream packet into the bytes it puts on the wire and the bytes it reads back. + * + * A reply of `0x80|n` writes `n` bytes to the bus -- address byte included -- and `0xc0|n` + * requests `n` bytes, with a zero count meaning a single byte. How many ACK/NACK status bytes + * the controller prepends is a firmware detail: a bare `0x80` reply is answered with one status + * byte, while a multi-byte reply may be answered with one status byte per written byte or with + * none. This count is therefore only used as an upper bound; [transaction] aligns the answer + * from its tail, which keeps the read data correct either way. + */ + private fun instructionLengths(segment: ByteArray): InstructionLengths { + var index = 1 // 0xAA stream marker + var writtenBytes = 0 + var dataBytes = 0 + while (index < segment.size) { + val command = segment[index].toInt() and 0xff + index += 1 + when { + command == STREAM_END -> return InstructionLengths(writtenBytes, dataBytes) + command == START || command == STOP -> Unit + command in WRITE_MIN..WRITE_MAX -> { + val written = encodedLength(command) + // Only the bare `0x80` command is answered with one ACK/NACK status byte per + // written byte. A batched `0x80|n` (n >= 1) suppresses those status bytes, so it + // contributes none -- otherwise the answer is over-requested by exactly the + // number of status bytes and the last packet waits out its quiet timeout. + if (command and LENGTH_MASK == 0) writtenBytes += written + index += written + } + command in READ_MIN..READ_MAX -> dataBytes += encodedLength(command) + command in SET_MIN..SET_MAX -> Unit + else -> throw I2cTransportException.Protocol( + "Unexpected CH341 I2C stream command 0x${command.toString(16)}", + ) + } + if (index > segment.size) { + throw I2cTransportException.Protocol("Truncated CH341 I2C stream command") + } + } + return InstructionLengths(writtenBytes, dataBytes) + } + + /** The count field of an OUT/IN command; the controller treats a zero count as one byte. */ + private fun encodedLength(command: Int): Int { + val encoded = command and LENGTH_MASK + return if (encoded == 0) 1 else encoded + } + + /** Rejects a stream answer whose leading ACK/NACK status bytes report an I2C NACK. */ + private fun checkStatusBytes(received: ByteArray, statusCount: Int) { + for (offset in 0 until minOf(statusCount, received.size)) { + if (received[offset].toInt() and ACK_BIT != 0) { + throw I2cTransportException.Nack( + "CH341 reported an I2C NACK on byte $offset of this stream packet", + ) + } + } + } + + private fun sleepBusIdle(millis: Long) { + try { + Thread.sleep(millis) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + throw I2cTransportException.DeviceUnavailable( + "Interrupted while holding the I2C bus idle", + interrupted, + ) + } + } + + private data class InstructionLengths(val writtenBytes: Int, val dataBytes: Int) + + private fun configure() { + session.bulkWrite(Ch341I2cStreamEncoder.configuration(speed), timeoutMillis) + } + + /** + * A simple lower bound for a complete I2C transaction at the selected clock, plus fixed USB + * scheduling margin. The caller's configured timeout can still be longer. This is not a + * hardware-performance claim; it only avoids rejecting a legal maximum-sized transfer under + * the old one-second default before its nominal wire time has elapsed. + */ + private fun minimumTransferTimeoutMillis(writeLength: Int, readLength: Int): Int { + val i2cBytes = writeLength.toLong() + readLength + I2C_TRANSACTION_OVERHEAD_BYTES + val wireMillis = (i2cBytes * BITS_PER_I2C_BYTE * MILLIS_PER_SECOND + speed.bitsPerSecond - 1) / + speed.bitsPerSecond + return (wireMillis + TRANSFER_MARGIN_MILLIS).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + private companion object { + const val DEFAULT_TIMEOUT_MILLIS = 1_000 + const val I2C_TRANSACTION_OVERHEAD_BYTES = 8L + const val BITS_PER_I2C_BYTE = 9L + const val MILLIS_PER_SECOND = 1_000L + const val TRANSFER_MARGIN_MILLIS = 1_000L + const val STREAM_END = 0x00 + const val START = 0x74 + const val STOP = 0x75 + const val SET_MIN = 0x60 + const val SET_MAX = 0x63 + const val WRITE_MIN = 0x80 + const val WRITE_MAX = 0xbf + const val READ_MIN = 0xc0 + const val READ_MAX = 0xff + const val LENGTH_MASK = 0x3f + const val ACK_BIT = 0x80 + const val SELECT_TO_READ_GAP_MILLIS = 5L + const val RESPONSE_QUIET_MILLIS = 25 + const val UIO_STREAM = 0xab + const val UIO_OUT = 0x80 + const val UIO_DIR = 0x40 + const val UIO_END = 0x20 + const val MFI_RESET_LOW_MILLIS = 10L + const val MFI_RESET_STARTUP_MILLIS = 11L + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341UsbHost.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341UsbHost.kt new file mode 100644 index 0000000..eaa3155 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Ch341UsbHost.kt @@ -0,0 +1,336 @@ +package com.shilapi.xcertplay.transport + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.util.Log +import java.io.Closeable +import java.util.concurrent.Executor + +/** + * Android USB Host discovery and authorization for a configured CH341 identity. + * + * This class intentionally stops before the CH341 I2C stream protocol. [openAsync] executes the + * potentially blocking open/claim work on the supplied executor; callers should also close the + * returned session away from the main thread. + */ +class Ch341UsbHost( + context: Context, + private val usbManager: UsbManager, + private val matcher: Ch341DeviceMatcher, + private val permissionAction: String = "${context.packageName}.CH341_USB_PERMISSION", +) { + private val appContext = context.applicationContext + sealed class PermissionRequest { + data class AlreadyGranted(val device: UsbDevice) : PermissionRequest() + data class Requested(val device: UsbDevice) : PermissionRequest() + } + + sealed class PermissionResult { + data class Granted(val device: UsbDevice) : PermissionResult() + data class Denied(val device: UsbDevice) : PermissionResult() + } + + sealed class OpenResult { + data class Connected(val session: Ch341UsbSession) : OpenResult() + data class Failed(val error: I2cTransportException) : OpenResult() + } + + fun discover(): List = + usbManager.deviceList.values.filter { matcher.matches(it.vendorId, it.productId) } + + @Throws(I2cTransportException::class) + fun requestPermission(device: UsbDevice): PermissionRequest { + requireConfiguredDevice(device) + if (usbManager.hasPermission(device)) return PermissionRequest.AlreadyGranted(device) + + usbManager.requestPermission(device, permissionPendingIntent()) + return PermissionRequest.Requested(device) + } + + /** Returns null for unrelated broadcasts, malformed results, or devices outside the matcher. */ + fun parsePermissionResult(intent: Intent): PermissionResult? { + if (intent.action != permissionAction) return null + val device = intent.usbDevice() ?: return null + if (!matcher.matches(device.vendorId, device.productId)) return null + return if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + PermissionResult.Granted(device) + } else { + PermissionResult.Denied(device) + } + } + + /** Register once for this host instance and close the returned handle to unregister it. */ + fun registerPermissionReceiver(onResult: (PermissionResult) -> Unit): Closeable { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + parsePermissionResult(intent)?.let(onResult) + } + } + val filter = IntentFilter(permissionAction) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + appContext.registerReceiver(receiver, filter) + } + return Closeable { appContext.unregisterReceiver(receiver) } + } + + /** Opens and claims bulk endpoints on [executor], never on the caller thread. */ + fun openAsync(device: UsbDevice, executor: Executor, callback: (OpenResult) -> Unit) { + executor.execute { + try { + callback(OpenResult.Connected(open(device))) + } catch (error: I2cTransportException) { + callback(OpenResult.Failed(error)) + } + } + } + + private fun open(device: UsbDevice): Ch341UsbSession { + requireConfiguredDevice(device) + if (!usbManager.hasPermission(device)) { + throw I2cTransportException.PermissionDenied("USB permission has not been granted") + } + val endpoints = findBulkEndpoints(device) + ?: throw I2cTransportException.DeviceUnavailable("No USB interface has both bulk IN and OUT endpoints") + val connection = usbManager.openDevice(device) + ?: throw I2cTransportException.DeviceUnavailable("UsbManager could not open the CH341 device") + + if (!connection.claimInterface(endpoints.usbInterface, true)) { + connection.close() + throw I2cTransportException.DeviceUnavailable("Could not claim CH341 USB interface") + } + return Ch341UsbSession(connection, endpoints.usbInterface, endpoints.input, endpoints.output) + } + + private fun requireConfiguredDevice(device: UsbDevice) { + if (!matcher.matches(device.vendorId, device.productId)) { + throw I2cTransportException.DeviceUnavailable("USB device is not a configured CH341 identity") + } + } + + private fun permissionPendingIntent(): PendingIntent { + val intent = Intent(permissionAction).setPackage(appContext.packageName) + return PendingIntent.getBroadcast( + appContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun Intent.usbDevice(): UsbDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + private fun findBulkEndpoints(device: UsbDevice): BulkEndpoints? { + for (index in 0 until device.interfaceCount) { + val usbInterface = device.getInterface(index) + var input: UsbEndpoint? = null + var output: UsbEndpoint? = null + for (endpointIndex in 0 until usbInterface.endpointCount) { + val endpoint = usbInterface.getEndpoint(endpointIndex) + if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) continue + when (endpoint.direction) { + UsbConstants.USB_DIR_IN -> input = input ?: endpoint + UsbConstants.USB_DIR_OUT -> output = output ?: endpoint + } + } + if (input != null && output != null) return BulkEndpoints(usbInterface, input, output) + } + return null + } + + private data class BulkEndpoints( + val usbInterface: UsbInterface, + val input: UsbEndpoint, + val output: UsbEndpoint, + ) +} + +/** Claimed CH341 USB resources. All transfer methods are blocking and must run off the main thread. */ +class Ch341UsbSession internal constructor( + private val connection: UsbDeviceConnection, + private val usbInterface: UsbInterface, + val inputEndpoint: UsbEndpoint, + val outputEndpoint: UsbEndpoint, +) : Closeable { + private var closed = false + + @Synchronized + internal fun bulkWrite(data: ByteArray, timeoutMillis: Int) { + val transferred = transfer(outputEndpoint, data, timeoutMillis, "write") + if (transferred != data.size) { + Log.w(TAG, "bulk write sent $transferred of ${data.size} bytes: ${data.toHexPreview()}") + throw I2cTransportException.Protocol( + "CH341 bulk write transferred $transferred of ${data.size} bytes", + ) + } + } + + /** + * Reads the controller's answer to one stream packet, stopping at whatever it sends. + * + * The number of ACK/NACK status bytes the CH341 prepends to the read data is a firmware detail, + * so callers pass the worst-case length and align the answer from its tail. The first packet is + * awaited for [firstPacketMillis]; continuation packets only wait [quietMillis], so an answer + * shorter than [maxLength] costs one short wait instead of the full transaction timeout. + * [allowEmpty] accepts a packet the controller chooses not to answer at all. + */ + @Synchronized + internal fun bulkReadAtMost( + maxLength: Int, + firstPacketMillis: Int, + quietMillis: Int, + allowEmpty: Boolean, + ): ByteArray { + if (maxLength < 0) { + throw I2cTransportException.InvalidRequest("Bulk read length must not be negative") + } + if (maxLength == 0) return ByteArray(0) + if (firstPacketMillis <= 0 || quietMillis <= 0) { + throw I2cTransportException.InvalidRequest("Bulk transfer timeout must be positive") + } + val data = ByteArray(maxLength) + // A full-size USB packet does NOT complete a bulk transfer: the read returns only + // once the buffer is full or a SHORT packet arrives. Asking for even one byte past + // the end of a whole-packet answer therefore makes bulkTransfer wait for data that + // never comes and time out, discarding the bytes that already arrived. The CH341 + // answers in whole 32-byte packets, so every request stops on a packet boundary. + val packetSize = minOf(inputEndpoint.maxPacketSize.coerceAtLeast(1), MAX_USB_PACKET_BYTES) + var offset = 0 + while (offset < maxLength) { + val packet = ByteArray(minOf(maxLength - offset, packetSize)) + val waitMillis = if (offset == 0) firstPacketMillis else quietMillis + val fatal = offset == 0 && !allowEmpty + val transferred = try { + transfer(inputEndpoint, packet, waitMillis, "read", clearHaltOnFailure = fatal) + } catch (error: I2cTransportException.DeviceUnavailable) { + // The answer ended early: fewer status bytes than the worst case, or a packet the + // controller does not answer at all. + if (offset > 0 || allowEmpty) { + Log.d( + TAG, + "bulk read ended early at $offset of $maxLength bytes " + + "(quiet-packet timeout of ${waitMillis}ms; accepted as end of answer)", + ) + break + } + throw error + } + if (transferred <= 0) { + if (offset > 0 || allowEmpty) break + throw I2cTransportException.DeviceUnavailable("CH341 bulk read returned no data") + } + System.arraycopy(packet, 0, data, offset, transferred) + offset += transferred + } + if (offset != maxLength) { + // The answer was shorter than the worst-case length the transport budgeted for: the + // controller returned fewer status bytes than it had written bytes, or the stream + // framing drifted off a packet boundary. Both are worth seeing in logcat. + Log.d(TAG, "bulk read answered $offset of $maxLength bytes (allowEmpty=$allowEmpty)") + } + return data.copyOf(offset) + } + + @Synchronized + override fun close() { + if (closed) return + closed = true + connection.releaseInterface(usbInterface) + connection.close() + } + + private fun transfer( + endpoint: UsbEndpoint, + data: ByteArray, + timeoutMillis: Int, + operation: String, + clearHaltOnFailure: Boolean = true, + ): Int { + if (closed) throw I2cTransportException.DeviceUnavailable("CH341 USB session is closed") + if (data.isEmpty()) throw I2cTransportException.InvalidRequest("Bulk $operation data must not be empty") + if (timeoutMillis <= 0) { + throw I2cTransportException.InvalidRequest("Bulk transfer timeout must be positive") + } + + val transferred = try { + connection.bulkTransfer(endpoint, data, data.size, timeoutMillis) + } catch (error: SecurityException) { + throw I2cTransportException.PermissionDenied("USB permission was denied during bulk $operation") + } catch (error: RuntimeException) { + throw I2cTransportException.DeviceUnavailable("CH341 bulk $operation failed", error) + } + if (transferred < 0) { + // Android's bulkTransfer result does not identify whether the device timed out, NAKed, + // or reported another USB failure. Do not manufacture a CH341 NAK classification. + clearEndpointHalt(endpoint, operation) + throw I2cTransportException.DeviceUnavailable("CH341 bulk $operation failed or timed out") + } + return transferred + } + + /** + * Releases a bulk endpoint left halted by a failed transfer. + * + * A USB stall stays latched per endpoint, so once one transfer fails every later transfer on + * that endpoint fails the same way until something clears the halt. Re-opening the device + * clears it, which is why a full stack rebuild always recovers; clearing it here keeps a single + * stalled transfer from consuming the caller's entire retry budget. + * + * A control transfer is harmless when the endpoint was never halted, so this is a one-sided bet. + */ + private fun clearEndpointHalt(endpoint: UsbEndpoint, operation: String) { + val result = try { + connection.controlTransfer( + UsbConstants.USB_DIR_OUT or UsbConstants.USB_TYPE_STANDARD or + USB_RECIP_ENDPOINT, + USB_REQUEST_CLEAR_FEATURE, + USB_FEATURE_ENDPOINT_HALT, + endpoint.address, + null, + 0, + RECOVERY_TIMEOUT_MILLIS, + ) + } catch (error: RuntimeException) { + Log.w(TAG, "halt clear on endpoint ${endpoint.address} threw after failed bulk $operation", error) + -1 + } + Log.w(TAG, "bulk $operation failed; halt clear on endpoint ${endpoint.address} returned $result") + } + + private fun ByteArray.toHexPreview(): String = joinToString(" ") { "%02x".format(it) } + + private companion object { + const val NANOS_PER_MILLI = 1_000_000L + + /** CH341A bulk endpoints report wMaxPacketSize 32; never request across a boundary. */ + const val MAX_USB_PACKET_BYTES = 32 + + const val TAG = "Ch341UsbHost" + + /** Milliseconds allowed for the halt-clear control transfer before recovery is abandoned. */ + const val RECOVERY_TIMEOUT_MILLIS = 200 + + // USB 2.0 standard request: CLEAR_FEATURE(ENDPOINT_HALT) on one endpoint. + // Android exposes USB_DIR_* and USB_TYPE_* on UsbConstants but not the USB_RECIP_* + // recipient codes, so the recipient value is spelled out locally. + const val USB_REQUEST_CLEAR_FEATURE = 0x01 + const val USB_FEATURE_ENDPOINT_HALT = 0x0000 + const val USB_RECIP_ENDPOINT = 0x02 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/EthernetIpv6Codec.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/EthernetIpv6Codec.kt new file mode 100644 index 0000000..e05ef93 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/EthernetIpv6Codec.kt @@ -0,0 +1,157 @@ +package com.shilapi.xcertplay.transport + +/** + * Untagged Ethernet II framing around IPv6 payloads, the shape NCM datagrams carry. + * + * The NCM data path moves raw Ethernet frames. This codec is the seam the later Android VPN layer + * uses to exchange IPv6 packets with the phone; it does not create or own a network interface. + */ +object EthernetIpv6Codec { + const val ETHERTYPE_IPV6 = 0x86dd + const val MAC_BYTES = 6 + + private const val HEADER_BYTES = 14 + + data class Ipv6Frame( + val sourceMac: ByteArray, + val destinationMac: ByteArray, + val ipv6: ByteArray, + ) + + data class Ipv6FrameView( + val sourceMac: ByteArray, + val payloadOffset: Int, + val payloadLength: Int, + ) + + /** Returns null for tagged frames, non-IPv6 frames, or truncated input. */ + fun parseIpv6(frame: ByteArray): Ipv6Frame? { + if (frame.size < HEADER_BYTES || readU16(frame, 12) != ETHERTYPE_IPV6) return null + return Ipv6Frame( + sourceMac = frame.copyOfRange(6, 12), + destinationMac = frame.copyOfRange(0, 6), + ipv6 = frame.copyOfRange(HEADER_BYTES, frame.size), + ) + } + + /** Returns an allocation-light view for callers that can consume the payload in place. */ + fun parseIpv6View(frame: ByteArray): Ipv6FrameView? { + if (frame.size <= HEADER_BYTES || readU16(frame, 12) != ETHERTYPE_IPV6) return null + return Ipv6FrameView( + sourceMac = frame.copyOfRange(6, 12), + payloadOffset = HEADER_BYTES, + payloadLength = frame.size - HEADER_BYTES, + ) + } + + fun build(sourceMac: ByteArray, destinationMac: ByteArray, ipv6: ByteArray): ByteArray { + require(sourceMac.size == MAC_BYTES) { "sourceMac must be $MAC_BYTES bytes" } + require(destinationMac.size == MAC_BYTES) { "destinationMac must be $MAC_BYTES bytes" } + require(ipv6.isNotEmpty()) { "ipv6 payload must not be empty" } + + val frame = ByteArray(HEADER_BYTES + ipv6.size) + destinationMac.copyInto(frame, 0) + sourceMac.copyInto(frame, 6) + putU16(frame, 12, ETHERTYPE_IPV6) + ipv6.copyInto(frame, HEADER_BYTES) + return frame + } + + /** Maps an IPv6 multicast destination to its Ethernet 33:33:xx:xx:xx:xx address. */ + fun multicastDestinationMac(ipv6: ByteArray): ByteArray? { + if (ipv6.size < IPV6_HEADER_BYTES || (ipv6[0].toInt() ushr 4 and 0x0f) != 6) return null + if ((ipv6[IPV6_DESTINATION_OFFSET].toInt() and 0xff) != 0xff) return null + return byteArrayOf( + 0x33, + 0x33, + ipv6[IPV6_DESTINATION_OFFSET + 12], + ipv6[IPV6_DESTINATION_OFFSET + 13], + ipv6[IPV6_DESTINATION_OFFSET + 14], + ipv6[IPV6_DESTINATION_OFFSET + 15], + ) + } + + /** + * A TUN interface has no layer-2 address, so Android emits Neighbor Advertisements without a + * Target Link-Layer Address option. Add the option required by the Ethernet peer and repair + * the IPv6 payload length and ICMPv6 checksum. + */ + fun addNeighborAdvertisementTargetMac(ipv6: ByteArray, hostMac: ByteArray): ByteArray { + require(hostMac.size == MAC_BYTES) { "hostMac must be $MAC_BYTES bytes" } + if (ipv6.size < ICMPV6_NA_MIN_BYTES || (ipv6[0].toInt() ushr 4 and 0x0f) != 6) return ipv6 + if ((ipv6[IPV6_NEXT_HEADER_OFFSET].toInt() and 0xff) != ICMPV6_NEXT_HEADER || + (ipv6[IPV6_HEADER_BYTES].toInt() and 0xff) != ICMPV6_NEIGHBOR_ADVERTISEMENT + ) return ipv6 + + val payloadBytes = readU16(ipv6, IPV6_PAYLOAD_LENGTH_OFFSET) + if (payloadBytes < ICMPV6_NA_BYTES || IPV6_HEADER_BYTES + payloadBytes > ipv6.size) return ipv6 + var optionOffset = IPV6_HEADER_BYTES + ICMPV6_NA_BYTES + val payloadEnd = IPV6_HEADER_BYTES + payloadBytes + while (optionOffset + 2 <= payloadEnd) { + val optionBytes = (ipv6[optionOffset + 1].toInt() and 0xff) * 8 + if (optionBytes == 0 || optionOffset + optionBytes > payloadEnd) break + if ((ipv6[optionOffset].toInt() and 0xff) == ICMPV6_TARGET_LINK_LAYER_OPTION && + optionBytes >= 8 + ) { + return ipv6.copyOf().also { result -> + hostMac.copyInto(result, optionOffset + 2) + writeIcmpv6Checksum(result) + } + } + optionOffset += optionBytes + } + + val result = ipv6.copyOf(payloadEnd + 8) + result[IPV6_PAYLOAD_LENGTH_OFFSET] = ((payloadBytes + 8) ushr 8).toByte() + result[IPV6_PAYLOAD_LENGTH_OFFSET + 1] = (payloadBytes + 8).toByte() + result[payloadEnd] = ICMPV6_TARGET_LINK_LAYER_OPTION.toByte() + result[payloadEnd + 1] = 1 + hostMac.copyInto(result, payloadEnd + 2) + writeIcmpv6Checksum(result) + return result + } + + private fun writeIcmpv6Checksum(ipv6: ByteArray) { + val payloadBytes = readU16(ipv6, IPV6_PAYLOAD_LENGTH_OFFSET) + ipv6[IPV6_HEADER_BYTES + 2] = 0 + ipv6[IPV6_HEADER_BYTES + 3] = 0 + var sum = 0L + fun addWords(start: Int, bytes: Int) { + var offset = start + val end = start + bytes + while (offset + 1 < end) { + sum += readU16(ipv6, offset).toLong() + offset += 2 + } + if (offset < end) sum += (ipv6[offset].toInt() and 0xff).toLong() shl 8 + } + addWords(IPV6_SOURCE_OFFSET, 32) + sum += (payloadBytes ushr 16).toLong() + sum += (payloadBytes and 0xffff).toLong() + sum += ICMPV6_NEXT_HEADER.toLong() + addWords(IPV6_HEADER_BYTES, payloadBytes) + while (sum ushr 16 != 0L) sum = (sum and 0xffff) + (sum ushr 16) + val checksum = sum.inv().toInt() and 0xffff + ipv6[IPV6_HEADER_BYTES + 2] = (checksum ushr 8).toByte() + ipv6[IPV6_HEADER_BYTES + 3] = checksum.toByte() + } + + private fun putU16(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 8).toByte() + target[offset + 1] = value.toByte() + } + + private fun readU16(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 8) or (source[offset + 1].toInt() and 0xff) + + private const val IPV6_HEADER_BYTES = 40 + private const val IPV6_PAYLOAD_LENGTH_OFFSET = 4 + private const val IPV6_NEXT_HEADER_OFFSET = 6 + private const val IPV6_SOURCE_OFFSET = 8 + private const val IPV6_DESTINATION_OFFSET = 24 + private const val ICMPV6_NEXT_HEADER = 58 + private const val ICMPV6_NEIGHBOR_ADVERTISEMENT = 136 + private const val ICMPV6_NA_BYTES = 24 + private const val ICMPV6_NA_MIN_BYTES = IPV6_HEADER_BYTES + ICMPV6_NA_BYTES + private const val ICMPV6_TARGET_LINK_LAYER_OPTION = 2 +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/I2cTransport.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/I2cTransport.kt new file mode 100644 index 0000000..2ca5c63 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/I2cTransport.kt @@ -0,0 +1,29 @@ +package com.shilapi.xcertplay.transport + +import java.io.IOException + +/** + * Transport boundary used by the MFi register layer. Implementations perform blocking I/O away + * from Android's main thread and use [I2cTransportException] for expected transport failures. + */ +interface I2cTransport { + /** + * Executes one I2C transaction. [address7Bit] must be in `0x00..0x7f` and [readLength] must + * be non-negative. An implementation writes [writeData], then, when [readLength] is positive, + * performs a repeated-start read and returns exactly [readLength] bytes. Empty [writeData] or + * a zero [readLength] represent read-only or write-only transactions respectively. + */ + @Throws(I2cTransportException::class) + fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray + +} + +/** Expected, recoverable I2C transport failures. */ +sealed class I2cTransportException(message: String, cause: Throwable? = null) : IOException(message, cause) { + class InvalidRequest(message: String) : I2cTransportException(message) + class DeviceUnavailable(message: String, cause: Throwable? = null) : I2cTransportException(message, cause) + class PermissionDenied(message: String) : I2cTransportException(message) + class TimedOut(message: String, cause: Throwable? = null) : I2cTransportException(message, cause) + class Nack(message: String) : I2cTransportException(message) + class Protocol(message: String, cause: Throwable? = null) : I2cTransportException(message, cause) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2ControlDeadline.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2ControlDeadline.kt new file mode 100644 index 0000000..ffdf88a --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2ControlDeadline.kt @@ -0,0 +1,24 @@ +package com.shilapi.xcertplay.transport + +/** An unlimited drive still has a bounded authentication handshake and bounded I/O polls. */ +internal class Iap2ControlDeadline( + private val timeoutMillis: Long, + private val clockNanos: () -> Long = System::nanoTime, +) { + private val unlimited = timeoutMillis == Long.MAX_VALUE + private val startedNanos = clockNanos() + private var authenticated = false + fun authenticated() { authenticated = true } + fun remainingMillis(): Long { + if (unlimited && authenticated) return MAX_POLL_MILLIS + val budget = if (unlimited) HANDSHAKE_MILLIS else timeoutMillis + val elapsedNanos = (clockNanos() - startedNanos).coerceAtLeast(0) + val remainingNanos = budget * 1_000_000 - elapsedNanos + if (remainingNanos <= 0) return 0 + return ((remainingNanos + 999_999) / 1_000_000).coerceAtMost(MAX_POLL_MILLIS) + } + companion object { + const val HANDSHAKE_MILLIS = 60_000L + const val MAX_POLL_MILLIS = 30_000L + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt new file mode 100644 index 0000000..ce8e89c --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt @@ -0,0 +1,236 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.wire.Iap2CsmFramer +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import java.io.IOException +import java.util.ArrayDeque +import kotlin.math.min + +/** + * Blocking CSM facade over one owned [Iap2LinkChannel]. + * + * It keeps CSM framing separate from typed control messages. Sending waits for a completed iAP2 + * negotiation so every complete CSM frame can be split to the peer's advertised session-10 limit. + * One receiver owns the stateful framer; concurrent senders are serialized so their split frames + * cannot interleave. Closing this channel closes its link and the link's owned byte stream. + */ +class Iap2CsmChannel private constructor( + private val link: Iap2LinkChannel, +) : AutoCloseable { + private val stateLock = Object() + private val sendLock = Object() + private val receiveLock = Object() + private val framer = Iap2CsmFramer() + private val receivedFrames = ArrayDeque() + private var receivedBytes = 0 + private var closed = false + private var terminalFailure: Throwable? = null + + /** True once this channel is closed or has recorded a terminal transport failure. */ + val isClosed: Boolean + get() = synchronized(stateLock) { + closed || terminalFailure != null + } + + /** Waits for the underlying link to negotiate a writable session 10. */ + fun awaitReady(timeoutMillis: Long): Boolean { + requireTimeout(timeoutMillis) + try { + checkOpen() + } catch (failure: Error) { + fatal(failure) + } + return try { + val ready = link.awaitReady(timeoutMillis) + if (ready && link.peerMaxControlPayloadBytes() == null) { + throw IOException("iAP2 link became writable without a peer control payload limit") + } + ready + } catch (failure: Throwable) { + if (failure is Error) fatal(failure) + failClosed(failure) + } + } + + /** + * Queues one complete CSM frame. Call [awaitReady] successfully first. + * + * A false result from the link can mean that a previous chunk of this frame was accepted. In + * that case continuing would corrupt CSM framing, so the owned link is closed and an exception + * is thrown instead of returning a partial-send result. + */ + fun send(frame: Iap2Frame, timeoutMillis: Long = DEFAULT_SEND_TIMEOUT_MILLIS) { + requireTimeout(timeoutMillis) + synchronized(sendLock) { + try { + checkOpen() + } catch (failure: Error) { + fatal(failure) + } + val peerLimit = link.peerMaxControlPayloadBytes() + ?: throw IllegalStateException("CSM send requires a successful iAP2 awaitReady") + val chunks = Iap2CsmFramer.splitForLink(frame, peerLimit) + val deadlineNanos = deadlineAfter(timeoutMillis) + for (chunk in chunks) { + val queued = try { + link.sendControlAwaitCapacity(chunk, remainingMillis(deadlineNanos)) + } catch (failure: Throwable) { + if (failure is Error) fatal(failure) + failClosed(failure) + } + if (!queued) { + failClosed(IOException("iAP2 could not queue the complete CSM frame before its timeout")) + } + } + } + } + + /** + * Returns the next complete CSM frame, null on timeout or clean close, and throws on failure. + * A single underlying control payload can decode into several frames; the remainder stays in a + * bounded queue for later calls. + */ + fun recv(timeoutMillis: Long): Iap2Frame? { + requireTimeout(timeoutMillis) + synchronized(receiveLock) { + takeReceived()?.let { return it } + val deadlineNanos = deadlineAfter(timeoutMillis) + while (true) { + try { + checkOpen() + } catch (failure: Error) { + fatal(failure) + } + val control = try { + link.recvControl(remainingMillis(deadlineNanos)) + } catch (failure: Throwable) { + if (failure is Error) fatal(failure) + failClosed(failure) + } ?: return null + + val frames = try { + framer.offer(control) + } catch (failure: Throwable) { + if (failure is Error) fatal(failure) + failClosed(IOException("Invalid CSM control payload", failure)) + } + for (frame in frames) enqueue(frame) + takeReceived()?.let { return it } + if (remainingMillis(deadlineNanos) == 0L) return null + } + } + } + + /** Closes the owned iAP2 link and its owned byte stream. */ + override fun close() { + val close = synchronized(stateLock) { + if (closed) false else { + closed = true + true + } + } + if (!close) return + try { + link.close() + } catch (failure: Throwable) { + if (failure is Error) { + terminalize(failure) + throw failure + } + throw terminalize(failure) + } + } + + private fun enqueue(frame: Iap2Frame) { + val encodedBytes = frame.encodedFrame().size + if (receivedFrames.size >= MAX_PENDING_FRAMES || encodedBytes > MAX_PENDING_FRAME_BYTES - receivedBytes) { + failClosed(IOException("CSM received-frame queue limit exceeded")) + } + receivedFrames += frame + receivedBytes += encodedBytes + } + + private fun takeReceived(): Iap2Frame? { + val frame = receivedFrames.pollFirst() ?: return null + receivedBytes -= frame.encodedFrame().size + return frame + } + + private fun checkOpen() { + synchronized(stateLock) { + terminalFailure?.let { throw it } + if (closed) throw IOException("CSM channel is closed") + } + } + + /** Records a terminal error, breaks a blocked receiver, then always throws that error. */ + private fun failClosed(failure: Throwable): Nothing { + val terminal = terminalize(failure) + try { + close() + } catch (closeFailure: Throwable) { + if (closeFailure is Error) throw closeFailure + if (closeFailure !== terminal) terminal.addSuppressed(closeFailure) + } + throw terminal + } + + /** Closes best-effort before propagating the original fatal VM or transport error. */ + private fun fatal(error: Error): Nothing { + terminalize(error) + try { + close() + } catch (_: Throwable) { + // The original Error is the failure the caller must observe. + } + throw error + } + + private fun terminalize(failure: Throwable): Throwable { + synchronized(stateLock) { + val prior = terminalFailure + if (prior == null) { + terminalFailure = failure + return failure + } + if (prior !== failure) prior.addSuppressed(failure) + return prior + } + } + + private fun deadlineAfter(timeoutMillis: Long): Long { + return System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + } + + private fun remainingMillis(deadlineNanos: Long): Long { + val remaining = deadlineNanos - System.nanoTime() + if (remaining <= 0) return 0 + return min(MAX_TIMEOUT_MILLIS, (remaining + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND) + } + + private fun requireTimeout(timeoutMillis: Long) { + require(timeoutMillis in 0..MAX_TIMEOUT_MILLIS) { + "timeoutMillis must be in 0..$MAX_TIMEOUT_MILLIS" + } + } + + companion object { + private const val MAX_PENDING_FRAMES = 64 + private const val MAX_PENDING_FRAME_BYTES = 1_048_576 + private const val NANOS_PER_MILLISECOND = 1_000_000L + private const val MAX_TIMEOUT_MILLIS = 5 * 60 * 1_000L + private const val DEFAULT_SEND_TIMEOUT_MILLIS = 5_000L + + /** Opens the owned iAP2 link over an owned carkit stream. */ + fun open(underlying: BlockingDuplexByteStream): Iap2CsmChannel = + Iap2CsmChannel(Iap2LinkChannel.open(underlying)) + + /** Opens the owned iAP2 link over an owned wireless RFCOMM stream. */ + fun openWireless(underlying: BlockingDuplexByteStream): Iap2CsmChannel = + Iap2CsmChannel(Iap2LinkChannel.openWireless(underlying)) + + /** Opens the owned iAP2 link over an AirPlay type-130 tunnel. */ + fun openTunnel(underlying: BlockingDuplexByteStream): Iap2CsmChannel = + Iap2CsmChannel(Iap2LinkChannel.openTunnel(underlying)) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt new file mode 100644 index 0000000..18e5684 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt @@ -0,0 +1,288 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoints +import com.shilapi.xcertplay.iap2.message.Iap2Messages +import com.shilapi.xcertplay.iap2.session.Iap2Session +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import java.io.IOException +import kotlin.math.min + +/** The wireless transport identity advertised on the Bluetooth identification session. */ +class Iap2WirelessIdentification( + val bluetoothMac: String, + val ssid: String, +) { + private val macBytes: ByteArray + + init { + require(MAC_ADDRESS.matches(bluetoothMac)) { + "bluetoothMac must be six colon-separated hexadecimal bytes" + } + require(ssid.isNotBlank()) { "Wireless SSID must not be blank" } + require('\u0000' !in ssid) { "Wireless SSID must not contain U+0000" } + macBytes = ByteArray(6) { index -> + bluetoothMac.substring(index * 3, index * 3 + 2).toInt(16).toByte() + } + } + + internal fun bluetoothMacBytes(): ByteArray = macBytes.copyOf() + + private companion object { + private val MAC_ADDRESS = Regex("^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$") + } +} + +/** Required identity for the minimal wired or wireless iAP2 identification exchange. */ +data class Iap2IdentificationConfig( + val name: String, + val modelIdentifier: String, + val manufacturer: String, + val serialNumber: String, + val firmwareVersion: String, + val hardwareVersion: String, + /** The iPhone USB interface used by CarPlay, supplied explicitly by the deployment. */ + val carPlayUsbInterfaceNumber: Int, + val language: String = "en", + /** This app's own EA protocol identifier; it deliberately does not claim a CarPlay EA flag. */ + val externalAccessoryProtocol: String = "com.shilapi.xcertplay", + /** Non-null selects the wireless Bluetooth and WirelessCarPlay transport components. */ + val wireless: Iap2WirelessIdentification? = null, + /** Advertises and enables iAP2 LocationInformation from the accessory to the phone. */ + val locationInformationEnabled: Boolean = false, +) { + constructor( + name: String, + modelIdentifier: String, + manufacturer: String, + serialNumber: String, + firmwareVersion: String, + hardwareVersion: String, + wireless: Iap2WirelessIdentification, + language: String = "en", + externalAccessoryProtocol: String = "com.shilapi.xcertplay", + ) : this( + name = name, + modelIdentifier = modelIdentifier, + manufacturer = manufacturer, + serialNumber = serialNumber, + firmwareVersion = firmwareVersion, + hardwareVersion = hardwareVersion, + carPlayUsbInterfaceNumber = 0, + language = language, + externalAccessoryProtocol = externalAccessoryProtocol, + wireless = wireless, + ) + + init { + listOf(name, modelIdentifier, manufacturer, serialNumber, firmwareVersion, hardwareVersion).forEach { + require(it.isNotBlank()) { "Identification identity strings must not be blank" } + } + require(language.isNotBlank()) { "Identification language must not be blank" } + require(externalAccessoryProtocol.isNotBlank()) { "External accessory protocol must not be blank" } + listOf( + name, + modelIdentifier, + manufacturer, + serialNumber, + firmwareVersion, + hardwareVersion, + language, + externalAccessoryProtocol, + ).forEach { + require('\u0000' !in it) { "NUL-terminated identification strings must not contain U+0000" } + } + require(carPlayUsbInterfaceNumber in 0..0xff) { + "carPlayUsbInterfaceNumber must be in 0..255" + } + } +} + +/** Identification failures distinguished from the underlying iAP2 transport failure. */ +sealed class Iap2IdentificationException(message: String) : IOException(message) { + class Rejected(parameterIds: Set) : Iap2IdentificationException( + "iAP2 identification rejected parameters ${parameterIds.sorted().joinToString(prefix = "[", postfix = "]") { "0x${it.toString(16).padStart(4, '0')}" }}; " + + "this minimal identification profile has no optional components to remove", + ) { + val parameterIds: Set = parameterIds.toSet() + } + + class UnexpectedMessage(messageId: Int) : Iap2IdentificationException( + "Unexpected iAP2 identification message 0x${messageId.toString(16).padStart(4, '0')}", + ) +} + +/** + * Synchronous accessory-side wired or wireless identification over an already owned CSM channel. + * + * This is intentionally only identification: it neither invokes MFi nor itself starts any + * CarPlay, subscription, power, media, or UI service. + */ +class Iap2IdentificationClient(private val session: Iap2Session) { + /** Waits for link negotiation, then completes the 1D00/1D01/1D02 exchange. */ + @Throws(IphoneUsbException::class, Iap2IdentificationException::class) + fun identify(config: Iap2IdentificationConfig, timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS) { + require(timeoutMillis in 1..MAXIMUM_TIMEOUT_MILLIS) { + "timeoutMillis must be in 1..$MAXIMUM_TIMEOUT_MILLIS" + } + val deadlineNanos = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + if (!session.awaitReady(remainingMillis(deadlineNanos))) { + throw IphoneUsbException.TimedOut("Timed out waiting for iAP2 control session readiness") + } + + while (true) { + val frame = session.recv(remainingMillis(deadlineNanos)) + ?: throw IphoneUsbException.TimedOut("Timed out waiting for iAP2 identification") + when (frame.messageId) { + START_IDENTIFICATION -> session.send(identificationInformation(config), remainingMillis(deadlineNanos)) + IDENTIFICATION_ACCEPTED -> return + IDENTIFICATION_REJECTED -> { + val rejected = Iap2BodyReader.of(frame).list().mapTo(LinkedHashSet()) { it.id } + throw Iap2IdentificationException.Rejected(rejected) + } + else -> throw Iap2IdentificationException.UnexpectedMessage(frame.messageId) + } + } + } + + companion object { + const val START_IDENTIFICATION = 0x1d00 + const val IDENTIFICATION_INFORMATION = 0x1d01 + const val IDENTIFICATION_ACCEPTED = 0x1d02 + const val IDENTIFICATION_REJECTED = 0x1d03 + + private const val DEFAULT_TIMEOUT_MILLIS = 10_000L + private const val MAXIMUM_TIMEOUT_MILLIS = 5 * 60 * 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + + /** Builds the smallest honest LIVI-compatible wired or wireless IdentificationInformation. */ + fun identificationInformation(config: Iap2IdentificationConfig): Iap2Frame { + val wireless = config.wireless + val sentMessages = if (config.locationInformationEnabled) { + MESSAGES_SENT_BY_ACCESSORY + LOCATION_INFORMATION + } else { + MESSAGES_SENT_BY_ACCESSORY + } + val receivedMessages = if (config.locationInformationEnabled) { + MESSAGES_RECEIVED_FROM_PHONE + START_LOCATION_INFORMATION + STOP_LOCATION_INFORMATION + } else { + MESSAGES_RECEIVED_FROM_PHONE + } + return Iap2Messages.build(Iap2Endpoints.IDENTIFICATION_INFORMATION) { + string(0, config.name) + string(1, config.modelIdentifier) + string(2, config.manufacturer) + string(3, config.serialNumber) + string(4, config.firmwareVersion) + string(5, config.hardwareVersion) + u16List( + 6, + if (wireless == null) { + sentMessages.asIterable() + } else { + sentMessages.filterNot { it == POWER_SOURCE_UPDATE }.toIntArray().asIterable() + + ACCESSORY_WIFI_CONFIGURATION_INFORMATION + }, + ) + u16List( + 7, + if (wireless == null) { + receivedMessages.asIterable() + } else { + receivedMessages.asIterable() + WIRELESS_PHONE_MESSAGES.asIterable() + }, + ) + u8(8, if (wireless == null) 2 else 0) + u16(9, 20) + group(10) { + u8(0, 1) + string(1, config.externalAccessoryProtocol) + u8(2, 0) + } + string(12, config.language) + strings(13, listOf(config.language)) + if (wireless == null) { + group(16) { + u16(0, 0) + string(1, "USBHostTransport") + void(2) + u8(3, config.carPlayUsbInterfaceNumber) + void(4) + } + } else { + group(17) { + u16(0, 0) + string(1, "blue") + void(2) + bytes(3, wireless.bluetoothMacBytes()) + string(4, "blue") + void(5) + } + group(24) { + u16(0, 1) + string(1, wireless.ssid) + void(2) + u16(3, 1) + void(4) + void(5) + } + } + if (config.locationInformationEnabled) { + group(22) { + u16(0, 0) + string(1, config.name) + void(17) + void(18) + } + } + } + } + + private fun remainingMillis(deadlineNanos: Long): Long { + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) throw IphoneUsbException.TimedOut("iAP2 identification timed out") + return min(MAXIMUM_TIMEOUT_MILLIS, (remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND) + } + + /* Include the authentication messages implemented by Iap2MfiAuthenticationClient. */ + private val MESSAGES_SENT_BY_ACCESSORY = intArrayOf( + 0xaa01, // AuthenticationCertificate + 0xaa03, // AuthenticationResponse + 0x5000, // StartNowPlayingUpdates + 0x5002, // StopNowPlayingUpdates + 0x5200, // StartRouteGuidanceUpdates + 0x5203, // StopRouteGuidanceUpdates + 0xae00, // StartPowerUpdates + 0xae02, // StopPowerUpdates + 0x4157, // StartCommunicationsUpdates + 0x4159, // StopCommunicationsUpdates + 0x4154, // StartCallStateUpdates + 0x4156, // StopCallStateUpdates + 0xae03, // PowerSourceUpdate + 0x4301, // CarPlayStartSession + ) + private val MESSAGES_RECEIVED_FROM_PHONE = intArrayOf( + 0xaa00, // RequestAuthenticationCertificate + 0xaa02, // RequestAuthenticationChallengeResponse + 0xaa04, // AuthenticationFailed + 0xaa05, // AuthenticationSucceeded + 0xea00, // StartExternalAccessoryProtocolSession + 0xea01, // StopExternalAccessoryProtocolSession + 0x5001, // NowPlayingUpdate + 0x5201, // RouteGuidanceUpdate + 0x5202, // RouteGuidanceManeuverUpdate + 0xae01, // PowerUpdate + 0x4158, // CommunicationsUpdate + 0x4155, // CallStateUpdate + 0x4300, // CarPlayAvailability + ) + private const val POWER_SOURCE_UPDATE = 0xae03 + private const val CARPLAY_AVAILABILITY = 0x4300 + private const val CARPLAY_START_SESSION = 0x4301 + private const val ACCESSORY_WIFI_CONFIGURATION_INFORMATION = 0x5703 + private const val LOCATION_INFORMATION = 0xfffb + private const val START_LOCATION_INFORMATION = 0xfffa + private const val STOP_LOCATION_INFORMATION = 0xfffc + private val WIRELESS_PHONE_MESSAGES = intArrayOf(0x4e0d, 0x4e0e, 0x5702) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt new file mode 100644 index 0000000..7e56590 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt @@ -0,0 +1,375 @@ +package com.shilapi.xcertplay.transport + +import java.io.IOException +import java.util.ArrayDeque +import kotlin.math.min + +/** + * Blocking facade for an iAP2 link over a caller-supplied byte stream. + * + * [open] starts one worker. That worker is the only code that drives [Iap2LinkEngine] or calls + * [BlockingDuplexByteStream.send]/[BlockingDuplexByteStream.recv]. Callers may concurrently wait + * for readiness, queue complete session-10 payloads, receive session-10 payloads, or close this + * channel. This class owns and closes [underlying]. + * + * It deliberately does not parse CSM control messages or implement EA, file transfer, media, UI, + * or any Lockdown setup. + */ +class Iap2LinkChannel private constructor( + private val underlying: BlockingDuplexByteStream, + private val linkConfig: Iap2LinkConfig, + private val initiateNegotiation: Boolean, +) : AutoCloseable { + private data class Command(val control: ByteArray) + + private val lock = Object() + private val commands = ArrayDeque() + private var commandBytes = 0 + private val controls = ArrayDeque() + private var controlBytes = 0 + + private var ready = false + private var peerMaxControlPayloadBytes: Int? = null + private var closing = false + private var terminated = false + private var terminalFailure: Throwable? = null + private var streamCloseStarted = false + + private val worker = Thread(::runPump, "xcertplay-iap2-link").apply { + isDaemon = true + } + + /** Waits until session 10 is writable; returns false on timeout or clean close, and throws on failure. */ + fun awaitReady(timeoutMillis: Long): Boolean { + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + synchronized(lock) { + waitFor(timeoutMillis) { (ready && !terminated) || terminated } + throwTerminalFailureLocked() + return ready && !terminated + } + } + + /** + * The peer's negotiated maximum session-10 payload length, available after [awaitReady] + * returns true. It excludes the nine-byte iAP2 header and one-byte payload checksum. + */ + fun peerMaxControlPayloadBytes(): Int? = synchronized(lock) { peerMaxControlPayloadBytes } + + /** + * Copies and queues one raw session-10 payload. Returns false after close/termination or when + * the bounded command queue is full. A peer LSP length violation is discovered by the worker + * when it reaches the engine and terminates the channel. + */ + fun sendControl(bytes: ByteArray): Boolean { + require(bytes.size <= Iap2LinkEngine.MAX_PAYLOAD_BYTES) { + "iAP2 control payload exceeds ${Iap2LinkEngine.MAX_PAYLOAD_BYTES} bytes" + } + val copy = bytes.copyOf() + synchronized(lock) { + if (terminated || closing) return false + return enqueueCommandLocked(copy) + } + } + + /** + * Like [sendControl], but waits for command-queue capacity. CSM uses this to queue every + * fragment of one frame without converting link backpressure into a partial CSM write. + */ + internal fun sendControlAwaitCapacity(bytes: ByteArray, timeoutMillis: Long): Boolean { + require(bytes.size <= Iap2LinkEngine.MAX_PAYLOAD_BYTES) { + "iAP2 control payload exceeds ${Iap2LinkEngine.MAX_PAYLOAD_BYTES} bytes" + } + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + val copy = bytes.copyOf() + synchronized(lock) { + waitFor(timeoutMillis) { + terminated || closing || hasCommandCapacityLocked(copy.size) + } + throwTerminalFailureLocked() + if (terminated || closing) return false + return enqueueCommandLocked(copy) + } + } + + /** Returns the next control payload; returns null on timeout or clean close, and throws on failure. */ + fun recvControl(timeoutMillis: Long): ByteArray? { + require(timeoutMillis >= 0) { "timeoutMillis must not be negative" } + synchronized(lock) { + waitFor(timeoutMillis) { controls.isNotEmpty() || terminated } + throwTerminalFailureLocked() + if (controls.isEmpty()) return null + val bytes = controls.removeFirst() + controlBytes -= bytes.size + return bytes + } + } + + /** + * Marks the channel closed, directly closes its owned stream to break a blocking recv, then + * waits a bounded time for the worker. Stream or worker failures are rethrown, including + * [Error]s; they are never silently discarded. + */ + override fun close() { + synchronized(lock) { + if (!closing) { + closing = true + terminalLocked(null) + } + } + + var failure = closeUnderlyingOnce() + if (failure != null) { + synchronized(lock) { + terminalFailure = combineFailures(terminalFailure, failure) + lock.notifyAll() + } + } + try { + worker.join(CLOSE_JOIN_MILLIS) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + failure = combineFailures(failure, interrupted) + } + if (worker.isAlive) { + failure = combineFailures(failure, IOException("iAP2 link worker did not stop after close")) + } + synchronized(lock) { + failure = combineFailures(failure, terminalFailure) + } + if (failure != null) throw failure + } + + private fun runPump() { + val engine = Iap2LinkEngine(linkConfig) + try { + engine.start(wiredInitiator = initiateNegotiation, nowMillis = nowMillis()) + while (!isClosing()) { + drainCommands(engine) + engine.advanceTime(nowMillis()) + flush(engine) + if (!drainEvents(engine)) return + if (isClosing()) return + + val next = engine.nextDeadlineMillis() + val waitMillis = min( + RECEIVE_POLL_MILLIS, + next?.let { (it - nowMillis()).coerceAtLeast(0) } ?: RECEIVE_POLL_MILLIS, + ).coerceAtLeast(MINIMUM_RECEIVE_POLL_MILLIS) + val received = underlying.recv(RECEIVE_CHUNK_BYTES, waitMillis) + if (isClosing()) return + when { + received == null -> Unit + received.isEmpty() -> engine.feedEof() + else -> engine.feed(received, nowMillis()) + } + } + finish(null) + } catch (failure: Throwable) { + if (failure is Error) { + finish(failure) + throw failure + } + if (isClosing()) finish(null) else finish(failure) + } + } + + private fun drainCommands(engine: Iap2LinkEngine) { + while (engine.writable()) { + val command = synchronized(lock) { + if (commands.isEmpty()) { + null + } else { + commands.removeFirst().also { + commandBytes -= it.control.size + lock.notifyAll() + } + } + } ?: return + engine.sendControl(command.control, nowMillis()) + if (isClosing()) return + } + } + + private fun flush(engine: Iap2LinkEngine) { + val bytes = engine.takeOutput() + if (bytes.isNotEmpty()) underlying.send(bytes) + } + + /** Returns false when a link terminal event has been observed. */ + private fun drainEvents(engine: Iap2LinkEngine): Boolean { + while (true) { + when (val event = engine.pollEvent() ?: return true) { + is Iap2LinkEngine.Event.Writable -> synchronized(lock) { + if (event.value) { + // The engine only emits writable after validating this is positive. + peerMaxControlPayloadBytes = engine.peerSynchronization().maxLength - 10 + } + ready = event.value && !terminated + lock.notifyAll() + } + + is Iap2LinkEngine.Event.Control -> { + val overflow = synchronized(lock) { + if (controls.size >= MAX_PENDING_CONTROLS || + event.bytes.size > MAX_PENDING_CONTROL_BYTES - controlBytes + ) { + true + } else { + controls += event.bytes + controlBytes += event.bytes.size + lock.notifyAll() + false + } + } + if (overflow) { + finish(IOException("iAP2 received-control queue limit exceeded")) + return false + } + } + + is Iap2LinkEngine.Event.Dead -> { + finish(IOException(event.reason ?: "iAP2 link ended")) + return false + } + } + } + } + + private fun finish(failure: Throwable?) { + synchronized(lock) { + finishLocked(failure) + } + closeUnderlyingOnce()?.let { closeFailure -> + synchronized(lock) { + terminalFailure = combineFailures(terminalFailure, closeFailure) + lock.notifyAll() + } + } + } + + private fun finishLocked(failure: Throwable?) { + if (!terminated) terminalLocked(failure) else if (failure != null) { + terminalFailure = combineFailures(terminalFailure, failure) + lock.notifyAll() + } + } + + private fun terminalLocked(failure: Throwable?) { + terminated = true + ready = false + commands.clear() + commandBytes = 0 + controls.clear() + controlBytes = 0 + terminalFailure = combineFailures(terminalFailure, failure) + lock.notifyAll() + } + + /** lock must already be held. */ + private fun throwTerminalFailureLocked() { + terminalFailure?.let { throw it } + } + + private fun isClosing(): Boolean = synchronized(lock) { closing || terminated } + + private fun closeUnderlyingOnce(): Throwable? { + synchronized(lock) { + if (streamCloseStarted) return null + streamCloseStarted = true + } + return try { + underlying.close() + null + } catch (failure: Throwable) { + failure + } + } + + /** lock must already be held. */ + private fun hasCommandCapacityLocked(bytes: Int): Boolean = + commands.size < MAX_PENDING_COMMANDS && bytes <= MAX_PENDING_COMMAND_BYTES - commandBytes + + /** lock must already be held. */ + private fun enqueueCommandLocked(bytes: ByteArray): Boolean { + if (!hasCommandCapacityLocked(bytes.size)) return false + commands += Command(bytes) + commandBytes += bytes.size + lock.notifyAll() + return true + } + + /** lock must already be held. */ + private fun waitFor(timeoutMillis: Long, readyPredicate: () -> Boolean): Boolean { + if (readyPredicate()) return true + if (timeoutMillis == 0L) return false + val budgetNanos = timeoutMillis.coerceAtMost(MAX_WAIT_MILLIS) * NANOS_PER_MILLISECOND + val startedNanos = System.nanoTime() + while (!readyPredicate()) { + val elapsedNanos = System.nanoTime() - startedNanos + if (elapsedNanos >= budgetNanos) return false + val remainingNanos = budgetNanos - elapsedNanos + try { + lock.wait( + remainingNanos / NANOS_PER_MILLISECOND, + (remainingNanos % NANOS_PER_MILLISECOND).toInt(), + ) + } catch (interrupted: InterruptedException) { + Thread.currentThread().interrupt() + return false + } + } + return true + } + + private fun combineFailures(first: Throwable?, second: Throwable?): Throwable? { + if (first == null) return second + if (second != null && second !== first) first.addSuppressed(second) + return first + } + + companion object { + private const val RECEIVE_CHUNK_BYTES = 8_192 + private const val RECEIVE_POLL_MILLIS = 100L + private const val MINIMUM_RECEIVE_POLL_MILLIS = 1L + private const val CLOSE_JOIN_MILLIS = 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + private const val MAX_WAIT_MILLIS = Long.MAX_VALUE / NANOS_PER_MILLISECOND + private const val MAX_PENDING_COMMANDS = 64 + private const val MAX_PENDING_COMMAND_BYTES = 1_048_576 + private const val MAX_PENDING_CONTROLS = 64 + private const val MAX_PENDING_CONTROL_BYTES = 1_048_576 + + private val WIRED_LINK_CONFIG = Iap2LinkConfig( + maxOutgoing = 4, + controlSessionVersion = 2, + zeroAcknowledgements = true, + ) + private val WIRELESS_LINK_CONFIG = Iap2LinkConfig( + maxOutgoing = 4, + controlSessionVersion = 2, + ) + + /** Opens and immediately starts a wired iAP2 link, taking ownership of the supplied stream. */ + fun open(underlying: BlockingDuplexByteStream): Iap2LinkChannel = + Iap2LinkChannel(underlying, WIRED_LINK_CONFIG, initiateNegotiation = true) + .also { it.worker.start() } + + /** + * Opens a wireless RFCOMM link. LIVI sends the iAP2 marker but lets the phone initiate + * synchronization, and keeps acknowledgements enabled for Bluetooth. + */ + fun openWireless(underlying: BlockingDuplexByteStream): Iap2LinkChannel = + Iap2LinkChannel(underlying, WIRELESS_LINK_CONFIG, initiateNegotiation = false) + .also { it.worker.start() } + + /** + * Opens the iAP2 link carried by an AirPlay type-130 tunnel. The accessory initiates + * synchronization and zero-acknowledgement mode matches the Wi-Fi transport. + */ + fun openTunnel(underlying: BlockingDuplexByteStream): Iap2LinkChannel = + Iap2LinkChannel(underlying, WIRED_LINK_CONFIG, initiateNegotiation = true) + .also { it.worker.start() } + + private fun nowMillis(): Long = System.nanoTime() / NANOS_PER_MILLISECOND + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkEngine.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkEngine.kt new file mode 100644 index 0000000..4fd7d65 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkEngine.kt @@ -0,0 +1,710 @@ +package com.shilapi.xcertplay.transport + +import java.io.ByteArrayOutputStream +import java.util.ArrayDeque + +/** + * The iAP2 link layer, deliberately kept independent from I/O, threads, CSM, and media. + * + * The caller supplies a monotonic millisecond clock, writes [takeOutput] to its byte stream, and + * feeds arbitrary receive fragments back through [feed]. This class only exposes control-session + * (session 10) bytes. It does not open a Lockdown connection or own a [BlockingDuplexByteStream]. + * + * An inbound wire frame is limited to the largest u16 length (`65535` bytes total). Pending + * outbound and out-of-order packets are additionally capped by [Iap2LinkConfig] (64 by default). + * Pending output is capped at 1 MiB and pending events at 256 entries by default, so neither peer + * input nor a caller that temporarily stops draining the engine can grow memory without bound. + */ +class Iap2LinkEngine( + private val config: Iap2LinkConfig = Iap2LinkConfig(), +) { + enum class State { + IDLE, + DETECTING, + NEGOTIATING, + NORMAL, + DEAD, + } + + sealed class Event { + data class Control(val bytes: ByteArray) : Event() + data class Writable(val value: Boolean) : Event() + data class Dead(val reason: String?) : Event() + } + + data class SessionDescriptor( + val id: Int, + val kind: Int, + val version: Int, + ) { + init { + require(id in 0..0xff) + require(kind in 0..0xff) + require(version in 0..0xff) + } + } + + data class SynchronizationPayload( + val maxOutgoing: Int, + val maxLength: Int, + val retransmissionTimeoutMillis: Int, + val acknowledgementTimeoutMillis: Int, + val maxRetransmissions: Int, + val maxAcknowledgements: Int, + val sessions: List, + ) { + init { + require(maxOutgoing in 0..0xff) + require(maxLength in 0..MAX_WIRE_FRAME_BYTES) + require(retransmissionTimeoutMillis in 0..0xffff) + require(acknowledgementTimeoutMillis in 0..0xffff) + require(maxRetransmissions in 0..0xff) + require(maxAcknowledgements in 0..0xff) + } + + fun encode(): ByteArray { + val out = ByteArrayOutputStream(10 + sessions.size * SESSION_DESCRIPTOR_BYTES) + out.write(SYNCHRONIZATION_VERSION) + out.write(maxOutgoing) + writeU16(out, maxLength) + writeU16(out, retransmissionTimeoutMillis) + writeU16(out, acknowledgementTimeoutMillis) + out.write(maxRetransmissions) + out.write(maxAcknowledgements) + sessions.forEach { + out.write(it.id) + out.write(it.kind) + out.write(it.version) + } + return out.toByteArray() + } + + companion object { + fun decode(bytes: ByteArray): SynchronizationPayload? { + if (bytes.size < SYNCHRONIZATION_FIXED_BYTES || u8(bytes[0]) != SYNCHRONIZATION_VERSION) { + return null + } + val descriptors = ArrayList() + var offset = SYNCHRONIZATION_FIXED_BYTES + while (offset + SESSION_DESCRIPTOR_BYTES <= bytes.size) { + descriptors += SessionDescriptor(u8(bytes[offset]), u8(bytes[offset + 1]), u8(bytes[offset + 2])) + offset += SESSION_DESCRIPTOR_BYTES + } + return SynchronizationPayload( + maxOutgoing = u8(bytes[1]), + maxLength = readU16(bytes, 2), + retransmissionTimeoutMillis = readU16(bytes, 4), + acknowledgementTimeoutMillis = readU16(bytes, 6), + maxRetransmissions = u8(bytes[8]), + maxAcknowledgements = u8(bytes[9]), + sessions = descriptors, + ) + } + } + } + + private data class Header( + val length: Int, + val control: Int, + val sequence: Int, + val acknowledgement: Int, + val sessionId: Int, + ) + + private data class Packet( + var sequence: Int, + val sessionId: Int, + val payload: ByteArray, + var retransmissions: Int = 0, + var deadlineMillis: Long = 0, + ) + + private var state = State.IDLE + private var peerSynchronization = localSynchronization() + private var peerSynchronizationReceived = false + private var sentSequence = INITIAL_SEQUENCE + private var lastSentAcknowledged: Int? = null + private var lastReceivedInOrder = 0 + private var lastAcknowledgedReceived: Int? = null + private var accumulatedAcknowledgements = 0 + private var writable = false + + private val unacknowledged = ArrayList() + private val queued = ArrayDeque() + private val outOfOrder = ArrayList() + private val events = ArrayDeque() + private val output = ByteArrayOutputStream() + private val receive = ReceiveBuffer() + private var awaitingPayload: Header? = null + + private var markerDeadlineMillis: Long? = null + private var synchronizationDeadlineMillis: Long? = null + private var sendAcknowledgementDeadlineMillis: Long? = null + private var receiveAcknowledgementDeadlineMillis: Long? = null + + fun state(): State = state + + fun writable(): Boolean = writable + + fun peerSynchronization(): SynchronizationPayload = peerSynchronization + + fun takeOutput(): ByteArray { + val bytes = output.toByteArray() + output.reset() + return bytes + } + + fun pollEvent(): Event? = if (events.isEmpty()) null else events.removeFirst() + + fun nextDeadlineMillis(): Long? = listOfNotNull( + markerDeadlineMillis, + synchronizationDeadlineMillis, + sendAcknowledgementDeadlineMillis, + receiveAcknowledgementDeadlineMillis, + ).minOrNull() + + /** Starts a link attempt. Wired CarKit uses [wiredInitiator] = true. */ + fun start(wiredInitiator: Boolean, nowMillis: Long) { + if (state != State.IDLE) return + state = State.DETECTING + if (!appendOutput(IAP2_MARKER)) return + markerDeadlineMillis = nowMillis + MARKER_RESEND_MILLIS + if (wiredInitiator && state != State.DEAD) enterNegotiating(nowMillis) + } + + /** Supplies any received byte fragment; empty fragments are harmless. */ + fun feed(bytes: ByteArray, nowMillis: Long) { + if (state == State.DEAD || bytes.isEmpty()) return + var offset = 0 + while (offset < bytes.size && state != State.DEAD) { + val room = receive.remainingCapacity() + if (room == 0) { + if (!parseAvailable(nowMillis)) { + die("iAP2 inbound frame exceeds $MAX_WIRE_FRAME_BYTES bytes") + return + } + continue + } + val count = minOf(room, bytes.size - offset) + receive.append(bytes, offset, count) + offset += count + while (parseAvailable(nowMillis) && state != State.DEAD) { + // Drain all complete frames before accepting more input. + } + } + } + + /** The byte stream ended. */ + fun feedEof() { + die(null) + } + + /** Advances all due protocol timers with the caller's monotonic clock. */ + fun advanceTime(nowMillis: Long) { + while (state != State.DEAD) { + val deadline = nextDeadlineMillis() ?: return + if (deadline > nowMillis) return + when { + markerDeadlineMillis?.let { it <= nowMillis } == true -> { + markerDeadlineMillis = null + if (state == State.DETECTING) { + if (appendOutput(IAP2_MARKER)) { + markerDeadlineMillis = nowMillis + MARKER_RESEND_MILLIS + } + } + } + + synchronizationDeadlineMillis?.let { it <= nowMillis } == true -> { + synchronizationDeadlineMillis = null + if (state == State.NEGOTIATING) { + sendSynchronization() + if (state != State.DEAD) { + synchronizationDeadlineMillis = nowMillis + SYNCHRONIZATION_RESEND_MILLIS + } + } + } + + sendAcknowledgementDeadlineMillis?.let { it <= nowMillis } == true -> { + sendAcknowledgementDeadlineMillis = null + if (state == State.NORMAL) { + lastAcknowledgedReceived = lastReceivedInOrder + sendAcknowledgement() + } + } + + receiveAcknowledgementDeadlineMillis?.let { it <= nowMillis } == true -> { + receiveAcknowledgementDeadlineMillis = null + retransmitDuePacket(nowMillis) + } + } + } + } + + /** Queues a complete raw control-session payload (session id 10). */ + fun sendControl(bytes: ByteArray, nowMillis: Long) { + require(bytes.size <= MAX_PAYLOAD_BYTES) { + "iAP2 control payload exceeds $MAX_PAYLOAD_BYTES bytes" + } + if (peerSynchronizationReceived || state == State.NORMAL) { + require(peerPayloadIsAcceptable(bytes)) { + "iAP2 control payload exceeds peer maxLength ${peerSynchronization.maxLength}" + } + } + sendPacket(Packet(0, CONTROL_SESSION_ID, bytes.copyOf()), nowMillis) + } + + private fun parseAvailable(nowMillis: Long): Boolean { + if (state == State.DEAD) return false + if (state == State.DETECTING) { + if (receive.size < IAP2_MARKER.size) return false + if (!receive.matches(IAP2_MARKER)) { + die("iAP2 marker was not received") + return false + } + receive.discard(IAP2_MARKER.size) + enterNegotiating(nowMillis) + return true + } + + val pending = awaitingPayload + if (pending != null) { + val payloadWithChecksumBytes = pending.length - HEADER_BYTES + if (receive.size < payloadWithChecksumBytes) return false + val payloadWithChecksum = receive.take(payloadWithChecksumBytes) + awaitingPayload = null + if (checksumValid(payloadWithChecksum)) { + processFrame( + pending, + payloadWithChecksum.copyOf(payloadWithChecksum.size - CHECKSUM_BYTES), + nowMillis, + ) + } + return true + } + + var discarded = false + while (receive.size >= 2 && (receive[0] != LINK_START_HIGH || receive[1] != LINK_START_LOW)) { + receive.discard(1) + discarded = true + } + if (receive.size < HEADER_BYTES) return discarded + val rawHeader = receive.take(HEADER_BYTES) + val header = parseHeader(rawHeader) + if (header == null || header.length < HEADER_BYTES) return true + if (header.length > MAX_WIRE_FRAME_BYTES) { + die("iAP2 frame length ${header.length} exceeds $MAX_WIRE_FRAME_BYTES") + return false + } + if (header.length == HEADER_BYTES) { + processFrame(header, null, nowMillis) + } else { + awaitingPayload = header + } + return true + } + + private fun enterNegotiating(nowMillis: Long) { + state = State.NEGOTIATING + markerDeadlineMillis = null + sendSynchronization() + if (state != State.DEAD) { + synchronizationDeadlineMillis = nowMillis + SYNCHRONIZATION_RESEND_MILLIS + } + } + + private fun processFrame(header: Header, payload: ByteArray?, nowMillis: Long) { + if ((header.control and CONTROL_RESET) != 0) { + die("peer sent iAP2 reset") + return + } + if ((header.control and CONTROL_SYNCHRONIZE) != 0) { + SynchronizationPayload.decode(payload ?: ByteArray(0))?.let { + handleSynchronization(it, header.sequence) + } + } + if ((header.control and CONTROL_ACKNOWLEDGEMENT) != 0) { + accumulatedAcknowledgements += 1 + handleAcknowledgement(header.acknowledgement, nowMillis) + } + if ((header.control and CONTROL_EXTENDED_ACKNOWLEDGEMENT) != 0 && payload != null) { + handleExtendedAcknowledgement(payload) + } + if ((header.control and CONTROL_ACKNOWLEDGEMENT.inv()) == 0 && payload != null) { + handleData(Packet(header.sequence, header.sessionId, payload), nowMillis) + } + if (peerSynchronization.maxAcknowledgements > 0 && + accumulatedAcknowledgements >= peerSynchronization.maxAcknowledgements + ) { + accumulatedAcknowledgements = 0 + lastAcknowledgedReceived = lastReceivedInOrder + sendAcknowledgement() + } + } + + private fun handleSynchronization(sync: SynchronizationPayload, sequence: Int) { + if (state != State.NEGOTIATING) return + peerSynchronization = sync + peerSynchronizationReceived = true + lastReceivedInOrder = sequence + lastAcknowledgedReceived = sequence + sendAcknowledgement() + } + + private fun handleAcknowledgement(acknowledgement: Int, nowMillis: Long) { + if (state == State.NEGOTIATING) { + if (!peerSynchronizationReceived || !peerLimitsAreUsable() || queued.any { !peerPayloadIsAcceptable(it.payload) }) { + die("peer iAP2 maxLength cannot carry pending control packets") + return + } + state = State.NORMAL + synchronizationDeadlineMillis = null + setWritable(true) + } + lastSentAcknowledged = acknowledgement + + var rearmed = false + while (unacknowledged.isNotEmpty()) { + val first = unacknowledged.first() + val distance = sequenceDistance(first.sequence, lastSentAcknowledged) + if (distance > 0 && distance <= peerSynchronization.maxAcknowledgements + ACK_SLACK) { + receiveAcknowledgementDeadlineMillis = first.deadlineMillis + rearmed = true + break + } + unacknowledged.removeAt(0) + } + if (!rearmed) receiveAcknowledgementDeadlineMillis = null + + while (sequenceDistance(sentSequence, lastSentAcknowledged) < peerSynchronization.maxOutgoing) { + val packet = queued.pollFirst() ?: break + sendPacket(packet, nowMillis) + setWritable(true) + } + } + + private fun handleExtendedAcknowledgement(missing: ByteArray) { + if (state != State.NORMAL) return + for (packet in unacknowledged) { + if (!missing.any { u8(it) == packet.sequence }) continue + packet.retransmissions += 1 + if (packet.retransmissions == peerSynchronization.maxRetransmissions) { + die("iAP2 packet ${packet.sequence} was not acknowledged") + return + } + sendData(packet.sequence, packet.sessionId, packet.payload) + sendAcknowledgementDeadlineMillis = null + receiveAcknowledgementDeadlineMillis = packet.deadlineMillis + } + } + + private fun handleData(packet: Packet, nowMillis: Long) { + val distance = sequenceDistance(packet.sequence, lastReceivedInOrder) + if (distance == 0 || distance > peerSynchronization.maxOutgoing + ACK_SLACK) { + sendAcknowledgement() + return + } + if (outOfOrder.any { it.sequence == packet.sequence }) return + if (outOfOrder.size >= config.maximumOutOfOrderPackets) { + die("iAP2 out-of-order packet limit exceeded") + return + } + outOfOrder += packet + if (distance > 1) { + if (distance >= peerSynchronization.maxOutgoing) { + val missing = ArrayList() + var sequence = lastReceivedInOrder + while (sequenceDistance(packet.sequence, sequence) > 1) { + sequence = nextSequence(sequence) + missing += sequence + } + sendAcknowledgementDeadlineMillis = null + sendExtendedAcknowledgement(missing) + } + return + } + + outOfOrder.sortBy { sequenceDistance(it.sequence, lastReceivedInOrder) } + while (outOfOrder.isNotEmpty() && sequenceDistance(outOfOrder.first().sequence, lastReceivedInOrder) == 1) { + val inOrder = outOfOrder.removeAt(0) + lastReceivedInOrder = inOrder.sequence + if (inOrder.sessionId == CONTROL_SESSION_ID) enqueueEvent(Event.Control(inOrder.payload)) + } + + if (peerSynchronization.maxAcknowledgements == 0) return + val windowBeforeForcedAck = (peerSynchronization.maxOutgoing - config.maxOutgoingDelta).coerceAtLeast(1) + if (sequenceDistance(lastReceivedInOrder, lastAcknowledgedReceived) >= windowBeforeForcedAck) { + sendAcknowledgementDeadlineMillis = null + lastAcknowledgedReceived = lastReceivedInOrder + sendAcknowledgement() + } else { + sendAcknowledgementDeadlineMillis = nowMillis + peerSynchronization.acknowledgementTimeoutMillis + } + } + + private fun sendPacket(packet: Packet, nowMillis: Long) { + if (state != State.NORMAL || + sequenceDistance(sentSequence, lastSentAcknowledged) > peerSynchronization.maxOutgoing + ) { + if (queued.size >= config.maximumQueuedPackets) { + die("iAP2 outbound queue limit exceeded") + } else { + queued += packet + setWritable(false) + } + return + } + sentSequence = nextSequence(sentSequence) + packet.sequence = sentSequence + packet.retransmissions = 0 + packet.deadlineMillis = nowMillis + peerSynchronization.retransmissionTimeoutMillis + sendAcknowledgementDeadlineMillis = null + sendData(packet.sequence, packet.sessionId, packet.payload) + lastAcknowledgedReceived = lastReceivedInOrder + if (peerSynchronization.maxRetransmissions > 0) { + unacknowledged += packet + receiveAcknowledgementDeadlineMillis = packet.deadlineMillis + } else { + lastSentAcknowledged = sentSequence + } + } + + private fun retransmitDuePacket(nowMillis: Long) { + if (state != State.NORMAL || unacknowledged.isEmpty()) return + val next = unacknowledged.minByOrNull { it.deadlineMillis } ?: return + val secondDeadline = unacknowledged.filter { it !== next }.minOfOrNull { it.deadlineMillis } + next.deadlineMillis = nowMillis + peerSynchronization.retransmissionTimeoutMillis + next.retransmissions += 1 + if (next.retransmissions == peerSynchronization.maxRetransmissions) { + die("iAP2 packet ${next.sequence} was not acknowledged") + return + } + sendData(next.sequence, next.sessionId, next.payload) + receiveAcknowledgementDeadlineMillis = secondDeadline ?: next.deadlineMillis + } + + private fun sendSynchronization() { + writePacket(localSynchronization().encode(), sentSequence, CONTROL_SYNCHRONIZE, 0) + } + + private fun sendAcknowledgement() { + writePacket(null, sentSequence, CONTROL_ACKNOWLEDGEMENT, 0) + } + + private fun sendExtendedAcknowledgement(missing: List) { + writePacket(missing.map { it.toByte() }.toByteArray(), sentSequence, CONTROL_EXTENDED_ACKNOWLEDGEMENT, 0) + } + + private fun sendData(sequence: Int, sessionId: Int, payload: ByteArray) { + writePacket(payload, sequence, CONTROL_ACKNOWLEDGEMENT, sessionId) + } + + private fun writePacket(payload: ByteArray?, sequence: Int, control: Int, sessionId: Int) { + if (state == State.DEAD) return + accumulatedAcknowledgements = 0 + val length = payload?.size?.plus(HEADER_BYTES + CHECKSUM_BYTES) ?: HEADER_BYTES + val header = ByteArray(HEADER_BYTES) + header[0] = LINK_START_HIGH + header[1] = LINK_START_LOW + header[2] = (length ushr 8).toByte() + header[3] = length.toByte() + header[4] = control.toByte() + header[5] = sequence.toByte() + header[6] = lastReceivedInOrder.toByte() + header[7] = sessionId.toByte() + header[8] = checksum(header, HEADER_BYTES - CHECKSUM_BYTES).toByte() + val frame = ByteArrayOutputStream(length) + frame.write(header) + if (payload != null) { + frame.write(payload) + frame.write(checksum(payload, payload.size)) + } + appendOutput(frame.toByteArray()) + } + + private fun setWritable(value: Boolean) { + if (writable == value) return + writable = value + enqueueEvent(Event.Writable(value)) + } + + private fun die(reason: String?) { + if (state == State.DEAD) return + markerDeadlineMillis = null + synchronizationDeadlineMillis = null + sendAcknowledgementDeadlineMillis = null + receiveAcknowledgementDeadlineMillis = null + state = State.DEAD + writable = false + output.reset() + events.clear() + events += Event.Dead(reason) + } + + private fun appendOutput(bytes: ByteArray): Boolean { + if (state == State.DEAD) return false + if (bytes.size > config.maximumPendingOutputBytes - output.size()) { + die("iAP2 pending output limit exceeded") + return false + } + output.write(bytes) + return true + } + + private fun enqueueEvent(event: Event) { + if (state == State.DEAD) return + if (events.size >= config.maximumPendingEvents) { + die("iAP2 pending event limit exceeded") + return + } + events += event + } + + // A usable session-10 link must carry at least one control byte in addition to its frame + // header and payload checksum. This also gives higher layers a positive chunk limit. + private fun peerLimitsAreUsable(): Boolean = peerSynchronization.maxLength > HEADER_BYTES + CHECKSUM_BYTES + + private fun peerPayloadIsAcceptable(payload: ByteArray): Boolean = + peerLimitsAreUsable() && payload.size <= peerSynchronization.maxLength - HEADER_BYTES - CHECKSUM_BYTES + + private fun localSynchronization() = SynchronizationPayload( + maxOutgoing = config.maxOutgoing, + maxLength = MAX_WIRE_FRAME_BYTES, + retransmissionTimeoutMillis = if (config.zeroAcknowledgements) 0 else DEFAULT_RETRANSMISSION_TIMEOUT_MILLIS, + acknowledgementTimeoutMillis = if (config.zeroAcknowledgements) 0 else config.acknowledgementTimeoutMillis, + maxRetransmissions = if (config.zeroAcknowledgements) 0 else DEFAULT_MAX_RETRANSMISSIONS, + maxAcknowledgements = if (config.zeroAcknowledgements) 0 else DEFAULT_MAX_ACKNOWLEDGEMENTS, + sessions = listOf( + SessionDescriptor(CONTROL_SESSION_ID, 0, config.controlSessionVersion), + SessionDescriptor(EA_SESSION_ID, 2, 1), + SessionDescriptor(FILE_TRANSFER_SESSION_ID, 1, 2), + ), + ) + + private class ReceiveBuffer { + private val bytes = ByteArray(MAX_WIRE_FRAME_BYTES) + private var head = 0 + private var tail = 0 + + val size: Int get() = tail - head + + fun remainingCapacity(): Int { + compact() + return bytes.size - tail + } + + operator fun get(index: Int): Byte = bytes[head + index] + + fun append(source: ByteArray, offset: Int, count: Int) { + require(count <= remainingCapacity()) + source.copyInto(bytes, tail, offset, offset + count) + tail += count + } + + fun discard(count: Int) { + require(count in 0..size) + head += count + if (head == tail) { + head = 0 + tail = 0 + } + } + + fun take(count: Int): ByteArray { + require(count in 0..size) + return bytes.copyOfRange(head, head + count).also { discard(count) } + } + + fun matches(expected: ByteArray): Boolean = expected.indices.all { bytes[head + it] == expected[it] } + + private fun compact() { + if (head == 0) return + if (head < tail) bytes.copyInto(bytes, 0, head, tail) + tail -= head + head = 0 + } + } + + companion object { + const val CONTROL_SESSION_ID = 10 + const val EA_SESSION_ID = 11 + const val FILE_TRANSFER_SESSION_ID = 12 + + val IAP2_MARKER = byteArrayOf(0xff.toByte(), 0x55, 0x02, 0x00, 0xee.toByte(), 0x10) + + private const val HEADER_BYTES = 9 + private const val CHECKSUM_BYTES = 1 + const val MAX_WIRE_FRAME_BYTES = 0xffff + const val MAX_PAYLOAD_BYTES = MAX_WIRE_FRAME_BYTES - HEADER_BYTES - CHECKSUM_BYTES + private const val SESSION_DESCRIPTOR_BYTES = 3 + private const val SYNCHRONIZATION_FIXED_BYTES = 10 + private const val SYNCHRONIZATION_VERSION = 1 + private const val LINK_START_HIGH: Byte = 0xff.toByte() + private const val LINK_START_LOW: Byte = 0x5a + private const val CONTROL_SYNCHRONIZE = 0x80 + private const val CONTROL_ACKNOWLEDGEMENT = 0x40 + private const val CONTROL_EXTENDED_ACKNOWLEDGEMENT = 0x20 + private const val CONTROL_RESET = 0x10 + private const val INITIAL_SEQUENCE = 99 + private const val MARKER_RESEND_MILLIS = 1_000L + private const val SYNCHRONIZATION_RESEND_MILLIS = 500L + private const val DEFAULT_RETRANSMISSION_TIMEOUT_MILLIS = 4_000 + private const val DEFAULT_MAX_RETRANSMISSIONS = 4 + private const val DEFAULT_MAX_ACKNOWLEDGEMENTS = 3 + private const val ACK_SLACK = 10 + + private fun parseHeader(bytes: ByteArray): Header? { + if (bytes.size != HEADER_BYTES || !checksumValid(bytes)) return null + if (bytes[0] != LINK_START_HIGH || bytes[1] != LINK_START_LOW) return null + return Header( + length = readU16(bytes, 2), + control = u8(bytes[4]), + sequence = u8(bytes[5]), + acknowledgement = u8(bytes[6]), + sessionId = u8(bytes[7]), + ) + } + + private fun checksumValid(bytes: ByteArray): Boolean = bytes.fold(0) { sum, byte -> (sum + u8(byte)) and 0xff } == 0 + + private fun checksum(bytes: ByteArray, count: Int): Int = + (-bytes.take(count).sumOf { u8(it) }) and 0xff + + private fun sequenceDistance(sequence: Int, previous: Int?): Int = + if (previous == null) 0 else (sequence - previous) and 0xff + + private fun nextSequence(sequence: Int): Int = (sequence + 1) and 0xff + + private fun readU16(bytes: ByteArray, offset: Int): Int = (u8(bytes[offset]) shl 8) or u8(bytes[offset + 1]) + + private fun u8(byte: Byte): Int = byte.toInt() and 0xff + + private fun writeU16(out: ByteArrayOutputStream, value: Int) { + out.write(value ushr 8) + out.write(value) + } + } +} + +/** Configuration advertised by the local link endpoint. */ +data class Iap2LinkConfig( + val maxOutgoing: Int = 30, + val maxOutgoingDelta: Int = 0, + val acknowledgementTimeoutMillis: Int = 500, + val zeroAcknowledgements: Boolean = false, + val controlSessionVersion: Int = 1, + val maximumQueuedPackets: Int = 64, + val maximumOutOfOrderPackets: Int = 64, + val maximumPendingOutputBytes: Int = 1_048_576, + val maximumPendingEvents: Int = 256, +) { + init { + require(maxOutgoing in 0..0xff) + require(maxOutgoingDelta in 0..0xff) + require(acknowledgementTimeoutMillis in 0..0xffff) + require(controlSessionVersion in 0..0xff) + require(maximumQueuedPackets in 1..256) + require(maximumOutOfOrderPackets in 1..256) + require(maximumPendingOutputBytes >= 1) + require(maximumPendingEvents >= 1) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LocationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LocationClient.kt new file mode 100644 index 0000000..b3661c9 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LocationClient.kt @@ -0,0 +1,143 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.message.Iap2ControlMessages +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import java.util.Locale +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** One Android location sample converted into the fields carried by the NMEA pair. */ +data class CarPlayLocationFix( + val latitudeDegrees: Double, + val longitudeDegrees: Double, + val altitudeMeters: Double? = null, + val bearingDegrees: Double? = null, + val speedMetersPerSecond: Double? = null, + val accuracyMeters: Double? = null, + val timestampMillis: Long? = null, +) { + init { + require(latitudeDegrees.isFinite() && latitudeDegrees in -90.0..90.0) { + "latitudeDegrees must be between -90 and 90" + } + require(longitudeDegrees.isFinite() && longitudeDegrees in -180.0..180.0) { + "longitudeDegrees must be between -180 and 180" + } + } +} + +/** Supplies location data only while the phone has subscribed to iAP2 LocationInformation. */ +interface Iap2LocationProvider : AutoCloseable { + /** Starts location updates and returns whether at least one source was subscribed. */ + fun start(): Boolean + + fun stop() + + fun latestNmea(): String? + + override fun close() { + stop() + } +} + +/** LIVI-compatible $GPGGA + $GPRMC encoding used by iAP2 0xFFFB. */ +object NmeaLocationEncoder { + fun encode(fix: CarPlayLocationFix): String { + val timestamp = fix.timestampMillis + ?.takeIf { it > 0 } + ?.let(::ofEpochMilli) + ?: ofEpochMilli(System.currentTimeMillis()) + val time = format("%02d%02d%02d.00", timestamp.hour, timestamp.minute, timestamp.second) + val date = format( + "%02d%02d%02d", + timestamp.day, + timestamp.month, + timestamp.year % 100, + ) + + val latitude = degreesToNmea(fix.latitudeDegrees, latitude = true) + val longitude = degreesToNmea(fix.longitudeDegrees, latitude = false) + val hdop = fix.accuracyMeters + ?.takeIf { it.isFinite() && it > 0 } + ?.let { min(50.0, max(0.5, it / 5.0)) } + ?: 1.0 + val altitude = fix.altitudeMeters + ?.takeIf { it.isFinite() } + ?.let { format("%.1f", it) } + ?: "0.0" + + val ggaBody = "GPGGA,$time,${latitude.value},${latitude.hemisphere}," + + "${longitude.value},${longitude.hemisphere},1,08,${format("%.1f", hdop)}," + + "$altitude,M,0.0,M,," + val speedKnots = fix.speedMetersPerSecond + ?.takeIf { it.isFinite() && it >= 0 } + ?.let { format("%.2f", it * KNOTS_PER_METER_PER_SECOND) } + ?: "0.00" + val course = fix.bearingDegrees + ?.takeIf { it.isFinite() } + ?.let { format("%.2f", it) } + ?: "0.00" + val rmcBody = "GPRMC,$time,A,${latitude.value},${latitude.hemisphere}," + + "${longitude.value},${longitude.hemisphere},$speedKnots,$course,$date,," + + return "$${ggaBody}*${checksum(ggaBody)}\r\n$${rmcBody}*${checksum(rmcBody)}\r\n" + } + + private fun degreesToNmea(value: Double, latitude: Boolean): NmeaCoordinate { + val absolute = abs(value) + var degrees = absolute.toInt() + var minutes = (absolute - degrees) * MINUTES_PER_DEGREE + if (minutes >= MINUTES_PER_DEGREE - MINUTE_ROUNDING_TOLERANCE) { + degrees += 1 + minutes = 0.0 + } + val degreeText = if (latitude) format("%02d", degrees) else format("%03d", degrees) + val hemisphere = if (latitude) { + if (value >= 0) "N" else "S" + } else { + if (value >= 0) "E" else "W" + } + return NmeaCoordinate("$degreeText${format("%07.4f", minutes)}", hemisphere) + } + + private fun checksum(body: String): String { + var value = 0 + for (character in body) value = value xor character.code + return format("%02X", value) + } + + private fun ofEpochMilli(millis: Long): Timestamp = Timestamp(millis) + + private fun format(format: String, vararg arguments: Any): String = + String.format(Locale.US, format, *arguments) + + private data class NmeaCoordinate(val value: String, val hemisphere: String) + + private class Timestamp(millis: Long) { + private val fields = java.time.Instant.ofEpochMilli(millis) + .atZone(java.time.ZoneOffset.UTC) + + val hour: Int = fields.hour + val minute: Int = fields.minute + val second: Int = fields.second + val day: Int = fields.dayOfMonth + val month: Int = fields.monthValue + val year: Int = fields.year + } + + private const val KNOTS_PER_METER_PER_SECOND = 1.94384449 + private const val MINUTES_PER_DEGREE = 60.0 + private const val MINUTE_ROUNDING_TOLERANCE = 0.00005 +} + +object Iap2LocationMessages { + const val START_LOCATION_INFORMATION = 0xfffa + const val LOCATION_INFORMATION = 0xfffb + const val STOP_LOCATION_INFORMATION = 0xfffc + + fun locationInformation(nmeaSentence: String): Iap2Frame { + require(nmeaSentence.isNotEmpty()) { "NMEA sentence must not be empty" } + return Iap2ControlMessages.locationInformation(nmeaSentence) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2UsbMuxHost.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2UsbMuxHost.kt new file mode 100644 index 0000000..f1402c4 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2UsbMuxHost.kt @@ -0,0 +1,558 @@ +package com.shilapi.xcertplay.transport + +import android.util.Log +import java.io.Closeable +import java.util.ArrayDeque + +/** + * USBMUX version-2 host over an already claimed iPhone bulk pipe. + * + * This is deliberately limited to USBMUX framing and one minimal outbound TCP connection. It + * does not parse lockdown messages, iAP2, TLS, NCM, AirPlay, or any media protocol. All calls + * may block and must run away from Android's main thread. + */ +class Iap2UsbMuxHost private constructor( + private val pipe: Iap2UsbSession, + private val readTimeoutMillis: Long, +) : Closeable { + private val stateLock = Any() + private val writeLock = Any() + private val connections = mutableMapOf() + private var closed = false + private var failure: IphoneUsbException? = null + private var nextMuxSequence = 0 + private var nextMuxAcknowledgement = 0 + private var nextSourcePort = FIRST_SOURCE_PORT + private lateinit var readerThread: Thread + private var receiveBuffer = ByteArray(0) + + private data class MuxFrame( + val protocol: Int, + val length: Int, + val word8: Int, + val payload: ByteArray, + ) + + /** Opens a TCP byte stream to the iPhone service on [destinationPort]. */ + fun connect( + destinationPort: Int = LOCKDOWN_PORT, + timeoutMillis: Long = CONNECT_TIMEOUT_MILLIS, + ): Iap2UsbMuxTcpConnection { + require(destinationPort in 1..0xffff) { "destinationPort must be a valid TCP port" } + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + + val connection = synchronized(stateLock) { + checkOpenLocked() + val sourcePort = allocateSourcePortLocked() + Iap2UsbMuxTcpConnection(this, sourcePort, destinationPort).also { + connections[sourcePort] = it + } + } + try { + connection.beginConnect() + if (!connection.awaitConnected(timeoutMillis)) { + connection.abort() + throw IphoneUsbException.TimedOut("USBMUX TCP connection to port $destinationPort timed out") + } + return connection + } catch (error: IphoneUsbException) { + removeConnection(connection) + throw error + } + } + + override fun close() { + val activeConnections = synchronized(stateLock) { + if (closed) return + closed = true + connections.values.toList().also { connections.clear() } + } + activeConnections.forEach(Iap2UsbMuxTcpConnection::closeFromHost) + pipe.close() + if (::readerThread.isInitialized && Thread.currentThread() !== readerThread) { + try { + readerThread.join(readTimeoutMillis + CLOSE_JOIN_MARGIN_MILLIS) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + } + + internal fun sendTcp( + sourcePort: Int, + destinationPort: Int, + sequence: Int, + acknowledgement: Int, + flags: Int, + payload: ByteArray, + ) { + if (payload.size > 512) Log.i("xcertplay-usb", "usbmux TX source=$sourcePort destination=$destinationPort bytes=${payload.size} seq=$sequence ack=$acknowledgement") + val tcp = ByteArray(TCP_HEADER_BYTES + payload.size) + putU16(tcp, 0, sourcePort) + putU16(tcp, 2, destinationPort) + putU32(tcp, 4, sequence) + putU32(tcp, 8, acknowledgement) + tcp[12] = (TCP_HEADER_BYTES / 4 shl 4).toByte() + tcp[13] = flags.toByte() + putU16(tcp, 14, TCP_WINDOW_FIELD) + payload.copyInto(tcp, TCP_HEADER_BYTES) + sendFrame(PROTOCOL_TCP, tcp) + } + + internal fun removeConnection(connection: Iap2UsbMuxTcpConnection) { + synchronized(stateLock) { + if (connections[connection.sourcePort] === connection) { + connections.remove(connection.sourcePort) + } + } + } + + private fun begin() { + val version = ByteArray(VERSION_MESSAGE_BYTES) + putU32(version, 0, PROTOCOL_VERSION) + putU32(version, 4, VERSION_MESSAGE_BYTES) + putU32(version, 8, USBMUX_VERSION) + pipe.write(version, HANDSHAKE_TIMEOUT_MILLIS.toInt()) + // The phone replies with the same proto=0, length=20, version=2 packet. Protocol 1 is not + // a distinct "version reply" here; waiting for it discards the valid reply and times out. + val deadline = System.nanoTime() + HANDSHAKE_TIMEOUT_MILLIS * NANOS_PER_MILLISECOND + var staleFrames = 0 + var reply: MuxFrame + while (true) { + val remainingNanos = deadline - System.nanoTime() + if (remainingNanos <= 0) { + throw IphoneUsbException.TimedOut("Timed out waiting for the USBMUX version reply") + } + val remainingMillis = (remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND + reply = takeFrame(remainingMillis) + ?: throw IphoneUsbException.TimedOut("Timed out waiting for the USBMUX version reply") + if ( + reply.protocol == PROTOCOL_VERSION && + reply.length == VERSION_MESSAGE_BYTES && + reply.word8 == USBMUX_VERSION + ) { + break + } + // Android can leave already-received TCP payloads queued in the bulk endpoint when + // an app process is replaced. They belong to the previous host instance and must not + // be mistaken for the version reply sent in response to the new handshake. + if (reply.protocol != PROTOCOL_TCP || ++staleFrames > MAX_STALE_HANDSHAKE_FRAMES) { + throw IphoneUsbException.Protocol( + "Invalid USBMUX version reply: proto=${reply.protocol} " + + "length=${reply.length} version=${reply.word8}", + ) + } + Log.i("xcertplay-usb", "discarding stale usbmux TCP frame before version reply") + } + Log.i("xcertplay-usb", "usbmux version accepted: ${reply.word8}") + sendFrame(PROTOCOL_SETUP, byteArrayOf(SETUP_VALUE.toByte())) + readerThread = Thread(::readerLoop, "iap2-usbmux-reader").apply { + isDaemon = true + start() + } + } + + /** Reads one complete USBMUX frame, keeping partial data buffered across reads. */ + private fun takeFrame(timeoutMillis: Long): MuxFrame? { + val deadline = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + while (true) { + synchronized(stateLock) { + if (closed) throw IphoneUsbException.DeviceUnavailable("USBMUX host is closed") + if (receiveBuffer.size >= MUX_HEADER_BYTES) { + val length = readU32(receiveBuffer, 4) + if (length < MUX_HEADER_BYTES || length > MAX_FRAME_BYTES) { + throw IphoneUsbException.Protocol("Invalid USBMUX frame length $length") + } + if (receiveBuffer.size >= length) { + // LIVI only trusts the length field on receive: iPhone replies do not + // carry the 0xFEEDFACE word in the header's fourth field. + Log.i( + "xcertplay-usb", + "usbmux rx proto=${readU32(receiveBuffer, 0)} length=$length word8=0x" + + readU32(receiveBuffer, 8).toUInt().toString(16), + ) + val protocol = readU32(receiveBuffer, 0) + val word8 = readU32(receiveBuffer, 8) + nextMuxAcknowledgement = readU16(receiveBuffer, 12) + val payload = receiveBuffer.copyOfRange(MUX_HEADER_BYTES, length) + receiveBuffer = receiveBuffer.copyOfRange(length, receiveBuffer.size) + return MuxFrame(protocol, length, word8, payload) + } + } + } + val remainingNanos = deadline - System.nanoTime() + if (remainingNanos <= 0) return null + val remainingMillis = (remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND + val bytes = pipe.read(remainingMillis) ?: continue + synchronized(stateLock) { + receiveBuffer += bytes + } + } + } + + private fun sendFrame(protocol: Int, payload: ByteArray) = synchronized(writeLock) { + val sequenceAndAcknowledgement = synchronized(stateLock) { + checkOpenLocked() + nextMuxSequence to nextMuxAcknowledgement + } + val frame = ByteArray(MUX_HEADER_BYTES + payload.size) + putU32(frame, 0, protocol) + putU32(frame, 4, frame.size) + putU32(frame, 8, MUX_MAGIC) + putU16(frame, 12, sequenceAndAcknowledgement.first) + putU16(frame, 14, sequenceAndAcknowledgement.second) + payload.copyInto(frame, MUX_HEADER_BYTES) + try { + pipe.write(frame, WRITE_TIMEOUT_MILLIS) + } catch (error: IphoneUsbException) { + fail(error) + throw error + } + synchronized(stateLock) { + nextMuxSequence = (nextMuxSequence + 1) and 0xffff + } + } + + private fun readerLoop() { + try { + while (true) { + synchronized(stateLock) { + if (closed) return + } + val frame = takeFrame(readTimeoutMillis) ?: continue + if (frame.protocol == PROTOCOL_TCP) dispatchTcp(frame.payload) + } + } catch (error: IphoneUsbException) { + fail(error) + } catch (error: RuntimeException) { + fail(IphoneUsbException.DeviceUnavailable("USBMUX reader failed", error)) + } + } + + private fun dispatchTcp(frame: ByteArray) { + val offset = 0 + val length = frame.size + if (length < TCP_HEADER_BYTES) { + throw IphoneUsbException.Protocol("USBMUX TCP frame is shorter than its header") + } + val tcpHeaderBytes = ((frame[offset + 12].toInt() ushr 4) and 0x0f) * 4 + if (tcpHeaderBytes < TCP_HEADER_BYTES || tcpHeaderBytes > length) { + throw IphoneUsbException.Protocol("Invalid USBMUX TCP header length") + } + val destinationPort = readU16(frame, offset + 2) + if (length == tcpHeaderBytes) Log.i("xcertplay-usb", "usbmux TCP control destination=$destinationPort flags=${frame[13].toInt() and 0xff} ack=${readU32(frame, 8)} window=${readU16(frame, 14)}") + val connection = synchronized(stateLock) { connections[destinationPort] } ?: return + connection.onPacket( + flags = frame[offset + 13].toInt() and 0xff, + sequence = readU32(frame, offset + 4), + payload = frame.copyOfRange(offset + tcpHeaderBytes, offset + length), + ) + } + + private fun fail(error: IphoneUsbException) { + val activeConnections = synchronized(stateLock) { + if (closed) return + failure = failure ?: error + closed = true + connections.values.toList().also { connections.clear() } + } + activeConnections.forEach { it.closeFromHost(error) } + pipe.close() + } + + private fun allocateSourcePortLocked(): Int { + repeat(0xffff) { + val candidate = nextSourcePort + nextSourcePort = if (candidate == 0xffff) FIRST_SOURCE_PORT else candidate + 1 + if (candidate !in connections) return candidate + } + throw IphoneUsbException.DeviceUnavailable("USBMUX has no free TCP source ports") + } + + private fun checkOpenLocked() { + failure?.let { throw it } + if (closed) throw IphoneUsbException.DeviceUnavailable("USBMUX host is closed") + } + + companion object { + const val LOCKDOWN_PORT = 62078 + + private const val PROTOCOL_VERSION = 0 + private const val PROTOCOL_SETUP = 2 + private const val PROTOCOL_TCP = 6 + private const val USBMUX_VERSION = 2 + private const val SETUP_VALUE = 0x07 + private const val MUX_MAGIC = 0xfeedface.toInt() + private const val VERSION_MESSAGE_BYTES = 20 + private const val NANOS_PER_MILLISECOND = 1_000_000L + private const val MUX_HEADER_BYTES = 16 + private const val TCP_HEADER_BYTES = 20 + private const val TCP_WINDOW_FIELD = 512 + private const val MAX_FRAME_BYTES = 65_536 + private const val FIRST_SOURCE_PORT = 1 + private const val HANDSHAKE_TIMEOUT_MILLIS = 60_000L + private const val MAX_STALE_HANDSHAKE_FRAMES = 32 + private const val CONNECT_TIMEOUT_MILLIS = 5_000L + private const val WRITE_TIMEOUT_MILLIS = 2_000 + private const val CLOSE_JOIN_MARGIN_MILLIS = 100L + + /** Performs the USBMUX v2 handshake and starts the framed reader. */ + fun open( + pipe: Iap2UsbSession, + readTimeoutMillis: Long = 1_000, + ): Iap2UsbMuxHost { + require(readTimeoutMillis > 0) { "readTimeoutMillis must be positive" } + return Iap2UsbMuxHost(pipe, readTimeoutMillis).also { + try { + it.begin() + } catch (error: Throwable) { + it.close() + throw error + } + } + } + + private fun putU16(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 8).toByte() + target[offset + 1] = value.toByte() + } + + private fun putU32(target: ByteArray, offset: Int, value: Int) { + target[offset] = (value ushr 24).toByte() + target[offset + 1] = (value ushr 16).toByte() + target[offset + 2] = (value ushr 8).toByte() + target[offset + 3] = value.toByte() + } + + private fun readU16(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 8) or (source[offset + 1].toInt() and 0xff) + + private fun readU32(source: ByteArray, offset: Int): Int = + ((source[offset].toInt() and 0xff) shl 24) or + ((source[offset + 1].toInt() and 0xff) shl 16) or + ((source[offset + 2].toInt() and 0xff) shl 8) or + (source[offset + 3].toInt() and 0xff) + } +} + +/** A blocking TCP byte stream carried by [Iap2UsbMuxHost]. */ +class Iap2UsbMuxTcpConnection internal constructor( + private val host: Iap2UsbMuxHost, + internal val sourcePort: Int, + private val destinationPort: Int, +) : BlockingDuplexByteStream { + private val stateLock = Object() + private val writeLock = Any() + private val received = ArrayDeque() + private var nextSequence = 0 + private var nextAcknowledgement = 0 + private var connected = false + private var closed = false + private var failure: IphoneUsbException? = null + + /** Sends [data] as an ordered byte stream, split into USBMUX TCP payloads of at most 16 KiB. */ + override fun send(data: ByteArray) { + synchronized(stateLock) { checkConnectedLocked() } + var offset = 0 + while (offset < data.size) { + val count = minOf(MAX_SEND_PAYLOAD_BYTES, data.size - offset) + val chunk = data.copyOfRange(offset, offset + count) + synchronized(writeLock) { + val sequenceAndAck = synchronized(stateLock) { + checkConnectedLocked() + nextSequence to nextAcknowledgement + } + host.sendTcp( + sourcePort, + destinationPort, + sequenceAndAck.first, + sequenceAndAck.second, + TCP_ACK, + chunk, + ) + synchronized(stateLock) { nextSequence += count } + } + offset += count + } + } + + /** + * Receives up to [maxBytes] from the byte stream. Returns null on timeout and an empty array + * after FIN or local close; a peer RST is reported as a transport failure. + */ + override fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? { + require(maxBytes > 0) { "maxBytes must be positive" } + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + val deadline = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + synchronized(stateLock) { + while (received.isEmpty() && !closed) { + val remainingNanos = deadline - System.nanoTime() + if (remainingNanos <= 0) return null + try { + stateLock.wait(remainingNanos / NANOS_PER_MILLISECOND, (remainingNanos % NANOS_PER_MILLISECOND).toInt()) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw IphoneUsbException.DeviceUnavailable("Interrupted while waiting for USBMUX TCP data") + } + } + failure?.let { throw it } + if (received.isEmpty()) { + return ByteArray(0) + } + val packet = received.removeFirst() + return if (packet.size <= maxBytes) { + packet + } else { + received.addFirst(packet.copyOfRange(maxBytes, packet.size)) + packet.copyOf(maxBytes) + } + } + } + + override fun close() { + val shouldSendFin = synchronized(stateLock) { + if (closed) return + closed = true + stateLock.notifyAll() + connected + } + if (shouldSendFin) { + try { + synchronized(writeLock) { + val sequenceAndAck = synchronized(stateLock) { nextSequence to nextAcknowledgement } + host.sendTcp( + sourcePort, + destinationPort, + sequenceAndAck.first, + sequenceAndAck.second, + TCP_FIN or TCP_ACK, + ByteArray(0), + ) + } + } catch (_: IphoneUsbException) { + // The host may have already closed its USB pipe; the closed state remains final. + } + } + host.removeConnection(this) + } + + internal fun beginConnect() { + sendControl(TCP_SYN) + } + + internal fun awaitConnected(timeoutMillis: Long): Boolean { + val deadline = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + synchronized(stateLock) { + while (!connected && !closed) { + val remainingNanos = deadline - System.nanoTime() + if (remainingNanos <= 0) return false + try { + stateLock.wait(remainingNanos / NANOS_PER_MILLISECOND, (remainingNanos % NANOS_PER_MILLISECOND).toInt()) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw IphoneUsbException.DeviceUnavailable("Interrupted while connecting USBMUX TCP") + } + } + failure?.let { throw it } + return connected && !closed + } + } + + internal fun abort() { + val shouldSendReset = synchronized(stateLock) { + if (closed) return + closed = true + stateLock.notifyAll() + true + } + if (shouldSendReset) { + try { + sendControl(TCP_RST or TCP_ACK) + } catch (_: IphoneUsbException) { + // A failed reset cannot make the local connection usable again. + } + } + host.removeConnection(this) + } + + internal fun closeFromHost(error: IphoneUsbException? = null) = synchronized(stateLock) { + if (closed) return@synchronized + failure = error ?: failure + closed = true + stateLock.notifyAll() + } + + internal fun onPacket(flags: Int, sequence: Int, payload: ByteArray) { + if ((flags and TCP_RST) != 0) { + synchronized(stateLock) { + failure = IphoneUsbException.DeviceUnavailable("USBMUX TCP connection was reset by the peer") + closed = true + stateLock.notifyAll() + } + host.removeConnection(this) + return + } + if ((flags and TCP_SYN) != 0 && (flags and TCP_ACK) != 0) { + synchronized(stateLock) { + if (closed) return + nextSequence += 1 + nextAcknowledgement = sequence + 1 + } + sendControl(TCP_ACK) + synchronized(stateLock) { + if (!closed) { + connected = true + stateLock.notifyAll() + } + } + return + } + if (payload.isNotEmpty()) { + synchronized(stateLock) { + if (closed) return + nextAcknowledgement += payload.size + received.addLast(payload) + stateLock.notifyAll() + } + sendControl(TCP_ACK) + } + if ((flags and TCP_FIN) != 0) { + synchronized(stateLock) { + if (closed) return + nextAcknowledgement += 1 + } + sendControl(TCP_ACK) + synchronized(stateLock) { + closed = true + stateLock.notifyAll() + } + host.removeConnection(this) + } + } + + private fun sendControl(flags: Int) = synchronized(writeLock) { + val sequenceAndAck = synchronized(stateLock) { nextSequence to nextAcknowledgement } + host.sendTcp( + sourcePort, + destinationPort, + sequenceAndAck.first, + sequenceAndAck.second, + flags, + ByteArray(0), + ) + } + + private fun checkConnectedLocked() { + failure?.let { throw it } + if (!connected || closed) throw IphoneUsbException.DeviceUnavailable("USBMUX TCP connection is not open") + } + + private companion object { + const val TCP_FIN = 0x01 + const val TCP_SYN = 0x02 + const val TCP_RST = 0x04 + const val TCP_ACK = 0x10 + const val MAX_SEND_PAYLOAD_BYTES = 16 * 1024 + const val NANOS_PER_MILLISECOND = 1_000_000L + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2WiredControlClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2WiredControlClient.kt new file mode 100644 index 0000000..41fef3e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2WiredControlClient.kt @@ -0,0 +1,265 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.message.Iap2CarPlayMessages +import com.shilapi.xcertplay.iap2.message.Iap2ControlMessages +import com.shilapi.xcertplay.iap2.session.Iap2Session +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.mfi.Iap2MfiAuthenticationClient +import java.net.Inet6Address +import java.net.InetAddress +import kotlin.math.min + +/** + * The wired LIVI control sequence after a CSM channel is ready: + * Identification, MFi, power announcement, five update subscriptions, then CarPlay availability. + * + * The caller retains ownership of [session]. While [run] is active it is the only receiver and + * forwards each non-availability CSM frame to [onIncoming]; it neither opens NCM nor implements + * an AirPlay receiver. + */ +class Iap2WiredControlClient( + private val session: Iap2Session, + private val mfi: Iap2MfiAuthenticationClient, +) { + fun run( + identification: Iap2IdentificationConfig, + endpoint: Iap2WiredCarPlayEndpoint, + availableCurrentMilliAmps: Int, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + locationProvider: Iap2LocationProvider? = null, + onIncoming: (Iap2Frame) -> Unit = {}, + onProgress: (String) -> Unit = {}, + ): Iap2WiredControlResult { + require(availableCurrentMilliAmps in 0..0xffff) { + "availableCurrentMilliAmps must be in 0..65535" + } + require(timeoutMillis == NO_TIMEOUT_MILLIS || timeoutMillis in 1..MAX_TIMEOUT_MILLIS) { + "timeoutMillis must be in 1..$MAX_TIMEOUT_MILLIS or NO_TIMEOUT_MILLIS" + } + + val deadlineNanos = Iap2ControlDeadline(timeoutMillis) + Iap2IdentificationClient(session).identify(identification, requireRemaining(deadlineNanos)) + onProgress("iap2 identification accepted") + var stage = Iap2WiredControlStage.IDENTIFIED + mfi.run(session, requireRemaining(deadlineNanos), onProgress) + stage = Iap2WiredControlStage.AUTHENTICATED + onProgress("iap2 authentication accepted") + deadlineNanos.authenticated() + + send(powerSourceUpdate(availableCurrentMilliAmps), deadlineNanos) + for (subscription in subscriptions()) send(subscription, deadlineNanos) + stage = Iap2WiredControlStage.SUBSCRIBED + onProgress("iap2 power/subscriptions sent") + + var forwardedFrames = 0 + var carPlayStartSessions = 0 + var locationActive = false + var locationSentLogged = false + try { + while (true) { + val remaining = remainingMillis(deadlineNanos) + if (remaining == 0L) { + return Iap2WiredControlResult(Iap2WiredControlTerminal.TIMED_OUT, stage, forwardedFrames, carPlayStartSessions) + } + if (locationActive && sendLatestLocation(locationProvider, deadlineNanos) && !locationSentLogged) { + locationSentLogged = true + onProgress("iap2 tx=0xfffb location-information") + } + val pollTimeout = if (locationActive) { + min(remaining, LOCATION_POLL_INTERVAL_MILLIS) + } else { + remaining + } + val incoming = session.recv(pollTimeout) + if (incoming == null) { + if (session.isClosed) { + return Iap2WiredControlResult( + Iap2WiredControlTerminal.CHANNEL_CLOSED, + stage, + forwardedFrames, + carPlayStartSessions, + ) + } + if (remainingMillis(deadlineNanos) == 0L) { + return Iap2WiredControlResult( + Iap2WiredControlTerminal.TIMED_OUT, + stage, + forwardedFrames, + carPlayStartSessions, + ) + } + continue + } + + when (incoming.messageId) { + CARPLAY_AVAILABILITY -> { + onProgress("iap2 rx=0x4300 carplay-availability") + onProgress(carPlayAvailabilitySummary(incoming.payload)) + // LIVI sends its wired answer on every availability notification; do not gate it on + // the phone's advertised availability boolean. + send(carPlayStartSession(endpoint), deadlineNanos) + stage = Iap2WiredControlStage.CARPLAY_START_SENT + carPlayStartSessions++ + onProgress("iap2 tx=0x4301 carplay-start-session") + } + + Iap2LocationMessages.START_LOCATION_INFORMATION -> { + onProgress("iap2 rx=0xfffa start-location-information") + locationActive = startLocationUpdates(locationProvider, onProgress) + locationSentLogged = false + if (locationActive && sendLatestLocation(locationProvider, deadlineNanos)) { + locationSentLogged = true + onProgress("iap2 tx=0xfffb location-information") + } + } + + Iap2LocationMessages.STOP_LOCATION_INFORMATION -> { + onProgress("iap2 rx=0xfffc stop-location-information") + locationActive = false + locationSentLogged = false + locationProvider?.stop() + } + + else -> { + onProgress("iap2 rx=0x${incoming.messageId.toString(16).padStart(4, '0')}") + onIncoming(incoming) + forwardedFrames++ + } + } + } + } finally { + locationProvider?.stop() + } + } + + private fun send(frame: Iap2Frame, deadlineNanos: Iap2ControlDeadline) { + session.send(frame, requireRemaining(deadlineNanos)) + } + + private fun sendLatestLocation( + provider: Iap2LocationProvider?, + deadlineNanos: Iap2ControlDeadline, + ): Boolean { + val sentence = provider?.latestNmea() ?: return false + session.send( + Iap2LocationMessages.locationInformation(sentence), + requireRemaining(deadlineNanos), + ) + return true + } + + private fun startLocationUpdates( + provider: Iap2LocationProvider?, + onProgress: (String) -> Unit, + ): Boolean { + if (provider == null) return false + return try { + provider.start().also { started -> + if (!started) onProgress("iap2 location provider did not start") + } + } catch (error: Exception) { + onProgress("iap2 location provider start failed: ${error.message}") + false + } + } + + companion object { + const val NO_TIMEOUT_MILLIS = Long.MAX_VALUE + private const val CARPLAY_AVAILABILITY = 0x4300 + private const val CARPLAY_START_SESSION = 0x4301 + private const val LOCATION_POLL_INTERVAL_MILLIS = 1_000L + private const val DEFAULT_TIMEOUT_MILLIS = 60_000L + private const val MAX_TIMEOUT_MILLIS = 24 * 60 * 60 * 1_000L + private const val MAX_RECV_TIMEOUT_MILLIS = 5 * 60 * 1_000L + private const val NANOS_PER_MILLISECOND = 1_000_000L + + /** Exact LIVI wired PowerSourceUpdate encoding: current and the charge-if-powered flag. */ + fun powerSourceUpdate(availableCurrentMilliAmps: Int): Iap2Frame = + Iap2ControlMessages.powerSourceUpdate(availableCurrentMilliAmps, charging = true) + + /** Exact five subscription requests emitted by LIVI's wired bring-up. */ + fun subscriptions(): List = Iap2ControlMessages.subscriptions() + + /** Builds the wired-only CarPlayStartSession message; no NCM or AirPlay socket is opened. */ + fun carPlayStartSession(endpoint: Iap2WiredCarPlayEndpoint): Iap2Frame = + Iap2CarPlayMessages.startSession( + wiredIpv6Addresses = endpoint.ipv6Addresses, + airPlayPort = endpoint.airPlayPort, + deviceIdentifier = endpoint.deviceIdentifier, + publicKey = endpoint.publicKey, + sourceVersion = endpoint.sourceVersion, + ) + + fun carPlayAvailabilitySummary(payload: ByteArray): String { + return try { + val availability = Iap2CarPlayMessages.availability(payload).wired + val available = availability?.available + val transport = availability?.identifier + "iap2 4300 wiredAvailable=$available usbTransport=${transport ?: "none"}" + } catch (error: RuntimeException) { + "iap2 4300 decode failed: ${error.message}" + } + } + + private fun requireRemaining(deadlineNanos: Iap2ControlDeadline): Long = remainingMillis(deadlineNanos).also { + if (it == 0L) throw IphoneUsbException.TimedOut("Timed out during wired iAP2 control bring-up") + } + + private fun remainingMillis(deadlineNanos: Iap2ControlDeadline): Long = deadlineNanos.remainingMillis() + + } +} + +/** A configured wired AirPlay endpoint; configuration does not claim that either service is live. */ +class Iap2WiredCarPlayEndpoint( + ipv6Addresses: List, + val airPlayPort: Int, + val publicKey: String, + val sourceVersion: String, + val deviceIdentifier: String? = null, +) { + /** A stable copy so a caller cannot mutate a validated endpoint before it is encoded. */ + val ipv6Addresses: List = ipv6Addresses.toList() + + init { + require(ipv6Addresses.isNotEmpty()) { "At least one wired IPv6 address is required" } + require(ipv6Addresses.all(::isIpv6Literal)) { "Every wired address must be an IPv6 text literal" } + require(airPlayPort in 1..65535) { "airPlayPort must be in 1..65535" } + require(publicKey.isNotEmpty()) { "publicKey is required and must not be empty" } + require(sourceVersion.isNotEmpty()) { "sourceVersion is required and must not be empty" } + require('\u0000' !in publicKey) { "publicKey must not contain U+0000" } + require('\u0000' !in sourceVersion) { "sourceVersion must not contain U+0000" } + deviceIdentifier?.let { + require(it.isNotEmpty()) { "deviceIdentifier is optional, but must not be empty when provided" } + require('\u0000' !in it) { "deviceIdentifier must not contain U+0000" } + } + } + + private companion object { + fun isIpv6Literal(value: String): Boolean { + if (value.contains('%') || '\u0000' in value || !value.contains(':')) return false + return try { + InetAddress.getByName(value) is Inet6Address + } catch (_: Exception) { + false + } + } + } +} + +enum class Iap2WiredControlStage { + IDENTIFIED, + AUTHENTICATED, + SUBSCRIBED, + CARPLAY_START_SENT, +} + +enum class Iap2WiredControlTerminal { CHANNEL_CLOSED, TIMED_OUT } + +/** End state of the control loop only; it is not evidence of NCM or AirPlay availability. */ +data class Iap2WiredControlResult( + val terminal: Iap2WiredControlTerminal, + val stage: Iap2WiredControlStage, + val forwardedFrames: Int, + val carPlayStartSessionsSent: Int, +) diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClient.kt new file mode 100644 index 0000000..7bcaeb6 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClient.kt @@ -0,0 +1,395 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.message.Iap2CarPlayMessages +import com.shilapi.xcertplay.iap2.message.Iap2WirelessMessages +import com.shilapi.xcertplay.iap2.message.Iap2WirelessSessionParameters +import com.shilapi.xcertplay.iap2.session.Iap2Session +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.mfi.Iap2MfiAuthenticationClient +import kotlin.math.min + +/** + * The wireless LIVI control sequence after a CSM channel is ready: + * Identification, MFi, the five update subscriptions, then Wi-Fi credentials and the + * 0x4E0D/0x4E0E Wireless CarPlay transport notifications. + * + * The caller retains ownership of [session]. While [run] is active it is the only receiver and + * forwards each non-control CSM frame to [onIncoming]. + */ +class Iap2WirelessControlClient( + private val session: Iap2Session, + private val mfi: Iap2MfiAuthenticationClient, +) { + fun run( + identification: Iap2IdentificationConfig, + endpoint: Iap2WirelessCarPlayEndpoint, + timeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + locationProvider: Iap2LocationProvider? = null, + onReady: () -> Unit = {}, + onIncoming: (Iap2Frame) -> Unit = {}, + onProgress: (String) -> Unit = {}, + ): Iap2WirelessControlResult { + require(identification.wireless != null) { + "Wireless control requires an Iap2IdentificationConfig with wireless transport" + } + require(timeoutMillis == NO_TIMEOUT_MILLIS || timeoutMillis in 1..MAX_TIMEOUT_MILLIS) { + "timeoutMillis must be in 1..$MAX_TIMEOUT_MILLIS or NO_TIMEOUT_MILLIS" + } + + val deadlineNanos = if (timeoutMillis == NO_TIMEOUT_MILLIS) { + Long.MAX_VALUE + } else { + deadlineAfter(timeoutMillis) + } + Iap2IdentificationClient(session).identify(identification, requireRemaining(deadlineNanos)) + onProgress("iap2 identification accepted") + var stage = Iap2WirelessControlStage.IDENTIFIED + + mfi.run(session, requireRemaining(deadlineNanos), onProgress) + stage = Iap2WirelessControlStage.AUTHENTICATED + onProgress("iap2 authentication accepted") + + for (subscription in Iap2WiredControlClient.subscriptions()) { + send(subscription, deadlineNanos) + } + stage = Iap2WirelessControlStage.SUBSCRIBED + onProgress("iap2 subscriptions sent") + onReady() + + var forwardedFrames = 0 + var wifiConfigurationsSent = 0 + var carPlayStartSessionsSent = 0 + var preTransportWiFiConfigurationsSent = 0 + var postTransportWiFiConfigurationsSent = 0 + var transportNotificationSeen = false + var wirelessCarPlayAvailableSeen = false + var locationActive = false + var locationSentLogged = false + while (true) { + val remaining = remainingMillis(deadlineNanos) + if (remaining == 0L) { + return Iap2WirelessControlResult( + Iap2WirelessControlTerminal.TIMED_OUT, + stage, + forwardedFrames, + wifiConfigurationsSent, + carPlayStartSessionsSent, + transportNotificationSeen, + postTransportWiFiConfigurationsSent, + wirelessCarPlayAvailableSeen, + ) + } + if (locationActive && sendLatestLocation(locationProvider, deadlineNanos) && !locationSentLogged) { + locationSentLogged = true + onProgress("iap2 tx=0xfffb location-information") + } + val pollTimeout = if (locationActive) { + min(remaining, LOCATION_POLL_INTERVAL_MILLIS) + } else { + remaining + } + val incoming = session.recv(pollTimeout) + if (incoming == null) { + if (session.isClosed) { + return Iap2WirelessControlResult( + Iap2WirelessControlTerminal.CHANNEL_CLOSED, + stage, + forwardedFrames, + wifiConfigurationsSent, + carPlayStartSessionsSent, + transportNotificationSeen, + postTransportWiFiConfigurationsSent, + wirelessCarPlayAvailableSeen, + ) + } + if (remainingMillis(deadlineNanos) == 0L) { + return Iap2WirelessControlResult( + Iap2WirelessControlTerminal.TIMED_OUT, + stage, + forwardedFrames, + wifiConfigurationsSent, + carPlayStartSessionsSent, + transportNotificationSeen, + postTransportWiFiConfigurationsSent, + wirelessCarPlayAvailableSeen, + ) + } + continue + } + + when (incoming.messageId) { + REQUEST_ACCESSORY_WIFI_CONFIGURATION -> { + onProgress("iap2 rx=0x5702 request-wifi-configuration") + val postTransport = transportNotificationSeen + val sentCount = if (postTransport) { + postTransportWiFiConfigurationsSent + } else { + preTransportWiFiConfigurationsSent + } + val limit = if (postTransport) { + MAX_POST_TRANSPORT_WIFI_CONFIGURATION_SENDS + } else { + MAX_PRE_TRANSPORT_WIFI_CONFIGURATION_SENDS + } + if (sentCount >= limit) { + onProgress( + "iap2 0x5703 ignored: maximum Wi-Fi configuration sends reached", + ) + } else { + send(accessoryWiFiConfiguration(endpoint), deadlineNanos) + stage = later( + stage, + if (postTransport) { + Iap2WirelessControlStage.POST_TRANSPORT_WIFI_CONFIG_SENT + } else { + Iap2WirelessControlStage.WIFI_CONFIG_SENT + }, + ) + wifiConfigurationsSent++ + if (postTransport) { + postTransportWiFiConfigurationsSent++ + } else { + preTransportWiFiConfigurationsSent++ + } + onProgress("iap2 tx=0x5703 accessory-wifi-configuration") + } + } + + CARPLAY_AVAILABILITY -> { + onProgress("iap2 rx=0x4300 carplay-availability") + send(carPlayStartSession(endpoint), deadlineNanos) + stage = later(stage, Iap2WirelessControlStage.CARPLAY_START_SENT) + carPlayStartSessionsSent++ + onProgress("iap2 tx=0x4301 carplay-start-session") + } + + WIRELESS_CARPLAY_UPDATE -> { + val available = Iap2WirelessMessages.wirelessCarPlayAvailability(incoming) + if (available) wirelessCarPlayAvailableSeen = true + onProgress("iap2 rx=0x4e0d wireless-carplay-update available=$available") + } + + DEVICE_TRANSPORT_IDENTIFIER_NOTIFICATION -> { + transportNotificationSeen = true + val identifiers = Iap2WirelessMessages.deviceTransportIdentifier(incoming) + stage = later(stage, Iap2WirelessControlStage.TRANSPORT_NOTIFIED) + onProgress( + "iap2 rx=0x4e0e device-transport-identifier " + + "bluetooth=${identifiers.bluetoothMac ?: "none"} " + + "usb=${identifiers.usbTransportIdentifier ?: "none"}; " + + "resending 0x5703", + ) + if ( + postTransportWiFiConfigurationsSent >= + MAX_POST_TRANSPORT_WIFI_CONFIGURATION_SENDS + ) { + onProgress( + "iap2 post-transport 0x5703 ignored: " + + "maximum Wi-Fi configuration sends reached", + ) + } else { + send(accessoryWiFiConfiguration(endpoint), deadlineNanos) + stage = later( + stage, + Iap2WirelessControlStage.POST_TRANSPORT_WIFI_CONFIG_SENT, + ) + wifiConfigurationsSent++ + postTransportWiFiConfigurationsSent++ + onProgress( + "iap2 tx=0x5703 post-transport accessory-wifi-configuration", + ) + } + } + + Iap2LocationMessages.START_LOCATION_INFORMATION -> { + onProgress("iap2 rx=0xfffa start-location-information") + locationActive = startLocationUpdates(locationProvider, onProgress) + locationSentLogged = false + if (locationActive && sendLatestLocation(locationProvider, deadlineNanos)) { + locationSentLogged = true + onProgress("iap2 tx=0xfffb location-information") + } + } + + Iap2LocationMessages.STOP_LOCATION_INFORMATION -> { + onProgress("iap2 rx=0xfffc stop-location-information") + locationActive = false + locationSentLogged = false + locationProvider?.stop() + } + + else -> { + onProgress("iap2 rx=0x${incoming.messageId.toString(16).padStart(4, '0')}") + onIncoming(incoming) + forwardedFrames++ + } + } + } + } + + private fun send(frame: Iap2Frame, deadlineNanos: Long) { + session.send(frame, requireRemaining(deadlineNanos)) + } + + private fun sendLatestLocation( + provider: Iap2LocationProvider?, + deadlineNanos: Long, + ): Boolean { + val sentence = provider?.latestNmea() ?: return false + session.send( + Iap2LocationMessages.locationInformation(sentence), + requireRemaining(deadlineNanos), + ) + return true + } + + private fun startLocationUpdates( + provider: Iap2LocationProvider?, + onProgress: (String) -> Unit, + ): Boolean { + if (provider == null) return false + return try { + provider.start().also { started -> + if (!started) onProgress("iap2 location provider did not start") + } + } catch (error: Exception) { + onProgress("iap2 location provider start failed: ${error.message}") + false + } + } + + companion object { + private const val REQUEST_ACCESSORY_WIFI_CONFIGURATION = 0x5702 + private const val ACCESSORY_WIFI_CONFIGURATION = 0x5703 + private const val CARPLAY_AVAILABILITY = 0x4300 + private const val CARPLAY_START_SESSION = 0x4301 + private const val WIRELESS_CARPLAY_UPDATE = 0x4e0d + private const val DEVICE_TRANSPORT_IDENTIFIER_NOTIFICATION = 0x4e0e + private const val LOCATION_POLL_INTERVAL_MILLIS = 1_000L + const val NO_TIMEOUT_MILLIS = Long.MAX_VALUE + private const val DEFAULT_TIMEOUT_MILLIS = 60_000L + private const val MAX_TIMEOUT_MILLIS = 24 * 60 * 60 * 1_000L + private const val MAX_RECV_TIMEOUT_MILLIS = 5 * 60 * 1_000L + private const val MAX_PRE_TRANSPORT_WIFI_CONFIGURATION_SENDS = 5 + private const val MAX_POST_TRANSPORT_WIFI_CONFIGURATION_SENDS = 2 + private const val NANOS_PER_MILLISECOND = 1_000_000L + + /** Reference-compatible 0x5703 body. BSSID is omitted when the platform does not expose it. */ + fun accessoryWiFiConfiguration(endpoint: Iap2WirelessCarPlayEndpoint): Iap2Frame = + Iap2WirelessMessages.accessoryWiFiConfiguration( + ssid = endpoint.ssid, + passphrase = endpoint.passphrase, + channel = endpoint.channel, + securityType = endpoint.security.wireValue, + ) + + /** Wireless 0x4301 reply carrying the receiver address, port and pairing identity. */ + fun carPlayStartSession(endpoint: Iap2WirelessCarPlayEndpoint): Iap2Frame = + Iap2CarPlayMessages.startSession( + wireless = Iap2WirelessSessionParameters( + ssid = endpoint.ssid, + passphrase = endpoint.passphrase, + channel = endpoint.channel, + ipAddresses = endpoint.ipAddresses, + securityType = endpoint.security.wireValue, + ), + airPlayPort = endpoint.airPlayPort, + deviceIdentifier = endpoint.deviceIdentifier, + publicKey = endpoint.publicKey, + sourceVersion = endpoint.sourceVersion, + ) + + private fun later( + current: Iap2WirelessControlStage, + next: Iap2WirelessControlStage, + ): Iap2WirelessControlStage = if (current.ordinal >= next.ordinal) current else next + + private fun deadlineAfter(timeoutMillis: Long): Long { + val now = System.nanoTime() + val delta = timeoutMillis * NANOS_PER_MILLISECOND + return if (Long.MAX_VALUE - now < delta) Long.MAX_VALUE else now + delta + } + + private fun requireRemaining(deadlineNanos: Long): Long = remainingMillis(deadlineNanos).also { + if (it == 0L) throw IphoneUsbException.TimedOut("Timed out during wireless iAP2 control bring-up") + } + + private fun remainingMillis(deadlineNanos: Long): Long { + val remaining = deadlineNanos - System.nanoTime() + if (remaining <= 0) return 0L + return min( + MAX_RECV_TIMEOUT_MILLIS, + (remaining + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND, + ) + } + } +} + +/** Wireless 0x5703 security values. */ +enum class Iap2WirelessSecurity(val wireValue: Int) { + NONE(0), + WEP(1), + WPA_WPA2(2), + WPA3_TRANSITION(3), + WPA3_ONLY(4), +} + +/** Wireless hotspot and AirPlay endpoint sent in 0x5703 and 0x4301. */ +class Iap2WirelessCarPlayEndpoint( + val ssid: String, + val passphrase: String, + val channel: Int, + val security: Iap2WirelessSecurity, + ipAddresses: List, + val airPlayPort: Int, + val deviceIdentifier: String, + val publicKey: String, + val sourceVersion: String, +) { + val ipAddresses: List = ipAddresses.toList() + + init { + require(ssid.isNotBlank()) { "ssid is required and must not be blank" } + require('\u0000' !in ssid) { "ssid must not contain U+0000" } + require('\u0000' !in passphrase) { "passphrase must not contain U+0000" } + if (security != Iap2WirelessSecurity.NONE) { + require(passphrase.isNotEmpty()) { "passphrase is required for secured Wi-Fi" } + } + require(channel in 0..0xff) { "channel must be in 0..255" } + require(ipAddresses.isNotEmpty()) { "At least one wireless IP address is required" } + require(ipAddresses.all { it.isNotBlank() && '\u0000' !in it }) { + "Every wireless IP address must be non-blank and must not contain U+0000" + } + require(airPlayPort in 1..65535) { "airPlayPort must be in 1..65535" } + require(deviceIdentifier.isNotBlank()) { "deviceIdentifier is required and must not be blank" } + require('\u0000' !in deviceIdentifier) { "deviceIdentifier must not contain U+0000" } + require(publicKey.isNotEmpty()) { "publicKey is required and must not be empty" } + require('\u0000' !in publicKey) { "publicKey must not contain U+0000" } + require(sourceVersion.isNotEmpty()) { "sourceVersion is required and must not be empty" } + require('\u0000' !in sourceVersion) { "sourceVersion must not contain U+0000" } + } +} + +enum class Iap2WirelessControlStage { + IDENTIFIED, + AUTHENTICATED, + SUBSCRIBED, + WIFI_CONFIG_SENT, + CARPLAY_START_SENT, + TRANSPORT_NOTIFIED, + POST_TRANSPORT_WIFI_CONFIG_SENT, +} + +enum class Iap2WirelessControlTerminal { CHANNEL_CLOSED, TIMED_OUT } + +/** End state of the control loop only; it is not evidence of a live Wi-Fi or AirPlay session. */ +data class Iap2WirelessControlResult( + val terminal: Iap2WirelessControlTerminal, + val stage: Iap2WirelessControlStage, + val forwardedFrames: Int, + val wifiConfigurationsSent: Int, + val carPlayStartSessionsSent: Int, + val transportNotificationSeen: Boolean, + val postTransportWiFiConfigurationsSent: Int, + val wirelessCarPlayAvailableSeen: Boolean, +) diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/IphoneCarPlayConfiguration.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/IphoneCarPlayConfiguration.kt new file mode 100644 index 0000000..bd242db --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/IphoneCarPlayConfiguration.kt @@ -0,0 +1,91 @@ +package com.shilapi.xcertplay.transport + +import android.hardware.usb.UsbConfiguration +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.util.Log + +/** + * Descriptor-based discovery of the iPhone's CarPlay configuration. + * + * Configuration ids differ between iPhone models, so the configuration is identified by its + * interfaces: Apple USB Multiplexor (USBMUX) plus the NCM/Ethernet function CarPlay uses. + */ +object IphoneCarPlayConfiguration { + const val TAG = "xcertplay-usb" + + private const val USBMUX_CLASS = 0xff + private const val USBMUX_SUBCLASS = 0xfe + private const val USBMUX_PROTOCOL = 0x02 + private const val APPLE_ETHERNET_CLASS = 0xff + private const val APPLE_ETHERNET_SUBCLASS = 0xfd + private const val APPLE_ETHERNET_PROTOCOL = 0x01 + private const val NCM_CONTROL_CLASS = 0x02 + private const val NCM_CONTROL_SUBCLASS = 0x0d + private const val PREFERRED_USBMUX_OUT = 0x04 + private const val PREFERRED_USBMUX_IN = 0x85 + + fun find(device: UsbDevice): UsbConfiguration? { + val configurations = (0 until device.configurationCount).map(device::getConfiguration) + val chosen = configurations.firstOrNull { usbMuxInterface(it) != null && hasCdcNcm(it) && hasAppleEthernet(it) } + ?: configurations.firstOrNull { usbMuxInterface(it) != null && hasCdcNcm(it) } + Log.i( + TAG, + "carplay config chosen=${chosen?.id} " + + "available=${configurations.map { it.id }} detail=${chosen?.let(::describe)}", + ) + return chosen + } + + fun describe(configuration: UsbConfiguration): String = + (0 until configuration.interfaceCount).joinToString(",") { index -> + val usbInterface = configuration.getInterface(index) + "${usbInterface.id}/${usbInterface.alternateSetting}" + + ":${usbInterface.interfaceClass.toString(16)}" + + ".${usbInterface.interfaceSubclass.toString(16)}" + + ".${usbInterface.interfaceProtocol.toString(16)}" + + "x${usbInterface.endpointCount}" + } + + fun usbMuxInterface(configuration: UsbConfiguration): UsbInterface? = + (0 until configuration.interfaceCount).map(configuration::getInterface).firstOrNull { + it.interfaceClass == USBMUX_CLASS && + it.interfaceSubclass == USBMUX_SUBCLASS && + it.interfaceProtocol == USBMUX_PROTOCOL + } + + fun usbMuxEndpoints(usbInterface: UsbInterface): Pair? { + val endpoints = (0 until usbInterface.endpointCount).map(usbInterface::getEndpoint) + val out = endpoints.firstOrNull { + it.address == PREFERRED_USBMUX_OUT && + it.direction == UsbConstants.USB_DIR_OUT && + it.type == UsbConstants.USB_ENDPOINT_XFER_BULK + } ?: endpoints.singleOrNull { + it.direction == UsbConstants.USB_DIR_OUT && + it.type == UsbConstants.USB_ENDPOINT_XFER_BULK + } + val input = endpoints.firstOrNull { + it.address == PREFERRED_USBMUX_IN && + it.direction == UsbConstants.USB_DIR_IN && + it.type == UsbConstants.USB_ENDPOINT_XFER_BULK + } ?: endpoints.singleOrNull { + it.direction == UsbConstants.USB_DIR_IN && + it.type == UsbConstants.USB_ENDPOINT_XFER_BULK + } + return if (out != null && input != null) out to input else null + } + + private fun hasCdcNcm(configuration: UsbConfiguration): Boolean = + (0 until configuration.interfaceCount).map(configuration::getInterface).any { + it.interfaceClass == NCM_CONTROL_CLASS && it.interfaceSubclass == NCM_CONTROL_SUBCLASS + } + + private fun hasAppleEthernet(configuration: UsbConfiguration): Boolean = + (0 until configuration.interfaceCount).map(configuration::getInterface).any { + it.interfaceClass == APPLE_ETHERNET_CLASS && + it.interfaceSubclass == APPLE_ETHERNET_SUBCLASS && + it.interfaceProtocol == APPLE_ETHERNET_PROTOCOL + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/IphoneUsbHost.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/IphoneUsbHost.kt new file mode 100644 index 0000000..d855ed3 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/IphoneUsbHost.kt @@ -0,0 +1,451 @@ +package com.shilapi.xcertplay.transport + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConfiguration +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.hardware.usb.UsbRequest +import android.os.Build +import android.util.Log +import java.io.Closeable +import java.io.IOException +import java.nio.ByteBuffer +import java.util.concurrent.Executor +import java.util.concurrent.TimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +/** Exact Apple USB identities allowed by the deployment configuration. */ +class IphoneUsbMatcher private constructor( + private val allowedDevices: Set?, + private val allowAnyAppleProduct: Boolean, +) { + constructor(allowedDevices: Collection) : this( + allowedDevices.toSet(), + allowAnyAppleProduct = false, + ) + + init { + require(allowedDevices == null || allowedDevices.isNotEmpty()) { + "At least one iPhone USB identity is required when not using Apple-vendor discovery" + } + require(allowedDevices == null || allowedDevices.all { it.vendorId == APPLE_VENDOR_ID }) { + "iPhone USB identities must use Apple vendor ID 0x${APPLE_VENDOR_ID.toString(16)}" + } + } + + fun matches(vendorId: Int, productId: Int): Boolean = + if (allowAnyAppleProduct) vendorId == APPLE_VENDOR_ID + else UsbDeviceId(vendorId, productId) in allowedDevices.orEmpty() + + companion object { + /** Apple VID used by LIVI commit 0a3dcaa0bf30d5319506d0e47c7b0d46bc942ec3. */ + const val APPLE_VENDOR_ID = 0x05ac + + /** Discovers every Apple device, matching only the vendor ID confirmed by LIVI. */ + fun appleVendor(): IphoneUsbMatcher = IphoneUsbMatcher(null, allowAnyAppleProduct = true) + } +} + +/** + * Android USB Host bring-up boundary for a configured iPhone identity. + * + * LIVI's fixed commit uses Apple vendor request `0x52`, value `0`, index `4`, and then selects + * configuration `6`. The vendor request can make the iPhone re-enumerate. Android does not offer + * Linux sysfs configuration control or a synchronous re-enumeration primitive, so this class + * closes the first connection and requires the caller to receive, re-authorize, and pass the new + * [UsbDevice] to [selectCarPlayConfigurationAsync]. All opens run on the supplied executor. + */ +class IphoneUsbHost( + context: Context, + private val usbManager: UsbManager, + private val matcher: IphoneUsbMatcher, + private val permissionAction: String = "${context.packageName}.IPHONE_USB_PERMISSION", +) { + private val appContext = context.applicationContext + + sealed class PermissionRequest { + data class AlreadyGranted(val device: UsbDevice) : PermissionRequest() + data class Requested(val device: UsbDevice) : PermissionRequest() + } + + sealed class PermissionResult { + data class Granted(val device: UsbDevice) : PermissionResult() + data class Denied(val device: UsbDevice) : PermissionResult() + } + + sealed class TransitionResult { + /** The connection was closed; wait for a new matching attached device before continuing. */ + data object ReenumerationRequested : TransitionResult() + + data class Failed(val error: IphoneUsbException) : TransitionResult() + } + + sealed class Iap2SessionResult { + data class Connected(val session: Iap2UsbSession) : Iap2SessionResult() + data class Failed(val error: IphoneUsbException) : Iap2SessionResult() + } + + fun discover(): List = + usbManager.deviceList.values.filter { matcher.matches(it.vendorId, it.productId) } + + @Throws(IphoneUsbException::class) + fun requestPermission(device: UsbDevice): PermissionRequest { + requireConfiguredDevice(device) + if (usbManager.hasPermission(device)) return PermissionRequest.AlreadyGranted(device) + + usbManager.requestPermission(device, permissionPendingIntent()) + return PermissionRequest.Requested(device) + } + + /** Returns null for unrelated broadcasts, malformed results, or non-configured devices. */ + fun parsePermissionResult(intent: Intent): PermissionResult? { + if (intent.action != permissionAction) return null + val device = intent.usbDevice() ?: return null + if (!matcher.matches(device.vendorId, device.productId)) return null + return if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) { + PermissionResult.Granted(device) + } else { + PermissionResult.Denied(device) + } + } + + /** Returns the new matching device after the vendor request caused Android USB re-enumeration. */ + fun parseAttachedDevice(intent: Intent): UsbDevice? { + if (intent.action != UsbManager.ACTION_USB_DEVICE_ATTACHED) return null + val device = intent.usbDevice() ?: return null + return device.takeIf { matcher.matches(it.vendorId, it.productId) } + } + + /** Register once for this host instance and close the returned handle to unregister it. */ + fun registerPermissionReceiver(onResult: (PermissionResult) -> Unit): Closeable = + registerReceiver(IntentFilter(permissionAction)) { parsePermissionResult(it)?.let(onResult) } + + /** Register once for this host instance and close the returned handle to unregister it. */ + fun registerAttachReceiver(onAttached: (UsbDevice) -> Unit): Closeable = + registerReceiver(IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + parseAttachedDevice(it)?.let(onAttached) + } + + /** + * Sends the LIVI-evidenced vendor request then closes the connection before re-enumeration. + * The callback is invoked from [executor]. + */ + fun requestCarPlayReenumerationAsync( + device: UsbDevice, + executor: Executor, + callback: (TransitionResult) -> Unit, + ) { + executor.execute { + callback(runTransition(device) { connection -> + val response = ByteArray(VENDOR_RESPONSE_LENGTH) + val transferred = connection.controlTransfer( + USB_VENDOR_DEVICE_IN, + CARPLAY_CONFIGURATION_REQUEST, + 0, + CARPLAY_CONFIGURATION_INDEX, + response, + response.size, + CONTROL_TRANSFER_TIMEOUT_MILLIS, + ) + if (transferred != response.size) { + throw IphoneUsbException.Protocol( + "CarPlay configuration request transferred $transferred of ${response.size} bytes", + ) + } + TransitionResult.ReenumerationRequested + }) + } + } + + /** + * Opens LIVI's USBMUX bulk pipe on the re-enumerated iPhone. + * + * This repeats CarPlay configuration selection on the newly opened Android connection and + * claims the Apple USB Multiplexor interface, preferring the LIVI bulk pair 0x04/0x85. + * After a successful callback, it owns the returned session and must close it. If the callback + * throws, this method closes the session before propagating the callback failure. + */ + fun openIap2UsbSessionAsync( + device: UsbDevice, + executor: Executor, + callback: (Iap2SessionResult) -> Unit, + ) { + executor.execute { + val result = try { + Iap2SessionResult.Connected(openIap2UsbSession(device)) + } catch (error: IphoneUsbException) { + Iap2SessionResult.Failed(error) + } catch (error: SecurityException) { + Iap2SessionResult.Failed( + IphoneUsbException.PermissionDenied("USB permission was denied", error), + ) + } catch (error: RuntimeException) { + Iap2SessionResult.Failed( + IphoneUsbException.DeviceUnavailable("iPhone USBMUX operation failed", error), + ) + } + try { + callback(result) + } catch (error: Throwable) { + if (result is Iap2SessionResult.Connected) { + try { + result.session.close() + } catch (closeError: Throwable) { + error.addSuppressed(closeError) + } + } + throw error + } + } + } + + private fun runTransition( + device: UsbDevice, + operation: (UsbDeviceConnection) -> TransitionResult, + ): TransitionResult = try { + requireConfiguredDevice(device) + if (!usbManager.hasPermission(device)) { + throw IphoneUsbException.PermissionDenied("USB permission has not been granted") + } + val connection = usbManager.openDevice(device) + ?: throw IphoneUsbException.DeviceUnavailable("UsbManager could not open the iPhone") + try { + operation(connection) + } finally { + connection.close() + } + } catch (error: IphoneUsbException) { + TransitionResult.Failed(error) + } catch (error: SecurityException) { + TransitionResult.Failed(IphoneUsbException.PermissionDenied("USB permission was denied", error)) + } catch (error: RuntimeException) { + TransitionResult.Failed(IphoneUsbException.DeviceUnavailable("iPhone USB operation failed", error)) + } + + private fun openIap2UsbSession(device: UsbDevice): Iap2UsbSession { + requireConfiguredDevice(device) + if (!usbManager.hasPermission(device)) { + throw IphoneUsbException.PermissionDenied("USB permission has not been granted") + } + val connection = usbManager.openDevice(device) + ?: throw IphoneUsbException.DeviceUnavailable("UsbManager could not open the iPhone") + var claimedInterface: UsbInterface? = null + try { + val configuration = IphoneCarPlayConfiguration.find(device) + ?: throw IphoneUsbException.Protocol( + "Re-enumerated iPhone exposes no USBMUX CarPlay configuration", + ) + if (!connection.setConfiguration(configuration)) { + Log.w( + IphoneCarPlayConfiguration.TAG, + "setConfiguration ${configuration.id} reported failure; claiming anyway", + ) + } + val usbMux = IphoneCarPlayConfiguration.usbMuxInterface(configuration) + ?: throw IphoneUsbException.Protocol("CarPlay configuration exposes no USBMUX interface") + val endpoints = IphoneCarPlayConfiguration.usbMuxEndpoints(usbMux) + ?: throw IphoneUsbException.Protocol("USBMUX interface exposes no bulk endpoint pair") + Log.i( + IphoneCarPlayConfiguration.TAG, + "usbmux iface=${usbMux.id} alt=${usbMux.alternateSetting} " + + "out=0x${endpoints.first.address.toString(16)} in=0x${endpoints.second.address.toString(16)}", + ) + if (!connection.claimInterface(usbMux, true)) { + throw IphoneUsbException.DeviceUnavailable("Android could not claim USBMUX interface 1") + } + claimedInterface = usbMux + return Iap2UsbSession(connection, endpoints.first, endpoints.second) + } catch (error: Throwable) { + if (claimedInterface != null) connection.releaseInterface(claimedInterface) + connection.close() + throw error + } + } + + private fun requireConfiguredDevice(device: UsbDevice) { + if (!matcher.matches(device.vendorId, device.productId)) { + throw IphoneUsbException.DeviceUnavailable("USB device is not a configured iPhone identity") + } + } + + private fun permissionPendingIntent(): PendingIntent { + val intent = Intent(permissionAction).setPackage(appContext.packageName) + return PendingIntent.getBroadcast( + appContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun registerReceiver(filter: IntentFilter, onReceive: (Intent) -> Unit): Closeable { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = onReceive(intent) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + appContext.registerReceiver(receiver, filter) + } + val registered = AtomicBoolean(true) + return Closeable { + if (registered.compareAndSet(true, false)) appContext.unregisterReceiver(receiver) + } + } + + private fun Intent.usbDevice(): UsbDevice? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(UsbManager.EXTRA_DEVICE) + } + + companion object { + private const val USB_VENDOR_DEVICE_IN = 0xc0 + private const val CARPLAY_CONFIGURATION_REQUEST = 0x52 + private const val CARPLAY_CONFIGURATION_INDEX = 0x0004 + private const val VENDOR_RESPONSE_LENGTH = 1 + private const val CONTROL_TRANSFER_TIMEOUT_MILLIS = 1_000 + } + +} + +/** + * A blocking, full-duplex USBMUX pipe. A null [read] result means only that its timeout elapsed. + * + * All operations must run off the Android main thread. The session does not parse iAP2 frames. + */ +class Iap2UsbSession internal constructor( + private val connection: UsbDeviceConnection, + private val outEndpoint: UsbEndpoint, + private val inEndpoint: UsbEndpoint, +) : Closeable { + private val stateLock = Any() + private val readLock = Any() + private val writeLock = Any() + private var closed = false + private var failure: IphoneUsbException? = null + private var pendingRead: UsbRequest? = null + + fun write(data: ByteArray, timeoutMillis: Int) = synchronized(writeLock) { + checkOpen() + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + if (data.isEmpty()) return@synchronized + val transferred = connection.bulkTransfer(outEndpoint, data, data.size, timeoutMillis) + if (transferred != data.size) { + throw IphoneUsbException.DeviceUnavailable( + "USBMUX write transferred $transferred of ${data.size} bytes", + ) + } + } + + /** Returns null only when no completed USB request arrives before [timeoutMillis]. */ + fun read(timeoutMillis: Long): ByteArray? = synchronized(readLock) { + checkOpen() + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + val request = UsbRequest() + var initialized = false + try { + if (!request.initialize(connection, inEndpoint)) { + throw IphoneUsbException.DeviceUnavailable("Android could not initialize USBMUX read request") + } + initialized = true + synchronized(stateLock) { + checkOpenLocked() + pendingRead = request + } + val buffer = ByteBuffer.allocateDirect(USBMUX_READ_CHUNK_BYTES) + if (!request.queue(buffer)) { + throw IphoneUsbException.DeviceUnavailable("Android could not queue USBMUX read request") + } + val completed = try { + connection.requestWait(timeoutMillis) + } catch (_: TimeoutException) { + drainCancelledRead(request) + return@synchronized null + } + if (completed == null) { + throw failSession("Android returned no USBMUX read request") + } + if (completed !== request) { + throw failSession("Android completed an unexpected USB request") + } + return@synchronized ByteArray(buffer.position()).also { + buffer.flip() + buffer.get(it) + } + } catch (error: IphoneUsbException) { + throw error + } catch (error: RuntimeException) { + throw failSession("USBMUX read failed", error) + } finally { + synchronized(stateLock) { + if (pendingRead === request) pendingRead = null + } + if (initialized) request.cancel() + request.close() + } + } + + override fun close() { + val requestToCancel = synchronized(stateLock) { + if (closed) return + closed = true + pendingRead + } + requestToCancel?.cancel() + connection.close() + } + + private fun checkOpen() { + synchronized(stateLock) { checkOpenLocked() } + } + + private fun checkOpenLocked() { + failure?.let { throw it } + if (closed) throw IphoneUsbException.DeviceUnavailable("USBMUX session is closed") + } + + private fun drainCancelledRead(request: UsbRequest) { + if (!request.cancel()) { + throw failSession("Android could not cancel timed out USBMUX read request") + } + val completed = try { + connection.requestWait(CANCEL_DRAIN_TIMEOUT_MILLIS) + } catch (_: TimeoutException) { + throw failSession("Timed out draining cancelled USBMUX read request") + } + if (completed !== request) { + throw failSession("Android did not drain the cancelled USBMUX read request") + } + } + + private fun failSession(message: String, cause: Throwable? = null): IphoneUsbException.DeviceUnavailable { + val error = IphoneUsbException.DeviceUnavailable(message, cause) + synchronized(stateLock) { + if (failure == null) failure = error + } + return error + } + + private companion object { + const val USBMUX_READ_CHUNK_BYTES = 65_536 + const val CANCEL_DRAIN_TIMEOUT_MILLIS = 1_000L + } +} + +/** USB bring-up failures that precede iAP2 and are distinct from MFi I2C failures. */ +sealed class IphoneUsbException(message: String, cause: Throwable? = null) : IOException(message, cause) { + class PermissionDenied(message: String, cause: Throwable? = null) : IphoneUsbException(message, cause) + class DeviceUnavailable(message: String, cause: Throwable? = null) : IphoneUsbException(message, cause) + class TimedOut(message: String, cause: Throwable? = null) : IphoneUsbException(message, cause) + class Protocol(message: String) : IphoneUsbException(message) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/LinuxI2cTransport.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/LinuxI2cTransport.kt new file mode 100644 index 0000000..c63a78f --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/LinuxI2cTransport.kt @@ -0,0 +1,145 @@ +package com.shilapi.xcertplay.transport + +import java.io.Closeable +import java.io.IOException + +/** + * I2C transport for a board-provided Linux `/dev/i2c-N` device node. + * + * The target build must grant this app access to the node. Android SELinux policy and Unix device + * permissions are not changed or bypassed here. All methods block and must not run on the main + * thread. + */ +class LinuxI2cTransport private constructor( + private val bridge: LinuxI2cBridge, + private val handle: Int, +) : I2cTransport, Closeable { + private val lock = Any() + private var closed = false + + override fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray = + synchronized(lock) { + checkOpen() + validateTransaction(address7Bit, writeData, readLength) + + try { + bridge.transaction(handle, address7Bit, writeData, readLength).also { response -> + if (response.size != readLength) { + throw I2cTransportException.Protocol( + "I2C backend returned ${response.size} bytes, expected $readLength", + ) + } + } + } catch (error: LinuxI2cNativeException) { + throw mapNativeError(error) + } + } + + override fun close() { + synchronized(lock) { + if (closed) return + closed = true + try { + bridge.close(handle) + } catch (error: LinuxI2cNativeException) { + throw mapNativeError(error) + } + } + } + + private fun checkOpen() { + if (closed) { + throw I2cTransportException.DeviceUnavailable("Linux I2C transport is closed") + } + } + + companion object { + private const val MAX_MESSAGE_LENGTH = 0xffff + private val I2C_DEVICE_PATH = Regex("/dev/i2c-[0-9]+") + + /** Opens a Linux I2C device node. */ + @Throws(I2cTransportException::class) + fun open(devicePath: String): LinuxI2cTransport = open(devicePath, NativeLinuxI2cBridge) + + internal fun open(devicePath: String, bridge: LinuxI2cBridge): LinuxI2cTransport { + if (!I2C_DEVICE_PATH.matches(devicePath)) { + throw I2cTransportException.InvalidRequest( + "I2C device path must be /dev/i2c-N: $devicePath", + ) + } + + return try { + LinuxI2cTransport(bridge, bridge.open(devicePath)) + } catch (error: LinuxI2cNativeException) { + throw mapNativeError(error) + } + } + + private fun validateTransaction(address7Bit: Int, writeData: ByteArray, readLength: Int) { + if (address7Bit !in 0..0x7f) { + throw I2cTransportException.InvalidRequest( + "I2C address must be 7-bit: 0x${address7Bit.toString(16)}", + ) + } + if (readLength !in 0..MAX_MESSAGE_LENGTH || writeData.size > MAX_MESSAGE_LENGTH) { + throw I2cTransportException.InvalidRequest( + "I2C message length must be at most $MAX_MESSAGE_LENGTH bytes", + ) + } + if (writeData.isEmpty() && readLength == 0) { + throw I2cTransportException.InvalidRequest("I2C transaction must read or write data") + } + } + + private fun mapNativeError(error: LinuxI2cNativeException): I2cTransportException = + when (error.errnoCode) { + 1, 13 -> I2cTransportException.PermissionDenied(error.message ?: "I2C permission denied") + 110 -> I2cTransportException.TimedOut(error.message ?: "I2C transaction timed out", error) + 121 -> I2cTransportException.Nack(error.message ?: "I2C remote NACK") + 2, 6, 9, 16, 19 -> I2cTransportException.DeviceUnavailable( + error.message ?: "I2C device unavailable", + error, + ) + else -> I2cTransportException.Protocol(error.message ?: "I2C native operation failed", error) + } + } +} + +internal interface LinuxI2cBridge { + fun open(devicePath: String): Int + fun transaction(handle: Int, address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray + fun close(handle: Int) +} + +internal class LinuxI2cNativeException( + val errnoCode: Int, + message: String, +) : IOException(message) + +private object NativeLinuxI2cBridge : LinuxI2cBridge { + init { + System.loadLibrary("xcertplay_i2c") + } + + override fun open(devicePath: String): Int = LinuxI2cNative.open(devicePath) + + override fun transaction( + handle: Int, + address7Bit: Int, + writeData: ByteArray, + readLength: Int, + ): ByteArray = LinuxI2cNative.transaction(handle, address7Bit, writeData, readLength) + + override fun close(handle: Int) = LinuxI2cNative.close(handle) +} + +private object LinuxI2cNative { + external fun open(devicePath: String): Int + external fun transaction( + handle: Int, + address7Bit: Int, + writeData: ByteArray, + readLength: Int, + ): ByteArray + external fun close(handle: Int) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownCarKitClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownCarKitClient.kt new file mode 100644 index 0000000..0e554f4 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownCarKitClient.kt @@ -0,0 +1,143 @@ +package com.shilapi.xcertplay.transport + +import java.io.Closeable +import java.security.GeneralSecurityException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Blocking minimum Lockdown path that opens the `com.apple.carkit.service` byte stream. + * + * Connect, plist receive, and TLS engine budgets are five seconds; writes retain the underlying + * transport's bounded timeout. The returned stream is owned by the caller. This client never + * closes its caller-owned [Iap2UsbMuxHost], persists a record, sends iAP2, or performs UI work. + */ +class LockdownCarKitClient( + private val host: Iap2UsbMuxHost, +) { + @Throws(IphoneUsbException::class, GeneralSecurityException::class) + fun open(pairedRecord: PairedRecord, label: String): BlockingDuplexByteStream = + open(pairedRecord.pairRecord, label) + + @Throws(IphoneUsbException::class, GeneralSecurityException::class) + fun open(pairRecord: LockdownPairRecord, label: String): BlockingDuplexByteStream = + openService(pairRecord, label, CARKIT_SERVICE) + + fun openService(pairRecord: LockdownPairRecord, label: String, serviceName: String): BlockingDuplexByteStream { + require(label.isNotBlank()) { "label must not be blank" } + require(serviceName.isNotBlank()) { "serviceName must not be blank" } + + val lockdownConnection = host.connect( + destinationPort = Iap2UsbMuxHost.LOCKDOWN_PORT, + timeoutMillis = STEP_TIMEOUT_MILLIS, + ) + val sessionTls = LockdownPlistChannel(lockdownConnection).use { plaintext -> + val response = plaintext.request( + LockdownPlistValue.Dictionary( + linkedMapOf( + "Label" to LockdownPlistValue.Text(label), + "Request" to LockdownPlistValue.Text("StartSession"), + "HostID" to LockdownPlistValue.Text(pairRecord.hostId), + "SystemBUID" to LockdownPlistValue.Text(pairRecord.systemBuid), + ), + ), + STEP_TIMEOUT_MILLIS, + ) + rejectError(response, "StartSession") + val enableSessionSsl = (response.entries["EnableSessionSSL"] as? LockdownPlistValue.Boolean)?.value + ?: throw IphoneUsbException.Protocol("StartSession response omitted boolean EnableSessionSSL") + if (!enableSessionSsl) { + throw IphoneUsbException.Protocol("StartSession did not enable TLS") + } + TlsDuplexChannel.open( + underlying = plaintext.detach(), + pairRecord = pairRecord, + handshakeTimeoutMillis = STEP_TIMEOUT_MILLIS, + ) + } + + val secureLockdown = LockdownPlistChannel(sessionTls) + var serviceStream: BlockingDuplexByteStream? = null + try { + val response = secureLockdown.request( + LockdownPlistValue.Dictionary( + linkedMapOf( + "Request" to LockdownPlistValue.Text("StartService"), + "Service" to LockdownPlistValue.Text(serviceName), + ), + ), + STEP_TIMEOUT_MILLIS, + ) + rejectError(response, "StartService") + val port = (response.entries["Port"] as? LockdownPlistValue.Integer)?.value + ?: throw IphoneUsbException.Protocol("StartService response omitted integer Port") + if (port !in 1..0xffff) { + throw IphoneUsbException.Protocol("StartService returned an invalid Port") + } + val enableServiceSsl = (response.entries["EnableServiceSSL"] as? LockdownPlistValue.Boolean)?.value + ?: false + + val serviceConnection = host.connect( + destinationPort = port.toInt(), + timeoutMillis = STEP_TIMEOUT_MILLIS, + ) + val readyStream = if (enableServiceSsl) { + TlsDuplexChannel.open( + underlying = serviceConnection, + pairRecord = pairRecord, + handshakeTimeoutMillis = STEP_TIMEOUT_MILLIS, + ) + } else { + serviceConnection + } + serviceStream = readyStream + val ownedStream = CarkitServiceStream(readyStream, secureLockdown) + serviceStream = null + return ownedStream + } finally { + if (serviceStream != null) { + try { + serviceStream.close() + } finally { + secureLockdown.close() + } + } + } + } + + /** Keeps the Lockdown session that created carkit alive for the service stream's lifetime. */ + private class CarkitServiceStream( + private val service: BlockingDuplexByteStream, + private val lockdown: Closeable, + ) : BlockingDuplexByteStream { + private val closed = AtomicBoolean(false) + + override fun send(data: ByteArray) = service.send(data) + + override fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? = + service.recv(maxBytes, timeoutMillis) + + override fun close() { + if (!closed.compareAndSet(false, true)) return + try { + service.close() + } finally { + lockdown.close() + } + } + } + + private fun rejectError(response: LockdownPlistValue.Dictionary, request: String) { + val code = when (val error = response.entries["Error"] ?: return) { + is LockdownPlistValue.Text -> error.value + is LockdownPlistValue.Integer -> (response.entries["ErrorString"] as? LockdownPlistValue.Text)?.value + ?: error.value.toString() + else -> throw IphoneUsbException.Protocol("$request response Error was not text or integer") + } + throw IphoneUsbException.Protocol("Lockdown $request failed: $code") + } + + private companion object { + const val STEP_TIMEOUT_MILLIS = 5_000L + const val CARKIT_SERVICE = "com.apple.carkit.service" + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPairRecord.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPairRecord.kt new file mode 100644 index 0000000..c58b0ad --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPairRecord.kt @@ -0,0 +1,433 @@ +package com.shilapi.xcertplay.transport + +import java.io.ByteArrayOutputStream +import java.math.BigInteger +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Signature +import java.security.interfaces.RSAPublicKey +import java.security.spec.RSAPublicKeySpec +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Pairing material assembled locally from Lockdown values already obtained by the caller. + * + * It deliberately has no transport dependency: it neither requests values nor sends Pair. + */ +class LockdownPairRecord private constructor( + val hostId: String, + val systemBuid: String, + val wifiMacAddress: String, + devicePublicKeyPem: ByteArray, + deviceCertificatePem: ByteArray, + hostPrivateKeyPem: ByteArray, + hostCertificatePem: ByteArray, + rootPrivateKeyPem: ByteArray, + rootCertificatePem: ByteArray, +) { + private val storedDevicePublicKeyPem = devicePublicKeyPem.copyOf() + private val storedDeviceCertificatePem = deviceCertificatePem.copyOf() + private val storedHostPrivateKeyPem = hostPrivateKeyPem.copyOf() + private val storedHostCertificatePem = hostCertificatePem.copyOf() + private val storedRootPrivateKeyPem = rootPrivateKeyPem.copyOf() + private val storedRootCertificatePem = rootCertificatePem.copyOf() + + val devicePublicKeyPem: ByteArray get() = storedDevicePublicKeyPem.copyOf() + val deviceCertificatePem: ByteArray get() = storedDeviceCertificatePem.copyOf() + val hostPrivateKeyPem: ByteArray get() = storedHostPrivateKeyPem.copyOf() + val hostCertificatePem: ByteArray get() = storedHostCertificatePem.copyOf() + val rootPrivateKeyPem: ByteArray get() = storedRootPrivateKeyPem.copyOf() + val rootCertificatePem: ByteArray get() = storedRootCertificatePem.copyOf() + + /** Builds only the nested Pair request value; EscrowBag is intentionally unavailable yet. */ + fun toPairRequestDictionary(): LockdownPlistValue.Dictionary = LockdownPlistValue.Dictionary( + linkedMapOf( + "DeviceCertificate" to LockdownPlistValue.Data(deviceCertificatePem), + "HostCertificate" to LockdownPlistValue.Data(hostCertificatePem), + "HostID" to LockdownPlistValue.Text(hostId), + "RootCertificate" to LockdownPlistValue.Data(rootCertificatePem), + "SystemBUID" to LockdownPlistValue.Text(systemBuid), + ), + ) + + override fun toString(): String = "LockdownPairRecord(redacted)" + + companion object { + /** Rebuilds a record from previously persisted material; all PEM buffers are copied. */ + fun restore( + hostId: String, + systemBuid: String, + wifiMacAddress: String, + devicePublicKeyPem: ByteArray, + deviceCertificatePem: ByteArray, + hostPrivateKeyPem: ByteArray, + hostCertificatePem: ByteArray, + rootPrivateKeyPem: ByteArray, + rootCertificatePem: ByteArray, + ): LockdownPairRecord { + require(hostId.isNotBlank()) { "hostId must not be blank" } + require(systemBuid.isNotBlank()) { "systemBuid must not be blank" } + require(wifiMacAddress.isNotBlank()) { "wifiMacAddress must not be blank" } + listOf( + devicePublicKeyPem, + deviceCertificatePem, + hostPrivateKeyPem, + hostCertificatePem, + rootPrivateKeyPem, + rootCertificatePem, + ).forEach { require(it.isNotEmpty()) { "PEM material must not be empty" } } + return LockdownPairRecord( + hostId = hostId, + systemBuid = systemBuid, + wifiMacAddress = wifiMacAddress, + devicePublicKeyPem = devicePublicKeyPem, + deviceCertificatePem = deviceCertificatePem, + hostPrivateKeyPem = hostPrivateKeyPem, + hostCertificatePem = hostCertificatePem, + rootPrivateKeyPem = rootPrivateKeyPem, + rootCertificatePem = rootCertificatePem, + ) + } + + internal fun create( + hostId: String, + systemBuid: String, + wifiMacAddress: String, + devicePublicKeyPem: ByteArray, + material: CertificateMaterial, + ) = LockdownPairRecord( + hostId = hostId, + systemBuid = systemBuid, + wifiMacAddress = wifiMacAddress, + devicePublicKeyPem = devicePublicKeyPem, + deviceCertificatePem = material.deviceCertificatePem, + hostPrivateKeyPem = material.hostPrivateKeyPem, + hostCertificatePem = material.hostCertificatePem, + rootPrivateKeyPem = material.rootPrivateKeyPem, + rootCertificatePem = material.rootCertificatePem, + ) + } +} + +/** Generates a Lockdown PairRecord from values supplied by the caller, without any USB I/O. */ +object LockdownPairRecordGenerator { + @Throws(GeneralSecurityException::class, IllegalArgumentException::class) + fun generate( + devicePublicKeyPkcs1Pem: ByteArray, + wifiAddress: String, + hostId: String, + systemBuid: String, + ): LockdownPairRecord { + require(devicePublicKeyPkcs1Pem.isNotEmpty()) { "devicePublicKeyPkcs1Pem must not be empty" } + require(wifiAddress.isNotBlank()) { "wifiAddress must not be blank" } + require(hostId.isNotBlank()) { "hostId must not be blank" } + require(systemBuid.isNotBlank()) { "systemBuid must not be blank" } + + val copiedDeviceKey = devicePublicKeyPkcs1Pem.copyOf() + val material = CertificateMaterialGenerator.generate(copiedDeviceKey) + return LockdownPairRecord.create( + hostId = hostId, + systemBuid = systemBuid, + wifiMacAddress = wifiAddress, + devicePublicKeyPem = copiedDeviceKey, + material = material, + ) + } +} + +internal class CertificateMaterial( + deviceCertificatePem: ByteArray, + hostPrivateKeyPem: ByteArray, + hostCertificatePem: ByteArray, + rootPrivateKeyPem: ByteArray, + rootCertificatePem: ByteArray, +) { + private val storedDeviceCertificatePem = deviceCertificatePem.copyOf() + private val storedHostPrivateKeyPem = hostPrivateKeyPem.copyOf() + private val storedHostCertificatePem = hostCertificatePem.copyOf() + private val storedRootPrivateKeyPem = rootPrivateKeyPem.copyOf() + private val storedRootCertificatePem = rootCertificatePem.copyOf() + + val deviceCertificatePem: ByteArray get() = storedDeviceCertificatePem.copyOf() + val hostPrivateKeyPem: ByteArray get() = storedHostPrivateKeyPem.copyOf() + val hostCertificatePem: ByteArray get() = storedHostCertificatePem.copyOf() + val rootPrivateKeyPem: ByteArray get() = storedRootPrivateKeyPem.copyOf() + val rootCertificatePem: ByteArray get() = storedRootCertificatePem.copyOf() +} + +/** Minimal Android/JCA implementation of the certificate profile used by the locked dependency. */ +private object CertificateMaterialGenerator { + private const val RSA_KEY_BITS = 2048 + private const val CERTIFICATE_LIFETIME_SECONDS = 10L * 365 * 24 * 60 * 60 + private const val MILLIS_PER_SECOND = 1_000L + private const val UTC_TIME_LAST_YEAR = 2049 + private val sha256WithRsa = algorithmIdentifier("1.2.840.113549.1.1.11") + private val rsaEncryption = algorithmIdentifier("1.2.840.113549.1.1.1") + + fun generate(devicePublicKeyPem: ByteArray): CertificateMaterial { + val devicePublicKey = parsePkcs1RsaPublicKey(devicePublicKeyPem) + val rootKeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(RSA_KEY_BITS) }.generateKeyPair() + val hostKeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(RSA_KEY_BITS) }.generateKeyPair() + val rootPublicKey = rootKeyPair.public as? RSAPublicKey + ?: throw GeneralSecurityException("Generated root key is not RSA") + val hostPublicKey = hostKeyPair.public as? RSAPublicKey + ?: throw GeneralSecurityException("Generated host key is not RSA") + val now = System.currentTimeMillis() + val emptyName = distinguishedName(null) + val rootDer = certificate( + issuer = emptyName, + subject = emptyName, + certificatePublicKey = rootPublicKey, + signingKey = rootKeyPair.private, + signingPublicKey = rootPublicKey, + nowMillis = now, + extensions = rootExtensions(), + ) + val hostDer = certificate( + issuer = emptyName, + subject = emptyName, + certificatePublicKey = hostPublicKey, + signingKey = rootKeyPair.private, + signingPublicKey = rootPublicKey, + nowMillis = now, + extensions = leafExtensions(hostPublicKey, includeSubjectKeyIdentifier = false), + ) + val deviceDer = certificate( + issuer = emptyName, + subject = emptyName, + certificatePublicKey = devicePublicKey, + signingKey = rootKeyPair.private, + signingPublicKey = rootPublicKey, + nowMillis = now, + extensions = leafExtensions(devicePublicKey, includeSubjectKeyIdentifier = true), + ) + return CertificateMaterial( + deviceCertificatePem = pem("CERTIFICATE", deviceDer), + hostPrivateKeyPem = pem("PRIVATE KEY", hostKeyPair.private.encoded), + hostCertificatePem = pem("CERTIFICATE", hostDer), + rootPrivateKeyPem = pem("PRIVATE KEY", rootKeyPair.private.encoded), + rootCertificatePem = pem("CERTIFICATE", rootDer), + ) + } + + private fun certificate( + issuer: ByteArray, + subject: ByteArray, + certificatePublicKey: RSAPublicKey, + signingKey: PrivateKey, + signingPublicKey: PublicKey, + nowMillis: Long, + extensions: ByteArray, + ): ByteArray { + val publicKeyBits = pkcs1PublicKey(certificatePublicKey) + val notAfterMillis = nowMillis + CERTIFICATE_LIFETIME_SECONDS * MILLIS_PER_SECOND + require(nowMillis in 0 until notAfterMillis) { "Invalid certificate validity" } + val tbs = sequence( + explicit(0, integer(BigInteger.valueOf(2))), + integer(BigInteger.ZERO), + sha256WithRsa, + issuer, + sequence( + certificateTime(nowMillis), + certificateTime(notAfterMillis), + ), + subject, + sequence(rsaEncryption, bitString(publicKeyBits)), + explicit(3, extensions), + ) + val signer = Signature.getInstance("SHA256withRSA").apply { + initSign(signingKey) + update(tbs) + } + val signature = signer.sign() + val encoded = sequence(tbs, sha256WithRsa, bitString(signature)) + verifyGeneratedCertificate(tbs, signature, signingPublicKey) + return encoded + } + + /** JBR's CertificateFactory rejects the locked empty issuer DN, so verify the signed TBS directly. */ + private fun verifyGeneratedCertificate(tbs: ByteArray, signature: ByteArray, signingPublicKey: PublicKey) { + val verifier = Signature.getInstance("SHA256withRSA").apply { + initVerify(signingPublicKey) + update(tbs) + } + if (!verifier.verify(signature)) throw GeneralSecurityException("Generated certificate signature does not verify") + } + + private fun rootExtensions(): ByteArray = sequence( + extension("2.5.29.19", critical = true, value = sequence(boolean(true))), + ) + + private fun leafExtensions( + publicKey: RSAPublicKey, + includeSubjectKeyIdentifier: Boolean, + ): ByteArray { + val extensions = mutableListOf( + extension("2.5.29.19", critical = true, value = sequence()), + ) + if (includeSubjectKeyIdentifier) { + extensions += extension( + "2.5.29.14", + critical = false, + value = octetString(MessageDigest.getInstance("SHA-1").digest(pkcs1PublicKey(publicKey))), + ) + } + extensions += extension( + "2.5.29.15", + critical = true, + value = bitString(byteArrayOf(0xa0.toByte()), unusedBits = 5), + ) + return sequence(*extensions.toTypedArray()) + } + + private fun extension(oid: String, critical: Boolean, value: ByteArray): ByteArray = + if (critical) sequence(objectIdentifier(oid), boolean(true), octetString(value)) + else sequence(objectIdentifier(oid), octetString(value)) + + private fun parsePkcs1RsaPublicKey(pem: ByteArray): RSAPublicKey { + val text = pem.toString(StandardCharsets.US_ASCII).trim() + val begin = "-----BEGIN RSA PUBLIC KEY-----" + val end = "-----END RSA PUBLIC KEY-----" + require(text.startsWith(begin) && text.endsWith(end)) { "Expected a PKCS#1 RSA public key" } + val encoded = text.substring(begin.length, text.length - end.length).filterNot(Char::isWhitespace) + val der = Base64.getDecoder().decode(encoded) + val outer = DerReader(der) + val sequence = outer.readConstructed(0x30) + val modulus = sequence.readPositiveInteger() + val exponent = sequence.readPositiveInteger() + require(sequence.exhausted() && outer.exhausted()) { "Trailing data in RSA public key" } + return KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(modulus, exponent)) as RSAPublicKey + } + + private fun pkcs1PublicKey(publicKey: RSAPublicKey): ByteArray = sequence( + integer(publicKey.modulus), + integer(publicKey.publicExponent), + ) + + private fun distinguishedName(commonName: String?): ByteArray = + if (commonName == null) sequence() else sequence(set(sequence(objectIdentifier("2.5.4.3"), utf8String(commonName)))) + + private fun pem(label: String, der: ByteArray): ByteArray { + val encoded = Base64.getMimeEncoder(64, byteArrayOf('\n'.code.toByte())).encodeToString(der) + return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n".toByteArray(StandardCharsets.US_ASCII) + } + + private fun algorithmIdentifier(oid: String): ByteArray = sequence(objectIdentifier(oid), der(0x05, ByteArray(0))) + private fun sequence(vararg values: ByteArray): ByteArray = der(0x30, concatenate(*values)) + private fun set(vararg values: ByteArray): ByteArray = der(0x31, concatenate(*values)) + private fun explicit(number: Int, value: ByteArray): ByteArray = der(0xa0 + number, value) + private fun utf8String(value: String): ByteArray = der(0x0c, value.toByteArray(StandardCharsets.UTF_8)) + + private fun certificateTime(valueMillis: Long): ByteArray { + val utc = TimeZone.getTimeZone("UTC") + val year = SimpleDateFormat("yyyy", Locale.US).apply { timeZone = utc } + .format(Date(valueMillis)).toInt() + val utcTime = year <= UTC_TIME_LAST_YEAR + val formatter = SimpleDateFormat( + if (utcTime) "yyMMddHHmmss'Z'" else "yyyyMMddHHmmss'Z'", + Locale.US, + ).apply { timeZone = utc } + return der(if (utcTime) 0x17 else 0x18, formatter.format(Date(valueMillis)).toByteArray(StandardCharsets.US_ASCII)) + } + + private fun integer(value: BigInteger): ByteArray { + require(value.signum() >= 0) { "DER integer must be non-negative" } + return der(0x02, value.toByteArray()) + } + + private fun boolean(value: Boolean): ByteArray = der(0x01, byteArrayOf(if (value) 0xff.toByte() else 0)) + private fun octetString(value: ByteArray): ByteArray = der(0x04, value) + private fun bitString(value: ByteArray, unusedBits: Int = 0): ByteArray { + require(unusedBits in 0..7) { "Invalid number of unused bits" } + return der(0x03, byteArrayOf(unusedBits.toByte()) + value) + } + + private fun objectIdentifier(value: String): ByteArray { + val arcs = value.split('.').map(String::toLong) + require(arcs.size >= 2 && arcs[0] in 0..2 && arcs[1] >= 0 && (arcs[0] == 2L || arcs[1] < 40)) { + "Invalid object identifier" + } + val output = ByteArrayOutputStream() + writeBase128(output, arcs[0] * 40 + arcs[1]) + arcs.drop(2).forEach { arc -> require(arc >= 0) { "Invalid object identifier" }; writeBase128(output, arc) } + return der(0x06, output.toByteArray()) + } + + private fun writeBase128(output: ByteArrayOutputStream, value: Long) { + var remaining = value + val bytes = ByteArray(10) + var offset = bytes.size + bytes[--offset] = (remaining and 0x7f).toByte() + remaining = remaining ushr 7 + while (remaining != 0L) { + bytes[--offset] = ((remaining and 0x7f) or 0x80).toByte() + remaining = remaining ushr 7 + } + output.write(bytes, offset, bytes.size - offset) + } + + private fun der(tag: Int, content: ByteArray): ByteArray { + val output = ByteArrayOutputStream(1 + 5 + content.size) + output.write(tag) + if (content.size < 0x80) output.write(content.size) else { + var length = content.size + val bytes = ByteArray(4) + var offset = bytes.size + while (length != 0) { bytes[--offset] = length.toByte(); length = length ushr 8 } + output.write(0x80 or (bytes.size - offset)) + output.write(bytes, offset, bytes.size - offset) + } + output.write(content) + return output.toByteArray() + } + + private fun concatenate(vararg values: ByteArray): ByteArray { + val output = ByteArrayOutputStream(values.sumOf(ByteArray::size)) + values.forEach(output::write) + return output.toByteArray() + } + + private class DerReader(private val bytes: ByteArray) { + private var offset = 0 + + fun readConstructed(expectedTag: Int): DerReader = DerReader(readValue(expectedTag)) + + fun readPositiveInteger(): BigInteger { + val value = readValue(0x02) + require(value.isNotEmpty() && (value[0].toInt() and 0x80) == 0) { "Invalid positive DER integer" } + require(value.size == 1 || value[0].toInt() != 0 || (value[1].toInt() and 0x80) != 0) { + "Non-minimal positive DER integer" + } + return BigInteger(value) + } + + fun exhausted(): Boolean = offset == bytes.size + + private fun readValue(expectedTag: Int): ByteArray { + require(offset < bytes.size && bytes[offset++].toInt() and 0xff == expectedTag) { "Unexpected DER tag" } + require(offset < bytes.size) { "Missing DER length" } + val first = bytes[offset++].toInt() and 0xff + val length = if (first < 0x80) first else { + val count = first and 0x7f + require(count in 1..4 && offset + count <= bytes.size) { "Invalid DER length" } + require(bytes[offset].toInt() != 0) { "Non-minimal DER length" } + var result = 0 + repeat(count) { result = (result shl 8) or (bytes[offset++].toInt() and 0xff) } + val minimumCount = (Integer.SIZE - Integer.numberOfLeadingZeros(result) + 7) / 8 + require(result >= 0x80 && count == minimumCount) { "Non-minimal DER length" } + result + } + require(length >= 0 && offset + length <= bytes.size) { "DER value is truncated" } + return bytes.copyOfRange(offset, offset + length).also { offset += length } + } + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPairingClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPairingClient.kt new file mode 100644 index 0000000..54d3c38 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPairingClient.kt @@ -0,0 +1,251 @@ +package com.shilapi.xcertplay.transport + +import android.util.Log +import java.io.IOException +import java.security.GeneralSecurityException + +/** + * Blocking, plaintext Lockdown pairing over an already-open USBMUX host. + * + * Call [pair] from a worker thread, never Android's main thread. This client owns only the + * connection it creates: it closes the plist channel (and therefore that connection) after every + * outcome, but leaves [Iap2UsbMuxHost] open for its caller. It neither persists material nor + * starts a session, negotiates TLS, starts a service, or establishes any CarPlay/iAP2 session. + */ +class LockdownPairingClient( + private val host: Iap2UsbMuxHost, +) { + /** + * Fetches the two pairing inputs, generates the local record, and sends plaintext Pair. + * + * [totalTimeoutMillis] covers the entire operation, including a pending pairing dialog. + * [isCancelled] is checked between blocking protocol operations and while waiting to retry a + * pending dialog; it cannot interrupt an already-blocking plist read, which is bounded to + * five seconds. It is intentionally a small synchronous callback rather than a coroutine. + */ + @Throws(IphoneUsbException::class, LockdownPairingException::class, GeneralSecurityException::class) + fun pair( + label: String, + hostId: String, + systemBuid: String, + totalTimeoutMillis: Long, + isCancelled: () -> Boolean = { false }, + ): PairedRecord { + require(label.isNotBlank()) { "label must not be blank" } + require(hostId.isNotBlank()) { "hostId must not be blank" } + require(systemBuid.isNotBlank()) { "systemBuid must not be blank" } + require(totalTimeoutMillis in 1..MAXIMUM_TOTAL_TIMEOUT_MILLIS) { + "totalTimeoutMillis must be between 1 and $MAXIMUM_TOTAL_TIMEOUT_MILLIS" + } + + val deadline = Deadline(totalTimeoutMillis) + var pairRecord: LockdownPairRecord? = null + var attempt = 0 + while (true) { + checkCancelled(isCancelled) + attempt += 1 + val connection = host.connect( + destinationPort = Iap2UsbMuxHost.LOCKDOWN_PORT, + timeoutMillis = stepTimeoutMillis(deadline), + ) + var pending = false + LockdownPlistChannel(connection).use { channel -> + setUntrustedHostBuid(channel, label, systemBuid, deadline, isCancelled) + val record = pairRecord ?: generatePairRecord( + channel = channel, + label = label, + hostId = hostId, + systemBuid = systemBuid, + deadline = deadline, + isCancelled = isCancelled, + ).also { + pairRecord = it + Log.i( + TAG, + "lockdown pair prepared hostId=uuid systemBuid=uuid " + + "deviceKey=${it.devicePublicKeyPem.size} deviceCert=${it.deviceCertificatePem.size} " + + "hostCert=${it.hostCertificatePem.size} rootCert=${it.rootCertificatePem.size}", + ) + } + Log.i(TAG, "lockdown pair attempt=$attempt") + val response = channel.request(pairRequest(label, record), stepTimeoutMillis(deadline)) + checkCancelled(isCancelled) + when (val error = response.errorCodeOrNull()) { + null -> return PairedRecord(record, response.entries["EscrowBag"].asOptionalData()) + "PairingDialogResponsePending" -> pending = true + "UserDeniedPairing" -> throw LockdownPairingException.UserDeniedPairing + "PasswordProtected" -> throw LockdownPairingException.PasswordProtected + else -> throw LockdownPairingException.RemoteError(error) + } + } + if (pending) { + Log.i(TAG, "lockdown trust pending; reconnecting before retry") + waitForRetry(deadline, isCancelled) + } + } + } + + private fun generatePairRecord( + channel: LockdownPlistChannel, + label: String, + hostId: String, + systemBuid: String, + deadline: Deadline, + isCancelled: () -> Boolean, + ): LockdownPairRecord { + val devicePublicKey = getValue(channel, label, "DevicePublicKey", deadline, isCancelled) + as? LockdownPlistValue.Data + ?: throw LockdownPairingException.InvalidResponse("DevicePublicKey was not data") + val wifiAddress = getValue(channel, label, "WiFiAddress", deadline, isCancelled) + as? LockdownPlistValue.Text + ?: throw LockdownPairingException.InvalidResponse("WiFiAddress was not text") + return LockdownPairRecordGenerator.generate( + devicePublicKeyPkcs1Pem = devicePublicKey.bytes, + wifiAddress = wifiAddress.value, + hostId = hostId, + systemBuid = systemBuid, + ) + } + + private fun pairRequest( + label: String, + pairRecord: LockdownPairRecord, + ): LockdownPlistValue.Dictionary = LockdownPlistValue.Dictionary( + linkedMapOf( + "Label" to LockdownPlistValue.Text(label), + "PairRecord" to pairRecord.toPairRequestDictionary(), + "Request" to LockdownPlistValue.Text("Pair"), + "ProtocolVersion" to LockdownPlistValue.Text("2"), + "PairingOptions" to LockdownPlistValue.Dictionary( + linkedMapOf("ExtendedPairingErrors" to LockdownPlistValue.Boolean(true)), + ), + ), + ) + + private fun setUntrustedHostBuid( + channel: LockdownPlistChannel, + label: String, + systemBuid: String, + deadline: Deadline, + isCancelled: () -> Boolean, + ) { + checkCancelled(isCancelled) + val response = channel.request( + LockdownPlistValue.Dictionary( + linkedMapOf( + "Label" to LockdownPlistValue.Text(label), + "Request" to LockdownPlistValue.Text("SetValue"), + "Key" to LockdownPlistValue.Text("UntrustedHostBUID"), + "Value" to LockdownPlistValue.Text(systemBuid), + ), + ), + stepTimeoutMillis(deadline), + ) + checkCancelled(isCancelled) + response.errorCodeOrNull()?.let { throw LockdownPairingException.RemoteError(it) } + Log.i(TAG, "lockdown UntrustedHostBUID set") + } + + private fun getValue( + channel: LockdownPlistChannel, + label: String, + key: String, + deadline: Deadline, + isCancelled: () -> Boolean, + ): LockdownPlistValue { + checkCancelled(isCancelled) + val response = channel.request( + LockdownPlistValue.Dictionary( + linkedMapOf( + "Label" to LockdownPlistValue.Text(label), + "Request" to LockdownPlistValue.Text("GetValue"), + "Key" to LockdownPlistValue.Text(key), + ), + ), + stepTimeoutMillis(deadline), + ) + checkCancelled(isCancelled) + response.errorCodeOrNull()?.let { throw LockdownPairingException.RemoteError(it) } + return response.entries["Value"] + ?: throw LockdownPairingException.InvalidResponse("GetValue response omitted Value") + } + + private fun LockdownPlistValue?.asOptionalData(): ByteArray? = when (this) { + null -> null + is LockdownPlistValue.Data -> bytes + else -> throw LockdownPairingException.InvalidResponse("EscrowBag was not data") + } + + private fun LockdownPlistValue.Dictionary.errorCodeOrNull(): String? { + return when (val error = entries["Error"] ?: return null) { + is LockdownPlistValue.Text -> error.value + is LockdownPlistValue.Integer -> (entries["ErrorString"] as? LockdownPlistValue.Text)?.value + ?: error.value.toString() + else -> throw LockdownPairingException.InvalidResponse("Error was not text or integer") + } + } + + private fun checkCancelled(isCancelled: () -> Boolean) { + if (isCancelled()) throw LockdownPairingException.Cancelled + } + + private fun stepTimeoutMillis(deadline: Deadline): Long = + minOf(MAXIMUM_STEP_TIMEOUT_MILLIS, deadline.remainingMillis()) + + private fun waitForRetry(deadline: Deadline, isCancelled: () -> Boolean) { + var remaining = RETRY_INTERVAL_MILLIS + while (remaining > 0) { + checkCancelled(isCancelled) + val sleepMillis = minOf(RETRY_CHECK_MILLIS, remaining, deadline.remainingMillis()) + try { + Thread.sleep(sleepMillis) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + throw LockdownPairingException.Cancelled + } + remaining -= sleepMillis + } + } + + private class Deadline(timeoutMillis: Long) { + private val deadlineNanos = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + + fun remainingMillis(): Long { + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) throw LockdownPairingException.TimedOut + return (remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND + } + } + + private companion object { + const val TAG = "xcertplay-usb" + const val RETRY_INTERVAL_MILLIS = 1_000L + const val RETRY_CHECK_MILLIS = 100L + const val NANOS_PER_MILLISECOND = 1_000_000L + const val MAXIMUM_STEP_TIMEOUT_MILLIS = 5_000L + const val MAXIMUM_TOTAL_TIMEOUT_MILLIS = 5 * 60_000L + } +} + +/** Successful pairing material kept in memory only. Byte-array accessors return defensive copies. */ +class PairedRecord internal constructor( + val pairRecord: LockdownPairRecord, + escrowBag: ByteArray?, +) { + private val storedEscrowBag = escrowBag?.copyOf() + + val escrowBag: ByteArray? + get() = storedEscrowBag?.copyOf() + + override fun toString(): String = "PairedRecord(redacted)" +} + +/** Typed pairing failures; their messages never include pairing keys, certificates, or escrow data. */ +sealed class LockdownPairingException(message: String) : IOException(message) { + data object Cancelled : LockdownPairingException("Lockdown pairing was cancelled") + data object TimedOut : LockdownPairingException("Lockdown pairing timed out") + data object UserDeniedPairing : LockdownPairingException("The user denied Lockdown pairing") + data object PasswordProtected : LockdownPairingException("The iPhone is password protected") + class RemoteError(val code: String) : LockdownPairingException("Lockdown pairing failed: $code") + class InvalidResponse(message: String) : LockdownPairingException("Invalid Lockdown pairing response: $message") +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPlistChannel.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPlistChannel.kt new file mode 100644 index 0000000..fdab0d2 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownPlistChannel.kt @@ -0,0 +1,331 @@ +package com.shilapi.xcertplay.transport + +import android.util.Xml +import java.io.Closeable +import java.io.StringReader +import java.nio.charset.StandardCharsets +import java.util.Base64 +import org.xmlpull.v1.XmlPullParser + +/** The small plist value set needed by Lockdown messages. */ +sealed class LockdownPlistValue { + data class Dictionary(val entries: Map) : LockdownPlistValue() + data class Text(val value: String) : LockdownPlistValue() + data class Integer(val value: Long) : LockdownPlistValue() + data class Boolean(val value: kotlin.Boolean) : LockdownPlistValue() + + class Data(bytes: ByteArray) : LockdownPlistValue() { + private val value = bytes.copyOf() + + val bytes: ByteArray + get() = value.copyOf() + } +} + +/** + * Blocking Lockdown plist framing over one already-connected USBMUX TCP byte stream. + * + * A frame is a four-byte big-endian XML plist byte length followed by UTF-8 XML. This class does + * not pair, start a session or service, negotiate TLS, or establish any CarPlay/carkit session. + * Calls may block and must run away from Android's main thread. + */ +class LockdownPlistChannel( + private val connection: BlockingDuplexByteStream, + private val maximumMessageBytes: Int = DEFAULT_MAXIMUM_MESSAGE_BYTES, + private val defaultTimeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, +) : Closeable { + private val stateLock = Any() + private val ioLock = Any() + private var closed = false + private var detached = false + + init { + require(maximumMessageBytes in 1..ABSOLUTE_MAXIMUM_MESSAGE_BYTES) { + "maximumMessageBytes must be between 1 and $ABSOLUTE_MAXIMUM_MESSAGE_BYTES" + } + require(defaultTimeoutMillis in 1..MAXIMUM_TIMEOUT_MILLIS) { + "defaultTimeoutMillis must be between 1 and $MAXIMUM_TIMEOUT_MILLIS" + } + } + + /** Sends one root dictionary as a length-prefixed XML plist. */ + fun send(message: LockdownPlistValue.Dictionary) = synchronized(ioLock) { + checkOpen() + sendLocked(message) + } + + /** Receives one root dictionary, consuming exactly one framed XML plist. */ + fun receive(timeoutMillis: Long = defaultTimeoutMillis): LockdownPlistValue.Dictionary = synchronized(ioLock) { + checkOpen() + receiveLocked(timeoutMillis) + } + + /** Sends [message] and receives its corresponding response without interleaving another request. */ + fun request( + message: LockdownPlistValue.Dictionary, + timeoutMillis: Long = defaultTimeoutMillis, + ): LockdownPlistValue.Dictionary = synchronized(ioLock) { + validateTimeout(timeoutMillis) + checkOpen() + sendLocked(message) + receiveLocked(timeoutMillis) + } + + /** + * Permanently closes this channel without closing its stream and transfers stream ownership. + * + * Detach is serialized after any active request and may succeed exactly once. Later channel + * operations fail as closed; later [close] calls are no-ops for the detached stream. + */ + fun detach(): BlockingDuplexByteStream = synchronized(ioLock) { + synchronized(stateLock) { + if (closed) { + val state = if (detached) "already detached" else "closed" + throw IphoneUsbException.DeviceUnavailable("Lockdown plist channel is $state") + } + detached = true + closed = true + connection + } + } + + override fun close() { + val shouldClose = synchronized(stateLock) { + if (closed) false else { + closed = true + true + } + } + if (shouldClose && !detached) connection.close() + } + + private fun sendLocked(message: LockdownPlistValue.Dictionary) { + val xml = encode(message).toByteArray(StandardCharsets.UTF_8) + if (xml.isEmpty() || xml.size > maximumMessageBytes) { + throw IphoneUsbException.Protocol("Lockdown plist message length ${xml.size} is outside 1..$maximumMessageBytes") + } + val frame = ByteArray(LENGTH_BYTES + xml.size) + putU32(frame, 0, xml.size.toLong()) + xml.copyInto(frame, LENGTH_BYTES) + connection.send(frame) + } + + private fun receiveLocked(timeoutMillis: Long): LockdownPlistValue.Dictionary { + validateTimeout(timeoutMillis) + val deadlineNanos = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + val header = readFully(LENGTH_BYTES, deadlineNanos) + val length = readU32(header, 0) + if (length !in 1..maximumMessageBytes.toLong()) { + throw IphoneUsbException.Protocol("Invalid Lockdown plist message length $length (maximum $maximumMessageBytes)") + } + val xml = readFully(length.toInt(), deadlineNanos) + return parse(xml) + } + + private fun readFully(byteCount: Int, deadlineNanos: Long): ByteArray { + val result = ByteArray(byteCount) + var offset = 0 + while (offset < result.size) { + val remainingNanos = deadlineNanos - System.nanoTime() + if (remainingNanos <= 0) { + throw IphoneUsbException.TimedOut("Timed out while reading Lockdown plist frame") + } + val timeoutMillis = maxOf(1L, (remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND) + val chunk = connection.recv(result.size - offset, timeoutMillis) + ?: throw IphoneUsbException.TimedOut("Timed out while reading Lockdown plist frame") + if (chunk.isEmpty()) { + throw IphoneUsbException.Protocol("USBMUX TCP stream ended inside a Lockdown plist frame") + } + chunk.copyInto(result, offset) + offset += chunk.size + } + return result + } + + private fun parse(xml: ByteArray): LockdownPlistValue.Dictionary { + try { + val parser = Xml.newPullParser().apply { + setFeature(XmlPullParser.FEATURE_PROCESS_DOCDECL, false) + setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) + setInput(StringReader(xml.toString(StandardCharsets.UTF_8))) + } + if (parser.nextTag() != XmlPullParser.START_TAG || parser.name != "plist") { + throw IphoneUsbException.Protocol("Lockdown plist root must be ") + } + if (parser.nextTag() != XmlPullParser.START_TAG) { + throw IphoneUsbException.Protocol("Lockdown plist must contain exactly one root value") + } + val value = parseValue(parser, 0) + if (parser.nextTag() != XmlPullParser.END_TAG || parser.name != "plist") { + throw IphoneUsbException.Protocol("Lockdown plist must contain exactly one root value") + } + while (true) { + when (parser.nextToken()) { + XmlPullParser.END_DOCUMENT -> break + XmlPullParser.TEXT, XmlPullParser.IGNORABLE_WHITESPACE -> { + if (!parser.text.orEmpty().isBlank()) { + throw IphoneUsbException.Protocol("Unexpected content after Lockdown plist") + } + } + else -> throw IphoneUsbException.Protocol("Unexpected content after Lockdown plist") + } + } + return value as? LockdownPlistValue.Dictionary + ?: throw IphoneUsbException.Protocol("Lockdown plist root value must be ") + } catch (error: IphoneUsbException) { + throw error + } catch (error: Exception) { + throw IphoneUsbException.Protocol("Invalid Lockdown plist XML: ${error.message ?: error.javaClass.simpleName}") + } + } + + private fun parseValue(parser: XmlPullParser, depth: Int): LockdownPlistValue { + if (depth > MAXIMUM_NESTING) throw IphoneUsbException.Protocol("Lockdown plist nesting exceeds $MAXIMUM_NESTING") + return when (parser.name) { + "dict" -> { + val entries = LinkedHashMap() + while (parser.nextTag() == XmlPullParser.START_TAG) { + if (parser.name != "key" || entries.size == MAXIMUM_DICTIONARY_ENTRIES) { + throw IphoneUsbException.Protocol("Invalid Lockdown plist dictionary") + } + val keyText = simpleText(parser, "key") + if (entries.containsKey(keyText)) throw IphoneUsbException.Protocol("Duplicate Lockdown plist key '$keyText'") + if (parser.nextTag() != XmlPullParser.START_TAG) { + throw IphoneUsbException.Protocol("Lockdown plist dictionary value is missing") + } + entries[keyText] = parseValue(parser, depth + 1) + } + if (parser.name != "dict") throw IphoneUsbException.Protocol("Invalid Lockdown plist dictionary") + LockdownPlistValue.Dictionary(entries) + } + "string" -> LockdownPlistValue.Text(simpleText(parser, "string")) + "integer" -> LockdownPlistValue.Integer( + simpleText(parser, "integer").trim().toLongOrNull() + ?: throw IphoneUsbException.Protocol("Invalid Lockdown plist integer"), + ) + "true" -> { + requireEmpty(parser, "true") + LockdownPlistValue.Boolean(true) + } + "false" -> { + requireEmpty(parser, "false") + LockdownPlistValue.Boolean(false) + } + "data" -> { + val encoded = simpleText(parser, "data").filterNot(Char::isWhitespace) + try { + LockdownPlistValue.Data(Base64.getDecoder().decode(encoded)) + } catch (error: IllegalArgumentException) { + throw IphoneUsbException.Protocol("Invalid base64 Lockdown plist data") + } + } + else -> throw IphoneUsbException.Protocol("Unsupported Lockdown plist value <${parser.name}>") + } + } + + private fun encode(message: LockdownPlistValue.Dictionary): String = buildString { + append("") + append("") + append("") + appendValue(message, 0) + append("") + } + + private fun StringBuilder.appendValue(value: LockdownPlistValue, depth: Int) { + if (depth > MAXIMUM_NESTING) throw IphoneUsbException.Protocol("Lockdown plist nesting exceeds $MAXIMUM_NESTING") + when (value) { + is LockdownPlistValue.Dictionary -> { + if (value.entries.size > MAXIMUM_DICTIONARY_ENTRIES) { + throw IphoneUsbException.Protocol("Lockdown plist dictionary exceeds $MAXIMUM_DICTIONARY_ENTRIES entries") + } + append("") + value.entries.forEach { (key, entryValue) -> + append("").appendEscaped(key).append("") + appendValue(entryValue, depth + 1) + } + append("") + } + is LockdownPlistValue.Text -> append("").appendEscaped(value.value).append("") + is LockdownPlistValue.Integer -> append("").append(value.value).append("") + is LockdownPlistValue.Boolean -> append(if (value.value) "" else "") + is LockdownPlistValue.Data -> append("") + .append(Base64.getEncoder().encodeToString(value.bytes)) + .append("") + } + } + + private fun StringBuilder.appendEscaped(value: String): StringBuilder = apply { + value.forEach { character -> + append( + when (character) { + '&' -> "&" + '<' -> "<" + '>' -> ">" + '\"' -> """ + '\'' -> "'" + else -> character.toString() + }, + ) + } + } + + private fun simpleText(parser: XmlPullParser, name: String): String { + val text = StringBuilder() + while (true) { + when (parser.nextToken()) { + XmlPullParser.TEXT, XmlPullParser.CDSECT -> text.append(parser.text.orEmpty()) + XmlPullParser.ENTITY_REF -> text.append( + parser.text ?: throw IphoneUsbException.Protocol("Unknown entity in Lockdown plist <$name>"), + ) + XmlPullParser.END_TAG -> { + if (parser.name != name) throw IphoneUsbException.Protocol("Invalid Lockdown plist <$name>") + return text.toString() + } + XmlPullParser.START_TAG -> throw IphoneUsbException.Protocol("Lockdown plist <$name> must not contain elements") + XmlPullParser.END_DOCUMENT -> throw IphoneUsbException.Protocol("Lockdown plist ended inside <$name>") + } + } + } + + private fun requireEmpty(parser: XmlPullParser, name: String) { + if (parser.nextTag() != XmlPullParser.END_TAG || parser.name != name) { + throw IphoneUsbException.Protocol("Lockdown plist <$name> must be empty") + } + } + + private fun checkOpen() { + synchronized(stateLock) { + if (closed) throw IphoneUsbException.DeviceUnavailable("Lockdown plist channel is closed") + } + } + + private fun validateTimeout(timeoutMillis: Long) { + require(timeoutMillis in 1..MAXIMUM_TIMEOUT_MILLIS) { + "timeoutMillis must be between 1 and $MAXIMUM_TIMEOUT_MILLIS" + } + } + + private companion object { + const val LENGTH_BYTES = 4 + const val DEFAULT_MAXIMUM_MESSAGE_BYTES = 1 * 1024 * 1024 + const val ABSOLUTE_MAXIMUM_MESSAGE_BYTES = 4 * 1024 * 1024 + const val DEFAULT_TIMEOUT_MILLIS = 5_000L + const val MAXIMUM_TIMEOUT_MILLIS = 5 * 60_000L + const val MAXIMUM_NESTING = 64 + const val MAXIMUM_DICTIONARY_ENTRIES = 1_024 + const val NANOS_PER_MILLISECOND = 1_000_000L + + fun putU32(target: ByteArray, offset: Int, value: Long) { + target[offset] = (value ushr 24).toByte() + target[offset + 1] = (value ushr 16).toByte() + target[offset + 2] = (value ushr 8).toByte() + target[offset + 3] = value.toByte() + } + + fun readU32(source: ByteArray, offset: Int): Long = + ((source[offset].toLong() and 0xff) shl 24) or + ((source[offset + 1].toLong() and 0xff) shl 16) or + ((source[offset + 2].toLong() and 0xff) shl 8) or + (source[offset + 3].toLong() and 0xff) + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownTlsEngineFactory.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownTlsEngineFactory.kt new file mode 100644 index 0000000..6f1fe6e --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/LockdownTlsEngineFactory.kt @@ -0,0 +1,104 @@ +package com.shilapi.xcertplay.transport + +import android.annotation.SuppressLint +import java.io.ByteArrayInputStream +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.security.spec.PKCS8EncodedKeySpec +import java.util.Base64 +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509TrustManager + +/** + * Builds an unstarted client TLS engine for the dedicated USB Lockdown channel. + * + * Matching the locked transport behavior, the engine does not authenticate the peer certificate. + * It must not be reused for internet or general-purpose TLS connections. + */ +object LockdownTlsEngineFactory { + @Throws(GeneralSecurityException::class) + fun create(pairRecord: LockdownPairRecord): SSLEngine { + val password = charArrayOf('l', 'o', 'c', 'k', 'd', 'o', 'w', 'n') + // Lockdown presents the root identity from the pair record for both the session and + // service TLS channels. HostCertificate is part of pairing, not this TLS identity. + val privateKeyPem = pairRecord.rootPrivateKeyPem + val certificatePem = pairRecord.rootCertificatePem + var privateKeyDer: ByteArray? = null + try { + privateKeyDer = decodePkcs8Pem(privateKeyPem) + val privateKey = KeyFactory.getInstance("RSA") + .generatePrivate(PKCS8EncodedKeySpec(privateKeyDer)) + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificate(ByteArrayInputStream(certificatePem)) as X509Certificate + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { + load(null, password) + setKeyEntry(KEY_ALIAS, privateKey, password, arrayOf(certificate)) + } + val keyManagers = KeyManagerFactory.getInstance("PKIX").apply { + init(keyStore, password) + }.keyManagers + val context = SSLContext.getInstance("TLS").apply { + init(keyManagers, arrayOf(UsbLockdownTrustManager), null) + } + return context.createSSLEngine(PEER_HOST, PEER_PORT).apply { + useClientMode = true + sslParameters = sslParameters.apply { endpointIdentificationAlgorithm = null } + } + } finally { + password.fill('\u0000') + privateKeyPem.fill(0) + certificatePem.fill(0) + privateKeyDer?.fill(0) + } + } + + private fun decodePkcs8Pem(pem: ByteArray): ByteArray { + val begin = pem.indexOf(BEGIN_PRIVATE_KEY) + val end = pem.indexOf(END_PRIVATE_KEY, begin + BEGIN_PRIVATE_KEY.size) + if (begin < 0 || end < 0) throw GeneralSecurityException("Invalid PKCS#8 private key PEM") + val encoded = pem.copyOfRange(begin + BEGIN_PRIVATE_KEY.size, end) + return try { + Base64.getMimeDecoder().decode(encoded) + } catch (error: IllegalArgumentException) { + throw GeneralSecurityException("Invalid PKCS#8 private key PEM", error) + } finally { + encoded.fill(0) + } + } + + private fun ByteArray.indexOf(needle: ByteArray, startIndex: Int = 0): Int { + if (needle.isEmpty()) return startIndex.coerceIn(0, size) + for (offset in startIndex.coerceAtLeast(0)..size - needle.size) { + var matches = true + for (index in needle.indices) { + if (this[offset + index] != needle[index]) { + matches = false + break + } + } + if (matches) return offset + } + return -1 + } + + @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager") + private object UsbLockdownTrustManager : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) = Unit + + override fun checkServerTrusted(chain: Array?, authType: String?) = Unit + + override fun getAcceptedIssuers(): Array = emptyArray() + } + + private const val KEY_ALIAS = "lockdown-host" + private const val PEER_HOST = "Device" + private const val PEER_PORT = 0 + private val BEGIN_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----".toByteArray(StandardCharsets.US_ASCII) + private val END_PRIVATE_KEY = "-----END PRIVATE KEY-----".toByteArray(StandardCharsets.US_ASCII) +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/NcmFunctionDiscovery.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/NcmFunctionDiscovery.kt new file mode 100644 index 0000000..47252bc --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/NcmFunctionDiscovery.kt @@ -0,0 +1,66 @@ +package com.shilapi.xcertplay.transport + +import android.hardware.usb.UsbConfiguration +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface + +/** + * Finds the NCM control/data interface pair inside an active iPhone configuration. + * + * LIVI claims the control interface, claims the data interface, and selects data alternate + * setting 1, the setting that carries the bulk endpoints. This class only reads descriptors. + */ +object NcmFunctionDiscovery { + const val CONTROL_CLASS = 0x02 + const val CONTROL_SUBCLASS = 0x0d + const val DATA_CLASS = 0x0a + const val APPLE_ETHERNET_CLASS = 0xff + const val APPLE_ETHERNET_SUBCLASS = 0xfd + const val APPLE_ETHERNET_PROTOCOL = 0x01 + const val DATA_ALTERNATE_SETTING = 1 + + data class NcmFunction( + val control: UsbInterface, + val data: UsbInterface, + val statusIn: UsbEndpoint?, + val bulkIn: UsbEndpoint, + val bulkOut: UsbEndpoint, + ) + + fun find(configuration: UsbConfiguration): NcmFunction? { + return findCdcNcm(configuration) + } + + private fun findCdcNcm(configuration: UsbConfiguration): NcmFunction? { + val control = interfaces(configuration).firstOrNull { + it.interfaceClass == CONTROL_CLASS && it.interfaceSubclass == CONTROL_SUBCLASS + } ?: return null + val data = interfaces(configuration) + .filter { it.interfaceClass == DATA_CLASS && bulkEndpoints(it) != null } + .minByOrNull { if (it.alternateSetting == DATA_ALTERNATE_SETTING) 0 else 1 } + ?: return null + val endpoints = bulkEndpoints(data) ?: return null + val statusIn = (0 until control.endpointCount) + .map(control::getEndpoint) + .singleOrNull { + it.direction == UsbConstants.USB_DIR_IN && + it.type == UsbConstants.USB_ENDPOINT_XFER_INT + } + return NcmFunction(control, data, statusIn, endpoints.first, endpoints.second) + } + + private fun interfaces(configuration: UsbConfiguration): List = + (0 until configuration.interfaceCount).map(configuration::getInterface) + + private fun bulkEndpoints(usbInterface: UsbInterface): Pair? { + val endpoints = (0 until usbInterface.endpointCount).map(usbInterface::getEndpoint) + val input = endpoints.singleOrNull { + it.direction == UsbConstants.USB_DIR_IN && it.type == UsbConstants.USB_ENDPOINT_XFER_BULK + } + val output = endpoints.singleOrNull { + it.direction == UsbConstants.USB_DIR_OUT && it.type == UsbConstants.USB_ENDPOINT_XFER_BULK + } + return if (input != null && output != null) input to output else null + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt new file mode 100644 index 0000000..1e5c66b --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/NcmUsbBridge.kt @@ -0,0 +1,372 @@ +package com.shilapi.xcertplay.transport + +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbConstants +import android.util.Log +import java.io.Closeable +import java.util.ArrayDeque +import java.util.concurrent.atomic.AtomicBoolean + +/** + * A blocking NCM data pipe that moves Ethernet frames as NTB16 blocks over bulk endpoints. + * + * The caller opens the USB connection while the CarPlay configuration is already active; this + * bridge claims only the NCM control/data interfaces and owns the connection thereafter. All + * calls may block and must run away from the Android main thread. + */ +class NcmUsbBridge internal constructor( + private val connection: UsbDeviceConnection, + private val outEndpoint: UsbEndpoint, + private val inEndpoint: UsbEndpoint, + private val statusEndpoint: UsbEndpoint?, + private val claimedInterfaces: List, + descriptorHostMac: ByteArray?, +) : Closeable { + private val descriptorMac = descriptorHostMac?.copyOf() + val hostMac: ByteArray? get() = descriptorMac?.copyOf() + private val stateLock = Any() + private val readLock = Any() + private val writeLock = Any() + private var closed = false + private var failure: IphoneUsbException? = null + private var sequence = 0 + private var loggedWriteTimeout = false + private val frames = ArrayDeque() + private var queuedBytes = 0 + private var buffered = ByteArray(0) + private var bufferedSize = 0 + private val readBuffer = ByteArray(READ_CHUNK_BYTES) + private val statusRunning = AtomicBoolean(statusEndpoint != null) + private val statusThread = statusEndpoint?.let { endpoint -> + Thread({ drainStatus(endpoint) }, "ncm-status-in").apply { + isDaemon = true + start() + } + } + + /** Wraps one Ethernet frame in one NTB16 block and writes it to bulk OUT. */ + fun send(frame: ByteArray, timeoutMillis: Int) = synchronized(writeLock) { + checkOpen() + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + val sequence = synchronized(stateLock) { + checkOpenLocked() + this.sequence.also { this.sequence = (this.sequence + 1) and 0xffff } + } + val block = Ntb16Codec.build(frame, sequence) + val transferred = connection.bulkTransfer(outEndpoint, block, block.size, timeoutMillis) + // Before StartCarPlaySession the phone keeps the NCM data path NAKed. Android reports the + // resulting timeout as -1; it is not a detach and later packets must be allowed to retry. + if (transferred <= 0) { + if (!loggedWriteTimeout) { + loggedWriteTimeout = true + Log.i(IphoneCarPlayConfiguration.TAG, "ncm bulk-out not ready; retaining bridge for retry") + } + return@synchronized + } + if (transferred != block.size) { + throw IphoneUsbException.DeviceUnavailable( + "NCM write transferred $transferred of ${block.size} bytes", + ) + } + if (loggedWriteTimeout) { + loggedWriteTimeout = false + Log.i(IphoneCarPlayConfiguration.TAG, "ncm bulk-out became ready") + } + } + + /** + * Returns the next complete Ethernet frame, or null when [timeoutMillis] elapses without one. + * USB reads may split or coalesce NTB blocks; this method reassembles whole blocks internally. + */ + fun recv(timeoutMillis: Long): ByteArray? { + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + synchronized(readLock) { + checkOpen() + if (frames.isNotEmpty()) return pollFrame() + + val deadline = System.nanoTime() + timeoutMillis * NANOS_PER_MILLISECOND + while (true) { + drainFrames() + if (frames.isNotEmpty()) return pollFrame() + val remainingNanos = deadline - System.nanoTime() + if (remainingNanos <= 0) return null + val chunkLength = + readChunk((remainingNanos + NANOS_PER_MILLISECOND - 1) / NANOS_PER_MILLISECOND) + ?: continue + appendBuffered(readBuffer, chunkLength) + } + } + } + + override fun close() { + statusRunning.set(false) + synchronized(stateLock) { + if (closed) return + closed = true + } + statusThread?.let { thread -> + try { + thread.join(STATUS_READ_TIMEOUT_MILLIS + 250L) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + for (usbInterface in claimedInterfaces.asReversed()) { + try { + connection.releaseInterface(usbInterface) + } catch (_: RuntimeException) { + // Best-effort release; the connection close below is authoritative. + } + } + connection.close() + } + + private fun drainStatus(endpoint: UsbEndpoint) { + val buffer = ByteArray(endpoint.maxPacketSize.coerceAtLeast(64)) + var loggedFirst = false + while (statusRunning.get()) { + val transferred = try { + connection.bulkTransfer(endpoint, buffer, buffer.size, STATUS_READ_TIMEOUT_MILLIS) + } catch (_: RuntimeException) { + return + } + if (transferred <= 0) continue + if (!loggedFirst) { + loggedFirst = true + Log.i( + IphoneCarPlayConfiguration.TAG, + "ncm status notification bytes=$transferred data=${buffer.copyOf(transferred).hex(32)}", + ) + } + } + } + + private fun ByteArray.hex(limit: Int): String = + take(limit).joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private fun drainFrames() { + while (true) { + if (bufferedSize < 12) return + if (readU32(buffered, 0) != Ntb16Codec.NTH16_SIG) { + throw failSession("NCM read buffer does not begin with an NTB16 header") + } + val blockLength = readU16(buffered, 8) + if (blockLength < 28) throw failSession("Invalid NTB16 block length $blockLength") + val padded = blockLength % USB_PACKET_SIZE == 0 + val wireLength = blockLength + if (padded) 1 else 0 + if (bufferedSize < wireLength) return + if (padded && buffered[blockLength].toInt() != 0) { + throw failSession("Invalid NTB16 short-packet pad") + } + for (frame in Ntb16Codec.parse(buffered, 0, blockLength)) enqueueFrame(frame) + val remaining = bufferedSize - wireLength + buffered.copyInto(buffered, 0, wireLength, bufferedSize) + bufferedSize = remaining + } + } + + private fun appendBuffered(source: ByteArray, length: Int) { + val required = bufferedSize + length + if (required > buffered.size) { + val capacity = maxOf(required, maxOf(READ_CHUNK_BYTES, buffered.size * 2)) + val grown = ByteArray(capacity) + buffered.copyInto(grown, 0, 0, bufferedSize) + buffered = grown + } + source.copyInto(buffered, bufferedSize, 0, length) + bufferedSize += length + } + + private fun enqueueFrame(frame: ByteArray) { + if (frames.size >= MAX_QUEUED_FRAMES || queuedBytes + frame.size > MAX_QUEUED_BYTES) { + throw failSession("NCM frame queue exceeded its bounds") + } + frames.addLast(frame) + queuedBytes += frame.size + } + + private fun pollFrame(): ByteArray { + val frame = frames.removeFirst() + queuedBytes -= frame.size + return frame + } + + private fun readChunk(timeoutMillis: Long): Int? { + checkOpen() + val transferred = try { + connection.bulkTransfer(inEndpoint, readBuffer, readBuffer.size, timeoutMillis.toInt()) + } catch (error: RuntimeException) { + throw failSession("NCM read failed", error) + } + // Android reports both an ordinary bulk-IN timeout and a NAK as a negative result. Keep + // polling: USBMUX owns authoritative detach/failure detection for the same phone. + if (transferred <= 0) return null + return transferred + } + + private fun failSession(message: String, cause: Throwable? = null): IphoneUsbException.DeviceUnavailable { + val error = IphoneUsbException.DeviceUnavailable(message, cause) + synchronized(stateLock) { + if (failure == null) failure = error + } + return error + } + + private fun checkOpen() { + synchronized(stateLock) { checkOpenLocked() } + } + + private fun checkOpenLocked() { + failure?.let { throw it } + if (closed) throw IphoneUsbException.DeviceUnavailable("NCM bridge is closed") + } + + private fun readU16(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or ((source[offset + 1].toInt() and 0xff) shl 8) + + private fun readU32(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or + ((source[offset + 1].toInt() and 0xff) shl 8) or + ((source[offset + 2].toInt() and 0xff) shl 16) or + ((source[offset + 3].toInt() and 0xff) shl 24) + + companion object { + private const val READ_CHUNK_BYTES = 32 * 1024 + private const val USB_PACKET_SIZE = 512 + private const val STATUS_READ_TIMEOUT_MILLIS = 1_000 + private const val MAX_QUEUED_FRAMES = 256 + private const val MAX_QUEUED_BYTES = 1 shl 20 + private const val NANOS_PER_MILLISECOND = 1_000_000L + + /** Claims and activates the NCM control/data interfaces; owns the connection on success. */ + fun open(connection: UsbDeviceConnection, function: NcmFunctionDiscovery.NcmFunction): NcmUsbBridge { + val claimed = ArrayList(2) + try { + val descriptorHostMac = readNcmHostMac(connection, function.control.id) + Log.i( + IphoneCarPlayConfiguration.TAG, + "ncm descriptor hostMac=${descriptorHostMac?.macString() ?: "unavailable"}", + ) + // Apple's Ethernet function exposes control and data as alternate settings of the + // same interface id, so it must be claimed once and switched with setInterface. + val sameInterface = function.control.id == function.data.id + val first = if (sameInterface) function.data else function.control + val firstClaimed = connection.claimInterface(first, true) + Log.i( + IphoneCarPlayConfiguration.TAG, + "claim iface=${first.id}/${first.alternateSetting} class=${first.interfaceClass}" + + " subclass=${first.interfaceSubclass} proto=${first.interfaceProtocol} ok=$firstClaimed", + ) + if (!firstClaimed) { + throw IphoneUsbException.DeviceUnavailable( + "Android could not claim the NCM interface ${first.id}", + ) + } + claimed.add(first) + if (!sameInterface) { + val dataClaimed = connection.claimInterface(function.data, true) + Log.i( + IphoneCarPlayConfiguration.TAG, + "claim iface=${function.data.id}/${function.data.alternateSetting}" + + " class=${function.data.interfaceClass} ok=$dataClaimed", + ) + if (!dataClaimed) { + throw IphoneUsbException.DeviceUnavailable( + "Android could not claim the NCM data interface ${function.data.id}", + ) + } + claimed.add(function.data) + } + val altSelected = connection.setInterface(function.data) + Log.i( + IphoneCarPlayConfiguration.TAG, + "setInterface iface=${function.data.id}/${function.data.alternateSetting} ok=$altSelected", + ) + if (!altSelected) { + throw IphoneUsbException.DeviceUnavailable( + "Android could not select the NCM data alternate setting", + ) + } + Log.i( + IphoneCarPlayConfiguration.TAG, + "ncm status endpoint=${function.statusIn?.address?.let { "0x${it.toString(16)}" } ?: "none"}", + ) + return NcmUsbBridge( + connection, + function.bulkOut, + function.bulkIn, + function.statusIn, + claimed, + descriptorHostMac, + ) + } catch (error: Throwable) { + for (usbInterface in claimed.asReversed()) { + try { + connection.releaseInterface(usbInterface) + } catch (_: RuntimeException) { + // The connection close below is authoritative. + } + } + connection.close() + if (error is IphoneUsbException) throw error + throw IphoneUsbException.DeviceUnavailable("Android NCM open failed", error) + } + } + + private fun readNcmHostMac(connection: UsbDeviceConnection, controlInterfaceId: Int): ByteArray? { + val index = ethernetMacStringIndex(connection.rawDescriptors, controlInterfaceId) ?: return null + val buffer = ByteArray(256) + val length = connection.controlTransfer( + UsbConstants.USB_DIR_IN or UsbConstants.USB_TYPE_STANDARD, + USB_REQUEST_GET_DESCRIPTOR, + (USB_STRING_DESCRIPTOR_TYPE shl 8) or index, + USB_ENGLISH_US, + buffer, + buffer.size, + USB_CONTROL_TIMEOUT_MILLIS, + ) + if (length < 4 || (buffer[1].toInt() and 0xff) != USB_STRING_DESCRIPTOR_TYPE) return null + val descriptorLength = (buffer[0].toInt() and 0xff).coerceAtMost(length) + if (descriptorLength < 4) return null + val value = buffer.copyOfRange(2, descriptorLength).toString(Charsets.UTF_16LE) + val hex = value.filter { it.digitToIntOrNull(16) != null } + if (hex.length != 12) return null + return ByteArray(6) { offset -> hex.substring(offset * 2, offset * 2 + 2).toInt(16).toByte() } + } + + private fun ethernetMacStringIndex(raw: ByteArray, controlInterfaceId: Int): Int? { + var offset = 0 + var currentInterface = -1 + while (offset + 2 <= raw.size) { + val length = raw[offset].toInt() and 0xff + val type = raw[offset + 1].toInt() and 0xff + if (length < 2 || offset + length > raw.size) return null + if (type == USB_INTERFACE_DESCRIPTOR_TYPE && length >= 9) { + currentInterface = raw[offset + 2].toInt() and 0xff + } else if ( + type == CDC_FUNCTIONAL_DESCRIPTOR_TYPE && + length >= 4 && + currentInterface == controlInterfaceId && + (raw[offset + 2].toInt() and 0xff) == CDC_ETHERNET_SUBTYPE + ) { + return (raw[offset + 3].toInt() and 0xff).takeIf { it != 0 } + } + offset += length + } + return null + } + + private fun ByteArray.macString(): String = + joinToString(":") { byte -> "%02x".format(byte.toInt() and 0xff) } + + private const val USB_INTERFACE_DESCRIPTOR_TYPE = 0x04 + private const val USB_REQUEST_GET_DESCRIPTOR = 0x06 + private const val USB_STRING_DESCRIPTOR_TYPE = 0x03 + private const val CDC_FUNCTIONAL_DESCRIPTOR_TYPE = 0x24 + private const val CDC_ETHERNET_SUBTYPE = 0x0f + private const val USB_ENGLISH_US = 0x0409 + private const val USB_CONTROL_TIMEOUT_MILLIS = 1_000 + } +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Ntb16Codec.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Ntb16Codec.kt new file mode 100644 index 0000000..00cf2be --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Ntb16Codec.kt @@ -0,0 +1,112 @@ +package com.shilapi.xcertplay.transport + +/** + * Minimal NTB16 codec for the iPhone NCM data link. + * + * Only the 16-bit transfer-block layout used by LIVI's `iap2-usbmux/src/ntb.rs` is implemented: + * one NTH16 header, one NDP16 table with one datagram, and the USB short-packet pad byte. There + * is no NTB32, alignment, CRC, or NCM control-plane handling here. + */ +object Ntb16Codec { + const val NTH16_SIG = 0x484d434e + const val NDP16_SIG = 0x304d434e + + private const val NTH_LENGTH = 12 + private const val NDP_LENGTH = 16 + private const val DATAGRAM_INDEX = 28 + private const val USB_PACKET_SIZE = 512 + + /** Wraps one Ethernet frame in one NTB16 block, padding an exact 512-byte boundary. */ + fun build(frame: ByteArray, sequence: Int): ByteArray { + require(frame.isNotEmpty()) { "frame must not be empty" } + require(frame.size <= MAX_DATAGRAM_BYTES) { "frame exceeds the NTB16 length field" } + require(sequence in 0..0xffff) { "sequence must fit in u16" } + + val blockLength = DATAGRAM_INDEX + frame.size + val block = ByteArray(blockLength) + putU32(block, 0, NTH16_SIG) + putU16(block, 4, NTH_LENGTH) + putU16(block, 6, sequence) + putU16(block, 8, blockLength) + putU16(block, 10, NTH_LENGTH) + + putU32(block, 12, NDP16_SIG) + putU16(block, 16, NDP_LENGTH) + putU16(block, 18, 0) + putU16(block, 20, DATAGRAM_INDEX) + putU16(block, 22, frame.size) + putU16(block, 24, 0) + putU16(block, 26, 0) + + frame.copyInto(block, DATAGRAM_INDEX) + // A transfer that ends exactly on a USB packet boundary is read as a short packet. + return if (block.size % USB_PACKET_SIZE == 0) block + byteArrayOf(0) else block + } + + /** + * Extracts the Ethernet frames carried by one NTB16 block. + * + * Wire garbage returns an empty list instead of throwing; malformed entries are skipped where + * a bounds check fails. A step guard stops a malformed NDP pointer chain from cycling. + */ + fun parse(block: ByteArray): List = parse(block, 0, block.size) + + /** Parses one NTB16 block from [offset] without copying the containing buffer. */ + fun parse(block: ByteArray, offset: Int, length: Int): List { + require(offset >= 0 && length >= 0 && offset <= block.size - length) { + "NTB16 range is outside the source buffer" + } + val end = offset + length + if (length < NTH_LENGTH || readU32(block, offset) != NTH16_SIG) return emptyList() + + val datagrams = ArrayList(1) + var ndpOffset = offset + readU16(block, offset + 10) + var hops = 0 + val maxHops = maxOf(1, length / 4) + while (ndpOffset != 0 && hops < maxHops) { + if (ndpOffset < offset || ndpOffset + 12 > end) break + if (readU32(block, ndpOffset) and 0x00ffffff != NDP16_SIG and 0x00ffffff) break + val ndpLength = readU16(block, ndpOffset + 4) + val nextNdp = readU16(block, ndpOffset + 6) + var entry = ndpOffset + 8 + val ndpEnd = minOf(ndpOffset + ndpLength, end) + while (entry + 4 <= ndpEnd) { + val datagramIndex = readU16(block, entry) + val datagramLength = readU16(block, entry + 2) + if (datagramIndex == 0 || datagramLength == 0) break + val datagramStart = offset + datagramIndex + val datagramEnd = datagramStart + datagramLength + if (datagramStart >= offset && datagramStart <= end && datagramEnd <= end) { + datagrams.add(block.copyOfRange(datagramStart, datagramEnd)) + } + entry += 4 + } + ndpOffset = if (nextNdp == 0) 0 else offset + nextNdp + hops++ + } + return datagrams + } + + private fun putU16(target: ByteArray, offset: Int, value: Int) { + target[offset] = value.toByte() + target[offset + 1] = (value ushr 8).toByte() + } + + private fun putU32(target: ByteArray, offset: Int, value: Int) { + target[offset] = value.toByte() + target[offset + 1] = (value ushr 8).toByte() + target[offset + 2] = (value ushr 16).toByte() + target[offset + 3] = (value ushr 24).toByte() + } + + private fun readU16(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or ((source[offset + 1].toInt() and 0xff) shl 8) + + private fun readU32(source: ByteArray, offset: Int): Int = + (source[offset].toInt() and 0xff) or + ((source[offset + 1].toInt() and 0xff) shl 8) or + ((source[offset + 2].toInt() and 0xff) shl 16) or + ((source[offset + 3].toInt() and 0xff) shl 24) + + private const val MAX_DATAGRAM_BYTES = 0xffff - DATAGRAM_INDEX +} diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/TlsDuplexChannel.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/TlsDuplexChannel.kt new file mode 100644 index 0000000..4a384b9 --- /dev/null +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/TlsDuplexChannel.kt @@ -0,0 +1,437 @@ +package com.shilapi.xcertplay.transport + +import java.nio.ByteBuffer +import java.security.GeneralSecurityException +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLEngineResult + +/** + * Blocking plaintext stream carried by one client-mode TLS engine and one owned byte stream. + * + * [open] completes the TLS handshake before returning. Calls must run on worker threads. This + * class does not send Lockdown StartSession/StartService messages and does not persist records. + */ +class TlsDuplexChannel private constructor( + private val underlying: BlockingDuplexByteStream, + private val engine: SSLEngine, +) : BlockingDuplexByteStream { + private val stateLock = Any() + private val engineLock = Any() + private val readLock = Any() + private val writeLock = Any() + private val encryptedInput = ByteQueue() + private val plaintextInput = ByteQueue() + private var closed = false + private var inboundEnded = false + private var failure: IphoneUsbException? = null + + override fun send(data: ByteArray) = synchronized(writeLock) { + checkWritable() + if (data.isEmpty()) return@synchronized + try { + val source = ByteBuffer.wrap(data) + while (source.hasRemaining()) { + val outcome = wrap(source) + sendEncrypted(outcome.bytes) + if (outcome.result.status == SSLEngineResult.Status.CLOSED) { + throw IphoneUsbException.Protocol("TLS outbound stream closed during send") + } + if (progressPostHandshake(outcome.result.handshakeStatus)) { + throw IphoneUsbException.Protocol("TLS send requires inbound post-handshake processing") + } + if (outcome.result.bytesConsumed() == 0 && outcome.bytes.isEmpty()) { + throw IphoneUsbException.Protocol("TLS send made no progress") + } + } + } catch (error: Exception) { + throw fail(error) + } + } + + override fun recv(maxBytes: Int, timeoutMillis: Long): ByteArray? = synchronized(readLock) { + require(maxBytes > 0) { "maxBytes must be positive" } + require(timeoutMillis > 0) { "timeoutMillis must be positive" } + try { + receiveLocked(maxBytes, Deadline(timeoutMillis)) + } catch (error: Exception) { + throw fail(error) + } + } + + override fun close() { + val shouldClose = synchronized(stateLock) { + if (closed) false else { + closed = true + true + } + } + if (!shouldClose) return + try { + synchronized(writeLock) { + synchronized(engineLock) { + if (!engine.isOutboundDone) engine.closeOutbound() + } + var steps = 0 + while (steps++ < MAXIMUM_CLOSE_WRAP_STEPS && + !synchronized(engineLock) { engine.isOutboundDone } + ) { + val outcome = wrap(ByteBuffer.allocate(0)) + sendEncrypted(outcome.bytes) + var status = outcome.result.handshakeStatus + if (status == SSLEngineResult.HandshakeStatus.NEED_TASK) { + status = runDelegatedTasks() + } + if (outcome.result.status == SSLEngineResult.Status.CLOSED || + status != SSLEngineResult.HandshakeStatus.NEED_WRAP + ) { + break + } + } + } + } catch (_: Exception) { + // close_notify is best effort; closing the owned transport is the final boundary. + } finally { + try { + underlying.close() + } catch (_: Exception) { + // The local state remains closed. + } + } + } + + private fun performHandshake(timeoutMillis: Long) { + val deadline = Deadline(timeoutMillis) + var status = synchronized(engineLock) { + engine.beginHandshake() + engine.handshakeStatus + } + var noProgress = 0 + while (!isHandshakeComplete(status)) { + deadline.requireRemainingMillis() + when { + status == SSLEngineResult.HandshakeStatus.NEED_TASK -> { + status = runDelegatedTasks() + noProgress = 0 + } + status == SSLEngineResult.HandshakeStatus.NEED_WRAP -> { + val outcome = synchronized(writeLock) { + wrap(ByteBuffer.allocate(0)).also { sendEncrypted(it.bytes) } + } + if (outcome.result.status == SSLEngineResult.Status.CLOSED) { + throw IphoneUsbException.Protocol("TLS handshake closed while wrapping") + } + status = outcome.result.handshakeStatus + noProgress = checkProgress(outcome, noProgress) + } + isNeedUnwrap(status) -> { + val outcome = unwrap() + plaintextInput.append(outcome.bytes) + when (outcome.result.status) { + SSLEngineResult.Status.OK -> { + status = outcome.result.handshakeStatus + noProgress = checkProgress(outcome, noProgress) + } + SSLEngineResult.Status.BUFFER_UNDERFLOW -> { + val incoming = underlying.recv(RECEIVE_CHUNK_BYTES, deadline.requireRemainingMillis()) + ?: throw IphoneUsbException.TimedOut("TLS handshake timed out") + if (incoming.isEmpty()) { + throw IphoneUsbException.Protocol("TLS transport ended during handshake") + } + encryptedInput.append(incoming) + noProgress = 0 + } + SSLEngineResult.Status.CLOSED -> { + throw IphoneUsbException.Protocol("TLS peer closed during handshake") + } + SSLEngineResult.Status.BUFFER_OVERFLOW -> error("unwrap handles overflow internally") + } + } + else -> throw IphoneUsbException.Protocol("Unsupported TLS handshake state ${status.name}") + } + } + deadline.requireRemainingMillis() + } + + private fun receiveLocked(maxBytes: Int, deadline: Deadline): ByteArray? { + plaintextInput.take(maxBytes)?.let { return it } + synchronized(stateLock) { + failure?.let { throw it } + if (inboundEnded) return ByteArray(0) + if (closed) throw IphoneUsbException.DeviceUnavailable("TLS channel is closed") + } + + var noProgress = 0 + while (true) { + if (deadline.remainingMillis() == null) return null + val outcome = unwrap() + plaintextInput.append(outcome.bytes) + when (outcome.result.status) { + SSLEngineResult.Status.OK -> { + noProgress = checkProgress(outcome, noProgress) + progressPostHandshake(outcome.result.handshakeStatus) + } + SSLEngineResult.Status.BUFFER_UNDERFLOW -> { + plaintextInput.take(maxBytes)?.let { return it } + val remaining = deadline.remainingMillis() ?: return null + val incoming = underlying.recv(RECEIVE_CHUNK_BYTES, remaining) ?: return null + if (incoming.isEmpty()) { + if (encryptedInput.size != 0) { + throw IphoneUsbException.Protocol("TLS transport ended inside a record") + } + throw IphoneUsbException.Protocol("TLS transport ended without close_notify") + } + encryptedInput.append(incoming) + noProgress = 0 + continue + } + SSLEngineResult.Status.CLOSED -> { + plaintextInput.take(maxBytes)?.let { + finishInbound() + return it + } + finishInbound() + return ByteArray(0) + } + SSLEngineResult.Status.BUFFER_OVERFLOW -> error("unwrap handles overflow internally") + } + plaintextInput.take(maxBytes)?.let { return it } + } + } + + /** Returns true only when further inbound TLS processing is required. */ + private fun progressPostHandshake(initialStatus: SSLEngineResult.HandshakeStatus): Boolean { + var status = initialStatus + var steps = 0 + while (true) { + if (++steps > MAXIMUM_CONTROL_STEPS) { + throw IphoneUsbException.Protocol("TLS post-handshake processing did not converge") + } + when { + status == SSLEngineResult.HandshakeStatus.NEED_TASK -> status = runDelegatedTasks() + status == SSLEngineResult.HandshakeStatus.NEED_WRAP -> { + val outcome = synchronized(writeLock) { + wrap(ByteBuffer.allocate(0)).also { sendEncrypted(it.bytes) } + } + if (outcome.result.status == SSLEngineResult.Status.CLOSED) { + throw IphoneUsbException.Protocol("TLS outbound stream closed during post-handshake processing") + } + if (outcome.result.bytesConsumed() == 0 && outcome.bytes.isEmpty() && + outcome.result.handshakeStatus == SSLEngineResult.HandshakeStatus.NEED_WRAP + ) { + throw IphoneUsbException.Protocol("TLS post-handshake wrap made no progress") + } + status = outcome.result.handshakeStatus + } + isNeedUnwrap(status) -> return true + isHandshakeComplete(status) -> return false + else -> throw IphoneUsbException.Protocol("Unsupported TLS post-handshake state ${status.name}") + } + } + } + + private fun runDelegatedTasks(): SSLEngineResult.HandshakeStatus = synchronized(engineLock) { + while (true) { + val task = engine.delegatedTask ?: break + task.run() + } + engine.handshakeStatus + } + + private fun wrap(source: ByteBuffer): EngineOutcome { + var capacity = synchronized(engineLock) { engine.session.packetBufferSize }.coerceAtLeast(1) + while (true) { + if (capacity > MAXIMUM_TLS_BUFFER_BYTES) { + throw IphoneUsbException.Protocol("TLS packet buffer exceeds the defensive limit") + } + val output = ByteBuffer.allocate(capacity) + val result = synchronized(engineLock) { engine.wrap(source, output) } + if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) { + if (result.bytesConsumed() != 0 || result.bytesProduced() != 0) { + throw IphoneUsbException.Protocol("TLS wrap overflow consumed data") + } + capacity = grow(capacity) + continue + } + if (result.status == SSLEngineResult.Status.BUFFER_UNDERFLOW) { + throw IphoneUsbException.Protocol("TLS wrap reported buffer underflow") + } + output.flip() + return EngineOutcome(result, ByteArray(output.remaining()).also(output::get)) + } + } + + private fun unwrap(): EngineOutcome { + var capacity = synchronized(engineLock) { engine.session.applicationBufferSize }.coerceAtLeast(1) + while (true) { + if (capacity > MAXIMUM_TLS_BUFFER_BYTES) { + throw IphoneUsbException.Protocol("TLS application buffer exceeds the defensive limit") + } + val input = encryptedInput.asByteBuffer() + val output = ByteBuffer.allocate(capacity) + val result = synchronized(engineLock) { engine.unwrap(input, output) } + if (result.status == SSLEngineResult.Status.BUFFER_OVERFLOW) { + if (result.bytesConsumed() != 0 || result.bytesProduced() != 0) { + throw IphoneUsbException.Protocol("TLS unwrap overflow consumed data") + } + capacity = grow(capacity) + continue + } + encryptedInput.discard(result.bytesConsumed()) + output.flip() + return EngineOutcome(result, ByteArray(output.remaining()).also(output::get)) + } + } + + private fun sendEncrypted(bytes: ByteArray) { + if (bytes.isNotEmpty()) underlying.send(bytes) + } + + private fun finishInbound() { + synchronized(stateLock) { inboundEnded = true } + close() + } + + private fun checkWritable() = synchronized(stateLock) { + failure?.let { throw it } + if (closed) throw IphoneUsbException.DeviceUnavailable("TLS channel is closed") + } + + private fun checkProgress(outcome: EngineOutcome, previous: Int): Int { + if (outcome.result.bytesConsumed() != 0 || outcome.bytes.isNotEmpty()) return 0 + if (previous + 1 >= MAXIMUM_NO_PROGRESS_STEPS) { + throw IphoneUsbException.Protocol("TLS engine made no progress") + } + return previous + 1 + } + + private fun fail(error: Exception): IphoneUsbException { + val mapped = when (error) { + is IphoneUsbException -> error + else -> IphoneUsbException.Protocol("TLS channel failed (${error.javaClass.simpleName})") + } + val shouldClose = synchronized(stateLock) { + if (failure == null) failure = mapped + if (closed) false else { + closed = true + true + } + } + if (shouldClose) { + try { + underlying.close() + } catch (_: Exception) { + // Preserve the original failure. + } + } + return synchronized(stateLock) { failure ?: mapped } + } + + private fun grow(capacity: Int): Int { + if (capacity >= MAXIMUM_TLS_BUFFER_BYTES) return MAXIMUM_TLS_BUFFER_BYTES + 1 + return minOf(MAXIMUM_TLS_BUFFER_BYTES, capacity * 2) + } + + private data class EngineOutcome( + val result: SSLEngineResult, + val bytes: ByteArray, + ) + + private class Deadline(timeoutMillis: Long) { + private val startNanos = System.nanoTime() + private val timeoutNanos = minOf(timeoutMillis, Long.MAX_VALUE / NANOS_PER_MILLISECOND) * + NANOS_PER_MILLISECOND + + fun remainingMillis(): Long? { + val remaining = timeoutNanos - (System.nanoTime() - startNanos) + if (remaining <= 0) return null + return (remaining - 1) / NANOS_PER_MILLISECOND + 1 + } + + fun requireRemainingMillis(): Long = remainingMillis() + ?: throw IphoneUsbException.TimedOut("TLS handshake timed out") + } + + private class ByteQueue { + private var bytes = ByteArray(0) + private var offset = 0 + + val size: Int + get() = bytes.size - offset + + fun append(value: ByteArray) { + if (value.isEmpty()) return + if (size > MAXIMUM_TLS_BUFFER_BYTES - value.size) { + throw IphoneUsbException.Protocol("TLS buffered data exceeds the defensive limit") + } + val combined = ByteArray(size + value.size) + bytes.copyInto(combined, 0, offset) + value.copyInto(combined, size) + bytes = combined + offset = 0 + } + + fun asByteBuffer(): ByteBuffer = ByteBuffer.wrap(bytes, offset, size).slice() + + fun discard(count: Int) { + require(count in 0..size) { "Invalid TLS queue discard" } + offset += count + if (offset == bytes.size) { + bytes = ByteArray(0) + offset = 0 + } + } + + fun take(maxBytes: Int): ByteArray? { + if (size == 0) return null + val count = minOf(maxBytes, size) + return bytes.copyOfRange(offset, offset + count).also { discard(count) } + } + } + + companion object { + private val ALLOWED_PROTOCOLS = arrayOf("TLSv1.3", "TLSv1.2") + private const val RECEIVE_CHUNK_BYTES = 16 * 1024 + private const val MAXIMUM_TLS_BUFFER_BYTES = 1024 * 1024 + private const val MAXIMUM_CONTROL_STEPS = 32 + private const val MAXIMUM_NO_PROGRESS_STEPS = 8 + private const val MAXIMUM_CLOSE_WRAP_STEPS = 4 + private const val NANOS_PER_MILLISECOND = 1_000_000L + + /** Creates and fully handshakes a TLS client stream, taking ownership of [underlying]. */ + @JvmStatic + @Throws(IphoneUsbException::class, GeneralSecurityException::class) + fun open( + underlying: BlockingDuplexByteStream, + pairRecord: LockdownPairRecord, + handshakeTimeoutMillis: Long = 5_000, + ): TlsDuplexChannel { + require(handshakeTimeoutMillis > 0) { "handshakeTimeoutMillis must be positive" } + try { + val engine = LockdownTlsEngineFactory.create(pairRecord) + val supported = engine.supportedProtocols.toSet() + val enabled = ALLOWED_PROTOCOLS.filter(supported::contains).toTypedArray() + if (enabled.isEmpty()) { + throw IphoneUsbException.Protocol("TLS engine supports neither TLSv1.2 nor TLSv1.3") + } + engine.enabledProtocols = enabled + return TlsDuplexChannel(underlying, engine).also { + it.performHandshake(handshakeTimeoutMillis) + } + } catch (error: Exception) { + try { + underlying.close() + } catch (_: Exception) { + // Preserve the handshake failure. + } + throw error + } + } + + private fun isHandshakeComplete(status: SSLEngineResult.HandshakeStatus): Boolean = + status == SSLEngineResult.HandshakeStatus.FINISHED || + status == SSLEngineResult.HandshakeStatus.NOT_HANDSHAKING + + private fun isNeedUnwrap(status: SSLEngineResult.HandshakeStatus): Boolean = + status == SSLEngineResult.HandshakeStatus.NEED_UNWRAP || status.name == "NEED_UNWRAP_AGAIN" + } +} diff --git a/shared/src/main/jni/Android.mk b/shared/src/main/jni/Android.mk new file mode 100644 index 0000000..45f9c33 --- /dev/null +++ b/shared/src/main/jni/Android.mk @@ -0,0 +1,6 @@ +LOCAL_PATH := $(call my-dir) + +include $(CLEAR_VARS) +LOCAL_MODULE := xcertplay_i2c +LOCAL_SRC_FILES := linux_i2c_jni.c +include $(BUILD_SHARED_LIBRARY) diff --git a/shared/src/main/jni/Application.mk b/shared/src/main/jni/Application.mk new file mode 100644 index 0000000..0e1d0d2 --- /dev/null +++ b/shared/src/main/jni/Application.mk @@ -0,0 +1,2 @@ +APP_PLATFORM := android-28 +APP_STL := none diff --git a/shared/src/main/jni/linux_i2c_jni.c b/shared/src/main/jni/linux_i2c_jni.c new file mode 100644 index 0000000..7f5ceb3 --- /dev/null +++ b/shared/src/main/jni/linux_i2c_jni.c @@ -0,0 +1,149 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_I2C_MESSAGE_LENGTH 0xffff + +static void throw_native_error(JNIEnv *env, int error_number, const char *operation) { + char message[160]; + if (error_number == 0) { + snprintf(message, sizeof(message), "%s returned an incomplete I2C transfer", operation); + } else { + snprintf(message, sizeof(message), "%s failed: errno=%d (%s)", operation, error_number, + strerror(error_number)); + } + + jclass exception_class = (*env)->FindClass( + env, "com/shilapi/xcertplay/transport/LinuxI2cNativeException"); + if (exception_class == NULL) return; + + jmethodID constructor = (*env)->GetMethodID(env, exception_class, "", "(ILjava/lang/String;)V"); + if (constructor == NULL) return; + + jstring detail = (*env)->NewStringUTF(env, message); + if (detail == NULL) return; + + jobject exception = (*env)->NewObject(env, exception_class, constructor, error_number, detail); + if (exception != NULL) (*env)->Throw(env, exception); +} + +JNIEXPORT jint JNICALL +Java_com_shilapi_xcertplay_transport_LinuxI2cNative_open( + JNIEnv *env, jobject receiver, jstring device_path) { + (void) receiver; + if (device_path == NULL) { + throw_native_error(env, EINVAL, "open"); + return -1; + } + + const char *path = (*env)->GetStringUTFChars(env, device_path, NULL); + if (path == NULL) return -1; + + int file_descriptor; + do { + file_descriptor = open(path, O_RDWR | O_CLOEXEC); + } while (file_descriptor < 0 && errno == EINTR); + + int error_number = file_descriptor < 0 ? errno : 0; + (*env)->ReleaseStringUTFChars(env, device_path, path); + if (file_descriptor < 0) throw_native_error(env, error_number, "open"); + return file_descriptor; +} + +JNIEXPORT jbyteArray JNICALL +Java_com_shilapi_xcertplay_transport_LinuxI2cNative_transaction( + JNIEnv *env, + jobject receiver, + jint file_descriptor, + jint address, + jbyteArray write_data, + jint read_length) { + (void) receiver; + if (file_descriptor < 0 || write_data == NULL || address < 0 || address > 0x7f || read_length < 0) { + throw_native_error(env, EINVAL, "I2C_RDWR"); + return NULL; + } + + jsize write_length = (*env)->GetArrayLength(env, write_data); + if (write_length > MAX_I2C_MESSAGE_LENGTH || read_length > MAX_I2C_MESSAGE_LENGTH || + (write_length == 0 && read_length == 0)) { + throw_native_error(env, EINVAL, "I2C_RDWR"); + return NULL; + } + + jbyte *write_bytes = NULL; + uint8_t *read_bytes = NULL; + struct i2c_msg messages[2]; + uint32_t message_count = 0; + + if (write_length > 0) { + write_bytes = (*env)->GetByteArrayElements(env, write_data, NULL); + if (write_bytes == NULL) return NULL; + messages[message_count++] = (struct i2c_msg) { + .addr = (uint16_t) address, + .flags = 0, + .len = (uint16_t) write_length, + .buf = (uint8_t *) write_bytes, + }; + } + + if (read_length > 0) { + read_bytes = malloc((size_t) read_length); + if (read_bytes == NULL) { + if (write_bytes != NULL) (*env)->ReleaseByteArrayElements(env, write_data, write_bytes, JNI_ABORT); + throw_native_error(env, ENOMEM, "I2C read buffer allocation"); + return NULL; + } + messages[message_count++] = (struct i2c_msg) { + .addr = (uint16_t) address, + .flags = I2C_M_RD, + .len = (uint16_t) read_length, + .buf = read_bytes, + }; + } + + struct i2c_rdwr_ioctl_data request = { + .msgs = messages, + .nmsgs = message_count, + }; + int result; + do { + result = ioctl(file_descriptor, I2C_RDWR, &request); + } while (result < 0 && errno == EINTR); + + if (write_bytes != NULL) (*env)->ReleaseByteArrayElements(env, write_data, write_bytes, JNI_ABORT); + if (result != (int) message_count) { + int error_number = result < 0 ? errno : 0; + free(read_bytes); + throw_native_error(env, error_number, "I2C_RDWR"); + return NULL; + } + + jbyteArray response = (*env)->NewByteArray(env, read_length); + if (response != NULL && read_length > 0) { + (*env)->SetByteArrayRegion(env, response, 0, read_length, (const jbyte *) read_bytes); + } + free(read_bytes); + return response; +} + +JNIEXPORT void JNICALL +Java_com_shilapi_xcertplay_transport_LinuxI2cNative_close( + JNIEnv *env, jobject receiver, jint file_descriptor) { + (void) receiver; + if (file_descriptor < 0) { + throw_native_error(env, EINVAL, "close"); + return; + } + if (close(file_descriptor) != 0) { + throw_native_error(env, errno, "close"); + } +} diff --git a/shared/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/shared/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/shared/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/shared/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/shared/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/shared/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/shared/src/main/res/mipmap-hdpi/ic_launcher.webp b/shared/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..218fd9c Binary files /dev/null and b/shared/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/shared/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/shared/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..cf3f376 Binary files /dev/null and b/shared/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/shared/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/shared/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ed67999 Binary files /dev/null and b/shared/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/shared/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/shared/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..64fee0f Binary files /dev/null and b/shared/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/shared/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/shared/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..daac315 Binary files /dev/null and b/shared/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/shared/src/main/res/mipmap-mdpi/ic_launcher.webp b/shared/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4d25ca2 Binary files /dev/null and b/shared/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/shared/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/shared/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..c578340 Binary files /dev/null and b/shared/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/shared/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/shared/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..3376dc7 Binary files /dev/null and b/shared/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/shared/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/shared/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..f40b4b6 Binary files /dev/null and b/shared/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/shared/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/shared/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..283a60f Binary files /dev/null and b/shared/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/shared/src/main/res/mipmap-xhdpi/ic_launcher.webp b/shared/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..cfc04b6 Binary files /dev/null and b/shared/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/shared/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/shared/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..f41c80b Binary files /dev/null and b/shared/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/shared/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/shared/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..dc9bff0 Binary files /dev/null and b/shared/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/shared/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/shared/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..b99acd4 Binary files /dev/null and b/shared/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/shared/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/shared/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..ab28d95 Binary files /dev/null and b/shared/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/shared/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/shared/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..67b3302 Binary files /dev/null and b/shared/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/shared/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..24e8fd7 Binary files /dev/null and b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/shared/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..d571075 Binary files /dev/null and b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/shared/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..5641722 Binary files /dev/null and b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/shared/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..40dbf4d Binary files /dev/null and b/shared/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/shared/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..83a3cec Binary files /dev/null and b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..0458418 Binary files /dev/null and b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..a1e28ab Binary files /dev/null and b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..a3b1449 Binary files /dev/null and b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..15c0868 Binary files /dev/null and b/shared/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/shared/src/main/res/xml/automotive_app_desc.xml b/shared/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..8dc476f --- /dev/null +++ b/shared/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayDisplaySettingsTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayDisplaySettingsTest.kt new file mode 100644 index 0000000..d43d8a3 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayDisplaySettingsTest.kt @@ -0,0 +1,51 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AirPlayDisplaySettingsTest { + @Test + fun maximumObservedWidthScalesPhysicalSizeWithCurrentActivityWidth() { + val size = AirPlayDisplaySettings.resolvePhysicalSizeMm( + currentWidthPixels = 768, + currentHeightPixels = 800, + maximumWidthPixels = 1920, + maximumHeightPixels = 1080, + referenceMillimeters = 200, + basis = AirPlayPhysicalSizeBasis.WIDTH, + ) + + assertEquals(80, size.widthMm) + assertEquals(83, size.heightMm) + } + + @Test + fun maximumObservedHeightScalesPhysicalSizeWithCurrentActivityHeight() { + val size = AirPlayDisplaySettings.resolvePhysicalSizeMm( + currentWidthPixels = 1920, + currentHeightPixels = 978, + maximumWidthPixels = 1920, + maximumHeightPixels = 1080, + referenceMillimeters = 200, + basis = AirPlayPhysicalSizeBasis.HEIGHT, + ) + + assertEquals(356, size.widthMm) + assertEquals(181, size.heightMm) + } + + @Test + fun maximumResolutionKeepsConfiguredReferenceLength() { + val size = AirPlayDisplaySettings.resolvePhysicalSizeMm( + currentWidthPixels = 1920, + currentHeightPixels = 1080, + maximumWidthPixels = 1920, + maximumHeightPixels = 1080, + referenceMillimeters = 250, + basis = AirPlayPhysicalSizeBasis.WIDTH, + ) + + assertEquals(250, size.widthMm) + assertEquals(141, size.heightMm) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayIapTunnelStreamTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayIapTunnelStreamTest.kt new file mode 100644 index 0000000..dcd4f4b --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayIapTunnelStreamTest.kt @@ -0,0 +1,73 @@ +package com.shilapi.xcertplay.airplay + +import java.net.InetAddress +import java.net.Socket +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AirPlayIapTunnelStreamTest { + @Test + fun inputConnectionCloseKeepsEventChannelAvailableForOutboundIap() { + val sent = mutableListOf() + val session = testSession() + val bridge = AirPlayIapTunnelStream( + session = session, + tunnel = IapTunnel(ByteArray(32)), + sendCommand = { data, _ -> + sent += data.copyOf() + true + }, + ) + + try { + val port = bridge.listen() + Socket(InetAddress.getByName("127.0.0.1"), port).use { } + + val payload = byteArrayOf(1, 2, 3) + bridge.send(payload) + + assertTrue(sent.size == 1) + assertArrayEquals(payload, sent.single()) + } finally { + bridge.close() + session.close() + } + } + + @Test + fun ipv6ListenerAcceptsIpv6Loopback() { + val bridge = AirPlayIapTunnelStream( + session = testSession(), + tunnel = IapTunnel( + readKey = ByteArray(32), + bindAddress = InetAddress.getByName("::"), + ), + sendCommand = { _, _ -> true }, + ) + + try { + val port = bridge.listen() + Socket(InetAddress.getByName("::1"), port).use { } + } finally { + bridge.close() + } + } + + private fun testSession(): AirPlaySession = AirPlaySession( + socket = Socket(), + config = AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:01", + sourceVersion = "1.0", + main = AirPlayDisplayConfig(widthPixels = 800, heightPixels = 480), + ), + identity = AirPlayIdentity.generate(), + pairings = PairingStore(), + mfi = null, + listener = object : AirPlaySessionListener {}, + media = object : AirPlayMediaHandler {}, + ) + +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt new file mode 100644 index 0000000..97c2a4a --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayInfoPlistTest.kt @@ -0,0 +1,233 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AirPlayInfoPlistTest { + @Test + fun defaultDisplayIncludesFullViewAndSafeAreas() { + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + ), + ) + + val display = (info["displays"] as List<*>).single() as Map<*, *> + val view = (display["viewAreas"] as List<*>).single() as Map<*, *> + val safe = view["safeArea"] as Map<*, *> + assertEquals(0, display["initialViewArea"]) + assertEquals(1280, view["widthPixels"]) + assertEquals(720, view["heightPixels"]) + assertEquals(0, view["originXPixels"]) + assertEquals(0, view["originYPixels"]) + assertEquals(1, display["primaryInputDevice"]) + assertNotNull(safe) + assertEquals(1280, safe["widthPixels"]) + assertEquals(720, safe["heightPixels"]) + assertEquals(true, safe["drawUIOutsideSafeArea"]) + } + + @Test + fun hevcCapabilityIsAdvertisedOnlyWhenEnabled() { + val base = AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + ) + + assertFalse(AirPlayInfoPlist.build(base).containsKey("hevcInfo")) + assertTrue(AirPlayInfoPlist.build(base.copy(hevc = true)).containsKey("hevcInfo")) + } + + @Test + fun drivingSideAndDisplayValuesAreAdvertised() { + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig( + widthPixels = 1280, + heightPixels = 720, + fps = 37, + widthPhysicalMm = 125, + ), + rightHandDrive = true, + manufacturer = "Example", + model = "HeadUnit", + ), + ) + + val display = (info["displays"] as List<*>).single() as Map<*, *> + assertEquals(true, info["rightHandDrive"]) + assertEquals("Example", info["manufacturer"]) + assertEquals("HeadUnit", info["model"]) + assertEquals(35, display["maxFPS"]) + assertEquals(125, display["widthPhysical"]) + assertEquals(70, display["heightPhysical"]) + } + + @Test + fun squareOemIconIsAdvertisedWithItsOriginalBytes() { + val iconBytes = byteArrayOf(0x01, 0x02, 0x03, 0x04) + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + icons = listOf(AirPlayIcon(1, 1, iconBytes)), + oemLabel = "xcertplay", + ), + ) + + val icon = (info["oemIcons"] as List<*>).single() as Map<*, *> + assertEquals(true, info["oemIconVisible"]) + assertEquals("xcertplay", info["oemIconLabel"]) + assertEquals(1, icon["widthPixels"]) + assertEquals(1, icon["heightPixels"]) + assertTrue((icon["imageData"] as ByteArray).contentEquals(iconBytes)) + } + + @Test + fun safeAreaInsetsReachTheAirPlayViewArea() { + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig( + widthPixels = 960, + heightPixels = 540, + safeArea = AirPlayInsets(top = 10, bottom = 15, left = 20, right = 25), + safeAreaDrawOutside = false, + ), + ), + ) + + val display = (info["displays"] as List<*>).single() as Map<*, *> + val view = (display["viewAreas"] as List<*>).single() as Map<*, *> + val safe = view["safeArea"] as Map<*, *> + assertEquals(915, safe["widthPixels"]) + assertEquals(515, safe["heightPixels"]) + assertEquals(20, safe["originXPixels"]) + assertEquals(10, safe["originYPixels"]) + assertEquals(false, safe["drawUIOutsideSafeArea"]) + } + + @Test + fun reportedSafeAreaUsesActivityMappingAndEvenAlignment() { + val fullHdInsets = AirPlaySafeArea.toInsets( + mapping = SafeAreaRect(left = 0, top = 0, right = 1920, bottom = 975), + activityWidthPixels = 1920, + activityHeightPixels = 1080, + displayWidthPixels = 1920, + displayHeightPixels = 1080, + ) + val compactInsets = AirPlaySafeArea.toInsets( + mapping = SafeAreaRect(left = 34, top = 75, right = 734, bottom = 725), + activityWidthPixels = 768, + activityHeightPixels = 800, + displayWidthPixels = 768, + displayHeightPixels = 800, + ) + + fun safeArea( + widthPixels: Int, + heightPixels: Int, + insets: AirPlayInsets, + ): Map<*, *> { + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig( + widthPixels = widthPixels, + heightPixels = heightPixels, + safeArea = insets, + ), + ), + ) + val display = (info["displays"] as List<*>).single() as Map<*, *> + val view = (display["viewAreas"] as List<*>).single() as Map<*, *> + return view["safeArea"] as Map<*, *> + } + + val fullHd = safeArea(1920, 1080, fullHdInsets) + val compact = safeArea(768, 800, compactInsets) + + assertEquals(1920, fullHd["widthPixels"]) + assertEquals(976, fullHd["heightPixels"]) + assertEquals(700, compact["widthPixels"]) + assertEquals(650, compact["heightPixels"]) + assertEquals(34, compact["originXPixels"]) + assertEquals(75, compact["originYPixels"]) + } + + @Test + fun microphoneInputsAreAdvertisedOnlyWhenEnabled() { + val base = AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + ) + + fun telephony(info: Map): Map<*, *> = + (info["audioFormats"] as List<*>) + .map { it as Map<*, *> } + .single { it["audioType"] == "telephony" } + + fun defaultAudio(info: Map): Map<*, *> = + (info["audioFormats"] as List<*>) + .map { it as Map<*, *> } + .single { it["type"] == 100 && it["audioType"] == "default" } + + assertFalse(telephony(AirPlayInfoPlist.build(base)).containsKey("audioInputFormats")) + assertTrue( + telephony(AirPlayInfoPlist.build(base.copy(microphone = true))) + .containsKey("audioInputFormats"), + ) + assertEquals( + 0x70004154, + defaultAudio(AirPlayInfoPlist.build(base.copy(microphone = true)))["audioInputFormats"], + ) + assertEquals( + 0x70004154, + telephony(AirPlayInfoPlist.build(base.copy(microphone = true)))["audioInputFormats"], + ) + } + + @Test + fun mainAltAndHighAudioStreamsAreDeclared() { + val info = AirPlayInfoPlist.build( + AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + ), + ) + + val types = (info["audioFormats"] as List<*>) + .map { (it as Map<*, *>)["type"] } + .toSet() + assertEquals(setOf(100, 101, 102), types) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayPeerDiagnosticsTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayPeerDiagnosticsTest.kt new file mode 100644 index 0000000..e88bce8 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/AirPlayPeerDiagnosticsTest.kt @@ -0,0 +1,16 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.* +import org.junit.Test + +class AirPlayPeerDiagnosticsTest { + @Test fun productAndVersionDetailsAreAvailableWithoutIdentity() { + assertEquals("Phone display peer model=iPhone11,2 ios=18.7.10 airplay=950.7.1", + AirPlayPeerDiagnostics.summary("iPhone11,2", "18.7.10", "950.7.1")) + } + + @Test fun absentOrUnexpectedFieldsCannotLeakPersonalOrMultilineValues() { + val summary = AirPlayPeerDiagnostics.summary("Jane's iPhone", "26.0\npassword=secret", "") + assertEquals("Phone display peer model=not_reported ios=not_reported airplay=not_reported", summary) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayDisplayScaleTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayDisplayScaleTest.kt new file mode 100644 index 0000000..5563d52 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayDisplayScaleTest.kt @@ -0,0 +1,33 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.assertEquals +import org.junit.Test + +class CarPlayDisplayScaleTest { + @Test + fun scalesHandshakeDisplayAtSupportedSteps() { + val native = AirPlayDisplayConfig(widthPixels = 1080, heightPixels = 2160) + + val half = CarPlayDisplayScale.apply(native, 5) + + assertEquals(540, half.widthPixels) + assertEquals(1080, half.heightPixels) + assertEquals("0.5x", CarPlayDisplayScale.label(5)) + } + + @Test + fun clampsScaleToTheUiRange() { + assertEquals(3, CarPlayDisplayScale.sanitize(0)) + assertEquals(10, CarPlayDisplayScale.sanitize(20)) + } + + @Test + fun alignsScaledDisplayDimensionsToEvenPixels() { + val native = AirPlayDisplayConfig(widthPixels = 1920, heightPixels = 978) + + val scaled = CarPlayDisplayScale.apply(native, 7) + + assertEquals(1344, scaled.widthPixels) + assertEquals(686, scaled.heightPixels) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngineTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngineTest.kt new file mode 100644 index 0000000..88d2a3d --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayMediaEngineTest.kt @@ -0,0 +1,83 @@ +package com.shilapi.xcertplay.airplay + +import java.io.Closeable +import java.net.Socket +import java.math.BigInteger +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CarPlayMediaEngineTest { + @Test + fun streamConnectionIdUsesUnsignedDecimalForHkdfSalt() { + assertEquals("18446744073709551615", unsignedPlistDecimal(-1L)) + assertEquals( + BigInteger("18446744073709551615"), + unsignedPlistInteger(-1L), + ) + } + + @Test + fun screenStreamTeardownReportsInactive() { + val events = mutableListOf>() + val sink = object : MediaSink { + override fun onScreenStreamActive(type: Int, active: Boolean) { + events += type to active + } + } + val session = testSession() + + try { + val engine = CarPlayMediaEngine(sink) + engine.onTeardown(session, 110) + engine.onTeardown(session, 100) + } finally { + session.close() + } + + assertEquals(listOf(110 to false), events) + } + + @Test + fun sessionCloseReportsAllScreenStreamsInactive() { + val events = mutableListOf>() + val sink = object : MediaSink { + override fun onScreenStreamActive(type: Int, active: Boolean) { + events += type to active + } + } + val session = testSession() + val engine = CarPlayMediaEngine(sink) + val streamsField = CarPlayMediaEngine::class.java.getDeclaredField("streams").apply { + isAccessible = true + } + @Suppress("UNCHECKED_CAST") + val streams = streamsField.get(engine) as + MutableMap + streams[CarPlayMediaEngine.StreamKey(session, 110)] = Closeable {} + streams[CarPlayMediaEngine.StreamKey(session, 111)] = Closeable {} + streams[CarPlayMediaEngine.StreamKey(session, 100)] = Closeable {} + + engine.onSessionClosed(session) + session.close() + + assertEquals(setOf(110 to false, 111 to false), events.toSet()) + assertTrue(streams.isEmpty()) + } + + private fun testSession(): AirPlaySession = AirPlaySession( + socket = Socket(), + config = AirPlayConfig( + deviceName = "test", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:01", + sourceVersion = "1.0", + main = AirPlayDisplayConfig(widthPixels = 800, heightPixels = 480), + ), + identity = AirPlayIdentity.generate(), + pairings = PairingStore(), + mfi = null, + listener = object : AirPlaySessionListener {}, + media = object : AirPlayMediaHandler {}, + ) +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayUiScaleTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayUiScaleTest.kt new file mode 100644 index 0000000..fefc425 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/CarPlayUiScaleTest.kt @@ -0,0 +1,63 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.* +import org.junit.Test + +class CarPlayUiScaleTest { + private val display = AirPlayDisplayConfig(1920, 1080, 200, 113, fps = 30) + + @Test fun smallerCanvasMatchesTheLiveVerifiedProfile() { + val smaller = CarPlayUiScale.apply(display, 75) + assertEquals(2560, smaller.widthPixels) + assertEquals(1440, smaller.heightPixels) + assertEquals(display, smaller.copy(widthPixels = 1920, heightPixels = 1080)) + val small = CarPlayUiScale.apply(display, 85) + assertEquals(2258, small.widthPixels) + assertEquals(1270, small.heightPixels) + val large = CarPlayUiScale.apply(display, 115) + assertEquals(1670, large.widthPixels) + assertEquals(940, large.heightPixels) + } + + @Test fun safeAndViewAreasKeepTheirPositionOnTheTouchSurface() { + val original = display.copy(safeArea = AirPlayInsets(30, 60, 90, 120), + viewArea = AirPlayInsets(6, 12, 18, 24)) + val smaller = CarPlayUiScale.apply(original, 75) + assertEquals(AirPlayInsets(40, 80, 120, 160), smaller.safeArea) + assertEquals(AirPlayInsets(8, 16, 24, 32), smaller.viewArea) + assertEquals(original.safeArea!!.left.toDouble() / original.widthPixels, + smaller.safeArea!!.left.toDouble() / smaller.widthPixels, 0.0001) + } + + @Test fun reducedResolutionRemainsASeparateBaseForTheCanvas() { + val reduced = CarPlayDisplayScale.apply(display, 6) + val smaller = CarPlayUiScale.apply(reduced, 75) + assertEquals(1536, smaller.widthPixels) + assertEquals(864, smaller.heightPixels) + assertEquals(30, smaller.fps) + } + + @Test fun defaultUnknownAndExcessiveRequestsDoNotAlterTheExistingDisplay() { + assertSame(display, CarPlayUiScale.apply(display, 100)) + assertSame(display, CarPlayUiScale.apply(display, 0)) + assertSame(display, CarPlayUiScale.apply(display, Int.MAX_VALUE)) + val fourK = display.copy(widthPixels = 3840, heightPixels = 2160) + assertSame(fourK, CarPlayUiScale.apply(fourK, 75)) + val portrait = display.copy(widthPixels = 2160, heightPixels = 3840) + assertSame(portrait, CarPlayUiScale.apply(portrait, 75)) + assertEquals(3840, CarPlayUiScale.apply(display.copy(widthPixels = 2880, heightPixels = 1620), 75).widthPixels) + } + + @Test fun negotiatedDisplayAndAreasUseTheSameCanvas() { + val main = CarPlayUiScale.apply(display, 75) + val info = AirPlayInfoPlist.build(AirPlayConfig("Test", "00:11:22:33:44:55", "00:11:22:33:44:55", sourceVersion = "950.7.1", main = main)) + val screen = (info["displays"] as List<*>).first() as Map<*, *> + val view = (screen["viewAreas"] as List<*>).first() as Map<*, *> + val safe = view["safeArea"] as Map<*, *> + for (area in listOf(screen, view, safe)) { + assertEquals(2560, area["widthPixels"]) + assertEquals(1440, area["heightPixels"]) + } + assertFalse(screen.containsKey("zoomFactor")) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/MicrophonePacketizerTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/MicrophonePacketizerTest.kt new file mode 100644 index 0000000..bc3e57e --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/MicrophonePacketizerTest.kt @@ -0,0 +1,52 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MicrophonePacketizerTest { + @Test + fun packetUsesRtpHeaderAndInputKeyAad() { + val key = ByteArray(32) { index -> (index + 1).toByte() } + val counters = MicrophoneCounters() + val body = byteArrayOf(0x28, 0x01, 0x02, 0x03) + + val packet = MicrophonePacketizer.sealPacket(key, 100, counters, body, 480) + + assertEquals(0x80, packet[0].toInt() and 0xff) + assertEquals(100, packet[1].toInt() and 0xff) + assertEquals(0, ((packet[2].toInt() and 0xff) shl 8) or (packet[3].toInt() and 0xff)) + assertEquals( + 0, + ((packet[4].toInt() and 0xff) shl 24) or + ((packet[5].toInt() and 0xff) shl 16) or + ((packet[6].toInt() and 0xff) shl 8) or + (packet[7].toInt() and 0xff), + ) + + val sealedEnd = packet.size - MicrophonePacketizer.NONCE_LEN + val nonce = ByteArray(12) + packet.copyInto(nonce, 4, sealedEnd, packet.size) + val opened = AirPlayCrypto.chachaOpen( + key, + nonce, + packet.copyOfRange(MicrophonePacketizer.RTP_HEADER_LEN, sealedEnd), + packet.copyOfRange(4, MicrophonePacketizer.RTP_HEADER_LEN), + ) + + assertArrayEquals(body, opened) + assertEquals(1, counters.sequence) + assertEquals(480, counters.timestamp) + assertEquals(1L, counters.nonce) + } + + @Test + fun pcmIsConvertedToBigEndian() { + assertArrayEquals( + byteArrayOf(0x12, 0x34, 0x56, 0x78), + MicrophonePacketizer.toWirePcm( + byteArrayOf(0x34, 0x12, 0x78, 0x56), + ), + ) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/SafeAreaTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/SafeAreaTest.kt new file mode 100644 index 0000000..c00c18d --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/SafeAreaTest.kt @@ -0,0 +1,134 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SafeAreaTest { + @Test + fun defaultEditorUsesQuarterInsetsFromEveryEdge() { + val rect = AirPlaySafeArea.default(1920, 1080) + + assertEquals(480, rect.left) + assertEquals(270, rect.top) + assertEquals(1440, rect.right) + assertEquals(810, rect.bottom) + } + + @Test + fun activityMappingIsScaledIntoNegotiatedDisplaySpace() { + val mapping = SafeAreaRect(left = 10, top = 20, right = 1910, bottom = 1060) + + val insets = AirPlaySafeArea.toInsets( + mapping = mapping, + activityWidthPixels = 1920, + activityHeightPixels = 1080, + displayWidthPixels = 960, + displayHeightPixels = 540, + ) + + assertEquals(5, insets.left) + assertEquals(10, insets.top) + assertEquals(5, insets.right) + assertEquals(10, insets.bottom) + } + + @Test + fun missingMappingResolvesToFullDisplay() { + val insets = AirPlaySafeArea.toInsets( + mapping = null, + activityWidthPixels = 1920, + activityHeightPixels = 1080, + displayWidthPixels = 576, + displayHeightPixels = 324, + ) + + assertEquals(AirPlayInsets(), insets) + } + + @Test + fun safeAreaDimensionsAreAlignedToEvenPixelsAfterActivityMapping() { + val insets = AirPlaySafeArea.toInsets( + mapping = SafeAreaRect(left = 0, top = 0, right = 1920, bottom = 975), + activityWidthPixels = 1920, + activityHeightPixels = 1080, + displayWidthPixels = 1920, + displayHeightPixels = 1080, + ) + + assertEquals(AirPlayInsets(bottom = 104), insets) + } + + @Test + fun alreadyEvenSafeAreaDimensionsAreUnchanged() { + val insets = AirPlaySafeArea.toInsets( + mapping = SafeAreaRect(left = 34, top = 75, right = 734, bottom = 725), + activityWidthPixels = 768, + activityHeightPixels = 800, + displayWidthPixels = 768, + displayHeightPixels = 800, + ) + + assertEquals(AirPlayInsets(top = 75, bottom = 75, left = 34, right = 34), insets) + } + + @Test + fun activity978MapsThroughEvenAlignedDisplayDimensions() { + val native = AirPlayDisplayConfig(widthPixels = 1920, heightPixels = 978) + val display = CarPlayDisplayScale.apply(native, 7) + + val full = AirPlaySafeArea.toInsets( + mapping = null, + activityWidthPixels = 1920, + activityHeightPixels = 978, + displayWidthPixels = display.widthPixels, + displayHeightPixels = display.heightPixels, + ) + val custom = AirPlaySafeArea.toInsets( + mapping = SafeAreaRect(left = 100, top = 50, right = 1800, bottom = 900), + activityWidthPixels = 1920, + activityHeightPixels = 978, + displayWidthPixels = display.widthPixels, + displayHeightPixels = display.heightPixels, + ) + + assertEquals(1344, display.widthPixels) + assertEquals(686, display.heightPixels) + assertEquals(AirPlayInsets(), full) + assertEquals(70, custom.left) + assertEquals(35, custom.top) + assertEquals(84, custom.right) + assertEquals(55, custom.bottom) + } + + @Test + fun editorCanScaleBetweenActivitySizesWithoutChangingRatios() { + val activityRect = SafeAreaRect(1900, 1000, 2100, 1200) + + val editorRect = AirPlaySafeArea.scaleRect( + rect = activityRect, + sourceWidthPixels = 2400, + sourceHeightPixels = 1600, + targetWidthPixels = 1200, + targetHeightPixels = 800, + ) + val restored = AirPlaySafeArea.scaleRect( + rect = editorRect, + sourceWidthPixels = 1200, + sourceHeightPixels = 800, + targetWidthPixels = 2400, + targetHeightPixels = 1600, + ) + + assertEquals(activityRect, restored) + } + + @Test + fun codecRoundTripsAndRejectsInvalidData() { + val rect = SafeAreaRect(100, 200, 900, 700) + + assertEquals(rect, SafeAreaCodec.decode(SafeAreaCodec.encode(rect))) + assertNull(SafeAreaCodec.decode("1,2,3")) + assertNull(SafeAreaCodec.decode("1,2,1,4")) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/airplay/ScreenCodecTest.kt b/shared/src/test/java/com/shilapi/xcertplay/airplay/ScreenCodecTest.kt new file mode 100644 index 0000000..fcf38c3 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/airplay/ScreenCodecTest.kt @@ -0,0 +1,34 @@ +package com.shilapi.xcertplay.airplay + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ScreenCodecTest { + @Test + fun validLengthPrefixesBecomeAnnexBInPlace() { + val first = byteArrayOf(0x40, 0x01) + val second = byteArrayOf(0x42, 0x01, 0x02) + val payload = + byteArrayOf(0, 0, 0, first.size.toByte()) + first + + byteArrayOf(0, 0, 0, second.size.toByte()) + second + + val converted = ScreenCodec.lengthPrefixedToAnnexB(payload) + + assertSame(payload, converted) + assertArrayEquals( + byteArrayOf(0, 0, 0, 1) + first + + byteArrayOf(0, 0, 0, 1) + second, + converted, + ) + } + + @Test + fun malformedLengthsLeavePayloadUntouched() { + val payload = byteArrayOf(0, 0, 0, 5, 0x40, 0x01) + val original = payload.copyOf() + + assertSame(payload, ScreenCodec.lengthPrefixedToAnnexB(payload)) + assertArrayEquals(original, payload) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/iap2/Iap2ProtocolTest.kt b/shared/src/test/java/com/shilapi/xcertplay/iap2/Iap2ProtocolTest.kt new file mode 100644 index 0000000..5838684 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/iap2/Iap2ProtocolTest.kt @@ -0,0 +1,189 @@ +package com.shilapi.xcertplay.iap2 + +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.iap2.catalog.Iap2Endpoints +import com.shilapi.xcertplay.iap2.message.Iap2CarPlayMessages +import com.shilapi.xcertplay.iap2.message.Iap2ClusterAsset +import com.shilapi.xcertplay.iap2.message.Iap2ControlMessages +import com.shilapi.xcertplay.iap2.message.Iap2Messages +import com.shilapi.xcertplay.iap2.message.Iap2WirelessMessages +import com.shilapi.xcertplay.iap2.message.Iap2WirelessSessionParameters +import com.shilapi.xcertplay.iap2.trace.Iap2FrameFormatter +import com.shilapi.xcertplay.iap2.trace.Iap2TraceDirection +import com.shilapi.xcertplay.iap2.wire.Iap2CsmFramer +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.iap2.wire.Iap2Parameter +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import com.shilapi.xcertplay.iap2.wire.Iap2ProtocolException +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class Iap2ProtocolTest { + @Test + fun catalogRegistersEveryReferenceEndpointOnce() { + assertEquals(151, Iap2Endpoints.ALL.size) + assertEquals(151, Iap2Endpoints.ALL.map { it.id }.toSet().size) + } + + @Test + fun framerAcceptsFragmentedAndConcatenatedFrames() { + val first = Iap2Frame(0x4300, byteArrayOf(0, 4, 0, 0)) + val second = Iap2Frame(0x4301, byteArrayOf(0, 5, 0, 0, 1)) + val bytes = first.encodedFrame() + second.encodedFrame() + val framer = Iap2CsmFramer() + + assertTrue(framer.offer(bytes.copyOfRange(0, 3)).isEmpty()) + assertEquals(listOf(first, second), framer.offer(bytes.copyOfRange(3, bytes.size))) + } + + @Test + fun orderedParameterListPreservesRepeatedAndUnknownIds() { + val original = listOf( + Iap2Parameter(0, byteArrayOf(1)), + Iap2Parameter(0, byteArrayOf(2)), + Iap2Parameter(0x7fff, byteArrayOf(3, 4)), + ) + val decoded = Iap2ParameterList.parse(Iap2ParameterList.of(original).encode()) + + assertEquals(listOf(0, 0, 0x7fff), decoded.asList().map { it.id }) + assertArrayEquals(byteArrayOf(1), decoded.all(0)[0].payload) + assertArrayEquals(byteArrayOf(2), decoded.all(0)[1].payload) + assertArrayEquals(byteArrayOf(3, 4), decoded.first(0x7fff)?.payload) + } + + @Test + fun genericBuilderCanConstructNestedUnmodeledEndpointBody() { + val frame = Iap2Messages.buildRaw(0x0d01) { + u16(0, 1) + group(4) { + u32(0, 7) + u8(1, 2) + } + } + val body = Iap2BodyReader.of(frame) + val roadSign = body.group(4) + + assertEquals(1, body.u16(0)) + assertEquals(7L, roadSign.u32(0)) + assertEquals(2, roadSign.u8(1)) + } + + @Test + fun carPlayStartSessionSupportsReferenceOptionalFields() { + val frame = Iap2CarPlayMessages.startSession( + wiredIpv6Addresses = listOf("fe80::2"), + wiredReserved = 3L, + wireless = Iap2WirelessSessionParameters( + ssid = "LIVI", + passphrase = "secret", + channel = 36, + ipAddresses = listOf("192.168.1.1", "192.168.1.2"), + securityType = 3, + ), + airPlayPort = 7000, + deviceIdentifier = "dev-1", + publicKey = "pub", + sourceVersion = "1.0", + sdkVersion = "27.0", + clusterAsset = Iap2ClusterAsset("cluster", 4), + mutualAuth = true, + ) + val body = Iap2BodyReader.of(frame) + val wired = body.group(0) + val wireless = body.group(1) + + assertEquals("fe80::2", wired.string(0)) + assertEquals(3L, wired.u32(1)) + assertEquals("LIVI", wireless.string(0)) + assertEquals(2, wireless.all(3).size) + assertEquals("192.168.1.2", wireless.all(3)[1].payload?.let { String(it).trimEnd('\u0000') }) + assertEquals(7000L, body.u32(2)) + assertEquals("27.0", body.string(6)) + assertEquals("cluster", body.group(7).string(0)) + assertEquals(4L, body.group(7).u32(1)) + assertEquals(1, body.u8(8)) + } + + @Test + fun wirelessCarPlayAvailabilityUsesBooleanReferenceSemantics() { + assertFalse( + Iap2WirelessMessages.wirelessCarPlayAvailability( + Iap2Messages.buildRaw(0x4e0d) { u8(0, 0) }, + ), + ) + assertTrue( + Iap2WirelessMessages.wirelessCarPlayAvailability( + Iap2Messages.buildRaw(0x4e0d) { u8(0, 1) }, + ), + ) + val invalid = Iap2Messages.buildRaw(0x4e0d) { u8(0, 2) } + try { + Iap2WirelessMessages.wirelessCarPlayAvailability(invalid) + throw AssertionError("Expected an invalid boolean availability to be rejected") + } catch (_: Iap2ProtocolException) { + // Expected. + } + } + + @Test + fun accessoryWifiConfigurationOmitsOrIncludesOptionalBssid() { + val withoutBssid = Iap2WirelessMessages.accessoryWiFiConfiguration( + ssid = "LIVI", + passphrase = "secret", + channel = 36, + securityType = 3, + ) + val withBssid = Iap2WirelessMessages.accessoryWiFiConfiguration( + ssid = "LIVI", + passphrase = "secret", + channel = 36, + securityType = 3, + bssid = byteArrayOf(1, 2, 3, 4, 5, 6), + ) + + assertNull(Iap2BodyReader.of(withoutBssid).first(0)) + assertArrayEquals(byteArrayOf(1, 2, 3, 4, 5, 6), Iap2BodyReader.of(withBssid).bytes(0)) + } + + @Test + fun formatterShowsEndpointNamesFieldsAndNestedGroups() { + val frame = Iap2CarPlayMessages.startSession( + wiredIpv6Addresses = listOf("fe80::2"), + airPlayPort = 7000, + publicKey = "pub", + sourceVersion = "1.0", + ) + + val formatted = Iap2FrameFormatter.format(Iap2TraceDirection.TX, "wired", frame) + + assertTrue(formatted.startsWith("IAP2 TX [wired] 0x4301 CarPlayStartSession")) + assertTrue("wired:" in formatted) + assertTrue("wiredIP: \"fe80::2\"" in formatted) + assertTrue("airPlayPort: u32=7000" in formatted) + assertTrue("raw-body=" in formatted) + } + + @Test + fun formatterFallsBackForMalformedBodies() { + val formatted = Iap2FrameFormatter.format( + Iap2TraceDirection.RX, + "wired", + Iap2Frame(0x4300, byteArrayOf(0, 1, 2)), + ) + + assertTrue(formatted.contains("body=malformed TLV")) + assertTrue(formatted.contains("raw-body=00 01 02")) + } + + @Test + fun formatterEscapesLineBreaksInNmeaStrings() { + val frame = Iap2ControlMessages.locationInformation("\$GPGGA,1\r\n") + val formatted = Iap2FrameFormatter.format(Iap2TraceDirection.TX, "wired", frame) + + assertTrue(formatted.contains("\\r\\n")) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/AndroidMediaSinkStateTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/AndroidMediaSinkStateTest.kt new file mode 100644 index 0000000..efb4a1c --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/media/AndroidMediaSinkStateTest.kt @@ -0,0 +1,21 @@ +package com.shilapi.xcertplay.media + +import org.junit.Assert.* +import org.junit.Test + +class AndroidMediaSinkStateTest { + @Test fun recreatingTheScreenRestoresItsActiveVideoState() { + val sink = AndroidMediaSink() + sink.onScreenStreamActive(110, true) + sink.onScreenStreamActive(111, true) + sink.onScreenStreamActive(111, false) + val events = mutableListOf>() + sink.setScreenStreamActiveChangedListener { type, active -> events.add(type to active) } + assertEquals(listOf(110 to true), events) + sink.close() + assertEquals(listOf(110 to true, 110 to false), events) + val afterClose = mutableListOf>() + sink.setScreenStreamActiveChangedListener { type, active -> afterClose.add(type to active) } + assertTrue(afterClose.isEmpty()) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/AudioChannelMappingTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/AudioChannelMappingTest.kt new file mode 100644 index 0000000..10d4d0d --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/media/AudioChannelMappingTest.kt @@ -0,0 +1,105 @@ +package com.shilapi.xcertplay.media + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AudioChannelMappingTest { + @Test + fun mobileCompatibleMappingMatchesTheOriginalRouting() { + assertMapped( + mode = AudioChannelMappingMode.MOBILE_COMPATIBLE, + audioType = "telephony", + payloadType = 100, + channel = AudioChannel.PHONE, + contentType = AudioContentType.SPEECH, + ) + assertMapped( + mode = AudioChannelMappingMode.MOBILE_COMPATIBLE, + audioType = "speechRecognition", + payloadType = 100, + channel = AudioChannel.ASSISTANT, + contentType = AudioContentType.SPEECH, + ) + assertMapped( + mode = AudioChannelMappingMode.MOBILE_COMPATIBLE, + audioType = "media", + payloadType = 100, + channel = AudioChannel.MEDIA, + contentType = AudioContentType.MUSIC, + ) + listOf("default", "alert", "compatibility").forEach { audioType -> + assertMapped( + mode = AudioChannelMappingMode.MOBILE_COMPATIBLE, + audioType = audioType, + payloadType = 100, + channel = AudioChannel.NAVIGATION, + contentType = AudioContentType.SPEECH, + ) + } + } + + @Test + fun automotiveMappingUsesTheBusSpecificCarPlayTypes() { + listOf("media", "default", "compatibility").forEach { audioType -> + assertMapped( + mode = AudioChannelMappingMode.AUTOMOTIVE_BUS, + audioType = audioType, + payloadType = 100, + channel = AudioChannel.MEDIA, + contentType = AudioContentType.MUSIC, + ) + } + assertMapped( + mode = AudioChannelMappingMode.AUTOMOTIVE_BUS, + audioType = "telephony", + payloadType = 100, + channel = AudioChannel.PHONE, + contentType = AudioContentType.SPEECH, + ) + assertMapped( + mode = AudioChannelMappingMode.AUTOMOTIVE_BUS, + audioType = "speechRecognition", + payloadType = 100, + channel = AudioChannel.ASSISTANT, + contentType = AudioContentType.SPEECH, + ) + assertMapped( + mode = AudioChannelMappingMode.AUTOMOTIVE_BUS, + audioType = "alert", + payloadType = 100, + channel = AudioChannel.NAVIGATION, + contentType = AudioContentType.SPEECH, + ) + } + + @Test + fun unknownTypesKeepTheMainHighAudioFallback() { + assertMapped( + mode = AudioChannelMappingMode.MOBILE_COMPATIBLE, + audioType = "unknown", + payloadType = AudioChannelMapper.STREAM_TYPE_MAIN_HIGH_AUDIO, + channel = AudioChannel.MEDIA, + contentType = AudioContentType.MUSIC, + ) + assertMapped( + mode = AudioChannelMappingMode.AUTOMOTIVE_BUS, + audioType = "unknown", + payloadType = 100, + channel = AudioChannel.NAVIGATION, + contentType = AudioContentType.SPEECH, + ) + } + + private fun assertMapped( + mode: AudioChannelMappingMode, + audioType: String, + payloadType: Int, + channel: AudioChannel, + contentType: AudioContentType, + ) { + assertEquals( + AudioChannelSelection(channel, contentType), + AudioChannelMapper.map(audioType, payloadType, mode), + ) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/MediaCodecSupportTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/MediaCodecSupportTest.kt new file mode 100644 index 0000000..dc15754 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/media/MediaCodecSupportTest.kt @@ -0,0 +1,85 @@ +package com.shilapi.xcertplay.media + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test +import com.shilapi.xcertplay.airplay.VideoCodec +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse + +class MediaCodecSupportTest { + @Test fun truncatedAccessUnitIsRejectedInsteadOfSubmittingItsValidPrefix() { + assertEquals(0, MediaCodecSupport.toAnnexB(byteArrayOf(0, 0, 0, 2, 0x41, 1, 0, 0, 0, 9, 0x41)).size) + assertEquals(0, MediaCodecSupport.toAnnexB(byteArrayOf(0, 0, 0, 2, 0x41, 1, 0)).size) + } + + @Test fun findsRandomAccessAfterParameterSetsButNeverOnInterframes() { + val sps = byteArrayOf(0, 0, 0, 1, 0x67, 10) + assertTrue(MediaCodecSupport.isRandomAccess(sps + byteArrayOf(0, 0, 1, 0x65, 20), VideoCodec.H264)) + assertFalse(MediaCodecSupport.isRandomAccess(sps + byteArrayOf(0, 0, 1, 0x41, 20), VideoCodec.H264)) + for (type in 16..21) assertTrue(MediaCodecSupport.isRandomAccess(byteArrayOf(0, 0, 0, 1, (type shl 1).toByte(), 1, 20), VideoCodec.H265)) + assertFalse(MediaCodecSupport.isRandomAccess(byteArrayOf(0, 0, 0, 1, 2, 1, 20), VideoCodec.H265)) + } + + @Test + fun lengthPrefixedNalUnitsBecomeOneAnnexBBuffer() { + val first = byteArrayOf(0x40, 0x01) + val second = byteArrayOf(0x42, 0x01, 0x02) + val lengthPrefixed = + byteArrayOf(0, 0, 0, first.size.toByte()) + first + + byteArrayOf(0, 0, 0, second.size.toByte()) + second + + assertArrayEquals( + byteArrayOf(0, 0, 0, 1) + first + + byteArrayOf(0, 0, 0, 1) + second, + MediaCodecSupport.toAnnexB(lengthPrefixed), + ) + } + + @Test + fun hevcCodecSpecificDataBuildsAnnexBParameterSets() { + val vps = byteArrayOf(0x40, 0x01) + val sps = byteArrayOf(0x42, 0x01, 0x02) + val pps = byteArrayOf(0x44, 0x01) + val record = hevcRecord( + vps, + sps, + pps, + ) + + assertArrayEquals( + byteArrayOf(0, 0, 0, 1) + vps + + byteArrayOf(0, 0, 0, 1) + sps + + byteArrayOf(0, 0, 0, 1) + pps, + MediaCodecSupport.hevcCodecSpecificData(record), + ) + } + + @Test + fun malformedHevcCodecSpecificDataIsRejected() { + val truncated = hevcRecord(byteArrayOf(0x40, 0x01), byteArrayOf()) + .copyOfRange(0, 25) + + assertEquals(0, MediaCodecSupport.hevcCodecSpecificData(truncated).size) + } + + private fun hevcRecord(vararg parameterSets: ByteArray): ByteArray { + var size = 23 + parameterSets.forEach { size += 5 + it.size } + val record = ByteArray(size) + record[0] = 1 + record[21] = 3 + record[22] = parameterSets.size.toByte() + var cursor = 23 + parameterSets.forEachIndexed { index, parameterSet -> + record[cursor++] = (32 + index).toByte() + record[cursor++] = 0 + record[cursor++] = 1 + record[cursor++] = (parameterSet.size ushr 8).toByte() + record[cursor++] = parameterSet.size.toByte() + parameterSet.copyInto(record, cursor) + cursor += parameterSet.size + } + return record + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt new file mode 100644 index 0000000..df9a6ea --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/media/VideoDecodeQueueTest.kt @@ -0,0 +1,76 @@ +package com.shilapi.xcertplay.media + +import com.shilapi.xcertplay.airplay.VideoCodec +import org.junit.Assert.* +import org.junit.Test + +class VideoDecodeQueueTest { + @Test fun lostReferenceChainWaitsForSuccessfullyQueuedKeyframe() { + val chain = VideoReferenceChain() + val predicted = byteArrayOf(0, 0, 0, 1, 0x41, 1) + val idr = byteArrayOf(0, 0, 0, 1, 0x65, 1) + assertFalse(chain.accepts(predicted, VideoCodec.H264)) + assertTrue(chain.accepts(idr, VideoCodec.H264)) + assertTrue(chain.needsKeyFrame) // Receiving it is insufficient if the codec is still busy. + chain.onQueued() + assertTrue(chain.accepts(predicted, VideoCodec.H264)) + chain.reset() + assertFalse(chain.accepts(predicted, VideoCodec.H264)) + } + + @Test fun overflowPreservesConfigurationAndResetsBeforeNewReferenceChain() { + val queue = VideoDecodeQueue(maxFrames = 2) + val config = VideoJob.Config(VideoCodec.H264, byteArrayOf(1)) + val surface = VideoJob.SurfaceChanged(null) + queue.offer(config) + queue.offer(VideoJob.Frame(byteArrayOf(1))) + queue.offer(surface) + queue.offer(VideoJob.Frame(byteArrayOf(2))) + queue.offer(VideoJob.Frame(byteArrayOf(3))) + assertSame(config, queue.poll(0)) + assertSame(surface, queue.poll(0)) + assertEquals(VideoJob.Resync, queue.poll(0)) + assertArrayEquals(byteArrayOf(3), (queue.poll(0) as VideoJob.Frame).nalus) + assertNull(queue.poll(0)) + } + + @Test fun byteBudgetAlsoTriggersRecoveryAndRejectsOversizedFrame() { + val queue = VideoDecodeQueue(maxFrames = 8, maxBytes = 5) + queue.offer(VideoJob.Frame(ByteArray(3))) + queue.offer(VideoJob.Frame(ByteArray(3))) + assertEquals(VideoJob.Resync, queue.poll(0)) + assertEquals(3, (queue.poll(0) as VideoJob.Frame).nalus.size) + queue.offer(VideoJob.Frame(ByteArray(6))) + assertEquals(VideoJob.Resync, queue.poll(0)) + assertNull(queue.poll(0)) + } + + @Test fun fullOutputMustBeDrainedWhileRetryingTheSameInput() { + var heldOutputs = 2 + var dequeues = 0 + val submitted = mutableListOf() + for (frame in 1..3) { + val index = VideoInputPump.acquire( + running = { true }, + drain = { if (heldOutputs > 0) heldOutputs-- }, + dequeue = { dequeues++; if (heldOutputs > 0) -1 else 0 }, + ) + assertEquals(0, index) + submitted.add(frame) + heldOutputs = 2 + } + assertEquals(listOf(1, 2, 3), submitted) + assertEquals(6, dequeues) + } + + @Test fun stalledDecoderHasFiniteWaitAndShutdownCancelsImmediately() { + var time = 0L + var attempts = 0 + assertEquals(-1, VideoInputPump.acquire( + running = { true }, drain = {}, dequeue = { attempts++; -1 }, + nanoTime = { time.also { time += 10 } }, timeoutNs = 30, + )) + assertEquals(3, attempts) + assertEquals(-1, VideoInputPump.acquire(running = { false }, drain = { fail() }, dequeue = { fail(); 0 })) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/mfi/LocalMfiAuthenticationClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/mfi/LocalMfiAuthenticationClientTest.kt new file mode 100644 index 0000000..45f07ef --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/mfi/LocalMfiAuthenticationClientTest.kt @@ -0,0 +1,110 @@ +package com.shilapi.xcertplay.mfi + +import java.io.File +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Signature +import java.security.cert.CertificateFactory +import java.security.spec.ECGenParameterSpec +import java.util.Date +import org.bouncycastle.asn1.ASN1Encodable +import org.bouncycastle.asn1.ASN1Integer +import org.bouncycastle.asn1.DERBitString +import org.bouncycastle.asn1.x500.X500Name +import org.bouncycastle.asn1.x509.AlgorithmIdentifier +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo +import org.bouncycastle.asn1.x509.Time +import org.bouncycastle.asn1.x509.V3TBSCertificateGenerator +import org.bouncycastle.asn1.x9.X9ObjectIdentifiers +import org.bouncycastle.asn1.DERSequence +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LocalMfiAuthenticationClientTest { + @get:Rule val temporary = TemporaryFolder() + + private fun identity(): File = temporary.newFolder().also { directory -> + // Fresh self-signed test material for each test. No stored accessory identity. + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1")) + val pair = generator.generateKeyPair() + val algorithm = AlgorithmIdentifier(X9ObjectIdentifiers.ecdsa_with_SHA256) + val name = X500Name("CN=DiPlay synthetic test only") + val tbs = V3TBSCertificateGenerator().apply { + setSerialNumber(ASN1Integer(BigInteger.ONE)) + setSignature(algorithm) + setIssuer(name) + setSubject(name) + setStartDate(Time(Date(0))) + setEndDate(Time(Date(4102444800000L))) + setSubjectPublicKeyInfo(SubjectPublicKeyInfo.getInstance(pair.public.encoded)) + }.generateTBSCertificate() + val signer = Signature.getInstance("SHA256withECDSA") + signer.initSign(pair.private) + signer.update(tbs.encoded) + val certificate = DERSequence(arrayOf( + tbs, algorithm, DERBitString(signer.sign()), + )).encoded + File(directory, "identity.pk8").writeBytes(pair.private.encoded) + File(directory, "certificate.p7b").writeBytes(certificate) + } + + @Test fun freshDigestsVerifyWithoutDoubleHashingAndRejectReplay() { + val directory = identity() + var count = 0 + val client = LocalMfiAuthenticationClient.load(directory) { count++ } + val certificate = CertificateFactory.getInstance("X.509") + .generateCertificates(client.readCertificate().inputStream()).single() + repeat(8) { index -> + val digest = MessageDigest.getInstance("SHA-256").digest("challenge-$index".toByteArray()) + val signature = client.signChallenge(digest) + assertEquals(64, signature.size) + val der = DERSequence(arrayOf( + ASN1Integer(BigInteger(1, signature.copyOfRange(0, 32))), + ASN1Integer(BigInteger(1, signature.copyOfRange(32, 64))), + )).encoded + val verifier = Signature.getInstance("NONEwithECDSA") + verifier.initVerify(certificate.publicKey) + verifier.update(digest) + assertTrue(verifier.verify(der)) + digest[0] = (digest[0].toInt() xor 1).toByte() + verifier.initVerify(certificate.publicKey) + verifier.update(digest) + assertFalse(verifier.verify(der)) + } + assertEquals(8, count) + assertEquals(3, client.protocolMajor()) + assertEquals(MfiCertificateType.MFI, client.certificateType) + } + + @Test fun rejectsDifferentPrivateKey() { + val directory = identity() + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(ECGenParameterSpec("secp256r1")) + File(directory, "identity.pk8").writeBytes(generator.generateKeyPair().private.encoded) + assertThrows(IllegalArgumentException::class.java) { LocalMfiAuthenticationClient.load(directory) } + } + + @Test fun rejectsIncompleteIdentityAndWrongChallengeLength() { + assertThrows(Exception::class.java) { LocalMfiAuthenticationClient.load(temporary.newFolder()) } + val client = LocalMfiAuthenticationClient.load(identity()) + for (length in listOf(0, 20, 31, 33, 64)) { + assertThrows(IllegalArgumentException::class.java) { client.signChallenge(ByteArray(length)) } + } + assertThrows(IllegalArgumentException::class.java) { client.readCertificate(1) } + } + + @Test fun certificateIsReturnedAsIndependentCopyAndSurvivesReload() { + val directory = identity() + val first = LocalMfiAuthenticationClient.load(directory) + val certificate = first.readCertificate() + val second = LocalMfiAuthenticationClient.load(directory) + assertArrayEquals(certificate, second.readCertificate()) + certificate.fill(0) + assertFalse(first.readCertificate().all { it == 0.toByte() }) + assertEquals(64, second.signChallenge(ByteArray(32) { it.toByte() }).size) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/mfi/MfiAuthenticationClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/mfi/MfiAuthenticationClientTest.kt new file mode 100644 index 0000000..52d0275 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/mfi/MfiAuthenticationClientTest.kt @@ -0,0 +1,72 @@ +package com.shilapi.xcertplay.mfi + +import com.shilapi.xcertplay.transport.I2cTransport +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MfiAuthenticationClientTest { + @Test + fun readsCertificateInIncrementing128ByteWindows() { + val certificate = bytes(300) { it } + val transport = ScriptedTransport( + select(0x30), pureRead(2, bytes(1, 44)), + select(0x31), pureRead(128, certificate.copyOfRange(0, 128)), + select(0x32), pureRead(128, certificate.copyOfRange(128, 256)), + select(0x33), pureRead(44, certificate.copyOfRange(256, 300)), + ) + + val result = MfiAuthenticationClient(transport, 0x11).readCertificate() + + assertArrayEquals(certificate, result) + assertEquals(0, transport.unconsumedSteps) + } + + @Test + fun signsChallengeWithSeparatedRegisterSelectAndRead() { + val challenge = bytes(20) { it + 1 } + val signature = bytes(128) { 0xa0 + it } + val transport = ScriptedTransport( + write(0x20, bytes(0, 20)), + write(0x21, challenge), + write(0x10, bytes(1)), + select(0x10), pureRead(1, bytes(0x10)), + select(0x11), pureRead(2, bytes(0, 128)), + select(0x12), pureRead(128, signature), + ) + + val result = MfiAuthenticationClient(transport, 0x10).signChallenge(challenge) + + assertArrayEquals(signature, result) + assertEquals(0, transport.unconsumedSteps) + } + + private class ScriptedTransport(vararg steps: Step) : I2cTransport { + private val steps = ArrayDeque(steps.toList()) + val unconsumedSteps: Int get() = steps.size + + override fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray { + assertTrue("Expected a scripted transaction", steps.isNotEmpty()) + val step = steps.removeFirst() + assertTrue("Unexpected I2C address 0x${address7Bit.toString(16)}", address7Bit in 0x10..0x11) + assertArrayEquals(step.writeData, writeData) + assertEquals(step.readLength, readLength) + return step.response.copyOf() + } + } + + private data class Step(val writeData: ByteArray, val readLength: Int, val response: ByteArray) + + private fun select(register: Int): Step = Step(bytes(register), 0, ByteArray(0)) + + private fun pureRead(readLength: Int, response: ByteArray): Step = + Step(ByteArray(0), readLength, response) + + private fun write(register: Int, payload: ByteArray): Step = + Step(bytes(register) + payload, 0, ByteArray(0)) + + private fun bytes(vararg values: Int): ByteArray = ByteArray(values.size) { values[it].toByte() } + + private fun bytes(size: Int, value: (Int) -> Int): ByteArray = ByteArray(size) { value(it).toByte() } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/mfi/MfiSelfCheckTest.kt b/shared/src/test/java/com/shilapi/xcertplay/mfi/MfiSelfCheckTest.kt new file mode 100644 index 0000000..30886be --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/mfi/MfiSelfCheckTest.kt @@ -0,0 +1,69 @@ +package com.shilapi.xcertplay.mfi + +import com.shilapi.xcertplay.transport.I2cTransport +import com.shilapi.xcertplay.transport.I2cTransportException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MfiSelfCheckTest { + @Test + fun ignoresFloatingBusValueAndSelectsRealSecondCandidate() { + var selectedRegister = -1 + val transport = object : I2cTransport { + override fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray { + if (writeData.isNotEmpty()) { + selectedRegister = writeData.single().toInt() and 0xff + return ByteArray(0) + } + return when { + selectedRegister != 0x00 || readLength != 1 -> + throw I2cTransportException.Nack("unexpected request") + address7Bit == 0x10 -> bytes(0xff) + address7Bit == 0x11 -> bytes(0x07) + else -> throw I2cTransportException.Nack("unexpected address") + } + } + } + + val result = MfiDeviceScanner(transport).scan() + + assertEquals(0x11, result.chip?.address7Bit) + assertEquals(0x07, result.chip?.deviceVersion) + assertTrue(result.failures.single().error is MfiDiscoveryError.InvalidDeviceVersion) + } + + @Test + fun fallsBackToSecondCandidateThenReportsRawProtocolMajor() { + val calls = mutableListOf>() + val transport = object : I2cTransport { + override fun transaction(address7Bit: Int, writeData: ByteArray, readLength: Int): ByteArray { + val register = writeData.firstOrNull()?.toInt()?.and(0xff) ?: -1 + calls += address7Bit to register + return when (address7Bit to register) { + 0x10 to 0x00 -> throw I2cTransportException.Nack("first candidate did not respond") + 0x11 to 0x00, 0x11 to 0x02 -> ByteArray(0) + 0x11 to -1 -> when (readLength) { + 1 -> if (calls.count { it == 0x11 to -1 } == 1) bytes(3) else bytes(7) + else -> throw I2cTransportException.Nack("unexpected read length") + } + else -> throw I2cTransportException.Nack("unexpected request") + } + } + } + + val result = MfiSelfCheck(transport).run() + + assertEquals( + listOf(0x10 to 0x00, 0x11 to 0x00, 0x11 to -1, 0x11 to 0x02, 0x11 to -1), + calls, + ) + assertEquals(0x11, result.discovery.chip?.address7Bit) + assertEquals(3, result.discovery.chip?.deviceVersion) + assertEquals(0x10, result.discovery.failures.single().address7Bit) + assertTrue(result.chip?.protocolMajor is MfiProtocolMajorResult.Value) + assertEquals(7, (result.chip?.protocolMajor as MfiProtocolMajorResult.Value).major) + } + + private fun bytes(vararg values: Int): ByteArray = ByteArray(values.size) { values[it].toByte() } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/mfi/RemoteMfiAuthenticationClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/mfi/RemoteMfiAuthenticationClientTest.kt new file mode 100644 index 0000000..20c3ea1 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/mfi/RemoteMfiAuthenticationClientTest.kt @@ -0,0 +1,195 @@ +package com.shilapi.xcertplay.mfi + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import com.shilapi.xcertplay.iap2.message.Iap2AuthenticationMessages +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.Base64 +import java.util.Collections +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class RemoteMfiAuthenticationClientTest { + private lateinit var server: HttpServer + private lateinit var serverExecutor: ExecutorService + + @Before + fun startServer() { + server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + serverExecutor = Executors.newCachedThreadPool() + server.executor = serverExecutor + server.start() + } + + @After + fun stopServer() { + server.stop(0) + serverExecutor.shutdownNow() + } + + @Test + fun resetsLoadsAndCachesCertificateThenSignsWithStableRequestId() { + val certificate = ByteArray(300) { it.toByte() } + val signature = ByteArray(64) { (0xa0 + it).toByte() } + val certificateRequests = AtomicInteger() + val signRequests = Collections.synchronizedList(mutableListOf()) + + server.createContext("/mfi/reset") { exchange -> + assertEquals("POST", exchange.requestMethod) + assertEquals("Bearer test-token", exchange.requestHeaders.getFirst("Authorization")) + assertEquals("{}", exchange.requestBody.readText()) + exchange.respond(200, "{\"detail\":\"\"}") + } + server.createContext("/mfi/certificate") { exchange -> + certificateRequests.incrementAndGet() + val encoded = Base64.getEncoder().encodeToString(certificate) + val digest = MessageDigest.getInstance("SHA-256").digest(certificate).toHex() + exchange.respond( + 200, + "{\"protocolMajor\":3,\"certificate\":\"$encoded\"," + + "\"certificateSha256\":\"$digest\"}", + ) + } + server.createContext("/mfi/sign") { exchange -> + val body = exchange.requestBody.readText() + signRequests += body + if (signRequests.size == 1) { + Thread.sleep(200) + } + exchange.respond( + 200, + "{\"signature\":\"${Base64.getEncoder().encodeToString(signature)}\"}", + ) + } + + val client = RemoteMfiAuthenticationClient( + serverAddress = serverAddress(), + token = "test-token", + readTimeoutMillis = 50, + ) + client.reset() + assertEquals(3, client.protocolMajor()) + assertArrayEquals(certificate, client.readCertificate()) + assertEquals(1, certificateRequests.get()) + + val challenge = byteArrayOf(1, 2, 3, 4) + assertArrayEquals(signature, client.signChallenge(challenge)) + assertEquals(2, signRequests.size) + val firstRequestId = RemoteMfiJson.string(signRequests[0], "requestId") + val secondRequestId = RemoteMfiJson.string(signRequests[1], "requestId") + assertEquals(firstRequestId, secondRequestId) + assertEquals( + Base64.getEncoder().encodeToString(challenge), + RemoteMfiJson.string(signRequests[0], "challenge"), + ) + } + + @Test + fun rejectsCertificateWhenSha256DoesNotMatch() { + server.createContext("/mfi/certificate") { exchange -> + exchange.respond( + 200, + "{\"protocolMajor\":3,\"certificate\":\"AQID\"," + + "\"certificateSha256\":\"${"00".repeat(32)}\"}", + ) + } + + val error = assertThrows(MfiInvalidDataException::class.java) { + RemoteMfiAuthenticationClient(serverAddress()).readCertificate() + } + assertEquals( + "Remote certificate SHA-256 does not match certificateSha256", + error.message, + ) + } + + @Test + fun loadsBaaPackageAndBuildsShowcaseIap2Payload() { + val leaf = ByteArray(40) { (0x10 + it).toByte() } + val intermediate = ByteArray(60) { (0x80 + it).toByte() } + val packageBytes = u32(leaf.size) + u32(intermediate.size) + leaf + intermediate + + server.createContext("/mfi/certificate") { exchange -> + val encoded = Base64.getEncoder().encodeToString(packageBytes) + val digest = MessageDigest.getInstance("SHA-256").digest(packageBytes).toHex() + exchange.respond( + 200, + "{\"type\":\"baa\",\"protocolMajor\":3,\"certificate\":\"$encoded\"," + + "\"certificateSha256\":\"$digest\"}", + ) + } + + val client = RemoteMfiAuthenticationClient(serverAddress()) + assertEquals(MfiCertificateType.BAA, client.certificateType) + assertArrayEquals( + Iap2AuthenticationMessages.baaCertificatePackage(leaf, intermediate).payload, + client.readCertificate(), + ) + val certificates = client.baaCertificates() + assertArrayEquals(leaf, certificates.leaf) + assertArrayEquals(intermediate, certificates.intermediate) + } + + @Test + fun rejectsUnknownCertificateType() { + server.createContext("/mfi/certificate") { exchange -> + exchange.respond( + 200, + "{\"type\":\"unknown\",\"protocolMajor\":3,\"certificate\":\"AQID\"," + + "\"certificateSha256\":\"${"00".repeat(32)}\"}", + ) + } + + val error = assertThrows(MfiInvalidDataException::class.java) { + RemoteMfiAuthenticationClient(serverAddress()).readCertificate() + } + assertEquals("Unsupported remote MFI certificate type 'unknown'", error.message) + } + + @Test + fun resetSurfacesHttp500DetailWithoutRetrying() { + val requests = AtomicInteger() + server.createContext("/mfi/reset") { exchange -> + requests.incrementAndGet() + exchange.respond(500, "{\"detail\":\"chip reset failed\"}") + } + + val error = assertThrows(RemoteMfiHttpException::class.java) { + RemoteMfiAuthenticationClient(serverAddress()).reset() + } + assertEquals(500, error.statusCode) + assertEquals("chip reset failed", error.detail) + assertEquals(1, requests.get()) + } + + private fun serverAddress(): String = "http://127.0.0.1:${server.address.port}" + + private fun HttpExchange.respond(statusCode: Int, body: String) { + val bytes = body.toByteArray(StandardCharsets.UTF_8) + responseHeaders.set("Content-Type", "application/json") + sendResponseHeaders(statusCode, bytes.size.toLong()) + responseBody.use { it.write(bytes) } + close() + } + + private fun java.io.InputStream.readText(): String = + use { String(it.readBytes(), StandardCharsets.UTF_8) } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it.toInt() and 0xff) } + + private fun u32(value: Int): ByteArray = byteArrayOf( + (value ushr 24).toByte(), + (value ushr 16).toByte(), + (value ushr 8).toByte(), + value.toByte(), + ) +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt new file mode 100644 index 0000000..0cd2409 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/CarPlayBonjourTest.kt @@ -0,0 +1,58 @@ +package com.shilapi.xcertplay.network + +import com.shilapi.xcertplay.airplay.AirPlayConfig +import com.shilapi.xcertplay.airplay.AirPlayDisplayConfig +import com.shilapi.xcertplay.airplay.AirPlayIdentity +import org.junit.Assert.assertEquals +import org.junit.Test + +class CarPlayBonjourTest { + private val config = AirPlayConfig( + deviceName = "xcertplay", + deviceId = "02:00:00:00:00:02", + btMac = "02:00:00:00:00:02", + sourceVersion = "366.0", + main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720), + model = "LIVI", + ) + private val identity = AirPlayIdentity( + privateKey = ByteArray(32), + publicKey = byteArrayOf(0x01, 0x23, 0xab.toByte()), + pairingId = "pairing-1", + ) + + @Test + fun airPlayTxtRecordsMatchLivi() { + assertEquals( + linkedMapOf( + "deviceid" to "02:00:00:00:00:02", + "features" to "0x44540380,0x61", + "flags" to "0x4", + "model" to "LIVI", + "srcvers" to "366.0", + "protovers" to "1.1", + "pi" to "pairing-1", + "pk" to "0123ab", + ), + CarPlayBonjourProtocol.airPlayTxtRecords(config, identity), + ) + } + + @Test + fun connectProbeRequestMatchesExactRequestLineAndHeaders() { + assertEquals( + "GET /ctrl-int/1/connect HTTP/1.1\r\n" + + "Host: [fe80::1]:7000\r\n" + + "User-Agent: AirPlay/366.0\r\n" + + "AirPlay-Receiver-Device-ID: 020000000002\r\n" + + "Connection: close\r\n" + + "\r\n", + CarPlayBonjourProtocol.connectProbeRequest( + host = "fe80::1%wlan0", + port = 7000, + sourceVersion = "366.0", + deviceId = "02:00:00:00:00:02", + ), + ) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/P2pOwnershipTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/P2pOwnershipTest.kt new file mode 100644 index 0000000..4027d32 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/P2pOwnershipTest.kt @@ -0,0 +1,25 @@ +package com.shilapi.xcertplay.network + +import org.junit.Assert.* +import org.junit.Test + +class P2pOwnershipTest { + @Test fun reinstallWithoutPreferencesCanReclaimOnlyItsOwnNamespace() { + val prefix = P2pOwnership.prefix("com.shihab.diplay", "device-user-signing-key-id") + val ssid = prefix + "aB23" + assertTrue(P2pOwnership.canReclaim(true, ssid, null, prefix)) + assertFalse(P2pOwnership.canReclaim(false, ssid, null, prefix)) + assertFalse(P2pOwnership.canReclaim(true, ssid, null, P2pOwnership.prefix("other.app", "device-user-signing-key-id"))) + assertFalse(P2pOwnership.canReclaim(true, ssid, null, P2pOwnership.prefix("com.shihab.diplay", "other-device"))) + assertTrue(ssid.length <= 32) + } + + @Test fun legacyGroupNeedsExactRecordAndUnknownGroupsNeedExplicitReset() { + val prefix = P2pOwnership.prefix("app", "id") + assertTrue(P2pOwnership.canReclaim(true, "DIRECT-xcaB23", "DIRECT-xcaB23", prefix)) + assertFalse(P2pOwnership.canReclaim(true, "DIRECT-xcaB23", null, prefix)) + assertFalse(P2pOwnership.canReclaim(true, null, null, prefix)) + assertFalse(P2pOwnership.canReclaim(true, prefix + "", null, prefix)) + assertFalse(P2pOwnership.canReclaim(true, prefix + "aB23-extra", null, prefix)) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/P2pStartupRecoveryTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/P2pStartupRecoveryTest.kt new file mode 100644 index 0000000..b443afa --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/P2pStartupRecoveryTest.kt @@ -0,0 +1,139 @@ +package com.shilapi.xcertplay.network + +import android.net.wifi.p2p.WifiP2pManager +import org.junit.Assert.* +import org.junit.Test +import java.io.IOException + +class P2pStartupRecoveryTest { + @Test fun confirmedFrequencyGoesFirstWithoutBeingRetriedLater() { + val preferred = P2pCreationRequest(P2pCreationMode.FIXED_2_GHZ, 2437) + val plan = P2pStartupRecovery.plan(5180, preferred) + assertEquals(preferred, plan.first()) + assertEquals(1, plan.count { it.frequencyMHz == 2437 }) + assertEquals(listOf(2437, 5180, 5745, 2412, 2462, null), plan.map { it.frequencyMHz }) + } + + @Test fun rejectedRememberedSystemConfigurationFallsBackToExplicitChannels() { + val attempted = mutableListOf() + val result = P2pStartupRecovery.create(5180, {}, P2pCreationRequest(P2pCreationMode.SYSTEM_DEFAULT)) { + attempted += it.frequencyMHz + if (it.frequencyMHz == null) throw P2pCreateRejected(WifiP2pManager.ERROR, "rejected") + } + assertEquals(listOf(null, 5180), attempted) + assertEquals(5180, result.frequencyMHz) + } + + @Test fun unsafeRememberedFrequenciesDoNotBypassTheChannelPolicy() { + for (frequency in listOf(0, 2472, 2484, 5500, 5955)) { + assertEquals(P2pStartupRecovery.plan(null), P2pStartupRecovery.plan(null, + P2pCreationRequest(P2pCreationMode.FIXED_5_GHZ, frequency))) + } + } + + @Test fun workingAlignedChannelNeverUsesFallback() { + val attempts = mutableListOf() + val mode = P2pStartupRecovery.create(5180, { fail("Unexpected retry") }) { attempts += it } + assertEquals(listOf(P2pCreationRequest(P2pCreationMode.ALIGNED_5_GHZ, 5180)), attempts) + assertEquals(P2pCreationMode.ALIGNED_5_GHZ, mode.mode) + } + + @Test fun rejectedChannelAndCustomGroupFallBackToSystemConfiguration() { + val attempts = mutableListOf() + var retries = 0 + val mode = P2pStartupRecovery.create(5180, { retries++ }) { + attempts += it + if (it.mode != P2pCreationMode.SYSTEM_DEFAULT) throw P2pCreateRejected(WifiP2pManager.ERROR, "rejected") + } + assertEquals(listOf(5180, 5745, 2437, 2412, 2462, null), attempts.map { it.frequencyMHz }) + assertEquals(P2pCreationMode.SYSTEM_DEFAULT, mode.mode) + assertEquals(5, retries) + } + + @Test fun unconnectedStationStartsWithFiveGhzAndBusyRetriesOnlyOnce() { + val attempts = mutableListOf() + try { + P2pStartupRecovery.create(null, {}) { + attempts += it + throw P2pCreateRejected(WifiP2pManager.BUSY, "busy") + } + fail("Expected busy") + } catch (failure: P2pCreateRejected) { assertEquals(WifiP2pManager.BUSY, failure.reason) } + assertEquals(List(2) { P2pCreationRequest(P2pCreationMode.FIXED_5_GHZ, 5180) }, attempts) + } + + @Test fun everyConfigurationRejectedStopsAfterBoundedAttempts() { + var calls = 0 + try { + P2pStartupRecovery.create(null, {}) { + calls++ + throw P2pCreateRejected(WifiP2pManager.ERROR, "rejected") + } + fail("Expected rejection") + } catch (_: P2pCreateRejected) { assertEquals(6, calls) } + } + + @Test fun twoGhzOnlyDriverWithoutChannelSelectionRecoversBeforeAnyAutomaticRequest() { + val attempts = mutableListOf() + val result = P2pStartupRecovery.create(null, {}) { + attempts += it + if (it.frequencyMHz !in listOf(2412, 2437, 2462)) { + throw P2pCreateRejected(WifiP2pManager.ERROR, "unsupported band or automatic channel") + } + } + assertEquals(2437, result.frequencyMHz) + assertTrue(attempts.all { it.frequencyMHz != null }) + assertEquals(listOf(5180, 5745, 2437), attempts.map { it.frequencyMHz }) + } + + @Test fun existingTwoGhzStationUsesItsExactChannelFirst() { + val result = P2pStartupRecovery.create(2422, { fail("Unexpected fallback") }) { + assertEquals(2422, it.frequencyMHz) + } + assertEquals(P2pCreationMode.ALIGNED_2_GHZ, result.mode) + } + + @Test fun rejectedChannelSixTriesOtherExplicitTwoGhzChannels() { + val attempted = mutableListOf() + val result = P2pStartupRecovery.create(null, {}) { + attempted += it.frequencyMHz + if (it.frequencyMHz != 2462) throw P2pCreateRejected(WifiP2pManager.ERROR, "rejected") + } + assertEquals(2462, result.frequencyMHz) + assertEquals(listOf(5180, 5745, 2437, 2412, 2462), attempted) + } + + @Test fun planNeverRequestsDfsSixGhzInvalidOrPhoneUnfriendlyStationFrequencies() { + for (station in listOf(0, -1, 5191, 5260, 5500, 5955, 2472, 2484)) { + val plan = P2pStartupRecovery.plan(station) + assertEquals(listOf(5180, 5745, 2437, 2412, 2462, null), plan.map { it.frequencyMHz }) + } + val plan = P2pStartupRecovery.plan(5200) + assertEquals(5200, plan.first().frequencyMHz) + assertEquals(plan.size, plan.distinctBy { it.frequencyMHz }.size) + assertTrue(plan.size <= 7) + } + + @Test fun permissionUnsupportedAndUncertainTimeoutNeverTriggerAnotherCreation() { + for (failure in listOf(P2pCreateRejected(WifiP2pManager.NO_PERMISSION, "permission"), + P2pCreateRejected(WifiP2pManager.P2P_UNSUPPORTED, "unsupported"), IOException("timeout"))) { + var calls = 0 + try { + P2pStartupRecovery.create(5180, { fail("Unsafe retry") }) { calls++; throw failure } + fail("Expected error") + } catch (actual: IOException) { assertSame(failure, actual) } + assertEquals(1, calls) + } + } + + @Test fun groupAppearingDuringRecoveryPreventsASecondCreation() { + var calls = 0 + try { + P2pStartupRecovery.create(5180, { throw P2pResetRequiredException() }) { + calls++ + throw P2pCreateRejected(WifiP2pManager.ERROR, "rejected") + } + fail("Expected occupied group") + } catch (_: P2pResetRequiredException) { assertEquals(1, calls) } + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt new file mode 100644 index 0000000..19ea3ef --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/network/WirelessHotspotManagerTest.kt @@ -0,0 +1,69 @@ +package com.shilapi.xcertplay.network + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class WirelessHotspotManagerTest { + @Test + fun convertsWifiP2pFrequencyToIap2Channel() { + assertEquals(1, wifiFrequencyMhzToChannel(2412)) + assertEquals(6, wifiFrequencyMhzToChannel(2437)) + assertEquals(14, wifiFrequencyMhzToChannel(2484)) + assertEquals(36, wifiFrequencyMhzToChannel(5180)) + assertEquals(149, wifiFrequencyMhzToChannel(5745)) + assertEquals(177, wifiFrequencyMhzToChannel(5885)) + assertEquals(1, wifiFrequencyMhzToChannel(5955)) + } + + @Test + fun rejectsUnsupportedOrInvalidFrequencies() { + assertNull(wifiFrequencyMhzToChannel(0)) + assertNull(wifiFrequencyMhzToChannel(2413)) + assertNull(wifiFrequencyMhzToChannel(6001)) + } + + @Test + fun convertsKnownChannelAndBandToFrequency() { + assertEquals(2412, wifiChannelToFrequencyMhz(1, band = 1)) + assertEquals(5180, wifiChannelToFrequencyMhz(36, band = 2)) + assertEquals(5955, wifiChannelToFrequencyMhz(1, band = 3)) + assertNull(wifiChannelToFrequencyMhz(0, band = 2)) + assertNull(wifiChannelToFrequencyMhz(36, band = null)) + } + + @Test + fun doesNotReportDesiredChannelWhenAndroidDoesNotExposeOne() { + assertEquals( + 0, + observedManualHotspotChannel( + apChannel = 0, + connectionFrequencyMHz = null, + scanFrequencyMHz = null, + apFrequencyMHz = null, + ), + ) + } + + @Test + fun reportsOnlyAnObservedActiveChannel() { + assertEquals( + 36, + observedManualHotspotChannel( + apChannel = 36, + connectionFrequencyMHz = null, + scanFrequencyMHz = null, + apFrequencyMHz = null, + ), + ) + assertEquals( + 44, + observedManualHotspotChannel( + apChannel = 0, + connectionFrequencyMHz = 5220, + scanFrequencyMHz = null, + apFrequencyMHz = null, + ), + ) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/orchestration/ManualHotspotConfigTest.kt b/shared/src/test/java/com/shilapi/xcertplay/orchestration/ManualHotspotConfigTest.kt new file mode 100644 index 0000000..81d541d --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/orchestration/ManualHotspotConfigTest.kt @@ -0,0 +1,21 @@ +package com.shilapi.xcertplay.orchestration + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ManualHotspotConfigTest { + @Test + fun autoBandAcceptsBoth2GhzAnd5GhzChannels() { + assertTrue(isManualHotspotChannelCompatible(ManualHotspotBand.AUTO, 6)) + assertTrue(isManualHotspotChannelCompatible(ManualHotspotBand.AUTO, 36)) + } + + @Test + fun explicitBandRejectsChannelsFromTheOtherBand() { + assertTrue(isManualHotspotChannelCompatible(ManualHotspotBand.GHZ_2_4, 11)) + assertFalse(isManualHotspotChannelCompatible(ManualHotspotBand.GHZ_2_4, 36)) + assertTrue(isManualHotspotChannelCompatible(ManualHotspotBand.GHZ_5, 149)) + assertFalse(isManualHotspotChannelCompatible(ManualHotspotBand.GHZ_5, 11)) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/orchestration/MfiTargetConfigTest.kt b/shared/src/test/java/com/shilapi/xcertplay/orchestration/MfiTargetConfigTest.kt new file mode 100644 index 0000000..58c0f63 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/orchestration/MfiTargetConfigTest.kt @@ -0,0 +1,76 @@ +package com.shilapi.xcertplay.orchestration + +import com.shilapi.xcertplay.transport.Iap2IdentificationConfig +import com.shilapi.xcertplay.transport.UsbDeviceId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class MfiTargetConfigTest { + @Test + fun usbTargetRequiresACh341Device() { + assertThrows(IllegalArgumentException::class.java) { + config(mfiTarget = MfiTarget.USB_CH341) + } + } + + @Test + fun i2cTargetUsesTheConfiguredDevicePath() { + val config = config( + mfiTarget = MfiTarget.I2C, + linuxI2cPath = "/dev/i2c-7", + ) + + assertEquals("/dev/i2c-7", config.linuxI2cPath) + } + + @Test + fun i2cTargetRejectsABlankDevicePath() { + assertThrows(IllegalArgumentException::class.java) { + config(mfiTarget = MfiTarget.I2C, linuxI2cPath = " ") + } + } + + @Test + fun remoteTargetDoesNotRequireALocalDevice() { + val config = config( + mfiTarget = MfiTarget.REMOTE, + remoteMfiServer = "https://mfi.example.test", + remoteMfiToken = "secret", + ) + + assertEquals(MfiTarget.REMOTE, config.mfiTarget) + assertEquals("https://mfi.example.test", config.remoteMfiServer) + assertEquals("secret", config.remoteMfiToken) + } + + @Test + fun remoteTargetRequiresAServerAddress() { + assertThrows(IllegalArgumentException::class.java) { + config(mfiTarget = MfiTarget.REMOTE, remoteMfiServer = " ") + } + } + + private fun config( + mfiTarget: MfiTarget, + ch341Devices: List = emptyList(), + linuxI2cPath: String? = null, + remoteMfiServer: String? = null, + remoteMfiToken: String? = null, + ): CarPlayRuntimeConfig = CarPlayRuntimeConfig( + mfiTarget = mfiTarget, + ch341Devices = ch341Devices, + linuxI2cPath = linuxI2cPath, + remoteMfiServer = remoteMfiServer, + remoteMfiToken = remoteMfiToken, + identification = Iap2IdentificationConfig( + name = "test", + modelIdentifier = "test", + manufacturer = "test", + serialNumber = "test", + firmwareVersion = "1", + hardwareVersion = "1", + carPlayUsbInterfaceNumber = 3, + ), + ) +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/orchestration/WirelessConnectionProofTest.kt b/shared/src/test/java/com/shilapi/xcertplay/orchestration/WirelessConnectionProofTest.kt new file mode 100644 index 0000000..38c25ab --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/orchestration/WirelessConnectionProofTest.kt @@ -0,0 +1,81 @@ +package com.shilapi.xcertplay.orchestration + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WirelessConnectionProofTest { + @Test fun authenticationWithoutVideoDoesNotConfirm() { + var saves = 0 + val proof = WirelessConnectionProof() + proof.begin(1) { saves++ } + proof.activate(1, Any()) + proof.authenticated(1) + assertEquals(0, saves) + } + + @Test fun bothEventsAreRequiredInEitherOrderAndOnlySaveOnce() { + for (videoFirst in listOf(true, false)) { + var saves = 0 + val session = Any() + val proof = WirelessConnectionProof() + proof.begin(1) { saves++ } + proof.activate(1, session) + if (videoFirst) proof.rendered(1, session) else proof.authenticated(1) + assertEquals(0, saves) + if (videoFirst) proof.authenticated(1) else proof.rendered(1, session) + proof.rendered(1, session) + proof.authenticated(1) + assertEquals(1, saves) + } + } + + @Test fun oldGenerationAndOldSessionCannotConfirmANewConnection() { + var saves = 0 + val old = Any() + val current = Any() + val proof = WirelessConnectionProof() + proof.begin(1) { saves++ } + proof.activate(1, old) + proof.authenticated(1) + proof.begin(2) { saves++ } + proof.activate(2, current) + proof.rendered(1, old) + proof.authenticated(1) + proof.rendered(2, old) + proof.authenticated(2) + assertEquals(0, saves) + proof.rendered(2, current) + assertEquals(1, saves) + } + + @Test fun endedOrClearedSessionCannotBeLearnedByLateCallbacks() { + val session = Any() + var saves = 0 + val proof = WirelessConnectionProof() + proof.begin(1) { saves++ } + proof.activate(1, session) + proof.rendered(1, session) + proof.end(1, session) + proof.authenticated(1) + proof.clear() + proof.activate(1, session) + proof.authenticated(1) + proof.rendered(1, session) + assertEquals(0, saves) + } + + @Test fun replacementSessionNeedsItsOwnAuthenticationAndFrame() { + val first = Any() + val second = Any() + var saves = 0 + val proof = WirelessConnectionProof() + proof.begin(1) { saves++ } + proof.activate(1, first) + proof.authenticated(1) + proof.activate(1, second) + proof.rendered(1, second) + assertEquals(0, saves) + proof.authenticated(1) + assertEquals(1, saves) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/orchestration/WirelessHandoffTest.kt b/shared/src/test/java/com/shilapi/xcertplay/orchestration/WirelessHandoffTest.kt new file mode 100644 index 0000000..635eafb --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/orchestration/WirelessHandoffTest.kt @@ -0,0 +1,29 @@ +package com.shilapi.xcertplay.orchestration + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class WirelessHandoffTest { + @Test + fun activeTunnelKeepsHandoffAliveAfterBluetoothBootstrapCloses() { + assertTrue( + isWirelessHandoffInProgress( + handoffRequested = false, + tunnelActive = true, + sessionActive = true, + ), + ) + } + + @Test + fun ordinaryBootstrapLossStillFailsWithoutHandoffState() { + assertFalse( + isWirelessHandoffInProgress( + handoffRequested = false, + tunnelActive = false, + sessionActive = false, + ), + ) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Ch341DeviceMatcherTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Ch341DeviceMatcherTest.kt new file mode 100644 index 0000000..feb4d25 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Ch341DeviceMatcherTest.kt @@ -0,0 +1,17 @@ +package com.shilapi.xcertplay.transport + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class Ch341DeviceMatcherTest { + @Test + fun matchesOnlyConfiguredUsbIdentities() { + val matcher = Ch341DeviceMatcher(listOf(UsbDeviceId(0x1A86, 0x5512))) + + assertTrue(matcher.matches(0x1A86, 0x5512)) + assertFalse(matcher.matches(0x1A86, 0x5513)) + assertFalse(matcher.matches(0x1234, 0x5512)) + } + +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Ch341I2cStreamEncoderTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Ch341I2cStreamEncoderTest.kt new file mode 100644 index 0000000..d8c75bb --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Ch341I2cStreamEncoderTest.kt @@ -0,0 +1,40 @@ +package com.shilapi.xcertplay.transport + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class Ch341I2cStreamEncoderTest { + @Test + fun encodesWriteReadWithRepeatedStartAndFinalReadNack() { + assertArrayEquals( + bytes(0xaa, 0x74, 0x80, 0x22, 0x80, 0x30, 0x74, 0x80, 0x23, 0xc1, 0xc0, 0x75, 0x00), + Ch341I2cStreamEncoder.transaction(0x11, bytes(0x30), 2), + ) + } + + @Test + fun encodesMaximumPureReadWithOnlyFinalNackAndStop() { + val stream = Ch341I2cStreamEncoder.transaction( + 0x11, + ByteArray(0), + Ch341I2cStreamEncoder.MAX_READ_BYTES, + ) + + assertEquals(1, stream.count { it.toInt() and 0xff == 0x75 }) + // The read-address setup of a segmented read is one batched `0x80|1` command, matching the + // hardware-proven reference (mfi3.py: `[START, OUT | 1, address]`), whose reply carries the + // requested data bytes with no leading ACK/NACK status byte. + assertArrayEquals(bytes(0xaa, 0x74, 0x81, 0x23), stream.copyOfRange(0, 4)) + assertArrayEquals(bytes(0xde, 0xc0, 0x75, 0x00), stream.takeLast(4).toByteArray()) + assertTrue(Ch341I2cStreamEncoder.transaction(0x11, ByteArray(129), 0).isNotEmpty()) + for (offset in 0 until stream.size - Ch341I2cStreamEncoder.MAX_STREAM_PACKET_BYTES step Ch341I2cStreamEncoder.MAX_STREAM_PACKET_BYTES) { + assertEquals(0xaa, stream[offset].toInt() and 0xff) + assertEquals(0, stream[offset + Ch341I2cStreamEncoder.MAX_STREAM_PACKET_BYTES - 1].toInt() and 0xff) + } + assertTrue(stream.size > Ch341I2cStreamEncoder.MAX_STREAM_PACKET_BYTES) + } + + private fun bytes(vararg values: Int): ByteArray = ByteArray(values.size) { values[it].toByte() } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/EthernetIpv6CodecTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/EthernetIpv6CodecTest.kt new file mode 100644 index 0000000..bb247dd --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/EthernetIpv6CodecTest.kt @@ -0,0 +1,84 @@ +package com.shilapi.xcertplay.transport + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class EthernetIpv6CodecTest { + @Test + fun `maps IPv6 multicast destination to Ethernet multicast`() { + val packet = ByteArray(40) + packet[0] = 0x60 + val destination = byteArrayOf( + 0xff.toByte(), 0x02, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 0xff.toByte(), 0, 0, 2, + ) + destination.copyInto(packet, 24) + + assertArrayEquals( + byteArrayOf(0x33, 0x33, 0xff.toByte(), 0, 0, 2), + EthernetIpv6Codec.multicastDestinationMac(packet), + ) + } + + @Test + fun `does not map IPv6 unicast destination`() { + val packet = ByteArray(40) + packet[0] = 0x60 + packet[24] = 0xfe.toByte() + packet[25] = 0x80.toByte() + + assertNull(EthernetIpv6Codec.multicastDestinationMac(packet)) + } + + @Test + fun `rejects truncated or non IPv6 packets`() { + assertNull(EthernetIpv6Codec.multicastDestinationMac(ByteArray(39))) + assertNull(EthernetIpv6Codec.multicastDestinationMac(ByteArray(40))) + } + + @Test + fun `adds target link layer option to tun neighbor advertisement`() { + val packet = ByteArray(64) + packet[0] = 0x60 + packet[5] = 24 + packet[6] = 58 + byteArrayOf(0xfe.toByte(), 0x80.toByte(), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2) + .copyInto(packet, 8) + byteArrayOf(0xfe.toByte(), 0x80.toByte(), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3) + .copyInto(packet, 24) + packet[40] = 136.toByte() + packet[44] = 0x60 + val mac = byteArrayOf(0x02, 0, 0, 0, 0, 2) + + val result = EthernetIpv6Codec.addNeighborAdvertisementTargetMac(packet, mac) + + assertEquals(72, result.size) + assertEquals(32, ((result[4].toInt() and 0xff) shl 8) or (result[5].toInt() and 0xff)) + assertEquals(2, result[64].toInt() and 0xff) + assertEquals(1, result[65].toInt() and 0xff) + assertArrayEquals(mac, result.copyOfRange(66, 72)) + assertEquals(0xffff, ipv6IcmpChecksumSum(result)) + } + + private fun ipv6IcmpChecksumSum(packet: ByteArray): Int { + val payloadBytes = ((packet[4].toInt() and 0xff) shl 8) or (packet[5].toInt() and 0xff) + var sum = 0L + fun words(start: Int, bytes: Int) { + var offset = start + val end = start + bytes + while (offset + 1 < end) { + sum += (((packet[offset].toInt() and 0xff) shl 8) or + (packet[offset + 1].toInt() and 0xff)).toLong() + offset += 2 + } + } + words(8, 32) + sum += payloadBytes + sum += 58 + words(40, payloadBytes) + while (sum ushr 16 != 0L) sum = (sum and 0xffff) + (sum ushr 16) + return sum.toInt() + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2ControlDeadlineTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2ControlDeadlineTest.kt new file mode 100644 index 0000000..ca1cda9 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2ControlDeadlineTest.kt @@ -0,0 +1,31 @@ +package com.shilapi.xcertplay.transport + +import org.junit.Assert.* +import org.junit.Test + +class Iap2ControlDeadlineTest { + @Test fun anAuthenticatedDriveSurvivesFiveMinutesAndAFullDay() { + var now = 0L + val deadline = Iap2ControlDeadline(Long.MAX_VALUE) { now } + deadline.authenticated() + for (minutes in listOf(6L, 90L, 25L * 60L)) { + now = minutes * 60_000_000_000L + assertEquals(30_000L, deadline.remainingMillis()) + } + } + @Test fun unlimitedDriveDoesNotLeaveAnUnauthenticatedHandshakeHanging() { + var now = 0L + val deadline = Iap2ControlDeadline(Long.MAX_VALUE) { now } + now = 59_999_000_000L + assertEquals(1L, deadline.remainingMillis()) + now = 60_000_000_000L + assertEquals(0L, deadline.remainingMillis()) + } + @Test fun explicitFiniteWindowsStillExpireAfterAuthentication() { + var now = -1_000_000_000L + val deadline = Iap2ControlDeadline(100) { now } + deadline.authenticated() + now += 100_000_000L + assertEquals(0L, deadline.remainingMillis()) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2LocationClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2LocationClientTest.kt new file mode 100644 index 0000000..313f17f --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2LocationClientTest.kt @@ -0,0 +1,123 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class Iap2LocationClientTest { + @Test + fun locationInformationEncodesNulTerminatedNmeaSentence() { + val frame = Iap2LocationMessages.locationInformation("\$GPGGA") + + assertEquals(Iap2LocationMessages.LOCATION_INFORMATION, frame.messageId) + val parameter = Iap2BodyReader.of(frame).list().single() + assertEquals(0, parameter.id) + assertTrue(parameter.payload.contentEquals("\$GPGGA\u0000".encodeToByteArray())) + } + + @Test + fun nmeaEncoderBuildsGgaAndRmcPairWithValidChecksums() { + val encoded = NmeaLocationEncoder.encode( + CarPlayLocationFix( + latitudeDegrees = 48.1173, + longitudeDegrees = 11.5166667, + altitudeMeters = 545.4, + bearingDegrees = 84.4, + speedMetersPerSecond = 11.524, + accuracyMeters = 4.5, + timestampMillis = Instant.parse("2026-03-23T12:35:19Z").toEpochMilli(), + ), + ) + + val sentences = encoded.split("\r\n").filter { it.isNotBlank() } + assertEquals(2, sentences.size) + assertTrue(sentences[0].startsWith("\$GPGGA,123519.00,4807.0380,N,01131.0000,E")) + assertTrue(sentences[1].startsWith("\$GPRMC,123519.00,A,4807.0380,N,01131.0000,E")) + sentences.forEach { sentence -> + val star = sentence.lastIndexOf('*') + assertTrue(star > 0) + assertEquals(checksum(sentence.substring(1, star)), sentence.substring(star + 1)) + } + } + + @Test + fun identificationAdvertisesLocationMessagesAndComponentOnlyWhenEnabled() { + val base = wiredIdentification(locationInformationEnabled = false) + val enabled = wiredIdentification(locationInformationEnabled = true) + + val baseParameters = parameters( + Iap2IdentificationClient.identificationInformation(base).payload, + ) + val enabledParameters = parameters( + Iap2IdentificationClient.identificationInformation(enabled).payload, + ) + + assertNull(baseParameters.firstOrNull { it.id == 22 }) + assertFalse(0xfffb in u16Values(baseParameters.single { it.id == 6 }.payload)) + assertFalse(0xfffa in u16Values(baseParameters.single { it.id == 7 }.payload)) + + val component = enabledParameters.single { it.id == 22 } + assertNotNull(component) + val fields = parameters(component.payload) + assertTrue(fields.single { it.id == 0 }.payload.contentEquals(byteArrayOf(0, 0))) + assertTrue(fields.single { it.id == 1 }.payload.contentEquals("xcertplay\u0000".encodeToByteArray())) + assertTrue(fields.any { it.id == 17 && it.payload.isEmpty() }) + assertTrue(fields.any { it.id == 18 && it.payload.isEmpty() }) + assertTrue(0xfffb in u16Values(enabledParameters.single { it.id == 6 }.payload)) + assertTrue(0xfffa in u16Values(enabledParameters.single { it.id == 7 }.payload)) + assertTrue(0xfffc in u16Values(enabledParameters.single { it.id == 7 }.payload)) + } + + @Test + fun wirelessIdentificationKeepsLocationMessagesAndOmitsWiredPowerSource() { + val config = wiredIdentification(locationInformationEnabled = true).copy( + wireless = Iap2WirelessIdentification( + bluetoothMac = "AA:BB:CC:DD:EE:FF", + ssid = "LIVI", + ), + ) + + val parameters = parameters( + Iap2IdentificationClient.identificationInformation(config).payload, + ) + val sent = u16Values(parameters.single { it.id == 6 }.payload) + val received = u16Values(parameters.single { it.id == 7 }.payload) + + assertTrue(0xfffb in sent) + assertTrue(0x5703 in sent) + assertFalse(0xae03 in sent) + assertTrue(0xfffa in received) + assertTrue(0xfffc in received) + } + + private fun wiredIdentification(locationInformationEnabled: Boolean) = Iap2IdentificationConfig( + name = "xcertplay", + modelIdentifier = "xcertplay", + manufacturer = "xcertplay", + serialNumber = "xcertplay", + firmwareVersion = "1.0.0", + hardwareVersion = "1.0", + carPlayUsbInterfaceNumber = 3, + locationInformationEnabled = locationInformationEnabled, + ) + + private fun u16Values(bytes: ByteArray): List = + List(bytes.size / 2) { index -> + ((bytes[index * 2].toInt() and 0xff) shl 8) or + (bytes[index * 2 + 1].toInt() and 0xff) + } + + private fun parameters(bytes: ByteArray) = Iap2ParameterList.parse(bytes).asList() + + private fun checksum(body: String): String { + var value = 0 + for (character in body) value = value xor character.code + return "%02X".format(value) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WiredControlClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WiredControlClientTest.kt new file mode 100644 index 0000000..6e6dde8 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WiredControlClientTest.kt @@ -0,0 +1,30 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.body.Iap2BodyReader +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class Iap2WiredControlClientTest { + @Test + fun carPlayStartSessionNestsAddressesInsideWiredAttributes() { + val frame = Iap2WiredControlClient.carPlayStartSession( + Iap2WiredCarPlayEndpoint( + ipv6Addresses = listOf("fe80::2"), + airPlayPort = 7000, + deviceIdentifier = "02:00:00:00:00:02", + publicKey = "abcd", + sourceVersion = "1.0", + ), + ) + + assertEquals(0x4301, frame.messageId) + val outer = Iap2BodyReader.of(frame).list() + val wired = outer.single { it.id == 0 } + val addresses = Iap2ParameterList.parse(wired.payload).asList() + assertEquals(1, addresses.size) + assertEquals(0, addresses.single().id) + assertArrayEquals("fe80::2\u0000".encodeToByteArray(), addresses.single().payload) + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt new file mode 100644 index 0000000..5f4ff44 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt @@ -0,0 +1,129 @@ +package com.shilapi.xcertplay.transport + +import com.shilapi.xcertplay.iap2.message.Iap2WirelessMessages +import com.shilapi.xcertplay.iap2.wire.Iap2Frame +import com.shilapi.xcertplay.iap2.wire.Iap2Parameter +import com.shilapi.xcertplay.iap2.wire.Iap2ParameterList +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class Iap2WirelessControlClientTest { + @Test + fun accessoryWiFiConfigurationMatchesLiviVector() { + val frame = Iap2WirelessControlClient.accessoryWiFiConfiguration(endpoint()) + + assertEquals( + "404000275703000900014c49564900000e00027365637265743132330000050003030005000424", + frame.encodedFrame().hex(), + ) + } + + @Test + fun carPlayStartSessionMatchesLiviWirelessVector() { + val frame = Iap2WirelessControlClient.carPlayStartSession(endpoint()) + + assertEquals( + "40400060430100350001000900004c49564900000e000173656372657431323300000500022400100003" + + "3139322e3136382e322e31000005000403000800020000c000000a00036465762d3100000b000461" + + "61626263630000080005312e3000", + frame.encodedFrame().hex(), + ) + } + + @Test + fun wirelessIdentificationAdvertisesTransportComponentsWithoutUsbHost() { + val config = Iap2IdentificationConfig( + name = "LIVI", + modelIdentifier = "LIVI", + manufacturer = "LIVI", + serialNumber = "0123456", + firmwareVersion = "1.0.0", + hardwareVersion = "1.0", + wireless = Iap2WirelessIdentification( + bluetoothMac = "AA:BB:CC:DD:EE:FF", + ssid = "LIVI", + ), + ) + val parameters = parameters( + Iap2IdentificationClient.identificationInformation(config).payload, + ) + + assertNull(parameters.firstOrNull { it.id == 16 }) + assertArrayEquals(byteArrayOf(0), parameters.single { it.id == 8 }.payload) + + val bluetooth = parameters(parameters.single { it.id == 17 }.payload) + assertEquals("blue\u0000", bluetooth.single { it.id == 1 }.payload.decodeToString()) + assertArrayEquals( + byteArrayOf(0xaa.toByte(), 0xbb.toByte(), 0xcc.toByte(), 0xdd.toByte(), 0xee.toByte(), 0xff.toByte()), + bluetooth.single { it.id == 3 }.payload, + ) + + val wireless = parameters(parameters.single { it.id == 24 }.payload) + assertEquals("LIVI\u0000", wireless.single { it.id == 1 }.payload.decodeToString()) + + val sent = u16Values(parameters.single { it.id == 6 }.payload) + val received = u16Values(parameters.single { it.id == 7 }.payload) + assertTrue(0x5703 in sent) + assertTrue(0x4301 in sent) + assertFalse(0xae03 in sent) + assertTrue(0x4300 in received) + assertTrue(0x4e0d in received) + assertTrue(0x4e0e in received) + assertTrue(0x5702 in received) + } + + @Test + fun wirelessCarPlayUpdateReadsBooleanAvailability() { + val frame = Iap2Frame( + 0x4e0d, + Iap2ParameterList.of(Iap2Parameter(0, byteArrayOf(1))).encode(), + ) + + assertTrue(Iap2WirelessMessages.wirelessCarPlayAvailability(frame)) + } + + @Test + fun wiredIdentificationKeepsCarPlayStartSessionDeclaration() { + val config = Iap2IdentificationConfig( + name = "wired", + modelIdentifier = "wired", + manufacturer = "test", + serialNumber = "1", + firmwareVersion = "1.0", + hardwareVersion = "1.0", + carPlayUsbInterfaceNumber = 4, + ) + val parameters = parameters( + Iap2IdentificationClient.identificationInformation(config).payload, + ) + + assertTrue(0x4301 in u16Values(parameters.single { it.id == 6 }.payload)) + assertTrue(0x4300 in u16Values(parameters.single { it.id == 7 }.payload)) + } + + private fun endpoint(): Iap2WirelessCarPlayEndpoint = Iap2WirelessCarPlayEndpoint( + ssid = "LIVI", + passphrase = "secret123", + channel = 36, + security = Iap2WirelessSecurity.WPA3_TRANSITION, + ipAddresses = listOf("192.168.2.1"), + airPlayPort = 49152, + deviceIdentifier = "dev-1", + publicKey = "aabbcc", + sourceVersion = "1.0", + ) + + private fun u16Values(bytes: ByteArray): List = + List(bytes.size / 2) { index -> + ((bytes[index * 2].toInt() and 0xff) shl 8) or (bytes[index * 2 + 1].toInt() and 0xff) + } + + private fun parameters(bytes: ByteArray) = Iap2ParameterList.parse(bytes).asList() + + private fun ByteArray.hex(): String = + joinToString(separator = "") { "%02x".format(it.toInt() and 0xff) } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/LinuxI2cTransportTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/LinuxI2cTransportTest.kt new file mode 100644 index 0000000..e15a490 --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/LinuxI2cTransportTest.kt @@ -0,0 +1,70 @@ +package com.shilapi.xcertplay.transport + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test + +class LinuxI2cTransportTest { + @Test + fun transactionDelegatesCombinedReadAndMapsNackBeforeClose() { + val bridge = RecordingBridge(response = byteArrayOf(0x12, 0x34)) + val transport = LinuxI2cTransport.open("/dev/i2c-2", bridge) + + assertArrayEquals(byteArrayOf(0x12, 0x34), transport.transaction(0x11, byteArrayOf(0x30), 2)) + assertEquals("/dev/i2c-2", bridge.openedPath) + assertEquals(0x11, bridge.address) + assertArrayEquals(byteArrayOf(0x30), bridge.writeData) + assertEquals(2, bridge.readLength) + + try { + LinuxI2cTransport.open( + "/dev/i2c-1", + RecordingBridge(error = LinuxI2cNativeException(121, "remote error")), + ).transaction(0x11, byteArrayOf(1), 0) + fail("expected Nack") + } catch (_: I2cTransportException.Nack) { + } + + transport.close() + assertEquals(7, bridge.closedHandle) + try { + transport.transaction(0x11, byteArrayOf(1), 0) + fail("expected closed transport") + } catch (_: I2cTransportException.DeviceUnavailable) { + } + } + + private class RecordingBridge( + private val response: ByteArray = byteArrayOf(), + private val error: LinuxI2cNativeException? = null, + ) : LinuxI2cBridge { + var openedPath: String? = null + var address: Int? = null + var writeData: ByteArray? = null + var readLength: Int? = null + var closedHandle: Int? = null + + override fun open(devicePath: String): Int { + openedPath = devicePath + return 7 + } + + override fun transaction( + handle: Int, + address7Bit: Int, + writeData: ByteArray, + readLength: Int, + ): ByteArray { + address = address7Bit + this.writeData = writeData + this.readLength = readLength + error?.let { throw it } + return response + } + + override fun close(handle: Int) { + closedHandle = handle + } + } +} diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Ntb16CodecTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Ntb16CodecTest.kt new file mode 100644 index 0000000..d8787fe --- /dev/null +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Ntb16CodecTest.kt @@ -0,0 +1,32 @@ +package com.shilapi.xcertplay.transport + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class Ntb16CodecTest { + @Test + fun parseCanReadBlockFromLargerBuffer() { + val frame = byteArrayOf(0x33, 0x33, 0, 0, 0, 1, 0x86.toByte(), 0xdd.toByte()) + val block = Ntb16Codec.build(frame, 7) + val buffer = ByteArray(11) + block + ByteArray(3) + + val parsed = Ntb16Codec.parse(buffer, 11, block.size) + + assertArrayEquals(frame, parsed.single()) + } + + @Test + fun largestDatagramKeepsBlockLengthWithinU16() { + val block = Ntb16Codec.build(ByteArray(65_507), 0x1234) + + assertEquals(65_535, block.size) + assertEquals(0xff, block[8].toInt() and 0xff) + assertEquals(0xff, block[9].toInt() and 0xff) + } + + @Test(expected = IllegalArgumentException::class) + fun datagramThatWouldOverflowBlockLengthIsRejected() { + Ntb16Codec.build(ByteArray(65_508), 0x1234) + } +} diff --git a/site/.nojekyll b/site/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/site/ar/index.html b/site/ar/index.html new file mode 100644 index 0000000..b26fdb6 --- /dev/null +++ b/site/ar/index.html @@ -0,0 +1,7 @@ + + +DiPlay · التنزيلات معلّقة + +
DiPlay
+
التنزيلات معلّقة

تنزيل DiPlay
متوقف مؤقتاً.

تم سحب ملف APK للإصدار 0.1.0 أثناء مراجعة بيانات اعتماد مصادقة الملحق. يُرجى عدم إعادة نشر الملف أو استخدام الهوية المضمّنة فيه. لا يتوفر ملف APK بديل حالياً.

إزالة رابط التنزيل أو إعادة ضبط سجل Git لا تسترجع النسخ التي تمت مشاركتها ولا تُبطل بيانات اعتماد مكشوفة.

+

مشروع مستقل مبني على xcertplay وبتصميم واجهة من DiAuto. غير تابع لـ Apple أو BYD. كار بلاي وأيقونته ملك لشركة Apple.

\ No newline at end of file diff --git a/site/assets/home.png b/site/assets/home.png new file mode 100644 index 0000000..21197f4 Binary files /dev/null and b/site/assets/home.png differ diff --git a/site/assets/icon.png b/site/assets/icon.png new file mode 100644 index 0000000..1f7e3f0 Binary files /dev/null and b/site/assets/icon.png differ diff --git a/site/assets/settings.png b/site/assets/settings.png new file mode 100644 index 0000000..c5d226d Binary files /dev/null and b/site/assets/settings.png differ diff --git a/site/assets/site.css b/site/assets/site.css new file mode 100644 index 0000000..01852c6 --- /dev/null +++ b/site/assets/site.css @@ -0,0 +1,4 @@ +:root{color-scheme:dark;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Arial,sans-serif;background:#0c121c;color:#eef3fb;font-synthesis:none}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;line-height:1.65}main{max-width:1160px;margin:auto;padding:36px 32px}a{color:#a6c9ff;text-underline-offset:4px}a:focus-visible{outline:3px solid #a6c9ff;outline-offset:5px}header{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap;padding-bottom:30px;border-bottom:1px solid #263449}.brand{display:flex;align-items:center;gap:14px;text-decoration:none;color:inherit}.brand strong{font-size:24px;letter-spacing:-.5px}.brand small{display:block;color:#a8b7cb;font-size:12px}.languages{display:flex;flex-wrap:wrap;gap:4px}.languages a{font-size:13px;min-height:44px;display:flex;align-items:center;padding:8px 12px;border-radius:10px;text-decoration:none;color:#bdcadd}.languages a[aria-current]{background:#1b2c43;color:#b9d5ff}.hero{padding:72px 0 52px;max-width:850px}.badge,.eyebrow{font-size:12px;font-weight:700;letter-spacing:2px;color:#aacaff}.badge{padding:7px 12px;background:#17253a;border:1px solid #304766;border-radius:8px}h1{font-size:clamp(40px,6.5vw,76px);line-height:1.1;letter-spacing:-2.5px;margin:26px 0}h2{font-size:25px;line-height:1.3;letter-spacing:-.4px;margin:0 0 18px}h3{font-size:18px;margin-top:30px}.intro{font-size:21px;line-height:1.6;color:#b9c7db;max-width:740px}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:30px 0 24px}.button{display:inline-flex;align-items:center;justify-content:center;gap:24px;background:#a6c9ff;color:#102542;border:1px solid transparent;border-radius:12px;padding:14px 23px;min-height:52px;text-decoration:none;font-weight:650}.button:hover{background:#bed8ff}.button.secondary{background:#172335;border-color:#2b3d55;color:#d4e4fc}.button.secondary:hover{background:#223650}.promise{font-size:16px;font-weight:600;margin-bottom:8px}.note{font-size:14px;color:#9faec2;max-width:760px}.gallery{margin-bottom:50px}.screens{display:grid;grid-template-columns:1fr 1fr;gap:24px}figure{margin:0;min-width:0}figure:first-child{grid-column:1/-1}figure img{width:100%;height:auto;display:block;border:1px solid #2c3d53;border-radius:16px;background:#121b29}figcaption{font-size:14px;color:#a8b8ce;padding:12px 3px}.grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.card{background:#141e2c;border:1px solid #28384d;border-radius:20px;padding:30px}.eyebrow{display:block;margin-bottom:16px}li{padding-inline-start:4px;margin-bottom:14px}ol,ul{padding-inline-start:22px;color:#b9c7da}.card p{color:#b9c7da}.updates{display:flex;align-items:center;justify-content:space-between;gap:28px;margin-top:24px}.updates h2{margin-bottom:8px}.updates p{margin:0}.updates .button{flex-shrink:0}.signing{padding:36px 0;max-width:900px}.signing h2{font-size:18px}.signing p,footer{font-size:14px;color:#9faec2}footer{border-top:1px solid #263449;padding:25px 0}footer nav{display:flex;flex-wrap:wrap;gap:22px}footer a{color:#b9c7da}html[dir=rtl] h1{letter-spacing:0;line-height:1.3}html[dir=rtl] .badge{letter-spacing:0}@media(max-width:700px){main{padding:24px 20px}.hero{padding:42px 0 30px}h1{letter-spacing:-1px}.intro{font-size:18px}.screens,.grid{grid-template-columns:1fr}.card{padding:24px}.updates{align-items:flex-start;flex-direction:column}header{gap:18px}.languages a{padding:8px 10px}.gallery{margin-bottom:32px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} + +/* DiPlay: two actual app screenshots, equal columns. */ +figure:first-child{grid-column:auto}.brand img{border-radius:12px}html[dir=rtl] .eyebrow{letter-spacing:0}@media(max-width:700px){figure:first-child{grid-column:1/-1}} diff --git a/site/content.json b/site/content.json new file mode 100644 index 0000000..5571123 --- /dev/null +++ b/site/content.json @@ -0,0 +1,57 @@ +{ + "en": { + "name": "English", + "dir": "ltr", + "badge": "DOWNLOADS SUSPENDED", + "title": "DiPlay downloads\nare paused.", + "body": "The 0.1.0 APK has been withdrawn while we review its accessory-authentication credentials. Please do not redistribute that APK or reuse its bundled identity. No replacement APK is available.", + "detail": "Removing a download or resetting Git history cannot recall copies already shared or invalidate an exposed credential.", + "source": "Source project", + "security": "Security status", + "footer": "Independent project based on xcertplay, with interface design from DiAuto. Not affiliated with Apple or BYD. CarPlay and its icon belong to Apple Inc." + }, + "ar": { + "name": "العربية", + "dir": "rtl", + "badge": "التنزيلات معلّقة", + "title": "تنزيل DiPlay\nمتوقف مؤقتاً.", + "body": "تم سحب ملف APK للإصدار 0.1.0 أثناء مراجعة بيانات اعتماد مصادقة الملحق. يُرجى عدم إعادة نشر الملف أو استخدام الهوية المضمّنة فيه. لا يتوفر ملف APK بديل حالياً.", + "detail": "إزالة رابط التنزيل أو إعادة ضبط سجل Git لا تسترجع النسخ التي تمت مشاركتها ولا تُبطل بيانات اعتماد مكشوفة.", + "source": "المشروع المصدري", + "security": "حالة الأمان", + "footer": "مشروع مستقل مبني على xcertplay وبتصميم واجهة من DiAuto. غير تابع لـ Apple أو BYD. كار بلاي وأيقونته ملك لشركة Apple." + }, + "ru": { + "name": "Русский", + "dir": "ltr", + "badge": "ЗАГРУЗКИ ПРИОСТАНОВЛЕНЫ", + "title": "Загрузка DiPlay\nприостановлена.", + "body": "APK версии 0.1.0 отозван на время проверки учётных данных аутентификации аксессуара. Пожалуйста, не распространяйте этот APK и не используйте встроенные в него данные идентификации. Замены APK пока нет.", + "detail": "Удаление загрузки или сброс истории Git не отзывает уже распространённые копии и не аннулирует раскрытые учётные данные.", + "source": "Исходный проект", + "security": "Информация о безопасности", + "footer": "Независимый проект на базе xcertplay с дизайном интерфейса DiAuto. Не связан с Apple или BYD. CarPlay и его значок принадлежат Apple Inc." + }, + "es": { + "name": "Español", + "dir": "ltr", + "badge": "DESCARGAS SUSPENDIDAS", + "title": "Las descargas de DiPlay\nestán pausadas.", + "body": "El APK 0.1.0 se ha retirado mientras revisamos sus credenciales de autenticación de accesorio. No redistribuyas ese APK ni reutilices la identidad incluida. Aún no hay un APK de reemplazo.", + "detail": "Retirar una descarga o reiniciar el historial de Git no recupera las copias compartidas ni invalida una credencial expuesta.", + "source": "Proyecto fuente", + "security": "Estado de seguridad", + "footer": "Proyecto independiente basado en xcertplay, con diseño de interfaz de DiAuto. Sin afiliación a Apple ni BYD. CarPlay y su icono pertenecen a Apple Inc." + }, + "zh-Hans": { + "name": "简体中文", + "dir": "ltr", + "badge": "下载已暂停", + "title": "DiPlay 下载\n已暂停。", + "body": "在审查配件认证凭据期间,0.1.0 APK 已撤下。请勿继续分发该 APK 或重复使用其中的配件身份。目前没有替代 APK。", + "detail": "移除下载链接或重置 Git 历史记录无法撤回已分享的副本,也不会使已泄露的凭据失效。", + "source": "源代码项目", + "security": "安全状态", + "footer": "基于 xcertplay 的独立项目,界面设计源自 DiAuto。与 Apple 或比亚迪无隶属关系。CarPlay 及其图标归 Apple Inc. 所有。" + } +} diff --git a/site/es/index.html b/site/es/index.html new file mode 100644 index 0000000..b865553 --- /dev/null +++ b/site/es/index.html @@ -0,0 +1,7 @@ + + +DiPlay · DESCARGAS SUSPENDIDAS + +
DiPlay
+
DESCARGAS SUSPENDIDAS

Las descargas de DiPlay
están pausadas.

El APK 0.1.0 se ha retirado mientras revisamos sus credenciales de autenticación de accesorio. No redistribuyas ese APK ni reutilices la identidad incluida. Aún no hay un APK de reemplazo.

Retirar una descarga o reiniciar el historial de Git no recupera las copias compartidas ni invalida una credencial expuesta.

+

Proyecto independiente basado en xcertplay, con diseño de interfaz de DiAuto. Sin afiliación a Apple ni BYD. CarPlay y su icono pertenecen a Apple Inc.

\ No newline at end of file diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..1f9efea --- /dev/null +++ b/site/index.html @@ -0,0 +1,7 @@ + + +DiPlay · DOWNLOADS SUSPENDED + +
DiPlay
+
DOWNLOADS SUSPENDED

DiPlay downloads
are paused.

The 0.1.0 APK has been withdrawn while we review its accessory-authentication credentials. Please do not redistribute that APK or reuse its bundled identity. No replacement APK is available.

Removing a download or resetting Git history cannot recall copies already shared or invalidate an exposed credential.

+

Independent project based on xcertplay, with interface design from DiAuto. Not affiliated with Apple or BYD. CarPlay and its icon belong to Apple Inc.

\ No newline at end of file diff --git a/site/ru/index.html b/site/ru/index.html new file mode 100644 index 0000000..2767c93 --- /dev/null +++ b/site/ru/index.html @@ -0,0 +1,7 @@ + + +DiPlay · ЗАГРУЗКИ ПРИОСТАНОВЛЕНЫ + +
DiPlay
+
ЗАГРУЗКИ ПРИОСТАНОВЛЕНЫ

Загрузка DiPlay
приостановлена.

APK версии 0.1.0 отозван на время проверки учётных данных аутентификации аксессуара. Пожалуйста, не распространяйте этот APK и не используйте встроенные в него данные идентификации. Замены APK пока нет.

Удаление загрузки или сброс истории Git не отзывает уже распространённые копии и не аннулирует раскрытые учётные данные.

+

Независимый проект на базе xcertplay с дизайном интерфейса DiAuto. Не связан с Apple или BYD. CarPlay и его значок принадлежат Apple Inc.

\ No newline at end of file diff --git a/site/zh-Hans/index.html b/site/zh-Hans/index.html new file mode 100644 index 0000000..e681ecc --- /dev/null +++ b/site/zh-Hans/index.html @@ -0,0 +1,7 @@ + + +DiPlay · 下载已暂停 + +
DiPlay
+
下载已暂停

DiPlay 下载
已暂停。

在审查配件认证凭据期间,0.1.0 APK 已撤下。请勿继续分发该 APK 或重复使用其中的配件身份。目前没有替代 APK。

移除下载链接或重置 Git 历史记录无法撤回已分享的副本,也不会使已泄露的凭据失效。

+
\ No newline at end of file