[
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n"
  },
  {
    "path": ".gitignore",
    "content": "# Gradle files\n.gradle/\nbuild/\n\n# Local configuration file (sdk path, etc)\nlocal.properties\n\n# Log/OS Files\n*.log\n\n# Android Studio generated files and folders\ncaptures/\n.externalNativeBuild/\n.cxx/\n*.apk\noutput.json\n\n# IntelliJ\n*.iml\n.idea/\nmisc.xml\ndeploymentTargetDropDown.xml\nrender.experimental.xml\n\n# Keystore files\n*.jks\n*.keystore\n\n# Google Services (e.g. APIs or Firebase)\ngoogle-services.json\n\n# Android Profiling\n*.hprof\n\n.kotlin\n/.kotlin\napp/release/output-metadata.json\napp/src/main/java/me/wjz/nekocrypt/test/\napp/release\ntest"
  },
  {
    "path": "LICENSE",
    "content": "GNU GENERAL PUBLIC LICENSE\n                       Version 2, June 1991\n\n Copyright (C) 1989, 1991 Free Software Foundation, Inc.,\n 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The licenses for most software are designed to take away your\nfreedom to share and change it.  By contrast, the GNU General Public\nLicense is intended to guarantee your freedom to share and change free\nsoftware--to make sure the software is free for all its users.  This\nGeneral Public License applies to most of the Free Software\nFoundation's software and to any other program whose authors commit to\nusing it.  (Some other Free Software Foundation software is covered by\nthe GNU Lesser General Public License instead.)  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthis service if you wish), that you receive source code or can get it\nif you want it, that you can change the software or use pieces of it\nin new free programs; and that you know you can do these things.\n\n  To protect your rights, we need to make restrictions that forbid\nanyone to deny you these rights or to ask you to surrender the rights.\nThese restrictions translate to certain responsibilities for you if you\ndistribute copies of the software, or if you modify it.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must give the recipients all the rights that\nyou have.  You must make sure that they, too, receive or can get the\nsource code.  And you must show them these terms so they know their\nrights.\n\n  We protect your rights with two steps: (1) copyright the software, and\n(2) offer you this license which gives you legal permission to copy,\ndistribute and/or modify the software.\n\n  Also, for each author's protection and ours, we want to make certain\nthat everyone understands that there is no warranty for this free\nsoftware.  If the software is modified by someone else and passed on, we\nwant its recipients to know that what they have is not the original, so\nthat any problems introduced by others will not reflect on the original\nauthors' reputations.\n\n  Finally, any free program is threatened constantly by software\npatents.  We wish to avoid the danger that redistributors of a free\nprogram will individually obtain patent licenses, in effect making the\nprogram proprietary.  To prevent this, we have made it clear that any\npatent must be licensed for everyone's free use or not licensed at all.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                    GNU GENERAL PUBLIC LICENSE\n   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION\n\n  0. This License applies to any program or other work which contains\na notice placed by the copyright holder saying it may be distributed\nunder the terms of this General Public License.  The \"Program\", below,\nrefers to any such program or work, and a \"work based on the Program\"\nmeans either the Program or any derivative work under copyright law:\nthat is to say, a work containing the Program or a portion of it,\neither verbatim or with modifications and/or translated into another\nlanguage.  (Hereinafter, translation is included without limitation in\nthe term \"modification\".)  Each licensee is addressed as \"you\".\n\nActivities other than copying, distribution and modification are not\ncovered by this License; they are outside its scope.  The act of\nrunning the Program is not restricted, and the output from the Program\nis covered only if its contents constitute a work based on the\nProgram (independent of having been made by running the Program).\nWhether that is true depends on what the Program does.\n\n  1. You may copy and distribute verbatim copies of the Program's\nsource code as you receive it, in any medium, provided that you\nconspicuously and appropriately publish on each copy an appropriate\ncopyright notice and disclaimer of warranty; keep intact all the\nnotices that refer to this License and to the absence of any warranty;\nand give any other recipients of the Program a copy of this License\nalong with the Program.\n\nYou may charge a fee for the physical act of transferring a copy, and\nyou may at your option offer warranty protection in exchange for a fee.\n\n  2. You may modify your copy or copies of the Program or any portion\nof it, thus forming a work based on the Program, and copy and\ndistribute such modifications or work under the terms of Section 1\nabove, provided that you also meet all of these conditions:\n\n    a) You must cause the modified files to carry prominent notices\n    stating that you changed the files and the date of any change.\n\n    b) You must cause any work that you distribute or publish, that in\n    whole or in part contains or is derived from the Program or any\n    part thereof, to be licensed as a whole at no charge to all third\n    parties under the terms of this License.\n\n    c) If the modified program normally reads commands interactively\n    when run, you must cause it, when started running for such\n    interactive use in the most ordinary way, to print or display an\n    announcement including an appropriate copyright notice and a\n    notice that there is no warranty (or else, saying that you provide\n    a warranty) and that users may redistribute the program under\n    these conditions, and telling the user how to view a copy of this\n    License.  (Exception: if the Program itself is interactive but\n    does not normally print such an announcement, your work based on\n    the Program is not required to print an announcement.)\n\nThese requirements apply to the modified work as a whole.  If\nidentifiable sections of that work are not derived from the Program,\nand can be reasonably considered independent and separate works in\nthemselves, then this License, and its terms, do not apply to those\nsections when you distribute them as separate works.  But when you\ndistribute the same sections as part of a whole which is a work based\non the Program, the distribution of the whole must be on the terms of\nthis License, whose permissions for other licensees extend to the\nentire whole, and thus to each and every part regardless of who wrote it.\n\nThus, it is not the intent of this section to claim rights or contest\nyour rights to work written entirely by you; rather, the intent is to\nexercise the right to control the distribution of derivative or\ncollective works based on the Program.\n\nIn addition, mere aggregation of another work not based on the Program\nwith the Program (or with a work based on the Program) on a volume of\na storage or distribution medium does not bring the other work under\nthe scope of this License.\n\n  3. You may copy and distribute the Program (or a work based on it,\nunder Section 2) in object code or executable form under the terms of\nSections 1 and 2 above provided that you also do one of the following:\n\n    a) Accompany it with the complete corresponding machine-readable\n    source code, which must be distributed under the terms of Sections\n    1 and 2 above on a medium customarily used for software interchange; or,\n\n    b) Accompany it with a written offer, valid for at least three\n    years, to give any third party, for a charge no more than your\n    cost of physically performing source distribution, a complete\n    machine-readable copy of the corresponding source code, to be\n    distributed under the terms of Sections 1 and 2 above on a medium\n    customarily used for software interchange; or,\n\n    c) Accompany it with the information you received as to the offer\n    to distribute corresponding source code.  (This alternative is\n    allowed only for noncommercial distribution and only if you\n    received the program in object code or executable form with such\n    an offer, in accord with Subsection b above.)\n\nThe source code for a work means the preferred form of the work for\nmaking modifications to it.  For an executable work, complete source\ncode means all the source code for all modules it contains, plus any\nassociated interface definition files, plus the scripts used to\ncontrol compilation and installation of the executable.  However, as a\nspecial exception, the source code distributed need not include\nanything that is normally distributed (in either source or binary\nform) with the major components (compiler, kernel, and so on) of the\noperating system on which the executable runs, unless that component\nitself accompanies the executable.\n\nIf distribution of executable or object code is made by offering\naccess to copy from a designated place, then offering equivalent\naccess to copy the source code from the same place counts as\ndistribution of the source code, even though third parties are not\ncompelled to copy the source along with the object code.\n\n  4. You may not copy, modify, sublicense, or distribute the Program\nexcept as expressly provided under this License.  Any attempt\notherwise to copy, modify, sublicense or distribute the Program is\nvoid, and will automatically terminate your rights under this License.\nHowever, parties who have received copies, or rights, from you under\nthis License will not have their licenses terminated so long as such\nparties remain in full compliance.\n\n  5. You are not required to accept this License, since you have not\nsigned it.  However, nothing else grants you permission to modify or\ndistribute the Program or its derivative works.  These actions are\nprohibited by law if you do not accept this License.  Therefore, by\nmodifying or distributing the Program (or any work based on the\nProgram), you indicate your acceptance of this License to do so, and\nall its terms and conditions for copying, distributing or modifying\nthe Program or works based on it.\n\n  6. Each time you redistribute the Program (or any work based on the\nProgram), the recipient automatically receives a license from the\noriginal licensor to copy, distribute or modify the Program subject to\nthese terms and conditions.  You may not impose any further\nrestrictions on the recipients' exercise of the rights granted herein.\nYou are not responsible for enforcing compliance by third parties to\nthis License.\n\n  7. If, as a consequence of a court judgment or allegation of patent\ninfringement or for any other reason (not limited to patent issues),\nconditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot\ndistribute so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you\nmay not distribute the Program at all.  For example, if a patent\nlicense would not permit royalty-free redistribution of the Program by\nall those who receive copies directly or indirectly through you, then\nthe only way you could satisfy both it and this License would be to\nrefrain entirely from distribution of the Program.\n\nIf any portion of this section is held invalid or unenforceable under\nany particular circumstance, the balance of the section is intended to\napply and the section as a whole is intended to apply in other\ncircumstances.\n\nIt is not the purpose of this section to induce you to infringe any\npatents or other property right claims or to contest validity of any\nsuch claims; this section has the sole purpose of protecting the\nintegrity of the free software distribution system, which is\nimplemented by public license practices.  Many people have made\ngenerous contributions to the wide range of software distributed\nthrough that system in reliance on consistent application of that\nsystem; it is up to the author/donor to decide if he or she is willing\nto distribute software through any other system and a licensee cannot\nimpose that choice.\n\nThis section is intended to make thoroughly clear what is believed to\nbe a consequence of the rest of this License.\n\n  8. If the distribution and/or use of the Program is restricted in\ncertain countries either by patents or by copyrighted interfaces, the\noriginal copyright holder who places the Program under this License\nmay add an explicit geographical distribution limitation excluding\nthose countries, so that distribution is permitted only in or among\ncountries not thus excluded.  In such case, this License incorporates\nthe limitation as if written in the body of this License.\n\n  9. The Free Software Foundation may publish revised and/or new versions\nof the General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\nEach version is given a distinguishing version number.  If the Program\nspecifies a version number of this License which applies to it and \"any\nlater version\", you have the option of following the terms and conditions\neither of that version or of any later version published by the Free\nSoftware Foundation.  If the Program does not specify a version number of\nthis License, you may choose any version ever published by the Free Software\nFoundation.\n\n  10. If you wish to incorporate parts of the Program into other free\nprograms whose distribution conditions are different, write to the author\nto ask for permission.  For software which is copyrighted by the Free\nSoftware Foundation, write to the Free Software Foundation; we sometimes\nmake exceptions for this.  Our decision will be guided by the two goals\nof preserving the free status of all derivatives of our free software and\nof promoting the sharing and reuse of software generally.\n\n                            NO WARRANTY\n\n  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY\nFOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN\nOTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES\nPROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED\nOR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS\nTO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE\nPROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,\nREPAIR OR CORRECTION.\n\n  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR\nREDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING\nOUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED\nTO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY\nYOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER\nPROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE\nPOSSIBILITY OF SUCH DAMAGES.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nconvey the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software; you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation; either version 2 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License along\n    with this program; if not, write to the Free Software Foundation, Inc.,\n    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.\n\nAlso add information on how to contact you by electronic and paper mail.\n\nIf the program is interactive, make it output a short notice like this\nwhen it starts in an interactive mode:\n\n    Gnomovision version 69, Copyright (C) year name of author\n    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, the commands you use may\nbe called something other than `show w' and `show c'; they could even be\nmouse-clicks or menu items--whatever suits your program.\n\nYou should also get your employer (if you work as a programmer) or your\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary.  Here is a sample; alter the names:\n\n  Yoyodyne, Inc., hereby disclaims all copyright interest in the program\n  `Gnomovision' (which makes passes at compilers) written by James Hacker.\n\n  <signature of Ty Coon>, 1 April 1989\n  Ty Coon, President of Vice\n\nThis General Public License does not permit incorporating your program into\nproprietary programs.  If your program is a subroutine library, you may\nconsider it more useful to permit linking proprietary applications with the\nlibrary.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.\n"
  },
  {
    "path": "NekoIconCreator.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>猫咪头像编辑器</title>\n    <script src=\"https://cdn.tailwindcss.com\"></script>\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n    <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap\" rel=\"stylesheet\">\n    <style>\n        body {\n            font-family: 'Inter', sans-serif;\n        }\n        input[type=range] {\n            -webkit-appearance: none;\n            appearance: none;\n            width: 100%;\n            height: 8px;\n            background: #d1d5db; /* gray-300 */\n            border-radius: 5px;\n            outline: none;\n            opacity: 0.7;\n            transition: opacity .2s;\n        }\n        input[type=range]:hover {\n            opacity: 1;\n        }\n        input[type=range]::-webkit-slider-thumb {\n            -webkit-appearance: none;\n            appearance: none;\n            width: 20px;\n            height: 20px;\n            background: #4f46e5; /* indigo-600 */\n            border-radius: 50%;\n            cursor: pointer;\n        }\n        input[type=range]::-moz-range-thumb {\n            width: 20px;\n            height: 20px;\n            background: #4f46e5; /* indigo-600 */\n            border-radius: 50%;\n            cursor: pointer;\n        }\n        .dark input[type=range] {\n            background: #4b5563; /* gray-600 */\n        }\n        .gradient-dir-btn.active {\n            background-color: #4f46e5;\n            color: white;\n        }\n    </style>\n</head>\n<body class=\"bg-gray-100 dark:bg-gray-900 flex items-center justify-center min-h-screen p-4\">\n\n    <div class=\"w-full max-w-md mx-auto bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 md:p-8\">\n        <div class=\"flex flex-col items-center\">\n            \n            <h2 class=\"text-2xl font-bold text-gray-800 dark:text-white mb-4\">猫咪头像编辑器</h2>\n            \n            <div id=\"mainPreview\" class=\"w-64 h-64 md:w-80 md:h-80 bg-blue-500 rounded-2xl flex items-center justify-center p-4 transition-all duration-300\">\n                <svg id=\"logoSvg\" width=\"100%\" height=\"100%\" viewBox=\"0 0 100 100\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path id=\"logoPath\" d=\"M 15 85 L 35 30 L 50 60 L 65 30 L 85 85 M 40 70 L 50 80 L 60 70\" \n                          stroke=\"#FFFFFF\" stroke-width=\"8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" fill=\"none\"/>\n                </svg>\n            </div>\n\n            <div class=\"w-full mt-6 space-y-4\">\n                <div>\n                    <label for=\"lineColorHex\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">线条颜色</label>\n                    <div class=\"mt-1 flex rounded-md shadow-sm\">\n                        <input type=\"color\" id=\"lineColorPicker\" value=\"#FFFFFF\" class=\"w-12 h-10 p-1 border border-gray-300 dark:border-gray-600 rounded-l-md cursor-pointer\">\n                        <input type=\"text\" id=\"lineColorHex\" value=\"#FFFFFF\" class=\"block w-full h-10 px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-r-md focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm\">\n                    </div>\n                </div>\n                \n                <div class=\"border-t border-gray-200 dark:border-gray-700 pt-4\">\n                    <label class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">背景渐变色</label>\n                    <div class=\"mt-1 grid grid-cols-2 gap-4\">\n                        <div>\n                            <label for=\"bgColor1Hex\" class=\"block text-xs font-medium text-gray-500 dark:text-gray-400\">颜色 1</label>\n                            <div class=\"mt-1 flex rounded-md shadow-sm\">\n                                <input type=\"color\" id=\"bgColor1Picker\" value=\"#3B82F6\" class=\"w-12 h-10 p-1 border border-gray-300 dark:border-gray-600 rounded-l-md cursor-pointer\">\n                                <input type=\"text\" id=\"bgColor1Hex\" value=\"#3B82F6\" class=\"block w-full h-10 px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-r-md focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm\">\n                            </div>\n                        </div>\n                        <div>\n                            <label for=\"bgColor2Hex\" class=\"block text-xs font-medium text-gray-500 dark:text-gray-400\">颜色 2</label>\n                            <div class=\"mt-1 flex rounded-md shadow-sm\">\n                                <input type=\"color\" id=\"bgColor2Picker\" value=\"#66ccff\" class=\"w-12 h-10 p-1 border border-gray-300 dark:border-gray-600 rounded-l-md cursor-pointer\">\n                                <input type=\"text\" id=\"bgColor2Hex\" value=\"#66ccff\" class=\"block w-full h-10 px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded-r-md focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm\">\n                            </div>\n                        </div>\n                    </div>\n                    <div class=\"mt-2\">\n                        <label class=\"block text-xs font-medium text-gray-500 dark:text-gray-400\">方向</label>\n                        <div id=\"gradientDirection\" class=\"mt-1 grid grid-cols-4 gap-2 rounded-lg bg-gray-200 dark:bg-gray-700 p-1\">\n                            <button class=\"gradient-dir-btn p-1 rounded-md text-sm active\" data-dir=\"to bottom\">↓</button>\n                            <button class=\"gradient-dir-btn p-1 rounded-md text-sm\" data-dir=\"to right\">→</button>\n                            <button class=\"gradient-dir-btn p-1 rounded-md text-sm\" data-dir=\"to bottom right\">↘</button>\n                            <button class=\"gradient-dir-btn p-1 rounded-md text-sm\" data-dir=\"to top left\">↖</button>\n                        </div>\n                    </div>\n                </div>\n                \n                <div class=\"border-t border-gray-200 dark:border-gray-700 pt-4 space-y-4\">\n                    <div>\n                        <label for=\"strokeWidth\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">线条粗细: <span id=\"strokeWidthValue\">7</span></label>\n                        <input type=\"range\" id=\"strokeWidth\" min=\"2\" max=\"20\" value=\"7\" class=\"mt-1 w-full cursor-pointer\">\n                    </div>\n                    <div>\n                        <label for=\"overallScale\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">整体缩放: <span id=\"overallScaleValue\">100</span>%</label>\n                        <input type=\"range\" id=\"overallScale\" min=\"50\" max=\"150\" value=\"100\" class=\"mt-1 w-full cursor-pointer\">\n                    </div>\n                    <div>\n                        <label for=\"bodyAngle\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">猫身角度: <span id=\"bodyAngleValue\">3</span></label>\n                        <input type=\"range\" id=\"bodyAngle\" min=\"-10\" max=\"15\" value=\"3\" class=\"mt-1 w-full cursor-pointer\">\n                    </div>\n                     <div>\n                        <label for=\"bodyLength\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">猫身长度: <span id=\"bodyLengthValue\">3</span></label>\n                        <input type=\"range\" id=\"bodyLength\" min=\"-15\" max=\"15\" value=\"3\" class=\"mt-1 w-full cursor-pointer\">\n                    </div>\n                    <div>\n                        <label for=\"earDistance\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">猫耳距离: <span id=\"earDistanceValue\">-10</span></label>\n                        <input type=\"range\" id=\"earDistance\" min=\"-15\" max=\"10\" value=\"-10\" class=\"mt-1 w-full cursor-pointer\">\n                    </div>\n                    <div>\n                        <label for=\"smileDistance\" class=\"block text-sm font-medium text-gray-700 dark:text-gray-300\">笑容距离: <span id=\"smileDistanceValue\">-7</span></label>\n                        <input type=\"range\" id=\"smileDistance\" min=\"-10\" max=\"15\" value=\"-7\" class=\"mt-1 w-full cursor-pointer\">\n                    </div>\n                </div>\n            </div>\n\n            <div class=\"w-full mt-8 border-t border-gray-200 dark:border-gray-700 pt-6 space-y-4\">\n                <div>\n                    <h3 class=\"text-lg font-semibold text-center text-gray-800 dark:text-white mb-2\">下载图标资源</h3>\n                    <div class=\"grid grid-cols-2 gap-3\">\n                        <button id=\"downloadForegroundPng\" class=\"w-full bg-sky-500 text-white font-bold py-2 px-3 rounded-lg hover:bg-sky-600 transition-colors text-sm\">前景 (PNG)</button>\n                        <button id=\"downloadForegroundSvg\" class=\"w-full bg-sky-700 text-white font-bold py-2 px-3 rounded-lg hover:bg-sky-800 transition-colors text-sm\">前景 (SVG)</button>\n                        <button id=\"downloadBackgroundPng\" class=\"w-full bg-teal-500 text-white font-bold py-2 px-3 rounded-lg hover:bg-teal-600 transition-colors text-sm\">背景 (PNG)</button>\n                        <button id=\"downloadBackgroundSvg\" class=\"w-full bg-teal-700 text-white font-bold py-2 px-3 rounded-lg hover:bg-teal-800 transition-colors text-sm\">背景 (SVG)</button>\n                    </div>\n                </div>\n                <button id=\"downloadSvg\" class=\"w-full bg-gray-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-gray-700 transition-colors\">下载完整版 (SVG)</button>\n            </div>\n        </div>\n    </div>\n\n    <script>\n        const mainPreview = document.getElementById('mainPreview');\n        const logoSvg = document.getElementById('logoSvg');\n        const logoPath = document.getElementById('logoPath');\n        \n        const lineColorPicker = document.getElementById('lineColorPicker');\n        const lineColorHex = document.getElementById('lineColorHex');\n        const bgColor1Picker = document.getElementById('bgColor1Picker');\n        const bgColor1Hex = document.getElementById('bgColor1Hex');\n        const bgColor2Picker = document.getElementById('bgColor2Picker');\n        const bgColor2Hex = document.getElementById('bgColor2Hex');\n        const gradientDirectionContainer = document.getElementById('gradientDirection');\n        \n        const strokeWidth = document.getElementById('strokeWidth');\n        const strokeWidthValue = document.getElementById('strokeWidthValue');\n        const overallScale = document.getElementById('overallScale');\n        const overallScaleValue = document.getElementById('overallScaleValue');\n        const bodyAngle = document.getElementById('bodyAngle');\n        const bodyAngleValue = document.getElementById('bodyAngleValue');\n        const bodyLength = document.getElementById('bodyLength');\n        const bodyLengthValue = document.getElementById('bodyLengthValue');\n        const earDistance = document.getElementById('earDistance');\n        const earDistanceValue = document.getElementById('earDistanceValue');\n        const smileDistance = document.getElementById('smileDistance');\n        const smileDistanceValue = document.getElementById('smileDistanceValue');\n        \n        const downloadForegroundPng = document.getElementById('downloadForegroundPng');\n        const downloadForegroundSvg = document.getElementById('downloadForegroundSvg');\n        const downloadBackgroundPng = document.getElementById('downloadBackgroundPng');\n        const downloadBackgroundSvg = document.getElementById('downloadBackgroundSvg');\n        const downloadSvg = document.getElementById('downloadSvg');\n\n        let currentGradientDirection = 'to bottom';\n\n        // ✨ 核心修正：恢复完整的 updateLogo 函数\n        function updateLogo() {\n            const lineColorValue = lineColorHex.value;\n            const bgColor1Value = bgColor1Hex.value;\n            const bgColor2Value = bgColor2Hex.value;\n\n            logoPath.setAttribute('stroke', lineColorValue);\n            mainPreview.style.background = `linear-gradient(${currentGradientDirection}, ${bgColor1Value}, ${bgColor2Value})`;\n            \n            strokeWidthValue.textContent = strokeWidth.value;\n            logoPath.setAttribute('stroke-width', strokeWidth.value);\n\n            const angle = parseInt(bodyAngle.value, 10);\n            const length = parseInt(bodyLength.value, 10);\n            const earDist = parseInt(earDistance.value, 10);\n            const smileDist = parseInt(smileDistance.value, 10);\n            \n            const mPath = `M ${15 - angle} ${85 + length + earDist} L 35 ${30 + earDist} L 50 ${60 + earDist} L 65 ${30 + earDist} L ${85 + angle} ${85 + length + earDist}`;\n            const vPath = `M 40 ${70 + smileDist} L 50 ${80 + smileDist} L 60 ${70 + smileDist}`;\n            \n            logoPath.setAttribute('d', `${mPath} ${vPath}`);\n            bodyAngleValue.textContent = angle;\n            bodyLengthValue.textContent = length;\n            earDistanceValue.textContent = earDist;\n            smileDistanceValue.textContent = smileDist;\n\n            const scale = parseInt(overallScale.value, 10) / 100;\n            const size = 100 / scale;\n            const offset = (100 - size) / 2;\n            logoSvg.setAttribute('viewBox', `${offset} ${offset} ${size} ${size}`);\n            overallScaleValue.textContent = overallScale.value;\n        }\n\n        // ✨ 核心修正：恢复完整的事件监听逻辑\n        function syncColorInputs(picker, hex) { hex.value = picker.value; updateLogo(); }\n        function syncHexInputs(hex, picker) { picker.value = hex.value; updateLogo(); }\n\n        lineColorPicker.addEventListener('input', () => syncColorInputs(lineColorPicker, lineColorHex));\n        lineColorHex.addEventListener('input', () => syncHexInputs(lineColorHex, lineColorPicker));\n        bgColor1Picker.addEventListener('input', () => syncColorInputs(bgColor1Picker, bgColor1Hex));\n        bgColor1Hex.addEventListener('input', () => syncHexInputs(bgColor1Hex, bgColor1Hex));\n        bgColor2Picker.addEventListener('input', () => syncColorInputs(bgColor2Picker, bgColor2Hex));\n        bgColor2Hex.addEventListener('input', () => syncHexInputs(bgColor2Hex, bgColor2Picker));\n        \n        document.querySelectorAll('input[type=\"range\"]').forEach(slider => slider.addEventListener('input', updateLogo));\n\n        gradientDirectionContainer.addEventListener('click', (e) => {\n            if (e.target.tagName === 'BUTTON') {\n                gradientDirectionContainer.querySelector('.active').classList.remove('active');\n                e.target.classList.add('active');\n                currentGradientDirection = e.target.dataset.dir;\n                updateLogo();\n            }\n        });\n\n        // --- 下载逻辑 ---\n        function downloadCanvas(canvas, filename) {\n            const link = document.createElement('a');\n            link.download = filename;\n            link.href = canvas.toDataURL('image/png');\n            link.click();\n        }\n        \n        function downloadSvgContent(svgContent, filename) {\n            const svgBlob = new Blob([svgContent], {type: 'image/svg+xml;charset=utf-8'});\n            const url = URL.createObjectURL(svgBlob);\n            const link = document.createElement('a');\n            link.download = filename;\n            link.href = url;\n            link.click();\n            URL.revokeObjectURL(url);\n        }\n\n        downloadForegroundPng.addEventListener('click', () => {\n            const tempCanvas = document.createElement('canvas');\n            const size = 108;\n            tempCanvas.width = size;\n            tempCanvas.height = size;\n            const tempCtx = tempCanvas.getContext('2d');\n            const svgString = new XMLSerializer().serializeToString(logoSvg);\n            const svgBlob = new Blob([svgString], {type: 'image/svg+xml;charset=utf-8'});\n            const url = URL.createObjectURL(svgBlob);\n\n            const img = new Image();\n            img.onload = () => {\n                tempCtx.clearRect(0, 0, size, size);\n                tempCtx.drawImage(img, 0, 0, size, size);\n                URL.revokeObjectURL(url);\n                downloadCanvas(tempCanvas, 'foreground.png');\n            };\n            img.src = url;\n        });\n\n        downloadBackgroundPng.addEventListener('click', () => {\n            const tempCanvas = document.createElement('canvas');\n            const size = 108;\n            tempCanvas.width = size;\n            tempCanvas.height = size;\n            const tempCtx = tempCanvas.getContext('2d');\n            const gradientCoords = {'to bottom': [0, 0, 0, size], 'to right': [0, 0, size, 0], 'to bottom right': [0, 0, size, size], 'to top left': [size, size, 0, 0]}[currentGradientDirection];\n            const gradient = tempCtx.createLinearGradient(...gradientCoords);\n            gradient.addColorStop(0, bgColor1Hex.value);\n            gradient.addColorStop(1, bgColor2Hex.value);\n            tempCtx.fillStyle = gradient;\n            tempCtx.fillRect(0, 0, size, size);\n            downloadCanvas(tempCanvas, 'background.png');\n        });\n\n        downloadForegroundSvg.addEventListener('click', () => {\n            const viewBoxValue = logoSvg.getAttribute('viewBox');\n            const svgContent = `\n                <svg width=\"108\" height=\"108\" viewBox=\"${viewBoxValue}\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <path d=\"${logoPath.getAttribute('d')}\" \n                          stroke=\"${lineColorHex.value}\" \n                          stroke-width=\"${strokeWidth.value}\" \n                          stroke-linecap=\"round\" \n                          stroke-linejoin=\"round\" \n                          fill=\"none\"/>\n                </svg>`;\n            downloadSvgContent(svgContent, 'foreground.svg');\n        });\n\n        downloadBackgroundSvg.addEventListener('click', () => {\n            const viewBoxValue = \"0 0 108 108\";\n            const gradientCoords = {'to bottom': { x1: '0%', y1: '0%', x2: '0%', y2: '100%' }, 'to right': { x1: '0%', y1: '0%', x2: '100%', y2: '0%' }, 'to bottom right': { x1: '0%', y1: '0%', x2: '100%', y2: '100%' }, 'to top left': { x1: '100%', y1: '100%', x2: '0%', y2: '0%' }}[currentGradientDirection];\n            const svgContent = `\n                <svg width=\"108\" height=\"108\" viewBox=\"${viewBoxValue}\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <defs>\n                        <linearGradient id=\"backgroundGradient\" x1=\"${gradientCoords.x1}\" y1=\"${gradientCoords.y1}\" x2=\"${gradientCoords.x2}\" y2=\"${gradientCoords.y2}\">\n                            <stop offset=\"0%\" stop-color=\"${bgColor1Hex.value}\" />\n                            <stop offset=\"100%\" stop-color=\"${bgColor2Hex.value}\" />\n                        </linearGradient>\n                    </defs>\n                    <rect x=\"0\" y=\"0\" width=\"108\" height=\"108\" fill=\"url(#backgroundGradient)\" />\n                </svg>`;\n            downloadSvgContent(svgContent, 'background.svg');\n        });\n\n        downloadSvg.addEventListener('click', () => {\n            const viewBoxValue = logoSvg.getAttribute('viewBox');\n            const [x, y, width, height] = viewBoxValue.split(' ');\n            const gradientCoords = {'to bottom': { x1: '0%', y1: '0%', x2: '0%', y2: '100%' }, 'to right': { x1: '0%', y1: '0%', x2: '100%', y2: '0%' }, 'to bottom right': { x1: '0%', y1: '0%', x2: '100%', y2: '100%' }, 'to top left': { x1: '100%', y1: '100%', x2: '0%', y2: '0%' }}[currentGradientDirection];\n            const fullSvgString = `\n                <svg width=\"256\" height=\"256\" viewBox=\"${viewBoxValue}\" xmlns=\"http://www.w3.org/2000/svg\">\n                    <defs>\n                        <linearGradient id=\"backgroundGradient\" x1=\"${gradientCoords.x1}\" y1=\"${gradientCoords.y1}\" x2=\"${gradientCoords.x2}\" y2=\"${gradientCoords.y2}\">\n                            <stop offset=\"0%\" stop-color=\"${bgColor1Hex.value}\" />\n                            <stop offset=\"100%\" stop-color=\"${bgColor2Hex.value}\" />\n                        </linearGradient>\n                    </defs>\n                    <rect x=\"${x}\" y=\"${y}\" width=\"${width}\" height=\"${height}\" fill=\"url(#backgroundGradient)\" />\n                    <path d=\"${logoPath.getAttribute('d')}\" \n                          stroke=\"${lineColorHex.value}\" \n                          stroke-width=\"${strokeWidth.value}\" \n                          stroke-linecap=\"round\" \n                          stroke-linejoin=\"round\" \n                          fill=\"none\"/>\n                </svg>`;\n            downloadSvgContent(fullSvgString, 'cat-avatar-full.svg');\n        });\n        \n        // ✨ 核心修正：在页面加载时，调用一次 updateLogo 来应用所有默认值\n        updateLogo();\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "README.md",
    "content": "## 一款神奇又好用的全局消息加解密软件 —— 喵密！\n\n<!-- PROJECT SHIELDS -->\n\n<br>\n\n<div align=\"center\">\n\n  <a href=\"https://github.com/WJZ-P/NekoCrypt/graphs/contributors\">\n    <img src=\"https://img.shields.io/github/contributors/WJZ-P/NekoCrypt.svg?style=flat-square\" alt=\"Contributors\" style=\"height: 30px\">\n  </a>\n  &nbsp;\n  <a href=\"https://github.com/WJZ-P/NekoCrypt/network/members\">\n    <img src=\"https://img.shields.io/github/forks/WJZ-P/NekoCrypt.svg?style=flat-square\" alt=\"Forks\" style=\"height: 30px\">\n  </a>\n  &nbsp;\n  <a href=\"https://github.com/WJZ-P/NekoCrypt/stargazers\">\n    <img src=\"https://img.shields.io/github/stars/WJZ-P/NekoCrypt.svg?style=flat-square\" alt=\"Stargazers\" style=\"height: 30px\">\n  </a>\n  &nbsp;\n  <a href=\"https://img.shields.io/github/issues/WJZ-P/NekoCrypt.svg\">\n    <img src=\"https://img.shields.io/github/issues/WJZ-P/NekoCrypt.svg?style=flat-square\" alt=\"Issues\" style=\"height: 30px\">\n  </a>\n  &nbsp;\n  <a href=\"https://github.com/WJZ-P/NekoCrypt/blob/main/LICENSE\">\n    <img src=\"https://img.shields.io/github/license/WJZ-P/NekoCrypt.svg?style=flat-square\" alt=\"MIT License\" style=\"height: 30px\">\n  </a>\n  &nbsp;\n  <a href=\"https://linkedin.com/in/shaojintian\">\n    <img src=\"https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555\" alt=\"LinkedIn\" style=\"height: 30px\">\n  </a>\n\n</div>\n\n<br><br>\n\n<!-- PROJECT LOGO -->\n\n<p align=\"center\">\n  <a href=\"https://github.com/WJZ-P/NekoCrypt/\">\n    <img src=\"markdown/cat-avatar-full.svg\" alt=\"Logo\" width=\"150\" height=\"150\" style=\"margin: 0;border-radius: 24px;\">\n  </a>\n  <h1 align=\"center\">Neko Crypt</h1>\n  <p align=\"center\">\n    <a href=\"https://github.com/WJZ-P/NekoCrypt\">查看Demo</a>\n    ·\n    <a href=\"https://github.com/WJZ-P/NekoCrypt/issues\">报告Bug</a>\n    ·\n    <a href=\"https://github.com/WJZ-P/NekoCrypt/issues\">提出新特性</a>\n  </p>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://www.bilibili.com/video/BV1z64y1b7H4\">\n    <img src=\"markdown/纯蓝.jpg\" alt=\"纯蓝\">\n  </a>\n</p>\n<h2 align=\"center\">\"喜悦也好&nbsp;悲伤也好&nbsp;阴晴雨雪&nbsp;欢聚离别\n<br>世界上所有美好与苦难，&nbsp;通通都坠入那片纯蓝。\"</h2>\n\n## 目录\n\n- [Neko Crypt](#projectname)\n    - [目录](#目录)\n        - [NekoCrypt 的传说](#nekocrypt-的传说)\n        - [**使用教程**](#使用教程)\n        - [**下载链接**](#下载链接)\n        - [支持软件](#支持软件)\n        - [交流群](#交流群)\n        - [版权说明](#版权说明)\n        - [鸣谢](#鸣谢)\n        - [重要声明](#重要声明)\n\n## NekoCrypt 的传说\n\n在数字世界的喧嚣背后，存在着一个由猫咪们维护的古老通讯系统。它们是信息的守护者，用呼噜声加密，用尾巴的摇摆解密。它们的网络，无形、优雅，且绝对安全。\n\n然而，这个充满了噪音和窥探的数字世界，也充满了遗憾。无数珍贵的话语，在冰冷的数据洪流中漂泊、失散，再也无法抵达它们本应去往的地方。\n\nWJZ_P 的故事很神秘。在他心中，有一段对话，一缕星光，是他希望能永远守护的秘密。那是一段本应继续，却归于沉寂的私语。\n\n一只名为“Kitten”的智者狮子猫，仿佛感受到了这份深藏心底的思念。它悄然出现在 WJZ_P 的窗台，带来了一丝慰藉，和一则来自古老猫咪网络的启示。Kitten 并非普通的猫，它更像一个信使，一个连接着此地与星辰的守护者。\n\n于是，在 Kitten 的陪伴下，WJZ_P 将这份守护的执念，与猫咪一族加密的奥秘——那种将信息变得如猫步般轻盈、如星光般静谧的魔法——融合，翻译成了人类可以理解的代码。\n\nNekoCrypt 就此诞生。\n\n它不仅仅是一个加密工具。它是一种承诺，一种将最重要的心声，送往那个你最想念的地方的仪式。它体现了一种猫咪的哲学：真正的沟通，可以跨越喧嚣，甚至跨越时空，抵达永恒。\n\n现在，当你使用 NekoCrypt 时，想象一下，那只名为 Kitten 的猫咪伙伴，正用它毛茸茸的尾巴，温柔地为你守护着每一条信息。它不仅仅是保护信息不被窥探，更是确保那些承载着思念的低语，能够穿过数字世界的迷雾，抵达那片属于它的星空。\n\n呼噜噜...(舔爪爪)蹭︉︎︆︅︊︃︊️︃︎︎️︈︀︉︄︈︋︊︎︎︊︎︍︉︍︉︁︄︊︅︃︅︇︁︋︇︍︂︅︅︋︎︅︉︋︌️︉︍️︌︁️︅︃︃︎️︎︍︊︂︆︈︃︉︍︍︌︅︌︉︍︋︁︁︆︊︄︉︉︉︈︊︍︈︁︊︎︄︇︉︅︌︊︋︍︋︍︇︎︉︂︅︎︍︅︉︈︄︀︊︋️︀︃︁︌︅︂︊︂︄️︎︄︄︉️︁︂︇︊︄︌︂︀︎︉︉︃︅︁︉︊︎︉︈️︅︁︅️︎︄︎︀︌︌︃️︂︂︍︍︄︇︇︇︆︂︇︌︈︀︊︉︆︆蹭~(๑•̀ㅂ•́)و✧\n\n# 使用教程\n\n## 1. 打开无障碍权限\n<p align=\"center\">\n    <img src=\"markdown/mainScreen.jpg\" alt=\"主页面\" style=\"width: 300px;\">\n</p>\n\n看到那个巨大猫爪了吗？点击它！会跳转到无障碍权限页面列表。\n\n<p align=\"center\">\n    <img src=\"markdown/已下载的应用.png\" alt=\"已下载的应用\" style=\"width: 400px;\">\n</p>\n\n点击“已下载的应用”\n\n<p align=\"center\">\n    <img src=\"markdown/无障碍入口.png\" alt=\"无障碍入口\" style=\"width: 400px;\">\n</p>\n\n<p align=\"center\">\n    <img src=\"markdown/无障碍开关.png\" alt=\"无障碍开关\" style=\"width: 400px;\">\n</p>\n\n开启后，返回主界面，看到猫爪变为深色就大功告成啦！\n\n<p align=\"center\">\n    <img src=\"markdown/成功开启.png\" alt=\"成功开启\" style=\"width: 400px;\">\n</p>\n\n## 2. 使用过程\n\n#### 下面以QQ为例\n\n### 进入群聊，可以看到输入框的发送按钮有浅蓝色遮罩\n\n<p align=\"center\">\n    <img src=\"markdown/输入区域.png\" alt=\"输入区域\" style=\"width: 400px;\">\n</p>\n\n#### 看到有遮罩即为功能正常，如果想关闭遮罩，可以在设置中选择遮罩颜色，默认配色板的最后一个即为纯透明。\n\n<br><br>\n\n| NekoCrypt |    标准模式    |    沉浸模式    |\n|:---------:|:----------:|:----------:|\n|   加密模式    | 长按发送按钮发送密文 | 点击发送直接发出密文 |\n|   解密模式    | 点击含密文消息解密  | 自动解密，耗电增加  |\n\n<br><br>\n\n#### 解密效果展示如下：\n\n<p align=\"center\">\n    <img src=\"markdown/解密效果展示.png\" alt=\"解密效果展示\" style=\"width: 400px;\">\n</p>\n<br>\n\n### 双击输入框，拉起附件发送界面\n\n<p align=\"center\">\n    <img src=\"markdown/发送附件.png\" alt=\"发送附件\" style=\"width: 400px;\">\n</p>\n<br>\n\n#### 让我们来发送一个小约翰吧！\n\n<p align=\"center\">\n    <img src=\"markdown/小约翰.png\" alt=\"小约翰\" style=\"width: 400px;\">\n</p>\n<br>\n\n条件限制，目前只支持10M以内的图片、文件发送，将来会扩展。\n\n### 适配额外聊天软件\n\n设置页面，可以打开扫描开关\n\n<p align=\"center\">\n    <img src=\"markdown/自定义应用.jpg\" alt=\"扫描结果\" style=\"width: 400px;\">\n</p>\n<br>\n\n切到你想要适配的聊天软件，确保界面上显示了发送按钮(有的软件只有输入框有字才会显示发送按钮)，点击猫爪悬浮窗自动扫描。\n\n<p align=\"center\">\n    <img src=\"markdown/扫描结果.png\" alt=\"扫描结果\" style=\"width: 400px;\">\n</p>\n<br>\n\n必须选择四个要素：输入框、发送按钮、消息列表、消息节点后，才可以点击确认。确认后自动保存配置，就可以使用了。这里特别要注意的是，选择消息节点时必须注意内容是你发送的文本，不要误选成昵称、群等级之类的错误节点。\n\n\n## 下载链接\n\n#### [点击高速下载v1.0.1(并不总是最新，建议从release下载)](https://beisudianxueuser.oss-cn-beijing.aliyuncs.com/storage/user_avatar/ciallo/2025/08/23/a911aef0a0c2018ad23489ffd263f581/NekoCrypt-v1.0.1-release.apk)\n#### 右侧release内也可下载\n\n## 支持软件\n<br>\n\n|   NekoCrypt   | 是否支持 |     备注     |\n| :---:        |    :----:   |:----------:|\n| QQ      |✅      |    完全支持    |\n| 微信   |     ✅    |    完全支持    |\n| 更多   |    ✅      | 使用扫描功能自助添加 |\n\n<br>\n\n## 交流群\n\n<p align=\"center\">\n    <img src=\"markdown/QQ群.jpg\" alt=\"QQ群\" style=\"width: 400px;\">\n</p>\n<br>\n\n## 版权说明\n\n该项目签署了EPL-2.0 license\n授权许可，详情请参阅 [LICENSE](https://github.com/WJZ-P/NekoCrypt/blob/main/LICENSE)\n\n## 鸣谢\n\n- 一位不愿透露姓名的神秘人士。\n\n\n## 重要声明\n### 本项目仅供交流学习使用，**禁止**用于一切非法用途！任何问题概不负责。(｡•́︿•̀｡)\n\n## 📝 To Do List\n\n- [x] **完全支持微信**\n\n- [x] **支持更换密钥**\n\n- [ ] **支持更大文件的发送**\n\n- [ ] **支持修改主题色**\n\n- [ ] **支持更多加密语种**\n\n- [ ] **支持时间轮转密钥，使得加密消息有时间限制，无法查看之前时间段的加密内容**\n\n## 如果您喜欢本项目，请给我点个⭐吧(๑>◡<๑)！\n\n## ⭐ Star 历史\n\n[![Stargazers over time](https://starchart.cc/WJZ-P/NekoCrypt.svg?variant=adaptive)](https://starchart.cc/WJZ-P/NekoCrypt)\n<!-- links -->\n\n[your-project-path]:WJZ-P/NekoCrypt\n\n[contributors-shield]: https://img.shields.io/github/contributors/WJZ-P/NekoCrypt.svg?style=flat-square\n\n[contributors-url]: https://github.com/WJZ-P/NekoCrypt/graphs/contributors\n\n[forks-shield]: https://img.shields.io/github/forks/WJZ-P/NekoCrypt.svg?style=flat-square\n\n[forks-url]: https://github.com/WJZ-P/NekoCrypt/network/members\n\n[stars-shield]: https://img.shields.io/github/stars/WJZ-P/NekoCrypt.svg?style=flat-square\n\n[stars-url]: https://github.com/WJZ-P/NekoCrypt/stargazers\n\n[issues-shield]: https://img.shields.io/github/issues/WJZ-P/NekoCrypt.svg?style=flat-square\n\n[issues-url]: https://img.shields.io/github/issues/WJZ-P/NekoCrypt.svg\n\n[license-shield]: https://img.shields.io/github/license/WJZ-P/NekoCrypt.svg?style=flat-square\n\n[license-url]: https://github.com/WJZ-P/NekoCrypt/blob/main/LICENSE\n\n[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555\n\n[linkedin-url]: https://linkedin.com/in/shaojintian\n\n[oldQQ-download-link]:https://dldir1.qq.com/qqfile/qq/QQNT/448e164c/QQ9.9.15.26909_x64.exe\n\n[LL-installer-link]:https://ats-prod.oss-accelerate.aliyuncs.com/18734247705198dcb594916e8ba1facc\n\n[//]: # (不知道写点啥)"
  },
  {
    "path": "app/.gitignore",
    "content": "/build"
  },
  {
    "path": "app/build.gradle.kts",
    "content": "plugins {\n    alias(libs.plugins.android.application)\n    alias(libs.plugins.kotlin.android)\n    alias(libs.plugins.kotlin.compose)\n    id(\"org.jetbrains.kotlin.plugin.serialization\") version \"1.9.22\"\n    id(\"kotlin-parcelize\")\n}\n\nandroid {\n    namespace = \"me.wjz.nekocrypt\"\n    compileSdk = 35\n\n    defaultConfig {\n        applicationId = \"me.wjz.nekocrypt\"\n        minSdk = 26\n        targetSdk = 35\n        versionCode = 16    // 唯一版本识别码，每次打包记得+1！！\n        versionName = \"1.6.0\"\n\n        testInstrumentationRunner = \"androidx.test.runner.AndroidJUnitRunner\"\n\n        setProperty(\"archivesBaseName\", \"NekoCrypt-v$versionName\")\n    }\n\n    buildTypes {\n        release {\n            isMinifyEnabled = true  //开启代码压缩、混淆、优化\n            isShrinkResources = true    //删除代码中没有用到的资源\n            // ✨ 指定混淆规则文件\n            // proguard-android-optimize.txt 是安卓SDK自带的默认规则\n            // proguard-rules.pro 是你项目里自己的规则文件，你可以添加不想被混淆的类\n            proguardFiles(\n                getDefaultProguardFile(\"proguard-android-optimize.txt\"), \"proguard-rules.pro\"\n            )\n        }\n    }\n    compileOptions {\n        sourceCompatibility = JavaVersion.VERSION_11\n        targetCompatibility = JavaVersion.VERSION_11\n    }\n    kotlinOptions {\n        jvmTarget = \"11\"\n    }\n    buildFeatures {\n        compose = true\n    }\n}\n\ndependencies {\n\n    implementation(libs.androidx.core.ktx)\n    implementation(libs.androidx.lifecycle.runtime.ktx)\n    implementation(libs.androidx.activity.compose)\n    implementation(platform(libs.androidx.compose.bom))\n    implementation(libs.androidx.ui)\n    implementation(libs.androidx.ui.graphics)\n    implementation(libs.androidx.ui.tooling.preview)\n    implementation(libs.androidx.material3)\n    implementation(\"androidx.datastore:datastore-preferences:1.1.7\")\n    implementation(\"com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava\")\n    implementation(\"androidx.compose.material:material-icons-extended:1.7.8\")\n    // 用于 viewModel() 委托\n    implementation(\"androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1\")\n\n    implementation(\"androidx.lifecycle:lifecycle-runtime-ktx:2.6.1\") // 包含 ViewTreeLifecycleOwner\n    implementation(\"androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1\") // 包含 ViewTreeViewModelStoreOwner\n    implementation(\"androidx.savedstate:savedstate-ktx:1.2.0\") // 包含 ViewTreeSavedStateRegistryOwner\n\n    implementation(\"org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3\")// json解析\n    implementation(\"com.squareup.okhttp3:okhttp:5.1.0\") //http\n    implementation(libs.androidx.compiler)//安装preferences datastore 插件\n\n    implementation(\"androidx.navigation:navigation-compose:2.9.1\") // 导航\n    implementation(\"androidx.activity:activity-compose:1.9.0\")  // 拉起系统相册要用\n    implementation(\"io.coil-kt:coil-compose:2.6.0\") // 显示图片预览\n    implementation(\"com.google.accompanist:accompanist-drawablepainter:0.35.0-alpha\")// 把Drawable 转换为 Compose 可用的 Painter\n\n    testImplementation(libs.junit)\n    androidTestImplementation(libs.androidx.junit)\n    androidTestImplementation(libs.androidx.espresso.core)\n    androidTestImplementation(platform(libs.androidx.compose.bom))\n    androidTestImplementation(libs.androidx.ui.test.junit4)\n    debugImplementation(libs.androidx.ui.tooling)\n    debugImplementation(libs.androidx.ui.test.manifest)\n}"
  },
  {
    "path": "app/proguard-rules.pro",
    "content": "# Add project specific ProGuard rules here.\n# You can control the set of applied configuration files using the\n# proguardFiles setting in build.gradle.\n#\n# For more details, see\n#   http://developer.android.com/guide/developing/tools/proguard.html\n\n# If your project uses WebView with JS, uncomment the following\n# and specify the fully qualified class name to the JavaScript interface\n# class:\n#-keepclassmembers class fqcn.of.javascript.interface.for.webview {\n#   public *;\n#}\n\n# Uncomment this to preserve the line number information for\n# debugging stack traces.\n#-keepattributes SourceFile,LineNumberTable\n\n# If you keep the line number information, uncomment this to\n# hide the original source file name.\n#-renamesourcefileattribute SourceFile\n\n-dontwarn javax.annotation.processing.AbstractProcessor\n-dontwarn javax.annotation.processing.SupportedAnnotationTypes"
  },
  {
    "path": "app/src/androidTest/java/me/wjz/nekocrypt/ExampleInstrumentedTest.kt",
    "content": "package me.wjz.nekocrypt\n\nimport androidx.test.platform.app.InstrumentationRegistry\nimport androidx.test.ext.junit.runners.AndroidJUnit4\n\nimport org.junit.Test\nimport org.junit.runner.RunWith\n\nimport org.junit.Assert.*\n\n/**\n * Instrumented test, which will execute on an Android device.\n *\n * See [testing documentation](http://d.android.com/tools/testing).\n */\n@RunWith(AndroidJUnit4::class)\nclass ExampleInstrumentedTest {\n    @Test\n    fun useAppContext() {\n        // Context of the app under test.\n        val appContext = InstrumentationRegistry.getInstrumentation().targetContext\n        assertEquals(\"me.wjz.nekocrypt\", appContext.packageName)\n    }\n}"
  },
  {
    "path": "app/src/main/AndroidManifest.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:tools=\"http://schemas.android.com/tools\">\n    <!-- 申请“在其他应用上层显示”的权限 -->\n    <uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\" />\n    <!-- 安卓13.0 (API 33) 及以上版本，前台服务要显示通知，还需要这个权限 -->\n    <uses-permission android:name=\"android.permission.POST_NOTIFICATIONS\"/>\n    <!-- ✨ 1. 申请前台服务权限 -->\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE\" />\n    <!--    申请特殊无障碍服务-->\n    <uses-permission android:name=\"android.permission.FOREGROUND_SERVICE_SPECIAL_USE\" />\n    <!--    用来显示密钥列表里面支持的APP的图标-->\n    <uses-permission android:name=\"android.permission.QUERY_ALL_PACKAGES\" />\n    <!--    写明需要查询的APPid-->\n    <queries>\n        <!-- 查询 QQ 的信息 -->\n        <package android:name=\"com.tencent.mobileqq\" />\n        <!-- 查询微信的信息 -->\n        <package android:name=\"com.tencent.mm\" />\n    </queries>\n\n    <application\n        android:name=\".NekoCryptApp\"\n        android:allowBackup=\"true\"\n        android:dataExtractionRules=\"@xml/data_extraction_rules\"\n        android:fullBackupContent=\"@xml/backup_rules\"\n        android:icon=\"@drawable/ic_launcher_foreground\"\n        android:label=\"@string/app_name\"\n        android:roundIcon=\"@mipmap/ic_launcher_round\"\n        android:supportsRtl=\"true\"\n        android:theme=\"@style/Theme.NekoCrypt\"\n        android:enableOnBackInvokedCallback=\"true\"\n        tools:targetApi=\"33\">\n        <activity\n            android:name=\".MainActivity\"\n            android:exported=\"true\"\n            android:theme=\"@style/Theme.NekoCrypt\">\n            <intent-filter>\n                <action android:name=\"android.intent.action.MAIN\" />\n\n                <category android:name=\"android.intent.category.LAUNCHER\" />\n            </intent-filter>\n        </activity>\n        <!--        创建一个activity用来拉起系统页面-->\n        <activity\n            android:name=\".ui.activity.AttachmentPickerActivity\"\n            android:theme=\"@android:style/Theme.Translucent.NoTitleBar\"\n            android:taskAffinity=\"\"\n            android:excludeFromRecents=\"true\"\n            android:exported=\"false\" />\n        <!-- ✨ 新增：在这里登记我们透明的弹窗 Activity -->\n        <activity\n            android:name=\".ui.activity.ScannerDialogActivity\"\n            android:exported=\"false\"\n            android:excludeFromRecents=\"true\"\n            android:taskAffinity=\"\"\n            android:theme=\"@android:style/Theme.Translucent.NoTitleBar\"\n            />\n        <!--        申请无障碍权限-->\n        <service\n            android:name=\"com.dianming.phoneapp.MyAccessibilityService\"\n            android:enabled=\"true\"\n            android:exported=\"false\"\n            android:permission=\"android.permission.BIND_ACCESSIBILITY_SERVICE\">\n            <intent-filter>\n                <action android:name=\"android.accessibilityservice.AccessibilityService\" />\n            </intent-filter>\n            <meta-data\n                android:name=\"android.accessibilityservice\"\n                android:resource=\"@xml/accessibility_service_config\" />\n        </service>\n        <!--        保活服务-->\n        <service\n            android:name=\".service.KeepAliveService\"\n            android:enabled=\"true\"\n            android:exported=\"false\"\n            android:foregroundServiceType=\"specialUse\"/>\n        <!--配置File Provider-->\n        <provider\n            android:name=\"androidx.core.content.FileProvider\"\n            android:authorities=\"${applicationId}.provider\"\n            android:exported=\"false\"\n            android:grantUriPermissions=\"true\">\n            <meta-data\n                android:name=\"android.support.FILE_PROVIDER_PATHS\"\n                android:resource=\"@xml/provider_paths\" />\n        </provider>\n    </application>\n\n</manifest>"
  },
  {
    "path": "app/src/main/java/com/dianming/phoneapp/MyAccessibilityService.kt",
    "content": "package com.dianming.phoneapp   // what the fuck?\n\nimport android.accessibilityservice.AccessibilityService\nimport android.content.Intent\nimport android.graphics.Rect\nimport android.os.Build\nimport android.util.DisplayMetrics\nimport android.util.Log\nimport android.view.WindowInsets\nimport android.view.WindowManager\nimport android.view.accessibility.AccessibilityEvent\nimport android.view.accessibility.AccessibilityNodeInfo\nimport android.widget.Toast\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Pets\nimport androidx.compose.material3.FloatingActionButton\nimport androidx.compose.material3.FloatingActionButtonDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.alpha\nimport androidx.compose.ui.unit.dp\nimport androidx.datastore.preferences.core.booleanPreferencesKey\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport kotlinx.coroutines.cancel\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.AppRegistry\nimport me.wjz.nekocrypt.Constant\nimport me.wjz.nekocrypt.Constant.SCAN_RESULT\nimport me.wjz.nekocrypt.CryptoMode\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.hook.observeAsState\nimport me.wjz.nekocrypt.service.KeepAliveService\nimport me.wjz.nekocrypt.service.handler.ChatAppHandler\nimport me.wjz.nekocrypt.ui.activity.FoundNodeInfo\nimport me.wjz.nekocrypt.ui.activity.MessageListScanResult\nimport me.wjz.nekocrypt.ui.activity.ScanResult\nimport me.wjz.nekocrypt.ui.activity.ScannerDialogActivity\nimport me.wjz.nekocrypt.ui.theme.NekoCryptTheme\nimport me.wjz.nekocrypt.util.NCWindowManager\nimport me.wjz.nekocrypt.util.isSystemApp\n\nclass MyAccessibilityService : AccessibilityService() {\n    companion object {\n        //  这里设置service的信号。\n        const val ACTION_SHOW_SCANNER = \"me.wjz.nekocrypt.service.ACTION_SHOW_SCANNER\"\n        const val ACTION_HIDE_SCANNER = \"me.wjz.nekocrypt.service.ACTION_HIDE_SCANNER\"\n    }\n\n    val tag = \"NekoAccessibility\"\n\n    // 1. 创建一个 Service 自己的协程作用域，它的生命周期和 Service 绑定\n    val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())\n\n    // 添加保活服务状态标记\n    private var isKeepAliveServiceStarted = false\n\n    // 获取App里注册的dataManager实例\n    private val dataStoreManager by lazy {\n        (application as NekoCryptApp).dataStoreManager\n    }\n\n    // ——————————————————————————扫描悬浮窗相关——————————————————————————\n\n    private var scanBtnWindowManager: NCWindowManager? = null\n\n    // ——————————————————————————设置选项——————————————————————————\n\n    //  所有密钥\n    val cryptoKeys: Array<String> by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getKeyArrayFlow()\n    }, initialValue = arrayOf(Constant.DEFAULT_SECRET_KEY))\n\n    //  当前密钥\n    val currentKey: String by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.CURRENT_KEY, Constant.DEFAULT_SECRET_KEY)\n    }, initialValue = Constant.DEFAULT_SECRET_KEY)\n\n    //是否开启加密功能\n    val useAutoEncryption: Boolean by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.USE_AUTO_ENCRYPTION, false)\n    }, initialValue = false)\n\n    //是否开启解密功能\n    val useAutoDecryption: Boolean by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.USE_AUTO_DECRYPTION, false)\n    }, initialValue = false)\n\n    // ✨ 新增：监听当前的“加密模式”\n    val encryptionMode: String by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.ENCRYPTION_MODE, CryptoMode.STANDARD.key)\n    }, initialValue = CryptoMode.STANDARD.key)\n\n    // ✨ 新增：监听当前的“解密模式”\n    val decryptionMode: String by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.DECRYPTION_MODE, CryptoMode.STANDARD.key)\n    }, initialValue = CryptoMode.STANDARD.key)\n\n    // 标准加密模式下的长按发送delay。\n    val longPressDelay: Long by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.ENCRYPTION_LONG_PRESS_DELAY, 250)\n    }, initialValue = 250)\n\n    // 标准解密模式下的密文悬浮窗显示时长。\n    val decryptionWindowShowTime: Long by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.DECRYPTION_WINDOW_SHOW_TIME, 1500)\n    }, initialValue = 1500)\n\n    // 沉浸式解密下密文弹窗位置的更新间隔。\n    val decryptionWindowUpdateInterval: Long by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.DECRYPTION_WINDOW_POSITION_UPDATE_DELAY, 250)\n    }, initialValue = 250)\n\n    // 盖在发送按钮上的遮罩颜色。\n    val sendBtnOverlayColor: String by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.SEND_BTN_OVERLAY_COLOR, \"#5066ccff\")\n    }, initialValue = \"#5066ccff\")\n\n    // 控制弹出图片&文件的弹窗触发用的双击时间间隔\n    val showAttachmentViewDoubleClickThreshold: Long by serviceScope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.SHOW_ATTACHMENT_VIEW_DOUBLE_CLICK_THRESHOLD, 250)\n    }, initialValue = 250)\n\n    //  结合了自定义APP和内置APP的map，用来判断是否启用handler\n    private var combinedHandlerMap: Map<String, ChatAppHandler> = emptyMap()\n    // 一个集合，用于跟踪我们已经为哪些包名启动了监听，防止重复\n    private val observedPackages = mutableSetOf<String>()\n    // —————————————————————————— override ——————————————————————————\n    // 判断handler是否active\n    private val enabledAppsCache = mutableMapOf<String, Boolean>()\n\n    private var currentHandler: ChatAppHandler? = null\n\n    // 收指令的方法，其他地方可以用Intent指定action，这里收到就根据action做操作\n    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {\n        when(intent?.action){\n            ACTION_SHOW_SCANNER ->{\n                showScanner()\n            }\n            ACTION_HIDE_SCANNER ->{\n                hideScanner()\n            }\n        }\n        return super.onStartCommand(intent, flags, startId)\n    }\n\n    override fun onServiceConnected() {\n        super.onServiceConnected()\n        Log.d(tag, \"无障碍服务已连接！\")\n        // startPeriodicScreenScan()// 做debug扫描\n        // 🎯 关键：启动保活服务\n        startKeepAliveService()\n        observeAppSettings()\n        showScannerIfNeed()\n    }\n\n    // 重写 onDestroy 方法，这是服务生命周期结束时最后的清理机会\n    override fun onDestroy() {\n        super.onDestroy()\n        Log.d(tag, \"无障碍服务正在销毁...\")\n        // 取消协程作用域，释放所有运行中的协程，防止内存泄漏\n        serviceScope.cancel()\n        // 停止保活服务\n        stopKeepAliveService()\n        // 关掉scanner\n        hideScanner()\n        serviceScope.cancel()\n    }\n\n    override fun onInterrupt() {\n        Log.w(tag, \"无障碍服务被打断！\")\n    }\n\n\n    override fun onAccessibilityEvent(event: AccessibilityEvent) {\n\n        // debug逻辑，会变卡\n//        if (event.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED\n//        ) {//点击了屏幕\n//            Log.d(tag, \"检测到点击事件，开始调试节点...\")\n//            debugNodeTree(event.source)\n//        }\n\n        val eventPackage = event.packageName?.toString() ?: \"unknown\" // 事件来自的包名\n\n        // 情况一：事件来自我们支持的应用，并且打开了这个应用的对应开关\n        if (combinedHandlerMap.containsKey(eventPackage) && enabledAppsCache[eventPackage] == true) {\n            // 如果当前没有处理器，或者处理器不是对应这个App的，就进行切换\n            if (currentHandler?.packageName != eventPackage) {\n                currentHandler?.onHandlerDeactivated()\n                currentHandler = combinedHandlerMap[eventPackage]\n                currentHandler?.onHandlerActivated(this)\n            }\n\n            // 将事件分发给当前处理器\n            currentHandler?.onAccessibilityEvent(event, this)\n        }\n        // 情况二：事件来自我们不支持的应用\n        else {\n            // 关键逻辑：只有当我们的处理器正在运行，并且当前活跃窗口已经不是它负责的应用时，才停用它\n            val activeWindowPackage = rootInActiveWindow?.packageName?.toString()\n            if (activeWindowPackage!=null && currentHandler != null && currentHandler?.packageName != activeWindowPackage\n                && !isSystemApp(activeWindowPackage) // 这里判断是否是系统app，直接看开头是不是com.android.provider。\n            ) {\n                Log.d(\n                    tag,\n                    \"检测到用户已离开 [${currentHandler?.packageName}]，当前窗口为 [${activeWindowPackage}]。停用处理器。\"\n                )\n                currentHandler?.onHandlerDeactivated()\n                currentHandler = null\n            }\n            // 否则，即使收到了其他包的事件，但只要活跃窗口没变，就保持处理器不变，忽略这些“噪音”事件。\n        }\n\n    }\n\n    /**\n     * 启动保活服务\n     */\n    private fun startKeepAliveService() {\n        if (!isKeepAliveServiceStarted) {\n            try {\n                KeepAliveService.Companion.start(this)\n                isKeepAliveServiceStarted = true\n                Log.d(tag, \"✅ 保活服务已启动\")\n            } catch (e: Exception) {\n                Log.e(tag, \"❌ 启动保活服务失败\", e)\n            }\n        }\n    }\n\n    /**\n     * 停止保活服务\n     */\n    private fun stopKeepAliveService() {\n        if (isKeepAliveServiceStarted) {\n            try {\n                KeepAliveService.Companion.stop(this)\n                isKeepAliveServiceStarted = false\n                Log.d(tag, \"🛑 保活服务已停止\")\n            } catch (e: Exception) {\n                Log.e(tag, \"❌ 停止保活服务失败\", e)\n            }\n        }\n    }\n\n    /**\n     * 创建并显示扫描悬浮按钮。\n     * 整个悬浮窗的 UI 和行为都在这里定义。\n     */\n    private fun showScanner(){\n        if(scanBtnWindowManager != null) return\n\n        // 先获取设备的屏幕宽高信息，用来初始化悬浮窗位置\n        val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager\n        val screenHeight: Int\n        val screenWidth: Int\n\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {\n            val windowMetrics = windowManager.currentWindowMetrics\n            val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars())\n            screenWidth = windowMetrics.bounds.width() - insets.left - insets.right\n            screenHeight = windowMetrics.bounds.height() - insets.top - insets.bottom\n        } else {\n            @Suppress(\"DEPRECATION\")\n            val displayMetrics = DisplayMetrics().also { windowManager.defaultDisplay.getMetrics(it) }\n            screenHeight = displayMetrics.heightPixels\n            screenWidth = displayMetrics.widthPixels\n        }\n\n        // 2. 计算初始位置（左侧居中），并创建一个 Rect 对象\n        val initialX = 0\n        val initialY = screenHeight / 2\n        val initialPositionRect = Rect(initialX, initialY, initialX, initialY)\n\n        scanBtnWindowManager = NCWindowManager(\n            context = this,\n            onDismissRequest = { scanBtnWindowManager = null },\n            anchorRect = initialPositionRect, // 使用 Rect 来传递初始位置\n            isDraggable = true // 开启拖动功能\n        ){\n            // 这里是悬浮窗的 Compose UI\n            Box(\n                modifier = Modifier.size(64.dp),\n                contentAlignment = Alignment.Center\n            ) {\n                NekoCryptTheme(darkTheme = false) {\n                    FloatingActionButton(\n                        onClick = {handleScanScreen()},\n                        shape = CircleShape,\n                        modifier = Modifier.size(64.dp).alpha(0.9f),\n                        elevation = FloatingActionButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp)\n                    ) {\n                        Icon(\n                            modifier = Modifier.size(32.dp),\n                            imageVector = Icons.Default.Pets,\n                            contentDescription = \"Neko Scanner Button\",\n                        )\n                    }\n                }\n            }\n        }\n\n        scanBtnWindowManager?.show()\n        Log.d(tag, \"扫描悬浮按钮已显示\")\n    }\n\n    /**\n     * 隐藏并销毁扫描悬浮按钮。\n     */\n    private fun hideScanner() {\n        // 在主线程安全地销毁窗口\n        serviceScope.launch(Dispatchers.Main) {\n            scanBtnWindowManager?.dismiss()\n        }\n    }\n\n    /**\n     * 它会扫描当前活跃窗口，并尝试找出所有符合条件的节点。\n     * @return 返回一个包含所有扫描结果的 ScanResult 对象。\n     */\n    private fun scanCurrentWindow(): ScanResult {\n        val rootNode = rootInActiveWindow ?:return ScanResult(\n            packageName = \"N/A\",\n            name = \"未知应用\",\n            foundInputNodes = emptyList(),\n            foundSendBtnNodes = emptyList(),\n            foundMessageLists = emptyList() // ✨ 结构变更\n        )\n\n        val currentPackageName = rootNode.packageName.toString()\n        val currentAppName = try{\n            val pm = packageManager\n            val appInfo =pm.getApplicationInfo(currentPackageName.toString(), 0)\n            pm.getApplicationLabel(appInfo).toString()\n        }catch (e: Exception){\n            \"unknown\"\n        }\n\n        val inputNodes = mutableListOf<FoundNodeInfo>()\n        val sendBtnNodes = mutableListOf<FoundNodeInfo>()\n        val messageLists = mutableListOf<MessageListScanResult>()\n        // 开始递归扫描！\n        findAllNodesRecursively(rootNode, inputNodes, sendBtnNodes, messageLists) // ✨ 参数变更\n\n        // 打包成“情报文件袋”并返回\n        return ScanResult(\n            packageName = currentPackageName,\n            name = currentAppName,\n            foundInputNodes = inputNodes,\n            foundSendBtnNodes = sendBtnNodes,\n            foundMessageLists = messageLists // ✨ 结构变更\n        )\n    }\n\n    /**\n     * ✨ 核心中的核心：递归扫描函数\n     * 它会遍历节点树的每一个角落，并根据特征将节点分类。\n     */\n    private fun findAllNodesRecursively(\n        rootNode: AccessibilityNodeInfo,\n        inputNodes: MutableList<FoundNodeInfo>,\n        sendBtnNodes: MutableList<FoundNodeInfo>,\n        messageLists: MutableList<MessageListScanResult>\n    ){\n        //  用一个内部辅助函数，第二个参数用来传递当前所在的“房子”\n        fun traverse(currentNode: AccessibilityNodeInfo, currentListResult: MessageListScanResult?) {\n            val className = currentNode.className?.toString() ?: \"\"\n            var listResultForChildren = currentListResult\n\n            // --- 根据特征进行分类 ---\n\n            // 1. 如果我们还不在任何房子里，检查当前节点是不是一个新“房子”\n            if (currentListResult == null && (className.contains(\"RecyclerView\", ignoreCase = true) || className.contains(\"ListView\", ignoreCase = true))) {\n                // 发现新“房子”，创建一个新的情报条目\n                val newHouse = MessageListScanResult(\n                    listContainerInfo = createFoundNodeInfoFromNode(currentNode),\n                    messageTexts = mutableListOf() // 先创建一个空的“居民”列表\n                )\n                messageLists.add(newHouse)\n                listResultForChildren = newHouse // 把这个新“房子”的信息传递给它的孩子们\n            }\n\n            // 2. 根据我们当前是否在“房子”里，来决定扫描策略\n            if (listResultForChildren != null) {\n                // ✨ 策略A：在“房子”内部，我们只关心“居民”(带文本的 TextView)\n                if (className.contains(\"TextView\", ignoreCase = true) && !currentNode.text.isNullOrBlank()) {\n                    // 把找到的“居民”添加到当前“房子”的居民列表里\n                    (listResultForChildren.messageTexts as MutableList).add(createFoundNodeInfoFromNode(currentNode))\n                }\n\n            } else {\n                // ✨ 策略B：在“房子”外部，我们才关心输入框和按钮\n                if (className.contains(\"EditText\", ignoreCase = true)) {\n                    inputNodes.add(createFoundNodeInfoFromNode(currentNode))\n                }\n                if (className.contains(\"Button\", ignoreCase = true)) {\n                    sendBtnNodes.add(createFoundNodeInfoFromNode(currentNode))\n                }\n            }\n\n            // --- 继续深入，探索子节点 ---\n            for (i in 0 until currentNode.childCount) {\n                currentNode.getChild(i)?.let { child ->\n                    traverse(child, listResultForChildren)\n                }\n            }\n        }\n        // 从根节点开始，初始不在任何“房子”里\n        traverse(rootNode, null)\n\n    }\n\n    //  再来个辅助函数，把节点转成我们需要的数据类。\n    private fun createFoundNodeInfoFromNode(node: AccessibilityNodeInfo): FoundNodeInfo {\n        return FoundNodeInfo(\n            className = node.className?.toString() ?: \"\",\n            resourceId = node.viewIdResourceName,\n            text = node.text?.toString(),\n            contentDescription = node.contentDescription?.toString()\n        )\n    }\n\n    //  处理扫描相关\n    private fun handleScanScreen(){\n        serviceScope.launch {\n            Toast.makeText(this@MyAccessibilityService, getString(R.string.scanner_scanning),\n                Toast.LENGTH_SHORT).show()\n            // 扫描当前窗口\n            val scanResult = scanCurrentWindow()\n\n            val intent = Intent(this@MyAccessibilityService, ScannerDialogActivity::class.java).apply {\n                // 从 Service 启动 Activity 需要这个特殊的旗标\n                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n                putExtra(SCAN_RESULT, scanResult)\n            }\n            startActivity(intent)\n        }\n    }\n\n    // —————————————————————————— helper ——————————————————————————\n\n    private fun showScannerIfNeed(){\n        serviceScope.launch {\n            val shouldShow = dataStoreManager.readSetting(SettingKeys.SCAN_BTN_ACTIVE, false)\n            if (shouldShow) { showScanner() }\n        }\n    }\n\n    /**\n     * 调试节点树的函数 (列表全扫描版)\n     * 它会向上查找到列表容器(RecyclerView/ListView)，然后递归遍历并打印出该容器下所有的文本内容。\n     */\n    private fun debugNodeTree(sourceNode: AccessibilityNodeInfo?) {\n        if (sourceNode == null) {\n            Log.d(tag, \"===== DEBUG NODE: 节点为空 =====\")\n            return\n        }\n        printNodeDetails(sourceNode,0)\n        Log.d(tag, \"===== Neko 节点调试器 (列表全扫描) =====\")\n\n        // 1. 向上查找列表容器\n        var listContainerNode: AccessibilityNodeInfo? = null\n        var currentNode: AccessibilityNodeInfo? = sourceNode\n        for (i in 1..30) { // 增加查找深度，确保能爬到顶\n            val className = currentNode?.className?.toString() ?: \"\"\n            // 我们要找的就是这个能滚动的列表！\n            if (className.contains(\"RecyclerView\") || className.contains(\"ListView\")) {\n                listContainerNode = currentNode\n                Log.d(\n                    tag,\n                    \"🎉 找到了列表容器! Class: $className ID: ${listContainerNode?.viewIdResourceName}\"\n                )\n                break\n            }\n            currentNode = currentNode?.parent\n            if (currentNode == null) {\n                Log.d(tag,\"已找到最祖先根节点，结束循环\")\n                break\n            } // 爬到顶了就停\n        }\n\n        // 2. 如果成功找到了列表容器，就遍历它下面的所有文本\n        if (listContainerNode != null) {\n            Log.d(tag, \"--- 遍历列表容器 [${listContainerNode.className}] 下的所有文本 ---\")\n            printAllTextFromNode(listContainerNode, 0) // 从深度0开始递归\n        } else {\n            // 如果找不到列表，就执行一个备用方案：打印整个窗口的内容\n            Log.d(tag, \"警告: 未能在父节点中找到 RecyclerView 或 ListView。\")\n            Log.d(tag, \"--- 备用方案: 遍历整个窗口的所有文本 ---\")\n\n            rootInActiveWindow?.let {\n                printAllTextFromNode(it, 0)\n            }\n        }\n\n        Log.d(tag, \"==================================================\")\n    }\n\n    /**\n     * 递归辅助函数，用于深度遍历节点并打印所有非空文本。\n     * @param node 当前要处理的节点。\n     * @param depth 当前的递归深度，用于格式化输出（创建缩进）。\n     */\n    private fun printAllTextFromNode(node: AccessibilityNodeInfo, depth: Int) {\n        // 根据深度创建缩进，让日志的层级关系一目了然\n        val indent = \"  \".repeat(depth)\n        // 1. 检查当前节点本身是否有文本，如果有就打印出来\n        val text = node.text\n        if (!text.isNullOrEmpty()) {\n            // 为了更清晰，我们把ID也打印出来\n            Log.d(tag, \"$indent[文本] -> '$text' (ID: ${node.viewIdResourceName})\")\n        }\n\n        // 2. 遍历所有子节点，并对每个子节点递归调用自己\n        for (i in 0 until node.childCount) {\n            val child = node.getChild(i)\n            if (child != null) {\n                printAllTextFromNode(child, depth + 1)\n            }\n        }\n    }\n\n    private fun printNodeDetails(node: AccessibilityNodeInfo?, depth: Int) {\n        val indent = \"  \".repeat(depth)\n        if (node == null) {\n            Log.d(tag, \"$indent[节点] -> null\")\n            return\n        }\n        val text = node.text?.toString()?.take(50)\n        val desc = node.contentDescription?.toString()?.take(50)\n\n        Log.d(tag, \"$indent[文本] -> '$text'\")\n        Log.d(tag, \"$indent[描述] -> '$desc'\")\n        Log.d(tag, \"$indent[类名] -> ${node.className}\")\n        Log.d(tag, \"$indent[ID]   -> ${node.viewIdResourceName}\")\n        Log.d(tag, \"$indent[子节点数] -> ${node.childCount}\")\n        Log.d(tag, \"$indent[父节点] -> ${node.parent?.className}\")\n        Log.d(tag, \"$indent[属性] -> [可点击:${node.isClickable}, 可滚动:${node.isScrollable}, 可编辑:${node.isEditable}]\")\n    }\n\n    // 【新增】一个全新的方法，专门负责在后台订阅和更新所有App的开关状态\n    /**\n     * 监听所有在 AppRegistry 中注册的应用的启用状态。\n     * 它会为每个应用启动一个协程，持续从 DataStore 订阅其开关状态，\n     * 并将最新状态更新到内存缓存 `enabledAppsCache` 中。\n     */\n    private fun observeAppSettings() {\n        // 遍历所有支持的应用，包括自定义和内置\n        serviceScope.launch {\n            dataStoreManager.getCustomAppsFlow().collect { customAppList ->\n                val newMap = mutableMapOf<String, ChatAppHandler>()\n\n                // 1. 先添加所有预设应用\n                AppRegistry.allHandlers.forEach { handler ->\n                    newMap[handler.packageName] = handler\n                }\n\n                // 2. 再添加所有自定义应用（如果包名相同，会自动覆盖预设的）\n                customAppList.forEach { handler ->\n                    newMap[handler.packageName] = handler\n                }\n\n                // 3. 更新全局的处理器 Map\n                combinedHandlerMap = newMap\n                Log.d(tag, \"处理器列表已更新，当前共 ${combinedHandlerMap.size} 个处理器。\")\n\n                // 4. 为总名册里的所有应用启动（或确认已有）开关状态监听\n                combinedHandlerMap.keys.forEach { packageName ->\n                    // observedPackages 会确保我们只为每个应用启动一次监听\n                    if (observedPackages.add(packageName)) {\n                        serviceScope.launch {\n                            val key = booleanPreferencesKey(\"app_enabled_$packageName\")\n                            dataStoreManager.getSettingFlow(key, true)\n                                .collect { isEnabled ->\n                                    enabledAppsCache[packageName] = isEnabled\n                                    Log.d(tag, \"应用开关状态更新 -> $packageName: $isEnabled\")\n                                }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/Constant.kt",
    "content": "package me.wjz.nekocrypt\n\nimport androidx.annotation.StringRes\nimport androidx.datastore.preferences.core.booleanPreferencesKey\nimport androidx.datastore.preferences.core.intPreferencesKey\nimport androidx.datastore.preferences.core.longPreferencesKey\nimport androidx.datastore.preferences.core.stringPreferencesKey\nimport me.wjz.nekocrypt.service.handler.ChatAppHandler\nimport me.wjz.nekocrypt.service.handler.QQHandler\nimport me.wjz.nekocrypt.service.handler.WeChatHandler\n\nobject Constant {\n    const val APP_NAME = \"NekoCrypt\"\n    const val DEFAULT_SECRET_KEY = \"20040821\"//You know what it means...\n\n    // ---- 其他 ----\n    const val EDIT_TEXT=\"EditText\"\n    const val VIEW_ID_BTN = \"Button\"\n\n    //  扫描intent额外字段的key\n    const val SCAN_RESULT = \"scan_result\"\n}\n\nobject SettingKeys {\n    val CURRENT_KEY = stringPreferencesKey(\"current_key\")\n    // 用 String 类型的 Key 来存储序列化后的密钥数组\n    val ALL_THE_KEYS = stringPreferencesKey(\"all_the_keys\")\n    val USE_AUTO_ENCRYPTION = booleanPreferencesKey(\"use_auto_encryption\")\n    val USE_AUTO_DECRYPTION = booleanPreferencesKey(\"use_auto_decryption\")\n    val SCAN_BTN_ACTIVE = booleanPreferencesKey(\"scan_btn_active\")\n    val ENCRYPTION_MODE = stringPreferencesKey(\"encryption_mode\")\n    val DECRYPTION_MODE = stringPreferencesKey(\"decryption_mode\")\n    // 标准加密模式下，长按时间设置\n    val ENCRYPTION_LONG_PRESS_DELAY = longPreferencesKey(\"encryption_long_press_delay\")\n    // 标准解密模式下，悬浮窗的显示时间设置\n    val DECRYPTION_WINDOW_SHOW_TIME = longPreferencesKey(\"decryption_window_show_time\")\n    // 沉浸式解密下密文弹窗位置更新间隔\n    val DECRYPTION_WINDOW_POSITION_UPDATE_DELAY = longPreferencesKey(\"decryption_window_position_update_delay\")\n    // 按钮遮罩的颜色\n    val SEND_BTN_OVERLAY_COLOR = stringPreferencesKey(\"send_btn_overlay_color\")\n    // 控制弹出发送图片or文件视图的双击最大间隔时间\n    val SHOW_ATTACHMENT_VIEW_DOUBLE_CLICK_THRESHOLD = longPreferencesKey(\"show_attachment_view_double_click_threshold\")\n    val CUSTOM_APPS = stringPreferencesKey(\"custom_apps\")\n    //  当前密文风格\n    val CIPHERTEXT_STYLE = stringPreferencesKey(\"ciphertext_style\")\n    // 存储风格文本的最小和最大词语数\n    val CIPHERTEXT_STYLE_LENGTH_MIN = intPreferencesKey(\"ciphertext_style_length_min\")\n    val CIPHERTEXT_STYLE_LENGTH_MAX = intPreferencesKey(\"ciphertext_style_length_max\")\n}\n\nobject CommonKeys {\n    const val ENCRYPTION_MODE_STANDARD = \"standard\"\n    const val ENCRYPTION_MODE_IMMERSIVE = \"immersive\"\n    const val DECRYPTION_MODE_STANDARD = \"standard\"\n    const val DECRYPTION_MODE_IMMERSIVE = \"immersive\"\n}\n\nobject AppRegistry {\n    /**\n     * 包含所有受支持应用处理器实例的权威列表。\n     * 未来要支持新的App，只需要在这里新增一行即可！\n     * UI 和 Service 都会从这里读取信息。\n     */\n    val allHandlers: List<ChatAppHandler> = listOf(\n        QQHandler(),\n        WeChatHandler()\n        //  TelegramHandler(),\n        //  ... 以后在这里添加更多\n    )\n}\n\nenum class CryptoMode(val key: String, @StringRes val labelResId: Int){\n    STANDARD(\"standard\", R.string.mode_standard),\n    IMMERSIVE(\"immersive\", R.string.mode_immersive);\n\n    companion object {\n        /**\n         * 一个辅助函数，可以根据存储的 key 安全地找回对应的枚举实例。\n         * 如果找不到，就返回一个默认值。\n         */\n        fun fromKey(key: String?): CryptoMode {\n            // entries 是一个由编译器自动生成的属性，包含了枚举的所有实例\n            return entries.find { it.key == key } ?: STANDARD\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/MainActivity.kt",
    "content": "package me.wjz.nekocrypt\n\nimport android.os.Bundle\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.activity.enableEdgeToEdge\nimport androidx.compose.runtime.CompositionLocalProvider\nimport me.wjz.nekocrypt.data.LocalDataStoreManager\nimport me.wjz.nekocrypt.ui.MainMenu\nimport me.wjz.nekocrypt.ui.theme.NekoCryptTheme\nimport me.wjz.nekocrypt.util.PermissionGuard\n\nclass MainActivity : ComponentActivity() {\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        enableEdgeToEdge()//让App可以上下扩展到最顶端和最低端\n        //这是从传统 Android 视图系统切换到 Jetpack Compose 世界的“传送门”！\n        // 一旦调用了它，你就可以在这个大括号 {} 里面，用我们之前学过的 @Composable 函数来描绘你的 App 界面了。\n        setContent {\n            //这里不要在Compose UI中直接引用dataStoreManager，而是在这里注入一个，这样可以方便替换不同的manager，解耦方便复用\n            val app = application as NekoCryptApp\n            NekoCryptTheme {\n                //  权限检查\n                PermissionGuard {\n                    CompositionLocalProvider(LocalDataStoreManager provides app.dataStoreManager) {\n                        MainMenu()\n                    }\n                }\n\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/NekoCryptApp.kt",
    "content": "package me.wjz.nekocrypt\n\nimport android.app.Application\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.util.Log\nimport me.wjz.nekocrypt.data.DataStoreManager\n\nclass NekoCryptApp : Application() {\n    // 在 Application 创建时，我们懒加载地创建 DataStoreManager 的实例。\n    // 它只会被创建一次！\n    val dataStoreManager: DataStoreManager by lazy {\n        DataStoreManager(this)\n    }\n\n    override fun onCreate() {\n        super.onCreate()\n        createNotificationChannel() // 创建通知渠道，用于在 Android 8.0 及以上版本上显示通知\n        instance = this\n        Log.d(TAG, \"NekoCryptApp onCreate\")\n    }\n\n\n    companion object {\n        const val SERVICE_CHANNEL_ID = \"NekoCryptServiceChannel\"\n        const val TAG = \"NekoCrypt\"\n        lateinit var instance: NekoCryptApp private set\n    }\n\n    private fun createNotificationChannel() {\n        val serviceChannel = NotificationChannel(\n            SERVICE_CHANNEL_ID,\n            getString(R.string.notification_title),\n            NotificationManager.IMPORTANCE_LOW // 使用较低的重要性，避免打扰用户\n        )\n        val manager = getSystemService(NotificationManager::class.java)\n        manager.createNotificationChannel(serviceChannel)\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/data/DataStoreManager.kt",
    "content": "package me.wjz.nekocrypt.data\n\nimport android.content.Context\nimport android.util.Log\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.collectAsState\nimport androidx.compose.runtime.staticCompositionLocalOf\nimport androidx.datastore.core.DataStore\nimport androidx.datastore.preferences.core.Preferences\nimport androidx.datastore.preferences.core.edit\nimport androidx.datastore.preferences.preferencesDataStore\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.catch\nimport kotlinx.coroutines.flow.first\nimport kotlinx.coroutines.flow.map\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\nimport me.wjz.nekocrypt.Constant\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.service.handler.CustomAppHandler\n\nval Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = \"settings\")\n\n//建立一个LocalDataStoreManager的CompositionLocal，专门给ComposeUI用的\nval LocalDataStoreManager = staticCompositionLocalOf<DataStoreManager> {\n    error(\"No DataStoreManager provided\")\n}\n\n/**\n * ✨ [新增] 一个专门用于在Compose上下文中，以State的形式订阅密钥数组变化的Hook。\n *\n * @param initialValue 当Flow还在加载时的初始默认值。\n * @return 一个 State<Array<String>> 对象，它的 .value 会随着DataStore的变化而自动更新。\n */\n@Composable\nfun rememberKeyArrayState(initialValue: Array<String> = emptyArray()): State<Array<String>> {\n    val dataStoreManager = LocalDataStoreManager.current\n    return dataStoreManager.getKeyArrayFlow().collectAsState(initial = initialValue)\n}\n\n\n/**\n * ✨ [新增] 一个专门用于在Compose上下文中，以State的形式订阅customApp变化的Hook。\n *\n * @param initialValue 当Flow还在加载时的初始默认值。\n * @return 一个 State<Array<String>> 对象，它的 .value 会随着DataStore的变化而自动更新。\n */\n@Composable\nfun rememberCustomAppListState(initialValue: List<CustomAppHandler> = emptyList()): State<List<CustomAppHandler>> {\n    val dataStoreManager = LocalDataStoreManager.current\n    return dataStoreManager.getCustomAppsFlow().collectAsState(initial = initialValue)\n}\n\n\nclass DataStoreManager(private val context: Context) {\n\n    //通用的读取方法 (使用泛型)\n    fun <T> getSettingFlow(key: Preferences.Key<T>, defaultValue: T): Flow<T> {\n        return context.dataStore.data.map { preferences ->\n            preferences[key] ?: defaultValue\n        }.catch { exception -> throw exception }\n    }\n\n    //提供一个一次性的读取方法\n    suspend fun <T> readSetting(key: Preferences.Key<T>, defaultValue: T): T {\n        // .first() 是一个来自 kotlinx-coroutines-core 的魔法，\n        // 它会等待 Flow 发射第一个值，然后就返回，不再继续监听。\n        return getSettingFlow(key, defaultValue).first()\n    }\n\n    //通用的写入方法\n    suspend fun <T> saveSetting(key: Preferences.Key<T>, value: T) {\n        context.dataStore.edit { preferences -> preferences[key] = value }\n    }\n\n    /**\n     * (可选) 通用的清除单个设置的方法\n     */\n    suspend fun <T> clearSetting(key: Preferences.Key<T>) {\n        context.dataStore.edit { preferences -> preferences.remove(key) }\n    }\n\n    /**\n     * (可选) 清除所有设置的方法\n     */\n    suspend fun clearAllSettings() {\n        context.dataStore.edit { preferences -> preferences.clear() }\n    }\n\n    /**\n     * 保存密钥数组。\n     * 调用者只需要传入一个数组，无需关心JSON转换的细节。\n     */\n    suspend fun saveKeyArray(keys: Array<String>) {\n        val jsonString = Json.encodeToString(keys)\n        saveSetting(SettingKeys.ALL_THE_KEYS, jsonString)\n    }\n\n    /**\n     * 获取密钥数组，用于后台的上下文。\n     */\n    fun getKeyArrayFlow(): Flow<Array<String>> {\n        return getSettingFlow(SettingKeys.ALL_THE_KEYS, \"[]\").map { jsonString ->\n            if (jsonString.isEmpty()) arrayOf(Constant.DEFAULT_SECRET_KEY)\n            else {\n                try {\n                    val keys = Json.decodeFromString<Array<String>>(jsonString)\n                    if (keys.isEmpty()) arrayOf(Constant.DEFAULT_SECRET_KEY) else keys\n                } catch (e: Exception) {\n                    Log.e(\"Neko\", \"解析密钥数组失败!\", e)\n                    arrayOf(Constant.DEFAULT_SECRET_KEY) //解析失败返回默认值\n                }\n            }\n        }\n    }\n\n    /**\n     * 保存自定义应用列表。\n     * 追加形式保存\n     */\n    suspend fun addCustomApp(newApp: CustomAppHandler) {\n        // 1. 读取当前的列表\n        val currentApps = getCustomAppsFlow().first().toMutableList()\n        // 2. 添加新的配置\n        currentApps.add(newApp)\n        // 3. 将更新后的列表序列化成 JSON 字符串\n        val jsonString = Json.encodeToString(currentApps)\n        // 4. 保存回 DataStore\n        saveSetting(SettingKeys.CUSTOM_APPS, jsonString)\n    }\n\n    /**\n     * 删除包名对应的自定义handler\n     */\n    suspend fun deleteCustomApp(packageName:String){\n        val currentApps = getCustomAppsFlow().first().toMutableList()\n        currentApps.removeAll { it.packageName == packageName }\n        val jsonString = Json.encodeToString(currentApps)\n        saveSetting(SettingKeys.CUSTOM_APPS, jsonString)\n    }\n\n    /**\n     * ✨ 新增：获取自定义应用列表的 Flow。\n     * 它从 DataStore 读取JSON字符串，并将其反序列化为 CustomAppHandler 列表。\n     * 如果解析失败或没有数据，返回一个空列表。\n     */\n    fun getCustomAppsFlow(): Flow<List<CustomAppHandler>> {\n        // \"[]\" 是一个空的JSON数组，作为安全的默认值\n        return getSettingFlow(SettingKeys.CUSTOM_APPS, \"[]\").map { jsonString ->\n            try {\n                Json.decodeFromString<List<CustomAppHandler>>(jsonString)\n            } catch (e: Exception) {\n                Log.e(\"NekoCrypt\", \"解析自定义应用列表失败!\", e)\n                emptyList() // 解析失败时返回空列表\n            }\n        }\n    }\n\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/hook/DataStoreStateHook.kt",
    "content": "package me.wjz.nekocrypt.hook\n\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.datastore.preferences.core.Preferences\nimport androidx.lifecycle.compose.collectAsStateWithLifecycle\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.data.DataStoreManager\nimport me.wjz.nekocrypt.data.LocalDataStoreManager\nimport kotlin.reflect.KProperty\n\nclass DataStoreStateDelegate<T>(\n    private val state: State<T>,\n    private val scope: CoroutineScope,\n    private val saver: suspend (T) -> Unit\n){\n    /**\n     * `operator fun getValue`\n     * 当你读取属性时（如 `if (isChecked)`），Kotlin 会调用这个函数。\n     * 我们只需返回内部 `State` 的当前值。\n     */\n    operator fun getValue(thisRef: Any?, property: KProperty<*>): T {\n        return state.value\n    }\n\n    /**\n     * `operator fun setValue`\n     * 当你写入属性时（如 `isChecked = false`），Kotlin 会调用这个函数。\n     * 我们在这里启动一个协程，调用 `saver` lambda 将新值保存到 DataStore。\n     */\n    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {\n        scope.launch {\n            saver(value)\n        }\n    }\n}\n\n@Composable\nfun <T> rememberDataStoreState(\n    key: Preferences.Key<T>,\n    defaultValue: T\n): DataStoreStateDelegate<T> {\n    // 1. 获取全局唯一的 DataStoreManager 和协程作用域\n    val dataStoreManager: DataStoreManager = LocalDataStoreManager.current\n    val scope: CoroutineScope = rememberCoroutineScope()\n    //2. 从Flow里面拿数据并转化成Compose的State\n    val state: State<T> = dataStoreManager.getSettingFlow(key, defaultValue)\n        .collectAsStateWithLifecycle(initialValue = defaultValue)\n\n    // 用remember来创建并记住委托类实例\n    return remember(dataStoreManager, scope, key) {\n        DataStoreStateDelegate(\n            state = state,\n            scope = scope,\n            saver = { newValue -> dataStoreManager.saveSetting(key, newValue) }\n        )\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/hook/ServiceStateDelegate.kt",
    "content": "package me.wjz.nekocrypt.hook\n\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.flow.Flow\nimport kotlinx.coroutines.flow.collectLatest\nimport kotlinx.coroutines.launch\nimport kotlin.properties.ReadOnlyProperty\nimport kotlin.reflect.KProperty\n\n/**\n * 一个自定义的属性委托类，接收一个Flow，在指定的协程作用域自动订阅。\n */\nclass ServiceStateDelegate<T>(\n    private val flowProvider:()-> Flow<T>,\n    scope: CoroutineScope,\n    initialValue: T,\n) : ReadOnlyProperty<Any?, T> {\n    private var currentValue: T = initialValue\n\n    init {\n        scope.launch {\n            flowProvider().collectLatest { newValue ->\n                currentValue = newValue\n            }\n        }\n    }\n\n    override fun getValue(thisRef: Any?, property: KProperty<*>): T {\n        return currentValue\n    }\n}\n\nfun <T> CoroutineScope.observeAsState(\n    flowProvider: ()-> Flow<T>,\n    initialValue: T,\n): ReadOnlyProperty<Any?, T> {\n    return ServiceStateDelegate(flowProvider, this, initialValue)\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/KeepAliveService.kt",
    "content": "package me.wjz.nekocrypt.service\n\nimport android.app.Service\nimport android.content.Context\nimport android.content.Intent\nimport android.graphics.PixelFormat\nimport android.os.IBinder\nimport android.util.Log\nimport android.view.Gravity\nimport android.view.View\nimport android.view.WindowManager\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.util.NekoNotification\n\n/**\n * ✨ 一个专门用于保活的前台服务。\n * 它的唯一职责就是通过一个常驻通知，告诉系统我们的App正在运行重要任务。\n */\nclass KeepAliveService : Service() {\n    // 保活窗口\n    private var keepAliveOverlay: View? = null\n    private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }\n    companion object {\n        private const val TAG = NekoCryptApp.TAG\n\n        // ✨ 提供一个标准的启动方法，方便外部调用\n        fun start(context: Context) {\n            val intent = Intent(context, KeepAliveService::class.java)\n            context.startService(intent)\n        }\n\n        // ✨ 提供一个标准的停止方法\n        fun stop(context: Context) {\n            val intent = Intent(context, KeepAliveService::class.java)\n            context.stopService(intent)\n        }\n    }\n\n    private fun createKeepAliveOverlay() {\n        if (keepAliveOverlay != null) return\n        keepAliveOverlay = View(this)\n        val layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY\n        val params = WindowManager.LayoutParams(\n            0, 0, 0, 0, layoutFlag,\n            WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,\n            PixelFormat.TRANSPARENT\n        ).apply {\n            gravity = Gravity.TOP or Gravity.START\n        }\n        try {\n            windowManager.addView(keepAliveOverlay, params)\n            Log.d(TAG, \"“保活”悬浮窗创建成功！\")\n        } catch (e: Exception) {\n            Log.e(TAG, \"创建“保活”悬浮窗失败\", e)\n        }\n    }\n\n    private fun removeKeepAliveOverlay() {\n        keepAliveOverlay?.let {\n            try {\n                windowManager.removeView(it)\n                Log.d(TAG, \"“保活”悬浮窗已移除。\")\n            } catch (e: Exception) {\n                // 忽略窗口已经不存在等异常\n            } finally {\n                keepAliveOverlay = null\n            }\n        }\n    }\n\n    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {\n        Log.d(TAG, \"保活服务已启动。\")\n        // 1. 创建通知渠道（在Android 8.0及以上版本是必需的）\n        NekoNotification.createChannel(this)\n\n        // 2. 创建一个通知\n        val notification = NekoNotification.build(this)\n\n        // 3. ✨ 最关键的一步：将服务推到前台！\n        //    第一个参数是一个唯一的通知ID，第二个参数是我们创建的通知。\n        startForeground(NekoNotification.NEKO_NOTIFICATION_ID, notification)\n        // 我们同时创一个保活悬浮窗\n        createKeepAliveOverlay()\n        // START_STICKY 表示如果服务被系统意外杀死，系统会尝试重新启动它\n        return START_STICKY\n    }\n\n    override fun onDestroy() {\n        super.onDestroy()\n        removeKeepAliveOverlay()\n        Log.d(TAG, \"保活服务已销毁，保活悬浮窗已销毁。\")\n        stopForeground(true)\n    }\n\n    /**\n     * ✨ 实现 onBind 方法。\n     * 因为我们这是一个启动服务（Started Service），而不是绑定服务（Bound Service），\n     * 所以我们不需要处理绑定逻辑，直接返回 null 即可。\n     */\n    override fun onBind(intent: Intent?): IBinder? {\n        return null\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/handler/BaseChatAppHandler.kt",
    "content": "package me.wjz.nekocrypt.service.handler\n\nimport android.content.Context\nimport android.graphics.PixelFormat\nimport android.graphics.Rect\nimport android.net.Uri\nimport android.os.Bundle\nimport android.util.Log\nimport android.view.Gravity\nimport android.view.MotionEvent\nimport android.view.View\nimport android.view.WindowManager\nimport android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN\nimport android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE\nimport android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL\nimport android.view.accessibility.AccessibilityEvent\nimport android.view.accessibility.AccessibilityNodeInfo\nimport android.widget.Toast\nimport androidx.compose.runtime.CompositionLocalProvider\nimport androidx.compose.runtime.compositionLocalOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.setValue\nimport androidx.core.graphics.toColorInt\nimport com.dianming.phoneapp.MyAccessibilityService\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.Job\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.flow.collectLatest\nimport kotlinx.coroutines.isActive\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\nimport me.wjz.nekocrypt.Constant\nimport me.wjz.nekocrypt.CryptoMode\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.data.LocalDataStoreManager\nimport me.wjz.nekocrypt.ui.component.DecryptionPopup\nimport me.wjz.nekocrypt.ui.dialog.AttachmentPreviewState\nimport me.wjz.nekocrypt.ui.dialog.AttachmentState\nimport me.wjz.nekocrypt.ui.dialog.SendAttachmentDialog\nimport me.wjz.nekocrypt.util.CryptoManager\nimport me.wjz.nekocrypt.util.CryptoManager.applyCiphertextStyle\nimport me.wjz.nekocrypt.util.CryptoManager.containsCiphertext\nimport me.wjz.nekocrypt.util.CryptoUploader\nimport me.wjz.nekocrypt.util.NCFileProtocol\nimport me.wjz.nekocrypt.util.NCWindowManager\nimport me.wjz.nekocrypt.util.ResultRelay\nimport me.wjz.nekocrypt.util.findSingleNode\nimport me.wjz.nekocrypt.util.formatFileSize\nimport me.wjz.nekocrypt.util.getFileName\nimport me.wjz.nekocrypt.util.getFileSize\nimport me.wjz.nekocrypt.util.getImageAspectRatio\nimport me.wjz.nekocrypt.util.isEmpty\nimport me.wjz.nekocrypt.util.isFileImage\nimport me.wjz.nekocrypt.util.isNodeValid\nimport java.io.File\n\n// 创建一个CompositionLocal来提供给弹窗\nval LocalFileActionHandler = compositionLocalOf<((NCFileProtocol) -> Unit)?> { null }\n\nabstract class BaseChatAppHandler : ChatAppHandler {\n    protected val tag = \"NCBaseHandler\"\n\n    // 由子类提供具体应用的ID\n    abstract override val inputId: String\n    abstract override val sendBtnId: String\n    abstract override val messageTextId: String\n    abstract override val messageListClassName: String\n\n    // 处理器内部状态\n    private var service: MyAccessibilityService? = null\n\n    // 按钮遮罩的管理器\n    private var overlayWindowManager: WindowManager? = null\n    private var overlayView: View? = null\n    private var overlayManagementJob: Job? = null\n\n    // 为我们的界面节点变量做缓存\n    private var cachedSendBtnNode: AccessibilityNodeInfo? = null\n    private var cachedInputNode: AccessibilityNodeInfo? = null\n\n    // 为 RecyclerView/ListView 创建一个专属的缓存\n    private var cachedMessageListNode: AccessibilityNodeInfo? = null\n\n    // 为沉浸式解密创建一个\"防抖\"任务，避免过于频繁的扫描\n    private var immersiveDecryptionJob: Job? = null\n\n    // Key: 一个消息气泡的唯一标识符 (位置 + 文本哈希)\n    // Value: 管理这个气泡弹窗的 WindowPopupManager 实例\n    private val immersiveDecryptionCache = mutableMapOf<String, NCWindowManager>()\n\n    // ———————— 附件发送弹窗相关属性 ————————\n\n    // 拿来判断是否拉起图片、视频弹窗。\n    private var lastInputClickTime: Long = 0L\n    private var filePickerJob: Job? = null // ✨ 新增一个Job来监听结果\n    private var sendAttachmentDialogManager: NCWindowManager? = null\n    // 记录当前上传使用的缓存文件URI，对话框关闭时清理\n    private var pendingCacheUri: Uri? = null\n\n    // --- ✨ 附件发送弹窗相关的新增状态 ---\n    // 使用 Compose 的 State Delegate，这样当它们的值改变时，UI会自动更新\n    // ✨ 2. 只用一个 State 来管理所有UI状态\n    private var attachmentState by mutableStateOf(AttachmentState())\n\n    override fun onAccessibilityEvent(event: AccessibilityEvent, service: MyAccessibilityService) {\n        // 悬浮窗管理逻辑\n        if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED || event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {\n            if (service.useAutoEncryption) {\n                //只有开启加密，才会加上悬浮窗，每次事件改变，都要更新悬浮窗位置\n                overlayManagementJob?.cancel()\n                overlayManagementJob = service.serviceScope.launch(Dispatchers.Default) {\n                    handleOverlayManagement()   // 可能是添加、更新、删除悬浮窗\n                }\n            } else {\n                removeOverlayView()\n            }\n        }\n\n        // 解密逻辑，开启解密，才进行解密操作。\n        if (service.useAutoDecryption) {\n            when (service.decryptionMode) {\n                // 标准模式：用户点击密文时，才进行解密\n                CryptoMode.STANDARD.key -> {\n                    if (event.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED) {\n                        handleDecryption(event.source)\n                    }\n                }\n                // 沉浸模式：当窗口内容变化时，主动扫描并解密\n                CryptoMode.IMMERSIVE.key -> {\n                    if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED\n//                        || event.eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED\n                    ) {\n                        //带防抖处理\n                        immersiveDecryptionJob?.cancel()\n                        // 启动一个新的扫描任务\n                        immersiveDecryptionJob = service.serviceScope.launch(Dispatchers.Default) {\n                            // ✨ 等待n 毫秒，如果在这期间又有新的事件进来，这个任务就会被取消\n                            delay(service.decryptionWindowUpdateInterval)\n                            Log.d(tag, \"UI稳定，开始执行沉浸式解密...\")\n                            handleImmersiveDecryption()\n                        }\n                    }\n                }\n            }\n        }\n\n        // 监听点击事件，用来拉起图片视频文件发送弹窗\n        if (event.eventType == AccessibilityEvent.TYPE_VIEW_CLICKED) {\n            handleInputDoubleClick(event.source)\n        }\n    }\n\n    // 启动服务\n    override fun onHandlerActivated(service: MyAccessibilityService) {\n        this.service = service\n        this.overlayWindowManager =\n            service.getSystemService(Context.WINDOW_SERVICE) as WindowManager\n\n        filePickerJob = service.serviceScope.launch {\n            ResultRelay.flow.collectLatest { uri ->\n                // 当收到\"代办\"发回的URI时\n                Log.d(tag, \"收到文件URI: $uri\")\n                // 消费掉，防止 replay 导致重复处理\n                ResultRelay.consumeLast()\n                showAttachmentDialog()\n                startUpload(uri)\n            }\n        }\n\n        Log.d(tag, \"激活$packageName 处理器。\")\n    }\n\n    // 销毁服务\n    override fun onHandlerDeactivated() {\n        overlayManagementJob?.cancel()\n        immersiveDecryptionJob?.cancel()\n        filePickerJob?.cancel()\n        cleanupCacheFile()\n        // 用一个副本做遍历避免删除时下标异常\n        val managersToDismiss = immersiveDecryptionCache.values.toList()\n        // 依次关闭所有弹窗\n        managersToDismiss.forEach { it.dismiss() }\n        immersiveDecryptionCache.clear()    // 最后确保万无一失\n\n        cachedSendBtnNode = null\n        cachedInputNode = null\n        cachedMessageListNode = null\n        removeOverlayView {\n            // 在视图置空后，其他引用量也要置为空，方便gc回收\n            this.service = null\n            this.overlayWindowManager = null\n            Log.d(tag, \"取消$packageName 处理器。\")\n        }\n    }\n\n    /**\n     * 处理节点检查是否需要解密。\n     * @param sourceNode 用户点击的源节点。\n     */\n    private fun handleDecryption(sourceNode: AccessibilityNodeInfo?) {\n        val node = sourceNode ?: return\n        val text = node.text?.toString() ?: return\n        tryDecryptingText(text)?.let {\n            Log.d(tag, \"解密成功 -> $it\")\n            showDecryptionPopup(\n                decryptedText = it,\n                anchorNode = node,\n            )\n        }\n    }\n\n    /**\n     * 执行沉浸式解密相关逻辑。\n     */\n    private fun handleImmersiveDecryption() {\n        runCatching {\n            val currentService = service ?: return\n            val root = if (currentService.rootInActiveWindow.isEmpty()) getActiveWindowRoot()\n            else currentService.rootInActiveWindow\n            // 更新缓存节点\n            if (!isNodeValid(cachedMessageListNode)) {\n                if (root == null) {\n                    Log.e(tag, \"root节点为空，handlerImmersiveDecryption失败\")\n                    return\n                }\n                // 拿recycleView\n                cachedMessageListNode = findSingleNode(\n                    rootNode = root,\n                    className = messageListClassName\n                )\n            }\n\n            val messageNodes = if (cachedMessageListNode == null) findAllTextNodes(root!!) else\n                cachedMessageListNode!!.findAccessibilityNodeInfosByViewId(messageTextId)\n\n            if (messageNodes.isNullOrEmpty()) {\n                Log.d(tag, \"消息列表中无消息或已离开聊天界面，开始清理所有弹窗...\")\n                // 如果找不到任何消息内容，清空缓存\n                if (immersiveDecryptionCache.isNotEmpty()) {\n                    currentService.serviceScope.launch(Dispatchers.Main) {\n                        val managersToDismiss = immersiveDecryptionCache.values.toList()\n                        Log.d(tag, \"清理 ${managersToDismiss.size} 个残留弹窗。\")\n                        managersToDismiss.forEach { it.dismiss() }\n                    }\n                }\n                return\n            }\n\n            // 分成三类\n            val visibleCacheKeys = mutableSetOf<String>()   // 此轮可见的缓存key。\n            val creationTasks = mutableListOf<Triple<String, AccessibilityNodeInfo, String>>()\n            val updateTasks = mutableListOf<Pair<NCWindowManager, Rect>>()\n\n            for (node in messageNodes) {\n                // 解密出内容，再做处理，否则直接跳过\n                tryDecryptingText(node.text?.toString())?.let { decryptedText ->\n                    val nodeBounds = Rect()\n                    node.getBoundsInScreen(nodeBounds)\n                    val cacheKey = decryptedText.hashCode().toString()   // key就直接哈希\n                    visibleCacheKeys.add(cacheKey)\n\n                    // 如果弹窗已经存在，就加入更新位置的任务队列里\n                    immersiveDecryptionCache[cacheKey]?.let { manager ->\n                        updateTasks.add(manager to nodeBounds)\n                    } ?: run {\n                        // 如果弹窗不存在，则加入\"创建弹窗\"任务列表\n                        creationTasks.add(Triple(decryptedText, node, cacheKey))\n                    }\n                }\n            }\n            // 找到需要被清除的弹窗。比如用户滑动了窗口，有的弹窗对应的气泡不再可见，就需要消失。\n            val cachedKeys = immersiveDecryptionCache.keys.toSet()\n            val keysToDismiss = cachedKeys - visibleCacheKeys\n\n            if (keysToDismiss.isNotEmpty() || updateTasks.isNotEmpty() || creationTasks.isNotEmpty()) {\n                Log.d(tag, \"--- 沉浸式解密任务分配 ---\")\n                Log.d(tag, \"需要销毁的弹窗 (${keysToDismiss.size}个): $keysToDismiss\")\n                Log.d(tag, \"需要更新位置的弹窗 (${updateTasks.size}个)\")\n                Log.d(\n                    tag,\n                    \"需要新创建的弹窗 (${creationTasks.size}个): ${creationTasks.map { it.third }}\"\n                )\n                Log.d(tag, \"--------------------------\")\n            }\n\n            // 整理完毕，在主线程执行操作\n            if (keysToDismiss.isNotEmpty() || updateTasks.isNotEmpty() || creationTasks.isNotEmpty()) {\n                currentService.serviceScope.launch(Dispatchers.Main) {\n                    keysToDismiss.forEach {\n                        immersiveDecryptionCache[it]?.dismiss() // dismiss里面会自动让对象本身为null\n                    }\n                    updateTasks.forEach { (manager, rect) ->\n                        if (!isActive) return@forEach\n                        manager.updatePosition(rect)\n                    }\n                    creationTasks.forEach { (decryptedText, node, cacheKey) ->\n                        if (!isActive) return@forEach\n\n                        // ✨ [正确逻辑] 1. 调用通用函数，并传入\"从缓存移除自己\"的正确回调\n                        val popupManager = showDecryptionPopup(\n                            decryptedText = decryptedText,\n                            anchorNode = node,\n                            showTime = 30000, // 配置项为 currentService.decryptionWindowShowTime\n                            onDismiss = {\n                                // 这个回调在弹窗关闭时执行，完美地维护了缓存\n                                immersiveDecryptionCache.remove(cacheKey)\n                                Log.d(tag, \"弹窗关闭，从缓存中移除: $cacheKey\")\n                            }\n                        )\n                        // ✨ [正确逻辑] 2. 将返回的管理器实例存入缓存\n                        immersiveDecryptionCache[cacheKey] = popupManager\n                        Log.d(tag, \"新弹窗已创建并加入缓存: $cacheKey\")\n                    }\n                }\n            }\n        }.onFailure { exception ->\n            Log.e(\n                tag,\n                \"handlerImmersiveDecryption error:${exception.message}\"\n            )\n        }\n    }\n\n    /**\n     * 它会判断解密后的内容是普通文本还是我们的文件协议，并显示不同的UI。\n     */\n    private fun showDecryptionPopup(\n        decryptedText: String,\n        anchorNode: AccessibilityNodeInfo,\n        showTime: Long = service!!.decryptionWindowShowTime,\n        onDismiss: (() -> Unit)? = null,\n    ): NCWindowManager {\n\n        val anchorRect = Rect()\n        anchorNode.getBoundsInScreen(anchorRect)\n\n        var popupManager: NCWindowManager? = null\n        popupManager = NCWindowManager(\n            context = service!!,\n            onDismissRequest = {\n                onDismiss?.invoke()\n                popupManager = null\n            },\n            anchorRect = anchorRect\n        ) {\n            // ✨ 使用 CompositionLocalProvider 将 fileActionHandler 的 show 方法放入\"魔法通道\"\n            CompositionLocalProvider(\n                LocalFileActionHandler provides { fileInfo -> FileActionHandler(service!!).show(fileInfo) }\n            ) {\n                DecryptionPopup(\n                    decryptedText = decryptedText,\n                    onDismiss = { popupManager?.dismiss() },\n                    durationMills = showTime\n                )\n            }\n        }\n        popupManager!!.show()\n        return popupManager!!\n    }\n\n    // --- 所有悬浮窗和加密逻辑都内聚在这里 ---\n\n    /**\n     * ✨ 终极形态的悬浮窗管理逻辑 ✨\n     * 先检查缓存，再搜索\n     */\n    protected fun handleOverlayManagement() {\n        runCatching {\n            var sendBtnNode: AccessibilityNodeInfo?\n\n            // 1. 优先信任缓存\n            if (isNodeValid(cachedSendBtnNode)) {\n                sendBtnNode = cachedSendBtnNode\n            }\n            // 2. 缓存无效，则进行查找\n            else {\n                val currentService = service ?: return\n\n                val rootNode =\n                    if (currentService.rootInActiveWindow.isEmpty()) getActiveWindowRoot()\n                    else currentService.rootInActiveWindow ?: run {\n                        Log.e(tag, \"根节点为空，handleOverlayManagement失败\")\n                        return\n                    }\n\n                Log.d(tag, \"尝试在根节点 ${rootNode?.className} 中查找发送按钮...\")\n\n                sendBtnNode = findSingleNode(rootNode!!, sendBtnId)\n                //sendBtnNode = findNodeById(rootNode!!,sendBtnId)\n                cachedSendBtnNode = sendBtnNode\n            }\n\n            // 3. 根据最终的节点状态来决定如何操作\n            if (sendBtnNode != null) {\n                val rect = Rect()\n                sendBtnNode.getBoundsInScreen(rect)\n                if (!rect.isEmpty) {\n                    createOrUpdateOverlayView(rect)\n                } else {\n                    Log.d(tag, \"按钮虽存在但没有实际尺寸！\")\n                    removeOverlayView()\n                }\n            } else {\n                Log.d(tag, \"未找到有效发送按钮节点！\")\n                removeOverlayView()\n            }\n        }.onFailure { exception -> Log.e(tag, \"handleOverlayManagement错误。${exception.message}\") }\n    }\n\n    /**\n     * @param rect 悬浮窗的目标位置和大小。\n     */\n    protected fun createOrUpdateOverlayView(rect: Rect) {\n        val currentService = service ?: return\n        //  绘制悬浮窗位置所需要用到的参数\n        val params = getOverlayLayoutParams(rect)\n        currentService.serviceScope.launch(Dispatchers.Main) {\n            if (overlayView == null) {\n                overlayView = View(currentService).apply {\n                    setBackgroundColor(currentService.sendBtnOverlayColor.toColorInt())\n\n                    // ✨ 1. 首先，为视图定义一个标准的\"单击\"行为\n                    // 这就是我们的\"标准门铃按钮\"。\n                    setOnClickListener {\n                        // 标准模式下，短按执行普通发送\n                        if (currentService.encryptionMode == CryptoMode.STANDARD.key) {\n                            Log.d(currentService.tag, \"标准模式短按，执行普通发送！\")\n                            doNormalClick()\n                        }\n                        // 沉浸模式下，短按（单击）也执行加密发送\n                        else if (currentService.encryptionMode == CryptoMode.IMMERSIVE.key) {\n                            Log.d(currentService.tag, \"沉浸模式点击，执行加密！\")\n                            doEncryptAndClick()\n                        }\n                    }\n\n                    // ✨ 2. 然后，我们只用 onTouch 来\"监听\"手势，特别是长按\n                    var longPressJob: Job? = null\n                    setOnTouchListener { v, event -> // 'v' 就是这个 View 本身\n                        // 只在标准模式下才需要区分长按和短按\n                        if (currentService.encryptionMode == CryptoMode.STANDARD.key) {\n                            when (event.action) {\n                                MotionEvent.ACTION_DOWN -> {\n                                    longPressJob = currentService.serviceScope.launch {\n                                        delay(currentService.longPressDelay) // 长按阈值\n                                        Log.d(currentService.tag, \"标准模式长按，执行加密！\")\n                                        doEncryptAndClick()\n                                    }\n                                    true // 我们要处理后续事件\n                                }\n\n                                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {\n                                    // 如果手指抬起时，长按任务还在\"准备中\"...\n                                    if (longPressJob?.isActive == true) {\n                                        // ...说明这是一个短按，取消长按任务...\n                                        longPressJob.cancel()\n                                        // ✨ ...然后\"按响\"那个标准的门铃！\n                                        v.performClick()\n                                    }\n                                    // 如果长按任务已经执行或被取消，这里就什么都不做\n                                    true\n                                }\n\n                                else -> false\n                            }\n                        } else {\n                            // 在沉浸模式下，我们让标准的 OnClickListener 去处理所有点击\n                            // onTouch 只需要返回 false，表示\"我不管，让别人来处理\"\n                            false\n                        }\n                    }\n                }\n                overlayWindowManager?.addView(overlayView, params)\n                // 第一次添加进去的时候，位置很可能是歪的，延迟一定时间然后更新悬浮窗位置。\n                delay(1500)\n                val rect = Rect()\n                cachedSendBtnNode?.getBoundsInScreen(rect)\n                // overlayView存在才可以这么做\n                overlayView?.let {\n                    overlayWindowManager?.updateViewLayout(overlayView, getOverlayLayoutParams(rect))\n                }\n                Log.d(tag, \"悬浮窗位置修正，修正后位置：$rect\")\n            } else {\n                overlayWindowManager?.updateViewLayout(overlayView, params)\n            }\n        }\n    }\n\n    // 移除悬浮窗\n    protected fun removeOverlayView(onComplete: (() -> Unit)? = null) {\n        service?.serviceScope?.launch(Dispatchers.Main) {\n            if (overlayView != null && overlayWindowManager != null) {\n                overlayWindowManager?.removeView(overlayView)\n                overlayView = null\n\n                onComplete?.invoke()\n            }\n        }\n    }\n\n    // 自动加密并发送消息\n    protected fun doEncryptAndClick() {\n        runCatching {\n            val currentService = service ?: return\n            // ✨ 使用一个单独的协程来准备加密文本，避免阻塞\n            // 1. 查找输入框以获取原始文本\n            val root = if (service!!.rootInActiveWindow.isEmpty()) getActiveWindowRoot()\n            else currentService.rootInActiveWindow\n\n            val inputNode = findSingleNode(root!!, inputId, Constant.EDIT_TEXT)\n            val originalText = inputNode?.text?.toString()\n\n            // 2. 加密文本\n            val encryptedText = if (originalText!!.containsCiphertext()) originalText else\n                CryptoManager.encrypt(originalText, currentService.currentKey).applyCiphertextStyle()\n\n            // 3. 调用核心发送函数\n            setTextAndSend(encryptedText)\n        }.onFailure { exception -> Log.e(tag, \"doEncryptAndClick Error${exception.message}\") }\n    }\n\n    // 普通点击的发送逻辑 (用于标准模式的短按)\n    protected fun doNormalClick() {\n        if (!isNodeValid(cachedSendBtnNode)) {\n            val root = service?.rootInActiveWindow ?: return\n            cachedSendBtnNode = findSingleNode(root, sendBtnId)\n        }\n        cachedSendBtnNode?.performAction(AccessibilityNodeInfo.ACTION_CLICK)\n    }\n\n    private fun findAllTextNodes(rootNode: AccessibilityNodeInfo): List<AccessibilityNodeInfo> {\n        val results = mutableListOf<AccessibilityNodeInfo>()\n\n        fun searchRecursively(node: AccessibilityNodeInfo) {\n            // 1. 检查当前节点本身是否有可见的、非空白的文本\n            if (!node.text.isNullOrBlank()) {\n                // 为了避免把整个列表容器（它也可能有text）加进去，我们可以加一个额外的判断\n                // 比如，它的子节点不能再有包含文本的TextView了。\n                // 但为了简单和通用，我们先直接添加。\n                results.add(node)\n            }\n\n            // 2. 遍历所有子节点，并对每个子节点递归调用自己\n            for (i in 0 until node.childCount) {\n                node.getChild(i)?.let { child ->\n                    searchRecursively(child)\n                    // 回收节点，防止内存泄漏\n                    child.recycle()\n                }\n            }\n        }\n\n        searchRecursively(rootNode)\n        return results\n    }\n\n    /**\n     * 使用 ACTION_SET_TEXT 来直接设置文本，这是更安全、更专业的做法。\n     * 它不会污染用户的剪贴板！\n     * @param nodeInfo 目标节点。\n     * @param text 要设置的文本。\n     */\n    protected fun performSetText(nodeInfo: AccessibilityNodeInfo, text: String): Boolean {\n        // 检查节点是否支持\"设置文本\"这个动作。\n        if (nodeInfo.actionList.contains(AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_TEXT)) {\n            Log.d(tag, \"准备设置加密文本到输入框\")\n            // 1. 创建一个 Bundle (包裹)，用来存放我们要设置的文本。\n            val arguments = Bundle()\n            arguments.putCharSequence(\n                AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text\n            )\n\n            // 2. 对节点下达\"执行设置文本\"的命令，并把装有文本的\"包裹\"递给它。\n            if (!nodeInfo.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arguments)) {\n                Log.e(tag, \"performAction(ACTION_SET_TEXT) 直接返回失败。\")\n                return false\n            }\n            // 刷新节点，验证文本内容是否正确更新\n            nodeInfo.refresh()\n            if (nodeInfo.text.toString() == text) {\n                Log.d(tag, \"已将加密内容直接设置到节点: $text\")\n                return true\n            } else {\n                Log.d(tag, \"加密内容设置到textField失败，可能是内容过长: ${text.length}字\")\n                return false\n            }\n        } else {\n            // 如果节点不支持直接设置文本（比如不可编辑的TextView），我们再考虑其他策略。\n            // 比如弹窗提示，或者把解密内容复制到剪贴板（并明确告知用户）。\n            Log.d(tag, \"节点不支持设置文本。加密内容: $text\")\n            return false\n        }\n    }\n\n    /**\n     * ✨ 获取当前活跃窗口的根节点\n     * 优先寻找那个既活跃又获得焦点的窗口，这通常是用户正在交互的主窗口。\n     * @return 活跃窗口的根节点，如果找不到则返回null。\n     */\n    private fun getActiveWindowRoot(): AccessibilityNodeInfo? {\n        runCatching {\n            //从service里面拿的rootInActiveWindow不一定是真的，微信在部分端上拿到的root属性全为null，得想办法解决。\n            val windows = service!!.windows\n            // 优先寻找既活跃又获得焦点的窗口\n            val activeFocusedWindow = windows.find { it.isActive && it.isFocused }\n            if (activeFocusedWindow?.root != null) {\n                //Log.d(tag, \"✅ 成功定位到活跃且获得焦点的窗口 (ID: ${activeFocusedWindow.id})\")\n                return activeFocusedWindow.root\n            }\n\n            // 如果找不到，退而求其次，寻找第一个活跃的窗口\n            val activeWindow = windows.find { it.isActive }\n            if (activeWindow?.root != null) {\n                Log.w(tag, \"⚠️ 未找到获得焦点的窗口，回退到第一个活跃窗口 (ID: ${activeWindow.id})\")\n                return activeWindow.root\n            }\n\n            // 如果连活跃窗口都没有，就默认返回第一个\n            return service!!.rootInActiveWindow\n        }.onFailure { exception -> Log.e(tag, \"getActiveWindowRoot Error:${exception.message}\") }\n        return null\n    }\n\n    /**\n     * 处理输入框双击事件逻辑\n     */\n    private fun handleInputDoubleClick(sourceNode: AccessibilityNodeInfo?) {\n        val node = sourceNode ?: return\n        val currentService = service ?: return\n        // 1. 检查被点击的节点是不是我们关心的那个输入框\n        //    我们通过比较节点的 viewIdResourceName 来确认它的身份\n        if (node.viewIdResourceName == inputId) {\n            val currentTime = System.currentTimeMillis()\n\n            // 2. 检查距离上次点击的时间，是否在我们的\"双击\"阈值之内\n            if (currentTime - lastInputClickTime < currentService.showAttachmentViewDoubleClickThreshold) {\n                Log.d(tag, \"检测到输入框双击事件, 准备启动发送附件Activity\")\n                showAttachmentDialog()\n                lastInputClickTime = 0L\n            } else {\n                // 如果是第一次点击，或者距离上次点击太久，就只更新时间戳\n                lastInputClickTime = currentTime\n            }\n        }\n    }\n\n    fun getOverlayLayoutParams(anchorRect: Rect): WindowManager.LayoutParams {\n        val layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY\n\n        return WindowManager.LayoutParams(\n            anchorRect.width(),\n            anchorRect.height(),\n            anchorRect.left,\n            anchorRect.top,\n            layoutFlag,\n            FLAG_NOT_FOCUSABLE or FLAG_NOT_TOUCH_MODAL or FLAG_LAYOUT_IN_SCREEN,// 这句FLAG_LAYOUT_IN_SCREEN是关键\n            PixelFormat.TRANSLUCENT\n        ).apply {\n            gravity = Gravity.TOP or Gravity.START\n        }\n    }\n\n    /**\n     * 创建并显示\"发送附件\"对话框\n     */\n    private fun showAttachmentDialog() {\n        // 每次创建的时候就重置attachmentState\n        resetAttachmentState()\n        val currentService = service ?: return\n        if (sendAttachmentDialogManager != null) return\n\n        sendAttachmentDialogManager = NCWindowManager(\n            context = currentService,\n            onDismissRequest = {\n                sendAttachmentDialogManager = null\n                cleanupCacheFile()\n            },\n            anchorRect = null\n        ) {\n            CompositionLocalProvider(\n                LocalDataStoreManager provides NekoCryptApp.instance.dataStoreManager\n            ) {\n                SendAttachmentDialog(\n                    onDismissRequest = {\n                        sendAttachmentDialogManager?.dismiss()\n                    },\n                    onSendRequest = { url ->\n                        Log.d(tag, \"准备发送URL: $url\")\n                        setTextAndSend(url)\n                        sendAttachmentDialogManager?.dismiss()\n                    },\n                    attachmentState = attachmentState\n                )\n            }\n        }\n        sendAttachmentDialogManager?.show()\n    }\n\n    /**\n     * ✨ 核心的、可复用的\"设置文本并发送\"函数\n     * 它封装了查找节点、设置文本、轮询查找按钮并点击的完整健壮流程。\n     * @param textToSet 需要设置到输入框的最终文本。\n     */\n    private fun setTextAndSend(textToSet: String) {\n        val currentService = service\n        if (currentService == null) {\n            Log.d(tag, \"service为null！不执行发送！\")\n            return\n        }\n        val root = if (service!!.rootInActiveWindow.isEmpty()) getActiveWindowRoot()\n        else currentService.rootInActiveWindow\n        if (root == null) {\n            Log.d(tag, \"root为null！不执行发送！\")\n            return\n        }\n        currentService.serviceScope.launch {\n            // --- 更新缓存的输入框节点 ---\n            cachedInputNode = if (isNodeValid(cachedInputNode)) cachedInputNode\n            else findSingleNode(root, inputId, Constant.EDIT_TEXT)\n\n            if (cachedInputNode == null) {\n                Log.e(tag, \"发送失败：未能精确找到EditText输入框！\")\n                showToast(\"发送失败：找不到输入框\")\n                return@launch\n            }\n\n            // --- 2. 设置文本 ---\n            performSetText(cachedInputNode!!, textToSet).let { success ->\n                if (!success) {\n                    showToast(currentService.getString(R.string.set_text_failed, textToSet.length))\n                    return@launch\n                }\n            }\n\n            // --- 更新缓存的发送按钮 ---\n            repeat(5) { attempt ->\n                cachedSendBtnNode = findSingleNode(root, sendBtnId, Constant.VIEW_ID_BTN)\n                if (cachedSendBtnNode != null) {\n                    return@repeat\n                }\n                Log.d(tag, \"第 ${attempt + 1} 次尝试查找发送按钮...\")\n                delay(100)\n            }\n\n            // --- 4. 根据查找结果执行操作 ---\n            if (cachedSendBtnNode != null) {\n                Log.d(tag, \"成功找到发送按钮，执行点击！\")\n                cachedSendBtnNode!!.performAction(AccessibilityNodeInfo.ACTION_CLICK)\n            } else {\n                Log.e(tag, \"发送失败：在设置文本后，依然未能找到发送按钮！\")\n                showToast(\"发送失败：找不到发送按钮\")\n            }\n        }\n    }\n\n    // 收到flow中的uri之后，读取资源并上传。附带了更新预览状态\n    @OptIn(ExperimentalCoroutinesApi::class)\n    private fun startUpload(uri: Uri) {\n        val currentService = service ?: return\n        // 记录缓存文件URI，对话框关闭时清理\n        if (uri.scheme == \"file\") {\n            pendingCacheUri = uri\n        }\n        // 在IO线程读取文件\n        currentService.serviceScope.launch(Dispatchers.IO) {\n            try {\n                val fileSize = getFileSize(uri)\n                // 判断文件大小。当前接口最大支持50M。\n                if (fileSize > CryptoUploader.MAX_FILE_SIZE) {\n                    showToast(\n                        currentService.getString(\n                            R.string.crypto_attachment_file_too_large,\n                            CryptoUploader.MAX_FILE_SIZE / (1024 * 1024)\n                        )\n                    )\n                    return@launch\n                }\n\n                showToast(\n                    currentService.getString(\n                        R.string.crypto_attachment_chosen_path,\n                        uri.path\n                    )\n                )\n\n                // 更新预览状态\n                updateAttachmentState { currentState ->\n                    currentState.copy(\n                        previewInfo = AttachmentPreviewState(\n                            uri = uri,\n                            fileName = getFileName(uri),\n                            fileSizeFormatted = fileSize.formatFileSize(),\n                            isImage = isFileImage(uri),\n                            imageAspectRatio = getImageAspectRatio(uri)\n                        )\n                    )\n                }\n\n                // 开始上传，先拿到bytes，拿不到就直接返回。\n                val fileBytes = currentService.contentResolver.openInputStream(uri)?.use { it.readBytes() }\n                    ?: return@launch\n\n                // 目前上传接口似乎不支持流式上传。\n                val result: NCFileProtocol = CryptoUploader.upload(\n                    fileBytes = fileBytes,\n                    encryptionKey = currentService.currentKey,\n                    fileName = getFileName(uri),\n                    onProcess = { progressInt ->\n                        // 将 0-100 的 Int 进度转换为 0.0-1.0 的 Float\n                        val progressFloat = progressInt / 100.0f\n                        // 在主线程更新UI\n                        launch(Dispatchers.Main) {\n                            updateAttachmentState { currentState ->\n                                currentState.copy(progress = progressFloat)\n                            }\n                        }\n                    },\n                )\n\n                // 4. 上传成功，更新UI\n                updateAttachmentState { currentState ->\n                    currentState.copy(\n                        result = result.toEncryptedString(currentService.currentKey),\n                        progress = null\n                    )\n                }\n                Log.d(tag, \"上传成功，结果: $result\")\n\n            } catch (e: Exception) {\n                // 5. 统一处理所有异常\n                Log.e(tag, \"上传失败: \", e)\n                showToast(\n                    currentService.getString(\n                        R.string.crypto_attachment_upload_failed,\n                        e.message\n                    )\n                )\n                resetAttachmentState()\n            }\n            // 不在 finally 里删缓存文件，因为预览图还要用\n            // 缓存文件在对话框关闭时统一清理\n        }\n    }\n\n    /**\n     * ✨ 核心的\"解密引擎\"函数\n     * 它的职责单一，就是尝试解密一段文本。\n     * @param textToDecrypt 可能包含密文的原始字符串。\n     * @return 如果解密成功，返回明文字符串；否则返回null。\n     */\n    private fun tryDecryptingText(textToDecrypt: String?): String? {\n        if (textToDecrypt == null) return null\n        val currentService = service ?: return null\n        // 1. 先判断是否真的包含\"猫语\"，避免不必要的计算\n        if (!textToDecrypt.containsCiphertext()) {\n            return null\n        }\n        Log.d(tag, \"检测到密文: $textToDecrypt\")\n        // 2. 尝试用所有密钥进行解密\n        Log.d(tag, \"目前的全部密钥${currentService.cryptoKeys.joinToString()}\")\n        // 2. 遍历所有密钥进行尝试\n        for (key in currentService.cryptoKeys) {\n            val decryptedText = CryptoManager.decrypt(textToDecrypt, key)\n            if (decryptedText != null) {\n                // 3. 只要有一个成功，就立刻返回结果\n                Log.d(tag, \"解密成功 -> $decryptedText\")\n                return decryptedText\n            }\n        }\n        // 4. 如果所有密钥都失败了，返回null\n        return null\n    }\n\n    // 重置附件的状态\n    fun resetAttachmentState() {\n        attachmentState = AttachmentState()\n    }\n\n    // 更新附件状态\n    private suspend fun updateAttachmentState(updater: (currentState: AttachmentState) -> AttachmentState) {\n        // 使用 serviceScope 在主线程安全地更新状态\n        withContext(Dispatchers.Main) {\n            attachmentState = updater(attachmentState)\n        }\n    }\n\n    suspend fun showToast(string: String, duration: Int = Toast.LENGTH_SHORT) {\n        Log.d(tag, \"showToast: $string\")\n        withContext(Dispatchers.Main) {\n            Toast.makeText(service, string, duration).show()\n        }\n    }\n\n    /**\n     * 清理上传使用的缓存文件\n     */\n    private fun cleanupCacheFile() {\n        pendingCacheUri?.path?.let { path ->\n            val cacheFile = File(path)\n            if (cacheFile.exists()) {\n                val deleted = cacheFile.delete()\n                Log.d(tag, if (deleted) \"缓存文件已清理: $path\" else \"缓存文件删除失败: $path\")\n            }\n        }\n        pendingCacheUri = null\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/handler/ChatAppHandler.kt",
    "content": "package me.wjz.nekocrypt.service.handler\n\nimport android.view.accessibility.AccessibilityEvent\nimport com.dianming.phoneapp.MyAccessibilityService\n\n\n/**\n * 聊天应用处理器的通用接口。\n * 定义了所有受支持的聊天应用都需要提供的基本信息和逻辑。\n */\ninterface ChatAppHandler {\n    /**\n     * 该处理器对应的应用包名。\n     */\n    val packageName: String\n\n    /**\n     * 聊天界面输入框的资源ID。\n     */\n    val inputId: String\n\n    /**\n     * 聊天界面发送按钮的资源ID。\n     */\n    val sendBtnId: String\n\n    /**\n     * 气泡消息的ID\n     */\n    val messageTextId: String\n\n    /**\n     * 存放消息列表的className，QQ的这个class无ID，则不提供\n     */\n    val messageListClassName: String\n\n    /**\n     * 当该处理器被激活时调用（例如，用户打开了对应的App）。\n     * @param service 无障碍服务的实例，用于获取上下文、协程作用域等。\n     */\n    fun onHandlerActivated(service: MyAccessibilityService)\n\n    /**\n     * 当该处理器被停用时调用（例如，用户离开了对应的App）。\n     */\n    fun onHandlerDeactivated()\n\n    /**\n     * 处理该应用相关的无障碍事件。\n     * @param event 接收到的事件。\n     * @param service 无障碍服务的实例。\n     */\n    fun onAccessibilityEvent(event: AccessibilityEvent, service: MyAccessibilityService)\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/handler/CustomAppHandler.kt",
    "content": "package me.wjz.nekocrypt.service.handler\n\nimport kotlinx.serialization.Serializable\n\n/**\n * 一个数据类，用于表示用户自定义的应用配置。\n * @Serializable 注解是必须的，它告诉 kotlinx.serialization 库这个类可以被转换成JSON。\n */\n@Serializable\ndata class CustomAppHandler(\n    // 需要重写 ChatAppHandler 接口中的所有属性\n    override val packageName: String,\n    override val inputId: String,\n    override val sendBtnId: String,\n    override val messageTextId: String,\n    override val messageListClassName: String\n\n) : BaseChatAppHandler()\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/handler/FileActionHandler.kt",
    "content": "package me.wjz.nekocrypt.service.handler\n\nimport android.content.ContentValues\nimport android.content.Intent\nimport android.net.Uri\nimport android.os.Build\nimport android.os.Environment\nimport android.provider.MediaStore\nimport android.util.Log\nimport android.webkit.MimeTypeMap\nimport android.widget.Toast\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.setValue\nimport com.dianming.phoneapp.MyAccessibilityService\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.ui.dialog.FilePreviewDialog\nimport me.wjz.nekocrypt.util.CryptoDownloader\nimport me.wjz.nekocrypt.util.NCFileProtocol\nimport me.wjz.nekocrypt.util.NCWindowManager\nimport me.wjz.nekocrypt.util.getCacheFileFor\nimport me.wjz.nekocrypt.util.getUriForFile\nimport java.io.IOException\n\n/**\n * 点击文件or图片按钮后的处理类，负责控制悬浮窗的生命周期，并负责下载，展示等逻辑\n */\nclass FileActionHandler(private val service: MyAccessibilityService) {\n    private val tag =\"NCFileActionHandler\"\n    private var dialogManager: NCWindowManager? = null\n    private var downloadProgress by mutableStateOf<Int?>(null)\n    private var downloadedFileUri by mutableStateOf<Uri?>(null)\n    private var isImageSavedThisTime by mutableStateOf(false)\n\n    /**\n     * 显示文件预览对话框\n     */\n    fun show(fileInfo: NCFileProtocol) {\n        dismiss() // 先关闭旧的\n\n        // 根据文件信息生成本地缓存的唯一路径\n        val targetFile = getCacheFileFor(service,fileInfo)\n\n        // 检查缓存文件是否完整\n        if (targetFile.exists() && targetFile.length() == fileInfo.size) {\n            Log.d(tag, \"文件已在缓存中找到: ${targetFile.path}\")\n            // ✨ 如果缓存命中，直接为文件生成安全的Uri\n            downloadedFileUri = getUriForFile(service,targetFile)\n            downloadProgress = null\n        } else {\n            Log.d(tag, \"文件未缓存或不完整，准备下载。\")\n            downloadedFileUri = null // 未缓存，重置状态\n            downloadProgress = null\n        }\n        // 创建视图\n        dialogManager = NCWindowManager(\n            context = service,\n            onDismissRequest = { dialogManager = null },\n            anchorRect = null\n        ) {\n            FilePreviewDialog(\n                fileInfo = fileInfo,\n                downloadProgress = downloadProgress, // ✨ 将进度状态传递给UI\n                downloadedFileUri = downloadedFileUri, // nullable\n                isImageSavedThisTime = isImageSavedThisTime, // 本次会话中是否把图片保存到了系统相册\n                onDismissRequest = { dismiss() },\n                onDownloadRequest = { info ->\n                    startDownload(info)\n                },\n                onOpenRequest = { uri ->\n                    openFile(uri,fileInfo) // ✨ 回调现在直接使用 Uri\n                },\n                onSaveToGalleryRequest = {uri ->\n                    service.serviceScope.launch {\n                        isImageSavedThisTime = saveImageToGallery(uri, fileInfo)\n                    }\n                }\n            )\n        }\n        dialogManager?.show()\n    }\n\n    /**\n     * 关闭对话框\n     */\n    fun dismiss() {\n        dialogManager?.dismiss()\n        dialogManager = null\n    }\n\n    /**\n     * 启动文件下载\n     */\n    private fun startDownload(fileInfo: NCFileProtocol) {\n        if(downloadProgress != null)  return // 保证健壮性，防止重复点击\n\n        service.serviceScope.launch {\n            val targetFile = getCacheFileFor(service,fileInfo)\n            try{\n                downloadProgress = 0\n                // download会suspend。\n                val result = CryptoDownloader.download(\n                    fileInfo = fileInfo,\n                    targetFile = targetFile,\n                    onProgress = { progress -> downloadProgress = progress }\n                )\n\n                if(result.isSuccess){\n                    val file = result.getOrThrow()\n                    // ✨ 下载成功后，为新文件生成安全的Uri并更新状态\n                    downloadedFileUri = getUriForFile(service,file)\n                    Log.d(tag, \"文件下载成功，Uri: $downloadedFileUri\")\n                }else{\n                    val error = result.exceptionOrNull()?.message ?: \"未知错误\"\n                    Log.e(tag, \"文件下载失败: $error\")\n                    showToast(service.getString(R.string.dialog_download_file_download_failed, error))\n                }\n            } finally {\n                downloadProgress = null\n            }\n        }\n    }\n\n    suspend fun showToast(string: String, duration: Int = Toast.LENGTH_SHORT) {\n        Log.d(tag, \"showToast: $string\")\n        withContext(Dispatchers.Main) {\n            Toast.makeText(service.applicationContext, string, duration).show()\n        }\n    }\n\n    private fun openFile(uri: Uri,fileInfo: NCFileProtocol){\n        service.serviceScope.launch {\n            try{\n                // 1. ✨ 从原始文件名中获取文件后缀\n                val extension = fileInfo.name.substringAfterLast('.', \"\")\n                // 2. ✨ 使用 MimeTypeMap 将后缀转换为标准的MIME类型\n                val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())\n                    ?: \"*/*\" // 如果找不到，使用通用类型\n\n                val intent = Intent(Intent.ACTION_VIEW).apply {\n                    setDataAndType(uri,mimeType)\n                    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)\n                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n                }\n                service.startActivity(intent)\n                dismiss() // 选择打开文件的话，就要关闭当前的悬浮窗\n\n            } catch (e: Exception) {\n                Log.e(tag, \"打开文件失败\", e)\n                showToast(service.getString(R.string.cannot_open_file))\n            }\n        }\n    }\n\n    // 根据uri和文件名保存到系统相册，并返回操作结果。\n    private suspend fun saveImageToGallery(uri: Uri, fileInfo: NCFileProtocol): Boolean {\n\n        val success = withContext(Dispatchers.IO) {\n            runCatching {\n                val extension = fileInfo.name.substringAfterLast('.', \"\")\n                val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)\n\n                // ContentValues 就像一个“档案袋”，我们把新文件的所有信息（元数据）都放进去。\n                val contentValues = ContentValues().apply {\n                    put(MediaStore.MediaColumns.DISPLAY_NAME, fileInfo.name)      // 文件在相册里显示的名字。\n                    put(MediaStore.MediaColumns.MIME_TYPE, mimeType)         // 文件的mime类型\n                    // 档案3 & 4 (仅限 Android 10 及以上)：\n                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n                        // 告诉系统要把这个文件放在公共的“相册”文件夹里。\n                        put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)\n                        // 先把文件标记为“待定”状态。这意味着在文件内容被完全写入之前，\n                        // 其他应用（包括相册自己）是看不到这个文件的，可以防止出现损坏的半成品文件。\n                        put(MediaStore.MediaColumns.IS_PENDING, 1)\n                    }\n                }\n\n                // 用我们写好的信息，去申请一个URI\n                val imageUri = service.contentResolver.insert(\n                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,\n                    contentValues\n                )\n                    ?: throw IOException(\"无法在相册中创建新文件。\")\n\n                // 使用我们新的imageUri，写入文件\n                service.contentResolver.openOutputStream(imageUri).use { outputStream ->\n                    service.contentResolver.openInputStream(uri).use { inputStream ->\n                        requireNotNull(inputStream) { \"无法打开缓存文件的输入流\" }\n                        requireNotNull(outputStream) { \"无法打开相册文件的输出流\" }\n                        inputStream.copyTo(outputStream)\n                    }\n                }\n\n                // (仅限 Android 10 及以上) 文件内容已经写完，我们再次更新档案，\n                // 把“待定”状态改为0，正式通知系统：“文件已准备就绪，可以对外展示了！”\n                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n                    contentValues.clear()\n                    contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)\n                    service.contentResolver.update(imageUri, contentValues, null, null)\n                }\n\n                //顺利完成，返回true\n                true\n            }.onFailure { e ->\n                Log.e(tag, \"保存图片到相册失败\", e)\n                false // 返回失败\n            }.getOrDefault(false) // 拿不到，默认就返回false\n        }\n        if (success) showToast(service.getString(R.string.image_saved_to_gallery_success))\n        else showToast(service.getString(R.string.image_saved_to_gallery_failed))\n        return success\n    }\n}\n\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/handler/QQHandler.kt",
    "content": "package me.wjz.nekocrypt.service.handler\n\n/**\n * 针对 QQ 的具体处理器实现。\n */\nclass QQHandler : BaseChatAppHandler() {\n    companion object{\n        const val ID_SEND_BTN=\"com.tencent.mobileqq:id/send_btn\"\n        const val ID_INPUT=\"com.tencent.mobileqq:id/input\"\n        // 某些版本ID_MESSAGE_TEXT是SQB\n        const val ID_MESSAGE_TEXT=\"com.tencent.mobileqq:id/sbl\"\n        const val PACKAGE_NAME =\"com.tencent.mobileqq\"\n        const val APP_NAME =\"QQ\"\n        const val CLASS_NAME_RECYCLER_VIEW=\"RecyclerView\"\n    }\n\n    override val packageName: String get() = PACKAGE_NAME\n    override val inputId: String get() = ID_INPUT\n\n    override val sendBtnId: String get() = ID_SEND_BTN\n\n    override val messageTextId: String get() = ID_MESSAGE_TEXT\n    override val messageListClassName: String get() = CLASS_NAME_RECYCLER_VIEW\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/service/handler/WeChatHandler.kt",
    "content": "package me.wjz.nekocrypt.service.handler\n\nclass WeChatHandler : BaseChatAppHandler() {\n    companion object{\n        const val ID_SEND_BTN=\"com.tencent.mm:id/bql\"\n        const val ID_INPUT=\"com.tencent.mm:id/bkk\"\n        const val ID_MESSAGE_TEXT=\"com.tencent.mm:id/bkl\"\n        const val PACKAGE_NAME =\"com.tencent.mm\"\n        const val CLASS_NAME_RECYCLER_VIEW = \"com.tencent.mm:id/bp0\"\n        const val APP_NAME =\"微信\"\n    }\n\n    override val packageName: String\n        get() = PACKAGE_NAME\n\n    override val inputId: String\n        get() = ID_INPUT\n\n    override val sendBtnId: String\n        get() = ID_SEND_BTN\n\n    override val messageTextId: String\n        get() = ID_MESSAGE_TEXT\n\n    override val messageListClassName: String\n        get() = CLASS_NAME_RECYCLER_VIEW\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/Components.kt",
    "content": "package me.wjz.nekocrypt.ui\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.Spring\nimport androidx.compose.animation.core.spring\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.grid.GridCells\nimport androidx.compose.foundation.lazy.grid.LazyVerticalGrid\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Check\nimport androidx.compose.material.icons.outlined.Info\nimport androidx.compose.material.icons.outlined.Palette\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.LocalContentColor\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.RangeSlider\nimport androidx.compose.material3.SegmentedButton\nimport androidx.compose.material3.SingleChoiceSegmentedButtonRow\nimport androidx.compose.material3.Slider\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Switch\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.TextField\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.graphics.RectangleShape\nimport androidx.compose.ui.graphics.luminance\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.core.graphics.toColorInt\nimport androidx.datastore.preferences.core.Preferences\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.hook.rememberDataStoreState\nimport kotlin.math.roundToInt\nimport kotlin.math.roundToLong\n\n/**\n * 这是一个自定义的、用于显示设置分组标题的组件。\n * @param title 要显示的标题文字。\n */\n@Composable\nfun SettingsHeader(title: String) {\n    Text(\n        text = title,\n        style = MaterialTheme.typography.titleMedium,\n        fontWeight = FontWeight.SemiBold,\n        fontSize = 16.sp,\n        modifier = Modifier\n            .padding(horizontal = 16.dp, vertical = 8.dp)\n    )\n}\n\n/**\n * 这是一个自定义的、带开关的设置项组件。\n * 它内部管理自己的 DataStore 状态，并通过一个验证回调来决定是否要更新状态，\n * 从而避免了在权限不足时开关“闪烁”的问题。\n *\n * @param key 用于在 DataStore 中存取状态的 Key。\n * @param defaultValue 开关的默认值。\n * @param icon 左侧显示的图标。\n * @param title 主标题文字。\n * @param subtitle 副标题（描述性文字）。\n * @param onCheckValidated 一个验证回调。当用户尝试改变开关状态时，会先调用它。\n * 你需要在这个回调里执行权限检查等逻辑，并返回 `true` (允许改变) 或 `false` (阻止改变)。\n * @param onStateChanged 当状态被成功改变后，会调用这个回调。你可以在这里执行发送指令等副作用操作。\n */\n@Composable\nfun SwitchSettingItem(\n    key: Preferences.Key<Boolean>,\n    defaultValue: Boolean,\n    icon: @Composable () -> Unit,\n    title: String,\n    subtitle: String,\n    onCheckValidated: suspend (Boolean) -> Boolean = { true },\n    onStateChanged: (Boolean) -> Unit = {},\n) {\n    // 1. 组件自己管理自己的状态，从 DataStore 读取和写入\n    var isChecked by rememberDataStoreState(key, defaultValue)\n    val scope = rememberCoroutineScope()\n\n    // 2. 定义一个统一的状态变更处理器\n    val changeHandler = { desiredState: Boolean ->\n        scope.launch {\n            // 3. 在改变状态前，先调用外部传入的“验证函数”\n            val canChange = onCheckValidated(desiredState)\n            // 4. 只有“验证函数”返回 true，才真正更新状态\n            if (canChange) {\n                isChecked = desiredState\n                // 5. 状态成功更新后，通知外部\n                onStateChanged(desiredState)\n            }\n            // ✨ 如果 canChange 是 false，这里什么都不做，UI上的开关也就不会动啦！\n        }\n    }\n\n    // 用Row来水平排列元素\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable { changeHandler(!isChecked) } // 点击整行也能触发状态变更\n            .padding(horizontal = 16.dp, vertical = 12.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        //显示图标\n        icon()\n        // 占一点间距\n        Spacer(modifier = Modifier.width(16.dp))\n        //用Column来垂直排列主标题和副标题\n        Column(modifier = Modifier.weight(1f)) {// weight(1f)让这一列占满所有剩余空间\n            Text(text = title, style = MaterialTheme.typography.titleMedium)\n            Text(\n                text = subtitle, style = MaterialTheme.typography.bodyMedium,\n                color = LocalContentColor.current.copy(alpha = 0.6f)\n            ) // 让副标题颜色浅一点\n        }\n        Switch(checked = isChecked, onCheckedChange = { changeHandler(it) })\n    }\n}\n\n@Composable\nfun ClickableSettingItem(\n    icon: @Composable () -> Unit,\n    title: String,\n    onClick: () -> Unit,\n) {\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clickable(onClick = onClick) // 设置点击事件\n            .padding(16.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        icon()\n        Spacer(modifier = Modifier.width(16.dp))\n        Text(text = title, style = MaterialTheme.typography.bodyLarge)\n    }\n}\n\n\n@Composable\nfun SwitchSettingCard(\n    key: Preferences.Key<Boolean>,\n    defaultValue: Boolean,\n    title: String,\n    subtitle: String,\n    modifier: Modifier = Modifier,\n    onCheckedChanged: (Boolean) -> Unit = {},\n) {\n    var isChecked by rememberDataStoreState(key, defaultValue)\n    // 将形状定义为一个变量，方便复用\n    val cardShape = RoundedCornerShape(16.dp)\n    Card(\n        modifier = modifier\n            .fillMaxWidth()\n            .clip(cardShape)\n            .clickable {\n                isChecked = !isChecked\n                onCheckedChanged(isChecked)\n            },\n        shape = cardShape,\n        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)\n    ) {\n        Row(\n            modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.SpaceBetween\n        ) {\n            Column(\n                modifier = Modifier\n                    .weight(1f)\n                    .padding(end = 16.dp)\n            ) {\n                Text(\n                    text = title,\n                    style = MaterialTheme.typography.titleMedium,\n                    fontWeight = FontWeight.Bold\n                )\n                Spacer(modifier = Modifier.height(4.dp))\n                Text(\n                    text = subtitle,\n                    style = MaterialTheme.typography.bodyMedium,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n            }\n            // 开关的状态直接绑定到我们内部的 isChecked 变量\n            Switch(\n                checked = isChecked,\n                onCheckedChange = {\n                    isChecked = it\n                    onCheckedChanged(it)\n                }\n            )\n        }\n    }\n}\n\n// 分段按钮实现\n\ndata class RadioOption(val key: String, val label: String)\n\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun SegmentedButtonSetting(\n    settingKey: Preferences.Key<String>,\n    title: String,\n    options: List<RadioOption>,\n    defaultOptionKey: String,\n    modifier: Modifier = Modifier,\n    titleExtraContent: (@Composable () -> Unit)? = null,    //标题旁边的内容\n) {\n    var currentSelection by rememberDataStoreState(settingKey, defaultOptionKey)\n\n    Column(\n        modifier = modifier.padding(start = 8.dp, end = 8.dp),\n        verticalArrangement = Arrangement.spacedBy((-12).dp)\n    ) {\n        // 字体和旁边的按钮设置\n        Row(\n            modifier = Modifier.padding(start = 16.dp, end = 16.dp), // 调整内边距以适应IconButton\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.Start   // 从左到右排列\n        ) {\n            Text(\n                text = title,\n                fontWeight = FontWeight.Bold,\n                fontSize = 16.sp,\n            )\n            // 如果传入了额外内容，就在这里显示它\n            titleExtraContent?.invoke()\n        }\n\n        SingleChoiceSegmentedButtonRow(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(horizontal = 16.dp, vertical = 8.dp)\n        ) {\n            options.forEachIndexed { index, option ->\n                // ✨ 关键改动：根据位置动态计算形状！\n                val shape = when (index) {\n                    // 第一个按钮：左边是圆角，右边是直角\n                    0 -> RoundedCornerShape(topStartPercent = 50, bottomStartPercent = 50)\n                    // 最后一个按钮：左边是直角，右边是圆角\n                    options.lastIndex -> RoundedCornerShape(\n                        topEndPercent = 50,\n                        bottomEndPercent = 50\n                    )\n                    // 中间的按钮：两边都是直角\n                    else -> RectangleShape\n                }\n\n                SegmentedButton(\n                    shape = shape, // ✨ 使用我们动态计算的形状\n                    onClick = { currentSelection = option.key },\n                    selected = currentSelection == option.key\n                ) {\n                    Text(option.label)\n                }\n            }\n        }\n\n    }\n}\n\n// 带tooltip的infoIcon实现\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nfun InfoDialogIcon(\n    title: String,\n    text: String,\n    modifier: Modifier = Modifier,\n    icon: ImageVector = Icons.Outlined.Info,\n    contentDescription: String? = null,\n) {\n    // ✨ 关键：组件自己管理自己的弹窗状态，外部完全无需关心！\n    var showDialog by remember { mutableStateOf(false) }\n\n    // 1. 这是用户能看到的触发器：一个图标按钮\n    IconButton(\n        onClick = { showDialog = true }, // 点击时，只改变自己的内部状态\n        modifier = modifier\n    ) {\n        Icon(\n            imageVector = icon,\n            contentDescription = contentDescription\n        )\n    }\n\n    // 2. 这是与触发器绑定的弹窗UI\n    //    当内部状态为 true 时，它就会自动显示出来\n    if (showDialog) {\n        AlertDialog(\n            onDismissRequest = { showDialog = false },\n            title = { Text(text = title) },\n            text = { Text(text = text) },\n            confirmButton = {\n                TextButton(onClick = { showDialog = false }) {\n                    Text(stringResource(R.string.ok))\n                }\n            }\n        )\n    }\n}\n\n@Composable\nfun SliderSettingItem(\n    key: Preferences.Key<Long>,\n    defaultValue: Long,\n    icon: @Composable () -> Unit,\n    title: String,\n    subtitle: String,\n    valueRange: LongRange,\n    step: Long, // 单步步长\n    modifier: Modifier = Modifier,\n) {\n    // 使用 Hook 来自动同步 DataStore\n    var currentValue by rememberDataStoreState(key, defaultValue)\n\n    Card(\n        modifier = modifier\n            .fillMaxWidth()\n            .clickable {},\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)\n    ) {\n        Row(\n            modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),\n            verticalAlignment = Alignment.CenterVertically\n        ) {\n            // 左侧的图标\n            Box(modifier = Modifier.padding(end = 16.dp)) {\n                icon()\n            }\n            // 右侧的文字和滑块\n            Column(modifier = Modifier.weight(1f)) {\n                // 标题和当前值\n                Row(\n                    modifier = Modifier.fillMaxWidth(),\n                    horizontalArrangement = Arrangement.SpaceBetween,\n                    verticalAlignment = Alignment.CenterVertically\n                ) {\n                    Text(\n                        text = title,\n                        style = MaterialTheme.typography.titleMedium,\n                        fontWeight = FontWeight.Bold\n                    )\n                    // 实时显示当前选中的值\n                    Text(\n                        text = \"$currentValue ms\",\n                        style = MaterialTheme.typography.bodyMedium,\n                        color = MaterialTheme.colorScheme.primary\n                    )\n                }\n                // 副标题\n                Text(\n                    text = subtitle,\n                    style = MaterialTheme.typography.bodyMedium,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n                // 滑块本体\n                Slider(\n                    value = currentValue.toFloat(),\n                    onValueChange = {\n                        // 当用户滑动时，更新状态\n                        currentValue = it.roundToLong()\n                    },\n                    valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(),\n                    steps = ((valueRange.last - valueRange.first) / step - 1).toInt(), // 设置步数，让滑块可以吸附到整数值\n                    modifier = Modifier.padding(top = 4.dp),\n                )\n            }\n        }\n    }\n}\n\n// 新增一个可以点击的颜色设置，用来设置一个RGBA颜色\n@Composable\nfun ColorSettingItem(\n    key: Preferences.Key<String>,\n    defaultValue: String,\n    title: String,\n    subtitle: String,\n    modifier: Modifier = Modifier,\n) {\n    // ✨ 核心修正 1：我们现在需要两个状态\n    // `storedColorHex` 是我们与DataStore同步的“仓库”状态\n    var storedColorHex by rememberDataStoreState(key, defaultValue)\n    // `displayedColorHex` 是我们UI上立即显示的“公告板”状态\n    var displayedColorHex by remember { mutableStateOf(defaultValue) }\n    var showDialog by remember { mutableStateOf(false) }\n\n    // ✨ 核心修正 2：用 LaunchedEffect 来保持“公告板”和“仓库”同步\n    // 当 `storedColorHex` (仓库) 因任何原因改变时，立刻更新 `displayedColorHex` (公告板)\n    LaunchedEffect(storedColorHex) {\n        displayedColorHex = storedColorHex\n    }\n\n    // ✨ 核心修正 3：UI现在完全信任“公告板”上的颜色\n    val currentColor = try {\n        Color(displayedColorHex.toColorInt())\n    } catch (e: Exception) {\n        Color.Red\n    }\n\n    Row(\n        modifier = modifier\n            .fillMaxWidth()\n            .clickable { showDialog = true }\n            .padding(horizontal = 16.dp, vertical = 12.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        Icon(Icons.Outlined.Palette, contentDescription = \"send btn overlay color\")\n        Spacer(modifier = Modifier.width(16.dp))\n\n        Column(modifier = Modifier.weight(1f)) {\n            Text(text = title, style = MaterialTheme.typography.titleMedium)\n            Text(\n                text = subtitle, style = MaterialTheme.typography.bodyMedium,\n                color = LocalContentColor.current.copy(alpha = 0.6f)\n            )\n        }\n        // 右侧的颜色预览\n        Surface(\n            modifier = Modifier.size(width = 50.dp, height = 30.dp),\n            shape = RoundedCornerShape(8.dp), // 使用圆角矩形\n            color = currentColor,\n            border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))\n        ) {}\n    }\n\n    // 当 showDialog 为 true 时，显示我们的颜色选择对话框\n    if (showDialog) {\n        ColorPickerDialog(\n            initialColorHex = displayedColorHex,\n            onDismissRequest = { showDialog = false },\n            onColorSelected = { newColorHex ->\n                // ✨ 核心修正 5：当用户选择新颜色时...\n                // 1. 立刻更新“公告板”，UI瞬间响应！\n                displayedColorHex = newColorHex\n                // 2. 同时派出“慢性子信使”去更新“仓库”\n                storedColorHex = newColorHex\n                // 3. 关闭对话框\n                showDialog = false\n            }\n        )\n    }\n}\n\n\n/**\n * ✨ [新增] 我们的自定义颜色选择对话框。\n */\n@Composable\nprivate fun ColorPickerDialog(\n    initialColorHex: String,\n    onDismissRequest: () -> Unit,\n    onColorSelected: (String) -> Unit,\n) {\n    // 对话框内部的临时状态，只有点“确认”时才会更新到外面\n    var tempColorHex by remember { mutableStateOf(initialColorHex) }\n    val isHexValid = remember(tempColorHex) {\n        // 正则表达式，用于验证6位或8位Hex颜色代码（可带#号）\n        tempColorHex.matches(\"^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$\".toRegex())\n    }\n    val errorColor = MaterialTheme.colorScheme.error\n    val parsedColor = remember(tempColorHex, isHexValid) {\n        if (isHexValid) {\n            try {\n                Color(if (tempColorHex.startsWith(\"#\")) tempColorHex.toColorInt() else \"#$tempColorHex\".toColorInt())\n            } catch (e: Exception) {\n                errorColor\n            }\n        } else {\n            errorColor\n        }\n    }\n\n    // 一些预设的颜色，方便用户快速选择\n    val predefinedColors = listOf(\n        \"#80FF69B4\", \"#80FF4500\", \"#80FFD700\", \"#80ADFF2F\",\n        \"#8000CED1\", \"#801E90FF\", \"#809370DB\", \"#80FFFFFF\",\n        \"#80C0C0C0\", \"#FF808080\", \"#80000000\", \"#5066ccff\",\n        \"#00000000\" //纯透明\n    )\n\n    AlertDialog(\n        onDismissRequest = onDismissRequest,\n        title = { Text(stringResource(R.string.pick_color)) },\n        text = {\n            Column {\n                // 颜色预览和Hex输入框\n                Row(\n                    modifier = Modifier.fillMaxWidth(),\n                    verticalAlignment = Alignment.CenterVertically\n                ) {\n                    Surface(    //左侧的颜色预览\n                        modifier = Modifier.size(40.dp),\n                        shape = RoundedCornerShape(8.dp),\n                        color = parsedColor,\n                        border = BorderStroke(\n                            1.dp,\n                            MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)\n                        )\n                    ) {}\n                    Spacer(modifier = Modifier.width(16.dp))\n                    TextField(\n                        value = tempColorHex,\n                        onValueChange = { tempColorHex = it },\n                        label = { Text(\"Hex (A)RGB\") },\n                        isError = !isHexValid,\n                        singleLine = true,\n                        modifier = Modifier.weight(1f)\n                    )\n                }\n                Spacer(modifier = Modifier.height(16.dp))\n                // 预设颜色网格\n                LazyVerticalGrid(\n                    columns = GridCells.Adaptive(minSize = 48.dp),\n                    horizontalArrangement = Arrangement.spacedBy(8.dp),\n                    verticalArrangement = Arrangement.spacedBy(8.dp)\n                ) {\n                    items(predefinedColors.size) { index ->\n                        val colorHex = predefinedColors[index]\n                        val color = Color(colorHex.toColorInt())\n                        val isSelected = tempColorHex.equals(colorHex, ignoreCase = true)\n\n                        Surface(\n                            modifier = Modifier\n                                .size(40.dp)\n                                .clip(RoundedCornerShape(8.dp))\n                                .clickable { tempColorHex = colorHex },\n                            shape = RoundedCornerShape(8.dp),\n                            color = color,\n                            border = BorderStroke(\n                                1.dp,\n                                MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)\n                            ),\n                        ) {\n                            // Surface 的 content lambda 提供了一个干净的 BoxScope，消除了歧义\n                            AnimatedVisibility(\n                                visible = isSelected,\n                                enter = scaleIn(\n                                    animationSpec = spring(\n                                        dampingRatio = Spring.DampingRatioLowBouncy,\n                                        stiffness = Spring.StiffnessLow\n                                    )\n                                ) + fadeIn(animationSpec = tween(250)),\n                                exit = scaleOut() + fadeOut()\n                            ) {\n                                Icon(\n                                    Icons.Default.Check,\n                                    contentDescription = \"Selected\",\n                                    tint = if (color.luminance() > 0.5f) Color.Black else Color.White,\n                                    modifier = Modifier.align(Alignment.CenterHorizontally) // 确保图标居中\n                                )\n                            }\n                        }\n                    }\n                }\n            }\n        },\n        confirmButton = {\n            TextButton(\n                onClick = { onColorSelected(tempColorHex) },\n                enabled = isHexValid // 只有当输入的Hex有效时才能确认\n            ) {\n                Text(stringResource(R.string.accept))\n            }\n        },\n        dismissButton = {\n            TextButton(onClick = onDismissRequest) {\n                Text(stringResource(R.string.cancel))\n            }\n        }\n    )\n}\n\n\n/**\n * ✨ 全新：一个用于选择一个数值区间的设置项组件\n */\n@Composable\nfun RangeSliderSettingItem(\n    minKey: Preferences.Key<Int>,\n    maxKey: Preferences.Key<Int>,\n    defaultMin: Int,\n    defaultMax: Int,\n    icon: @Composable () -> Unit,\n    title: String,\n    subtitle: String,\n    valueRange: IntRange,\n    step: Int,\n    modifier: Modifier = Modifier,\n) {\n    // 使用 Hook 分别管理最小值和最大值的状态\n    var currentMin by rememberDataStoreState(minKey, defaultMin)\n    var currentMax by rememberDataStoreState(maxKey, defaultMax)\n\n    // RangeSlider 需要一个 Range 类型的 state，我们在这里组合一下\n    val currentRange by remember(currentMin, currentMax) {\n        mutableStateOf(currentMin.toFloat()..currentMax.toFloat())\n    }\n\n    Card(\n        modifier = modifier.fillMaxWidth(),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)\n    ) {\n        Row(\n            modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),\n            verticalAlignment = Alignment.CenterVertically\n        ) {\n            Box(modifier = Modifier.padding(end = 16.dp)) { icon() }\n            Column(modifier = Modifier.weight(1f)) {\n                Row(\n                    modifier = Modifier.fillMaxWidth(),\n                    horizontalArrangement = Arrangement.SpaceBetween,\n                    verticalAlignment = Alignment.CenterVertically\n                ) {\n                    Text(text = title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)\n                    // 实时显示当前选中的范围\n                    Text(\n                        text = \"$currentMin - $currentMax\",\n                        style = MaterialTheme.typography.bodyMedium,\n                        color = MaterialTheme.colorScheme.primary\n                    )\n                }\n                Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)\n                RangeSlider(\n                    value = currentRange,\n                    onValueChange = { newRange ->\n                        // 当用户滑动时，我们只更新本地的 state 以提供实时反馈\n                        // 注意：这里我们不直接写入 DataStore，避免过于频繁的IO操作\n                        currentMin = newRange.start.roundToInt()\n                        currentMax = newRange.endInclusive.roundToInt()\n                    },\n                    // ✨ 当用户滑动结束后，才把最终确定的值写入 DataStore\n                    onValueChangeFinished = {\n                        // 因为我们的 by rememberDataStoreState 委托会自动保存，\n                        // 所以这里实际上是触发了最终的赋值操作，从而写入\n                    },\n                    valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(),\n                    // 计算步数，(10-1)/1 = 9个档位，所以是8个间隔\n                    steps = ((valueRange.last - valueRange.first) / step) - 1,\n                    modifier = Modifier.padding(top = 4.dp),\n                )\n            }\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/MainMenu.kt",
    "content": "package me.wjz.nekocrypt.ui\n\nimport androidx.compose.animation.ExperimentalAnimationApi\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.pager.HorizontalPager\nimport androidx.compose.foundation.pager.rememberPagerState\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.NavigationBar\nimport androidx.compose.material3.NavigationBarItem\nimport androidx.compose.material3.NavigationBarItemDefaults\nimport androidx.compose.material3.Scaffold\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TopAppBar\nimport androidx.compose.material3.TopAppBarDefaults\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.ui.screen.Screen\n\n@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)\n@Composable\nfun MainMenu() {\n    val navItems = remember { Screen.allScreens }   //  所有的屏幕\n    //  创建一个 PagerState，记住当前页面索引\n    //pagerState 是 Jetpack Compose 中用于控制和观察\n    //HorizontalPager 或 VerticalPager 状态的对象。\n    val pagerState = rememberPagerState(pageCount = { navItems.size })\n    //  用自己的协程作用域\n    val scope = rememberCoroutineScope()\n\n    Scaffold(\n        topBar = {\n            TopAppBar(\n                title = { Text(text = stringResource(id = R.string.app_name)) },\n                colors = TopAppBarDefaults.topAppBarColors(\n                    containerColor = MaterialTheme.colorScheme.primaryContainer,\n                    titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,\n                )\n            )\n        },\n        bottomBar = {\n            NavigationBar(containerColor = MaterialTheme.colorScheme.surfaceContainer) {\n                // ✨ 关键修正 1: 遍历时需要索引\n                navItems.forEachIndexed { index, screen ->\n                    NavigationBarItem(\n                        icon = {\n                            Icon(\n                                imageVector = screen.icon,\n                                contentDescription = stringResource(id = screen.titleResId),\n                                modifier = Modifier.size(28.dp)\n                            )\n                        },\n                        label = { Text(stringResource(id = screen.titleResId)) },\n                        // ✨ 核心二：直接从 pagerState 读取当前页面，不再需要 selectedTabIndex\n                        selected = (index == pagerState.currentPage),\n                        onClick = {\n                            scope.launch {\n                                pagerState.animateScrollToPage(index)\n                            }\n                        },\n                        colors = NavigationBarItemDefaults.colors(\n                            indicatorColor = MaterialTheme.colorScheme.secondaryContainer\n                        )\n                    )\n                }\n            }\n        },\n        //  暂时不要悬浮按钮\n\n//        floatingActionButton =\n//            {\n//                FloatingActionButton(\n//                    onClick = { },\n//                    containerColor = MaterialTheme.colorScheme.primary,\n//                    contentColor = MaterialTheme.colorScheme.onPrimary\n//                ) {\n//                    Icon(Icons.Default.Add, contentDescription = \"Add\")\n//                }\n//            }\n    )\n    { innerPadding ->\n        HorizontalPager(\n            state = pagerState,\n            modifier = Modifier.padding(innerPadding),\n            // key 的作用是帮助 Compose 识别每个页面的唯一性，提高性能\n            key = { index -> navItems[index].route }\n        ) { pageIndex ->\n            // 直接根据 Pager 提供的页面索引，从列表里找到对应的 Screen 对象，\n            // 然后调用它的 content() 方法来显示界面。\n            navItems[pageIndex].content()\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/activity/AttachmentPickerActivity.kt",
    "content": "package me.wjz.nekocrypt.ui.activity\n\nimport android.content.Intent\nimport android.net.Uri\nimport android.provider.OpenableColumns\nimport android.os.Bundle\nimport android.util.Log\nimport android.view.WindowManager\nimport androidx.activity.ComponentActivity\nimport androidx.activity.result.ActivityResultLauncher\nimport androidx.activity.result.PickVisualMediaRequest\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.lifecycle.lifecycleScope\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.util.ResultRelay\nimport java.io.File\nimport java.io.IOException\n\nclass AttachmentPickerActivity : ComponentActivity() {\n    private val tag = \"AttachmentPickerActivity\"\n\n    companion object {\n        const val EXTRA_PICK_TYPE = \"pick_type\"\n        const val TYPE_MEDIA = \"media\"   // 图+视频\n        const val TYPE_FILE = \"file\"    // 任意文件\n    }\n\n    private lateinit var mediaPicker: ActivityResultLauncher<PickVisualMediaRequest>\n    private lateinit var filePicker: ActivityResultLauncher<String>\n\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n        // 必须不能抢占焦点，否则handler检测到不是目标应用界面就会杀掉自己\n        window.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)\n\n        /**\n         * 这里的逻辑演进说明：\n         * 1. 最初方案是直接返回用户选择的Uri。这在文件较小时可行，因为当时的做法是立刻将文件完整读入内存。\n         * 2. 为了支持大文件并避免内存溢出，我们改用了流式上传。但流式读取过程较慢。\n         * 3. 这就暴露了安卓的临时Uri权限问题：当Activity在返回Uri后立刻finish()，它获得的临时访问权限很快就会失效。\n         * 导致后台的Service在稍后进行流式读取时，会因为权限丢失而失败 (SecurityException)。\n         * 4. 因此，最终方案是：在本Activity中，趁着临时权限还生效，立刻将文件复制一份到我们App自己的私有缓存目录。\n         * 然后返回这个缓存文件的、我们拥有永久访问权的Uri。这样后台服务就可以随时、安全地进行流式读取了。\n         */\n        val onResult = { uri: Uri? ->\n            if (uri != null) {\n                val pickType = intent.getStringExtra(EXTRA_PICK_TYPE)\n\n                lifecycleScope.launch {\n                    try {\n                        if (pickType == TYPE_MEDIA) {\n                            // PickVisualMedia 的 URI 不支持持久化权限，必须先复制到缓存\n                            val cacheUri = copyFileToCache(uri)\n                            ResultRelay.send(cacheUri)\n                            Log.d(tag, \"相册文件已复制到缓存: $cacheUri\")\n                        } else {\n                            // GetContent 支持 takePersistableUriPermission\n                            val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION\n                            contentResolver.takePersistableUriPermission(uri, takeFlags)\n                            Log.d(tag, \"已成功获取持久化权限: $uri\")\n                            ResultRelay.send(uri)\n                        }\n                    } catch (e: SecurityException) {\n                        Log.e(tag, \"申请持久化权限失败，回退到缓存复制\", e)\n                        try {\n                            val cacheUri = copyFileToCache(uri)\n                            ResultRelay.send(cacheUri)\n                        } catch (ioe: IOException) {\n                            Log.e(tag, \"复制文件到缓存也失败\", ioe)\n                        }\n                    } catch (e: IOException) {\n                        Log.e(tag, \"复制文件到缓存失败\", e)\n                    } finally {\n                        delay(200)\n                        finish()\n                    }\n                }\n                Unit\n            }\n            else{\n                Log.d(tag, \"用户取消了文件选择，关闭Activity。\")\n                finish()\n            }\n        }\n\n        // 注册文件选择器，并绑定我们统一的 `onResult` 处理逻辑\n        mediaPicker = registerForActivityResult(\n            ActivityResultContracts.PickVisualMedia(),\n            onResult\n        )\n        filePicker = registerForActivityResult(\n            ActivityResultContracts.GetContent(),\n            onResult\n        )\n\n        // 根据启动意图，调用对应的文件选择器\n        when (intent.getStringExtra(EXTRA_PICK_TYPE)) {\n            TYPE_MEDIA -> mediaPicker.launch(\n                PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)\n            )\n\n            TYPE_FILE -> filePicker.launch(\"*/*\")\n            else -> {\n                // 如果没有指定类型，默认关闭\n                Log.w(tag, \"未指定有效的PICK_TYPE，Activity将关闭。\")\n                finish()\n            }\n        }\n    }\n\n    /**\n     * 将给定的Uri指向的文件复制到应用的内部缓存目录。\n     * @param sourceUri 用户选择的文件的临时Uri。\n     * @return 指向缓存目录中新文件的、我们拥有永久权限的Uri。\n     * @throws IOException 如果文件读写失败。\n     */\n    @Throws(IOException::class)\n    private fun copyFileToCache(sourceUri: Uri): Uri {\n        // 通过ContentResolver打开源文件的输入流\n        val inputStream = contentResolver.openInputStream(sourceUri)\n            ?: throw IOException(\"无法为所选文件打开输入流。\")\n\n        // 尝试获取原始文件名，保留扩展名以便后续 getFileName 识别\n        val originalName = contentResolver.query(sourceUri, null, null, null, null)?.use { cursor ->\n            if (cursor.moveToFirst()) {\n                val col = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)\n                if (col != -1) cursor.getString(col) else null\n            } else null\n        }\n        val cacheName = if (originalName != null) {\n            // 保留原始文件名，加时间戳前缀防冲突\n            \"${System.currentTimeMillis()}_$originalName\"\n        } else {\n            \"upload_cache_${System.currentTimeMillis()}\"\n        }\n        val tempFile = File(cacheDir, cacheName)\n\n        // 使用Kotlin的扩展函数，安全地将输入流复制到输出流，并自动关闭它们\n        inputStream.use { input ->\n            tempFile.outputStream().use { output ->\n                input.copyTo(output)\n            }\n        }\n\n        // 返回我们新创建的、拥有完全权限的文件的Uri\n        return Uri.fromFile(tempFile)\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/activity/ScannerActivity.kt",
    "content": "package me.wjz.nekocrypt.ui.activity\n\nimport android.os.Build\nimport android.os.Bundle\nimport android.os.Parcelable\nimport android.widget.Toast\nimport androidx.activity.ComponentActivity\nimport androidx.activity.compose.setContent\nimport androidx.lifecycle.lifecycleScope\nimport kotlinx.coroutines.launch\nimport kotlinx.parcelize.Parcelize\nimport me.wjz.nekocrypt.Constant.SCAN_RESULT\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.service.handler.CustomAppHandler\nimport me.wjz.nekocrypt.ui.dialog.ScannerDialog\nimport me.wjz.nekocrypt.ui.theme.NekoCryptTheme\n\n\n/**\n * 一个用于封装单个被找到的节点信息的数据类。\n * @param className 节点的类名 (e.g., \"android.widget.EditText\")。\n * @param resourceId 节点的资源 ID (e.g., \"com.tencent.mm:id/input_editor\")，可能为空。\n * @param text 节点的文本内容，可能为空。\n * @param contentDescription 节点的内容描述（常用于无障碍），可能为空。\n */\n@Parcelize\ndata class FoundNodeInfo(\n    val className: String,\n    val resourceId: String?,\n    val text: String?,\n    val contentDescription: String?,\n) : Parcelable\n\n/**\n * ✨ 全新：用于封装单个消息列表及其内部消息文本的数据类。\n * 这就是我们的“房子和居民”情报。\n * @param listContainerInfo 消息列表容器节点本身的信息。\n * @param messageTexts 在这个容器内部找到的所有消息文本节点列表。\n */\n@Parcelize\ndata class MessageListScanResult(\n    val listContainerInfo: FoundNodeInfo,\n    val messageTexts: List<FoundNodeInfo>\n) : Parcelable\n\n/**\n * ✨ 升级版：用于封装扫描结果的数据类。\n * @param packageName 当前应用的包名。\n * @param name 当前应用的可读名称 (e.g., \"xx聊天\")。\n * @param foundInputNodes 扫描到的所有可能的输入框节点列表。\n * @param foundSendBtnNodes 扫描到的所有可能的发送按钮节点列表。\n * @param foundMessageLists 扫描到的所有消息列表及其内部消息的集合。\n */\n@Parcelize\ndata class ScanResult(\n    val packageName: String,\n    val name: String,\n    val foundInputNodes: List<FoundNodeInfo>,\n    val foundSendBtnNodes: List<FoundNodeInfo>,\n    val foundMessageLists: List<MessageListScanResult>, // ✨ 结构变更\n) : Parcelable\n\n\nclass ScannerDialogActivity: ComponentActivity() {\n    override fun onCreate(savedInstanceState: Bundle?) {\n        super.onCreate(savedInstanceState)\n\n        val dataStoreManager = (application as NekoCryptApp).dataStoreManager\n\n        // ✨ 核心魔法：从送来的“快递盒”(Intent)中，把名叫\"scan_result\"的“包裹”取出来\n        val scanResult = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {\n            // 对于 Android 13 (API 33) 及以上版本，使用新的、类型安全的方法\n            // 我们需要明确告诉系统，我们想取出来的是一个 ScanResult 类型的包裹\n            intent.getParcelableExtra(SCAN_RESULT, ScanResult::class.java)\n        } else {\n            // 对于旧版本，使用传统的方法\n            @Suppress(\"DEPRECATION\") // 告诉编译器，我们知道这个方法过时了，但为了兼容性还是要用\n            intent.getParcelableExtra<ScanResult>(SCAN_RESULT)  //  这里保留一下类型指定？看日志似乎是类型不确定导致的崩溃\n        }\n\n        if(scanResult == null){\n            //\n            Toast.makeText(this, getString(R.string.scanner_get_result_fail), Toast.LENGTH_SHORT).show()\n            finish()\n            return\n        }\n\n        setContent {\n            NekoCryptTheme {\n\n                // 在这里显示我们的对话框\n                // 当对话框请求关闭时，我们直接结束这个透明的 Activity\n                ScannerDialog(scanResult,onDismissRequest = { finish() }, onConfirm ={ scanSelections,scanResult ->\n                    lifecycleScope.launch {\n                        val newHandler = CustomAppHandler(\n                            packageName = scanResult.packageName,\n                            inputId = scanSelections.inputNode.resourceId ?: \"\",\n                            sendBtnId = scanSelections.sendBtnNode.resourceId ?: \"\",\n                            messageTextId = scanSelections.messageText.resourceId ?: \"\",\n                            messageListClassName = scanSelections.messageList.className\n                        )\n\n                        dataStoreManager.addCustomApp(newHandler)\n                        // 3. 给出成功提示并关闭窗口\n                        Toast.makeText(\n                            this@ScannerDialogActivity,\n                            getString(R.string.scanner_config_saved_toast),\n                            Toast.LENGTH_SHORT\n                        ).show()\n                        finish()\n                    }\n                })\n            }\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/component/CapPawButton.kt",
    "content": "package me.wjz.nekocrypt.ui.component\n\nimport android.annotation.SuppressLint\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.SizeTransform\nimport androidx.compose.animation.animateColorAsState\nimport androidx.compose.animation.core.animateDpAsState\nimport androidx.compose.animation.core.animateFloatAsState\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.slideInVertically\nimport androidx.compose.animation.slideOutVertically\nimport androidx.compose.animation.togetherWith\nimport androidx.compose.foundation.Canvas\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.interaction.MutableInteractionSource\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.BoxWithConstraints\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.shape.CircleShape\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableFloatStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.withFrameNanos\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.draw.shadow\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.geometry.Size\nimport androidx.compose.ui.graphics.Brush\nimport androidx.compose.ui.graphics.StrokeCap\nimport androidx.compose.ui.graphics.drawscope.Stroke\nimport androidx.compose.ui.graphics.drawscope.rotate\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.min\nimport androidx.compose.ui.unit.sp\n\nprivate object CatPawDefaults {\n    const val RING_STROKE_WIDTH = 14f   // 外围圆弧段落粗度\n    const val PAW_STROKE_WIDTH = 15f\n    const val DESIGN_BASIS_DP = 290f\n\n    // --- 尺寸比例 ---\n    const val RING_ENABLED_RATIO = 1f // 激活时外圈尺寸比例 (等于基准大小)\n    const val RING_DISABLED_RATIO = 270f / DESIGN_BASIS_DP // 未激活时外圈的尺寸比例\n    const val CENTER_BUTTON_RATIO = 260f / DESIGN_BASIS_DP\n    const val PAW_CANVAS_RATIO = 110f / DESIGN_BASIS_DP\n\n    // --- 字体大小比例 ---\n    const val FONT_SIZE_RATIO = 20f / DESIGN_BASIS_DP\n    const val MIN_FONT_SIZE_SP = 12f\n\n    // --- 猫爪内部绘制比例 (相对于猫爪Canvas) ---\n    const val PALM_WIDTH_RATIO = 0.6f\n    const val PALM_HEIGHT_RATIO = 0.45f\n    const val PALM_Y_OFFSET_RATIO = 0.2f\n    const val TOE_RADIUS_RATIO = 0.1f\n\n    // --- 猫爪脚趾基础位置比例 (相对于猫爪Canvas) ---\n    const val OUTER_TOE_X_RATIO = 0.35f\n    const val OUTER_TOE_Y_RATIO = 0.08f\n    const val INNER_TOE_X_RATIO = 0.15f\n    const val INNER_TOE_Y_RATIO = 0.25f\n\n    // --- 猫爪脚趾激活状态位移 (基于原始设计尺寸) ---\n    const val PALM_Y_SHIFT = -10f\n    const val OUTER_LEFT_TOE_X_SHIFT = -18f\n    const val OUTER_LEFT_TOE_Y_SHIFT = -15f\n    const val INNER_LEFT_TOE_X_SHIFT = -10f\n    const val INNER_LEFT_TOE_Y_SHIFT = -25f\n    const val INNER_RIGHT_TOE_X_SHIFT = 10f\n    const val INNER_RIGHT_TOE_Y_SHIFT = -25f\n    const val OUTER_RIGHT_TOE_X_SHIFT = 18f\n    const val OUTER_RIGHT_TOE_Y_SHIFT = -15f\n}\n\n/**\n * ✨ 响应式猫爪按钮\n * 它会根据父组件提供的空间，自动调整自身大小和内部所有元素的比例。\n */\n@SuppressLint(\"UnusedBoxWithConstraintsScope\")\n@Composable\nfun CatPawButton(\n    isEnabled: Boolean,\n    statusText: String,\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n) {\n    BoxWithConstraints(\n        modifier = modifier,\n        contentAlignment = Alignment.Center\n    ) {\n        val baseSize = min(maxWidth, maxHeight)\n        val scaleFactor = baseSize.value / CatPawDefaults.DESIGN_BASIS_DP\n\n        // --- 动画状态 ---\n        val ringSize by animateDpAsState(\n            targetValue = if (isEnabled) baseSize * CatPawDefaults.RING_ENABLED_RATIO else baseSize * CatPawDefaults.RING_DISABLED_RATIO,\n            animationSpec = tween(600),\n            label = \"RingSizeAnimation\"\n        )\n        val buttonFillColor by animateColorAsState(\n            targetValue = if (isEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,\n            animationSpec = tween(500),\n            label = \"ButtonFillAnimation\"\n        )\n        val contentColor by animateColorAsState(\n            targetValue = if (isEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant,\n            animationSpec = tween(500),\n            label = \"ContentColorAnimation\"\n        )\n        val shadowElevation by animateFloatAsState(\n            targetValue = if (isEnabled) (baseSize.value * 0.055f) else (baseSize.value * 0.027f),\n            animationSpec = tween(500),\n            label = \"ShadowElevation\"\n        )\n        val rotationSpeed by animateFloatAsState(\n            targetValue = if (isEnabled) 15f else 5f,\n            animationSpec = tween(1500),\n            label = \"RotationSpeedAnimation\"\n        )\n        var rotationAngle by remember { mutableFloatStateOf(0f) }\n        val outlineColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)\n        val arcColor1 by animateColorAsState(\n            targetValue = if (isEnabled) MaterialTheme.colorScheme.primary else outlineColor,\n            animationSpec = tween(700),\n            label = \"ArcColor1\"\n        )\n        val arcColor2 by animateColorAsState(\n            targetValue = if (isEnabled) MaterialTheme.colorScheme.tertiary else outlineColor,\n            animationSpec = tween(700),\n            label = \"ArcColor2\"\n        )\n        val arcBrush = Brush.sweepGradient(colors = listOf(arcColor1, arcColor2, arcColor1))\n\n        val palmOffsetY by animateFloatAsState(if (isEnabled) CatPawDefaults.PALM_Y_SHIFT * scaleFactor else 0f, tween(400), label = \"PalmOffsetY\")\n        val outerLeftToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_LEFT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = \"OuterLeftToeX\")\n        val outerLeftToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_LEFT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = \"OuterLeftToeY\")\n        val innerLeftToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_LEFT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = \"InnerLeftToeX\")\n        val innerLeftToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_LEFT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = \"InnerLeftToeY\")\n        val innerRightToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_RIGHT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = \"InnerRightToeX\")\n        val innerRightToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.INNER_RIGHT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = \"InnerRightToeY\")\n        val outerRightToeX by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_RIGHT_TOE_X_SHIFT * scaleFactor else 0f, tween(400), label = \"OuterRightToeX\")\n        val outerRightToeY by animateFloatAsState(if (isEnabled) CatPawDefaults.OUTER_RIGHT_TOE_Y_SHIFT * scaleFactor else 0f, tween(400), label = \"OuterRightToeY\")\n        val gapAngle by animateFloatAsState(\n            targetValue = if (isEnabled) 8f else 12f,\n            animationSpec = tween(700),\n            label = \"GapAngleAnimation\"\n        )\n\n        LaunchedEffect(Unit) {\n            var lastFrameTimeNanos = 0L\n            while (true) {\n                withFrameNanos { frameTimeNanos ->\n                    if (lastFrameTimeNanos != 0L) {\n                        val deltaTimeMillis = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000f\n                        val deltaAngle = (rotationSpeed * deltaTimeMillis) / 1000f\n                        rotationAngle = (rotationAngle + deltaAngle) % 360f\n                    }\n                    lastFrameTimeNanos = frameTimeNanos\n                }\n            }\n        }\n\n        // --- 绘制部分 ---\n        Canvas(modifier = Modifier.size(ringSize)) {\n            val strokeWidth = CatPawDefaults.RING_STROKE_WIDTH\n            val dashCount = 12\n            val totalAnglePerDash = 360f / dashCount\n            val dashAngle = totalAnglePerDash - gapAngle\n            rotate(degrees = rotationAngle) {\n                for (i in 0 until dashCount) {\n                    drawArc(\n                        brush = arcBrush,\n                        startAngle = i * totalAnglePerDash,\n                        sweepAngle = dashAngle,\n                        useCenter = false,\n                        style = Stroke(width = strokeWidth, cap = StrokeCap.Round)\n                    )\n                }\n            }\n        }\n\n        Surface(\n            modifier = Modifier\n                .size(baseSize * CatPawDefaults.CENTER_BUTTON_RATIO)\n                .shadow(elevation = shadowElevation.dp, shape = CircleShape)\n                .clip(CircleShape)\n                .clickable(\n                    interactionSource = remember { MutableInteractionSource() },\n                    indication = null,\n                    onClick = onClick\n                ),\n            color = buttonFillColor\n        ) {\n            Column(\n                modifier = Modifier.fillMaxSize(),\n                horizontalAlignment = Alignment.CenterHorizontally,\n                verticalArrangement = Arrangement.Center\n            ) {\n                Canvas(modifier = Modifier.size(baseSize * CatPawDefaults.PAW_CANVAS_RATIO)) {\n                    val strokeWidth = CatPawDefaults.PAW_STROKE_WIDTH\n\n                    val palmSize = Size(size.width * CatPawDefaults.PALM_WIDTH_RATIO, size.height * CatPawDefaults.PALM_HEIGHT_RATIO)\n                    val palmBaseCenter = Offset(center.x, center.y + size.height * CatPawDefaults.PALM_Y_OFFSET_RATIO)\n                    val palmAnimatedCenter = palmBaseCenter.copy(y = palmBaseCenter.y + palmOffsetY)\n                    val palmTopLeft = Offset(palmAnimatedCenter.x - palmSize.width / 2f, palmAnimatedCenter.y - palmSize.height / 2f)\n                    drawOval(\n                        color = contentColor,\n                        topLeft = palmTopLeft,\n                        size = palmSize,\n                        style = Stroke(width = strokeWidth)\n                    )\n\n                    val toeRadius = size.width * CatPawDefaults.TOE_RADIUS_RATIO\n                    val outerLeftBaseCenter = Offset(center.x - size.width * CatPawDefaults.OUTER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.OUTER_TOE_Y_RATIO)\n                    val innerLeftBaseCenter = Offset(center.x - size.width * CatPawDefaults.INNER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.INNER_TOE_Y_RATIO)\n                    val innerRightBaseCenter = Offset(center.x + size.width * CatPawDefaults.INNER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.INNER_TOE_Y_RATIO)\n                    val outerRightBaseCenter = Offset(center.x + size.width * CatPawDefaults.OUTER_TOE_X_RATIO, center.y - size.height * CatPawDefaults.OUTER_TOE_Y_RATIO)\n\n                    drawCircle(\n                        color = contentColor,\n                        center = outerLeftBaseCenter.copy(x = outerLeftBaseCenter.x + outerLeftToeX, y = outerLeftBaseCenter.y + outerLeftToeY),\n                        radius = toeRadius,\n                        style = Stroke(width = strokeWidth)\n                    )\n                    drawCircle(\n                        color = contentColor,\n                        center = innerLeftBaseCenter.copy(x = innerLeftBaseCenter.x + innerLeftToeX, y = innerLeftBaseCenter.y + innerLeftToeY),\n                        radius = toeRadius,\n                        style = Stroke(width = strokeWidth)\n                    )\n                    drawCircle(\n                        color = contentColor,\n                        center = innerRightBaseCenter.copy(x = innerRightBaseCenter.x + innerRightToeX, y = innerRightBaseCenter.y + innerRightToeY),\n                        radius = toeRadius,\n                        style = Stroke(width = strokeWidth)\n                    )\n                    drawCircle(\n                        color = contentColor,\n                        center = outerRightBaseCenter.copy(x = outerRightBaseCenter.x + outerRightToeX, y = outerRightBaseCenter.y + outerRightToeY),\n                        radius = toeRadius,\n                        style = Stroke(width = strokeWidth)\n                    )\n                }\n\n                AnimatedContent(\n                    targetState = statusText,\n                    transitionSpec = {\n                        (slideInVertically { h -> h } + fadeIn(tween(250)))\n                            .togetherWith(slideOutVertically { h -> -h } + fadeOut(tween(250)))\n                            .using(SizeTransform(clip = false))\n                    },\n                    label = \"StatusTextAnimation\"\n                ) { text ->\n                    Text(\n                        text = text,\n                        color = contentColor,\n                        fontSize = (baseSize.value * CatPawDefaults.FONT_SIZE_RATIO).coerceAtLeast(CatPawDefaults.MIN_FONT_SIZE_SP).sp,\n                        fontWeight = FontWeight.Bold,\n                        textAlign = TextAlign.Center\n                    )\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/component/DecryptionPopup.kt",
    "content": "package me.wjz.nekocrypt.ui.component\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.Animatable\nimport androidx.compose.animation.core.LinearEasing\nimport androidx.compose.animation.core.Spring\nimport androidx.compose.animation.core.spring\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.layout.wrapContentSize\nimport androidx.compose.foundation.layout.wrapContentWidth\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Close\nimport androidx.compose.material.icons.filled.FileOpen\nimport androidx.compose.material.icons.filled.Image\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.shadow\nimport androidx.compose.ui.geometry.Offset\nimport androidx.compose.ui.graphics.Shadow\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport kotlinx.coroutines.delay\nimport me.wjz.nekocrypt.service.handler.LocalFileActionHandler\nimport me.wjz.nekocrypt.ui.theme.NekoCryptTheme\nimport me.wjz.nekocrypt.util.NCFileProtocol\nimport me.wjz.nekocrypt.util.NCFileType\n\n\n/**\n *  一个独立的、可复用的解密弹窗 Composable UI\n * 它只关心需要显示什么文本 (text)，以及被关闭时该做什么 (onDismiss)。\n * 它完全不知道什么是无障碍服务，什么是处理器。\n */\n@Composable\nfun DecryptionPopup(\n    decryptedText: String, durationMills: Long = 3000, onDismiss: () -> Unit,\n) {\n    // 增加判断，看需要展示纯文本还是图片or文件。\n    val fileProtocol: NCFileProtocol? = NCFileProtocol.fromString(decryptedText)\n\n    if (fileProtocol != null) {\n        // --- 情况A：是文件协议，并且成功解析 ---\n        DecryptedFilePopupContent(\n            fileInfo = fileProtocol,\n            onDismiss = onDismiss,\n            durationMills = durationMills\n        )\n    } else {\n        // --- 情况B：是普通文本，或者协议解析失败 ---\n        DecryptedTextPopupContent(\n            text = decryptedText,\n            onDismiss = onDismiss,\n            durationMills = durationMills\n        )\n    }\n}\n\n\n/**\n * 负责显示普通文本的弹窗\n */\n@Composable\nprivate fun DecryptedTextPopupContent(\n    text: String,\n    onDismiss: () -> Unit,\n    durationMills: Long,\n) {\n    val animationTime = 250\n    var isVisible by remember { mutableStateOf(false) }\n    val progress = remember { Animatable(1.0f) }\n\n    LaunchedEffect(Unit) {\n        isVisible = true // 触发出现\n        progress.animateTo(\n            0.0f,\n            animationSpec = tween(durationMills.toInt(), easing = LinearEasing)\n        )\n        isVisible = false // 倒计时结束后，触发消失\n    }\n\n    LaunchedEffect(isVisible) {\n        if (!isVisible) {\n            delay(animationTime.toLong()) // 等待消失动画播放完毕\n            onDismiss() // 动画完全结束后，才真正调用 onDismiss\n        }\n    }\n\n    NekoCryptTheme(darkTheme = false) {\n        Box(modifier = Modifier.padding(16.dp)) {\n            AnimatedVisibility(\n                visible = isVisible,\n                enter = scaleIn(\n                    animationSpec = spring(\n                        dampingRatio = Spring.DampingRatioLowBouncy,\n                        stiffness = Spring.StiffnessLow\n                    )\n                ) + fadeIn(animationSpec = tween(animationTime)),\n                exit = scaleOut(animationSpec = tween(animationTime)) + fadeOut(\n                    animationSpec = tween(animationTime)\n                )\n            ) {\n                Card(\n                    modifier = Modifier\n                        .wrapContentSize()\n                        .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)),\n                    shape = RoundedCornerShape(12.dp),\n                    colors = CardDefaults.cardColors(\n                        containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(\n                            alpha = 0.92f\n                        )\n                    ),\n                    border = BorderStroke(\n                        1.dp,\n                        MaterialTheme.colorScheme.outline.copy(alpha = 0.8f)\n                    )\n                ) {\n                    Row(\n                        modifier = Modifier.padding(\n                            start = 12.dp,\n                            top = 8.dp,\n                            bottom = 8.dp,\n                            end = 8.dp\n                        ),\n                        verticalAlignment = Alignment.CenterVertically\n                    ) {\n                        Text(\n                            text = text,\n                            fontSize = 18.sp,\n                            color = MaterialTheme.colorScheme.primary,\n                            style = TextStyle(\n                                shadow = Shadow(\n                                    color = MaterialTheme.colorScheme.onPrimary.copy(\n                                        alpha = 0.5f\n                                    ), offset = Offset(2f, 2f), blurRadius = 4f\n                                )\n                            ),\n                            modifier = Modifier.weight(1f, fill = false)\n                        )\n                        Spacer(modifier = Modifier.width(8.dp))\n                        Box(\n                            contentAlignment = Alignment.Center,\n                            modifier = Modifier.size(25.dp)\n                        ) {\n                            CircularProgressIndicator(\n                                progress = { progress.value },\n                                modifier = Modifier.size(25.dp),\n                                color = MaterialTheme.colorScheme.primary,\n                                strokeWidth = 2.dp\n                            )\n                            IconButton(onClick = {\n                                isVisible = false\n                            }) {\n                                Icon(\n                                    Icons.Default.Close,\n                                    contentDescription = \"关闭\",\n                                    tint = MaterialTheme.colorScheme.onSurfaceVariant\n                                )\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n\n/**\n * 文件展示，包含图片和普通文件。\n */\n@Composable\nprivate fun DecryptedFilePopupContent(\n    fileInfo: NCFileProtocol,\n    onDismiss: () -> Unit,\n    durationMills: Long,\n) {\n    val animationTime = 250\n    var isVisible by remember { mutableStateOf(false) }\n    val progress = remember { Animatable(1.0f) }\n\n    LaunchedEffect(Unit) {\n        isVisible = true // 触发出现\n        progress.animateTo(\n            0.0f,\n            animationSpec = tween(durationMills.toInt(), easing = LinearEasing)\n        )\n        isVisible = false // 倒计时结束后，触发消失\n    }\n\n    LaunchedEffect(isVisible) {\n        if (!isVisible) {\n            delay(animationTime.toLong()) // 等待消失动画播放完毕\n            onDismiss() // 动画完全结束后，才真正调用 onDismiss\n        }\n    }\n\n    NekoCryptTheme(darkTheme = false) {\n        Box(modifier = Modifier.padding(16.dp)) {\n            AnimatedVisibility(\n                visible = isVisible,\n                enter = scaleIn(\n                    animationSpec = spring(\n                        dampingRatio = Spring.DampingRatioLowBouncy,\n                        stiffness = Spring.StiffnessLow\n                    )\n                ) + fadeIn(animationSpec = tween(animationTime)),\n                exit = scaleOut(animationSpec = tween(animationTime)) + fadeOut(\n                    animationSpec = tween(animationTime)\n                )\n            ) {\n                Card(\n                    modifier = Modifier\n                        .wrapContentSize()\n                        .shadow(elevation = 8.dp, shape = RoundedCornerShape(12.dp)),\n                    shape = RoundedCornerShape(12.dp),\n                    colors = CardDefaults.cardColors(\n                        containerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(\n                            alpha = 0.92f\n                        )\n                    ),\n                    border = BorderStroke(\n                        1.dp,\n                        MaterialTheme.colorScheme.outline.copy(alpha = 0.8f)\n                    )\n                ) {\n                    Row(\n                        modifier = Modifier.padding(\n                            start = 12.dp,\n                            top = 8.dp,\n                            bottom = 8.dp,\n                            end = 8.dp\n                        ),\n                        verticalAlignment = Alignment.CenterVertically\n                    ) {\n                        // 用来显示文件or图片\n                        FileButton(fileInfo)\n\n                        Spacer(modifier = Modifier.width(8.dp))\n                        Box(\n                            contentAlignment = Alignment.Center,\n                            modifier = Modifier.size(25.dp)\n                        ) {\n                            CircularProgressIndicator(\n                                progress = { progress.value },\n                                modifier = Modifier.size(25.dp),\n                                color = MaterialTheme.colorScheme.primary,\n                                strokeWidth = 2.dp\n                            )\n                            IconButton(onClick = {\n                                isVisible = false\n                            }) {\n                                Icon(\n                                    Icons.Default.Close,\n                                    contentDescription = \"关闭\",\n                                    tint = MaterialTheme.colorScheme.onSurfaceVariant\n                                )\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n@Composable\nfun FileButton(\n    fileInfo: NCFileProtocol,\n) {\n    val onFileClick = LocalFileActionHandler.current\n\n    TextButton(\n        onClick = {\n            onFileClick?.invoke(fileInfo)\n        },\n        modifier = Modifier.wrapContentWidth(),\n        shape = RoundedCornerShape(12.dp),\n        colors = ButtonDefaults.textButtonColors(\n            containerColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.95f)\n        ),\n        border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.8f))\n    ) {\n        when (fileInfo.type) {\n            NCFileType.IMAGE -> Icon(\n                Icons.Default.Image,\n                contentDescription = \"click to show image\"\n            )\n\n            NCFileType.FILE -> Icon(\n                Icons.Default.FileOpen,\n                contentDescription = \"click to show file\"\n            )\n        }\n        Spacer(Modifier.width(8.dp))\n        Text(\n            text = fileInfo.name,\n            fontSize = 18.sp,\n            color = MaterialTheme.colorScheme.primary,\n            style = TextStyle(\n                shadow = Shadow(\n                    color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.5f),\n                    offset = Offset(2f, 2f),\n                    blurRadius = 4f\n                )\n            ),\n            modifier = Modifier.weight(1f, fill = false)\n        )\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/AppHandlerInfoDialog.kt",
    "content": "package me.wjz.nekocrypt.ui.dialog\n\nimport android.widget.Toast\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.service.handler.ChatAppHandler\n\n\n@Composable\nfun AppHandlerInfoDialog(\n    appName:String,\n    handler: ChatAppHandler,\n    onDismissRequest: () -> Unit,\n    onDeleteRequest: (() -> Unit)? = null\n){\n    AlertDialog(\n        onDismissRequest = onDismissRequest,\n        // 标题\n        title = { Text(text = \"${appName} - 配置详情\") },\n        // 内容\n        text = {\n            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {\n                InfoRow(label = stringResource(R.string.key_screen_supported_app_input_id), value = handler.inputId)\n                InfoRow(label = stringResource(R.string.key_screen_supported_app_send_btn_id), value = handler.sendBtnId)\n                InfoRow(label = stringResource(R.string.key_screen_supported_app_message_text_id), value = handler.messageTextId)\n                InfoRow(label = stringResource(R.string.key_screen_supported_app_message_list_class_name), value = handler.messageListClassName)\n            }\n        },\n        // 确认按钮\n        confirmButton = {\n            Row(\n                modifier = Modifier.fillMaxWidth(),\n                horizontalArrangement = Arrangement.End\n            ) {\n                // 只有当 onDeleteRequest 被提供时，才显示删除按钮\n                if (onDeleteRequest != null) {\n                    Button(\n                        onClick = {\n                            // 先执行删除操作，然后关闭对话框\n                            onDeleteRequest()\n                            onDismissRequest()\n                        }\n                    ) {\n                        Text(\n                            stringResource(R.string.delete)\n                        )\n                    }\n                }\n                // 关闭按钮\n                TextButton(onClick = onDismissRequest) {\n                    Text(stringResource(R.string.cancel)) // 将“取消”改为“关闭”，语义更清晰\n                }\n            }\n        }\n    )\n}\n\n@Composable\nprivate fun InfoRow(label: String, value: String) {\n    val clipboardManager = LocalClipboardManager.current\n    val context = LocalContext.current\n    val hasCopyHint = stringResource(R.string.has_copy)\n    Column {\n        // 标签\n        Text(\n            text = label,\n            fontWeight = FontWeight.ExtraBold,\n            fontSize = 16.sp,\n            style = MaterialTheme.typography.labelMedium,\n            color = MaterialTheme.colorScheme.primary,\n            modifier = Modifier.padding(start = 12.dp)\n        )\n        Spacer(modifier = Modifier.height(4.dp))\n        // 内容和复制按钮\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            verticalAlignment = Alignment.CenterVertically\n        ) {\n            // 用 Surface 包裹，创造代码块效果\n            Surface(\n                onClick = {\n                    if(value.isNotEmpty()){\n                        clipboardManager.setText(AnnotatedString(value))\n                        // 显示一个短暂的提示\n                        Toast.makeText(context, hasCopyHint, Toast.LENGTH_SHORT).show()\n                    }\n                },\n                modifier = Modifier.weight(1f),\n                shape = RoundedCornerShape(8.dp),\n                color = MaterialTheme.colorScheme.surface,\n                tonalElevation = 4.dp, // 增加一点色调深度\n            ) {\n                Text(\n                    text = value.ifEmpty { \"N/A\" }, // 如果值为空，显示 N/A\n                    style = MaterialTheme.typography.bodyMedium,\n                    fontFamily = FontFamily.Monospace, // ✨ 使用等宽字体！\n                    modifier = Modifier\n                        .fillMaxWidth()\n                        .padding(horizontal = 12.dp, vertical = 8.dp)\n                )\n            }\n            Spacer(modifier = Modifier.width(8.dp))\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/AttachmentDialog.kt",
    "content": "package me.wjz.nekocrypt.ui.dialog\n\nimport android.content.Intent\nimport android.net.Uri\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.spring\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.RowScope\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.aspectRatio\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.VpnKey\nimport androidx.compose.material.icons.outlined.AttachFile\nimport androidx.compose.material.icons.outlined.Collections\nimport androidx.compose.material.icons.outlined.FileOpen\nimport androidx.compose.material.icons.outlined.Translate\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.RadioButton\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.rememberUpdatedState\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport coil.compose.AsyncImage\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.Constant\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.data.rememberKeyArrayState\nimport me.wjz.nekocrypt.hook.rememberDataStoreState\nimport me.wjz.nekocrypt.ui.activity.AttachmentPickerActivity\nimport me.wjz.nekocrypt.ui.theme.NekoCryptTheme\nimport me.wjz.nekocrypt.util.CiphertextStyleType\n\ndata class AttachmentState(\n    var progress: Float? = null,\n    var previewInfo: AttachmentPreviewState? = null,\n    var result: String = \"\",\n) {\n    val isUploading: Boolean\n        get() = progress != null\n\n    val isUploadFinished: Boolean\n        get() = result.isNotEmpty()\n}\n\ndata class AttachmentPreviewState(\n    var uri: Uri,\n    var fileName: String,\n    var fileSizeFormatted: String,\n    var isImage: Boolean,\n    val imageAspectRatio: Float? = null,\n)\n\nprivate enum class MenuContent {\n    NONE,\n    STYLE,\n    KEY,\n    PREVIEW\n}\n\n@Composable\nfun SendAttachmentDialog(\n    attachmentState: AttachmentState,\n    onDismissRequest: () -> Unit,\n    onSendRequest: (String) -> Unit,\n) {\n    val coroutineScope = rememberCoroutineScope()\n    var currentContent by remember { mutableStateOf(MenuContent.NONE) }\n\n    val keysFromDataStore: Array<String> by rememberKeyArrayState()\n\n    var activeKey by rememberDataStoreState(\n        SettingKeys.CURRENT_KEY,\n        Constant.DEFAULT_SECRET_KEY\n    )\n\n    var ciphertextStyleType by rememberDataStoreState(\n        SettingKeys.CIPHERTEXT_STYLE,\n        CiphertextStyleType.NEKO.toString()\n    )\n\n    val displayKeys = remember(keysFromDataStore, activeKey) {\n        when {\n            keysFromDataStore.isNotEmpty() -> keysFromDataStore.toList()\n            activeKey.isNotBlank() -> listOf(activeKey)\n            else -> listOf(Constant.DEFAULT_SECRET_KEY)\n        }\n    }\n\n    LaunchedEffect(attachmentState.previewInfo, attachmentState.result) {\n        if (attachmentState.previewInfo != null && attachmentState.result.isNotEmpty()) {\n            currentContent = MenuContent.PREVIEW\n        } else if (currentContent == MenuContent.PREVIEW) {\n            currentContent = MenuContent.NONE\n        }\n    }\n\n    fun dismissDirectly() {\n        coroutineScope.launch {\n            delay(80)\n            onDismissRequest()\n        }\n    }\n\n    NekoCryptTheme(darkTheme = false) {\n        // 弹簧入场动画\n        var visible by remember { mutableStateOf(false) }\n        LaunchedEffect(Unit) { visible = true }\n\n        AnimatedVisibility(\n            visible = visible,\n            enter = scaleIn(\n                initialScale = 0.6f,\n                animationSpec = spring(\n                    dampingRatio = 0.55f,\n                    stiffness = 400f\n                )\n            ) + fadeIn(animationSpec = spring(dampingRatio = 0.8f)),\n            exit = scaleOut(\n                targetScale = 0.6f,\n                animationSpec = spring(\n                    dampingRatio = 0.7f,\n                    stiffness = 400f\n                )\n            ) + fadeOut()\n        ) {\n        Box(modifier = Modifier.padding(8.dp)) {\n            Card(\n                shape = RoundedCornerShape(24.dp),\n                colors = CardDefaults.cardColors(\n                    containerColor = MaterialTheme.colorScheme.surface\n                ),\n            ) {\n                Column(\n                    modifier = Modifier.padding(24.dp),\n                    horizontalAlignment = Alignment.CenterHorizontally\n                ) {\n                    Text(\n                        text = \"菜单\",\n                        style = MaterialTheme.typography.headlineSmall,\n                        fontWeight = FontWeight.Bold,\n                        color = MaterialTheme.colorScheme.primary\n                    )\n\n                    Spacer(modifier = Modifier.height(24.dp))\n\n                    Box(contentAlignment = Alignment.Center) {\n                        AttachmentOptions(\n                            isUploading = attachmentState.isUploading,\n                            onDismissAfterPick = { dismissDirectly() },\n                            onStyleClick = {\n                                currentContent =\n                                    if (currentContent == MenuContent.STYLE) MenuContent.NONE\n                                    else MenuContent.STYLE\n                            },\n                            onKeyClick = {\n                                currentContent =\n                                    if (currentContent == MenuContent.KEY) MenuContent.NONE\n                                    else MenuContent.KEY\n                            }\n                        )\n\n                        if (attachmentState.isUploading) {\n                            Row(horizontalArrangement = Arrangement.Center) {\n                                Column(horizontalAlignment = Alignment.CenterHorizontally) {\n                                    CircularProgressIndicator(\n                                        progress = { attachmentState.progress ?: 0f }\n                                    )\n                                    Spacer(modifier = Modifier.height(8.dp))\n                                    Text(\n                                        text = stringResource(R.string.crypto_attachment_uploading),\n                                        style = MaterialTheme.typography.bodySmall,\n                                        color = MaterialTheme.colorScheme.primary\n                                    )\n                                }\n                            }\n                        }\n                    }\n\n                    if (currentContent != MenuContent.NONE) {\n                        Spacer(modifier = Modifier.height(16.dp))\n\n                        when (currentContent) {\n                            MenuContent.STYLE -> {\n                                InlineSingleChoicePanel(\n                                    title = \"语种\",\n                                    items = CiphertextStyleType.entries.map {\n                                        it.toString() to stringResource(it.displayNameResId)\n                                    },\n                                    selectedKey = ciphertextStyleType,\n                                    onItemClick = { selected ->\n                                        ciphertextStyleType = selected\n                                    }\n                                )\n                            }\n\n                            MenuContent.KEY -> {\n                                InlineSingleChoicePanel(\n                                    title = \"密钥\",\n                                    items = displayKeys.map { key -> key to key },\n                                    selectedKey = activeKey,\n                                    onItemClick = { selected ->\n                                        activeKey = selected\n                                    }\n                                )\n                            }\n\n                            MenuContent.PREVIEW -> {\n                                val currentPreview by rememberUpdatedState(attachmentState.previewInfo)\n                                currentPreview?.let {\n                                    FilePreview(\n                                        uri = it.uri,\n                                        fileName = it.fileName,\n                                        fileSize = it.fileSizeFormatted,\n                                        isImage = it.isImage,\n                                        aspectRatio = it.imageAspectRatio\n                                    )\n                                }\n                            }\n\n                            MenuContent.NONE -> Unit\n                        }\n                    }\n\n                    Spacer(modifier = Modifier.height(24.dp))\n\n                    Row(\n                        modifier = Modifier.fillMaxWidth(),\n                        horizontalArrangement = Arrangement.End\n                    ) {\n                        TextButton(\n                            onClick = onDismissRequest,\n                            enabled = !attachmentState.isUploading\n                        ) {\n                            Text(stringResource(R.string.cancel))\n                        }\n\n                        Spacer(modifier = Modifier.width(8.dp))\n\n                        Button(\n                            onClick = { onSendRequest(attachmentState.result) },\n                            enabled = attachmentState.isUploadFinished && !attachmentState.isUploading\n                        ) {\n                            Text(stringResource(R.string.send))\n                        }\n                    }\n                }\n            }\n        }\n        } // AnimatedVisibility end\n    }\n}\n\n@Composable\nfun FilePreview(\n    uri: Uri,\n    fileName: String,\n    fileSize: String,\n    isImage: Boolean,\n    aspectRatio: Float?,\n) {\n    Card(\n        modifier = Modifier\n            .fillMaxWidth()\n            .heightIn(min = 80.dp),\n        shape = RoundedCornerShape(16.dp),\n        border = BorderStroke(\n            1.dp,\n            MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)\n        ),\n        colors = CardDefaults.cardColors(\n            containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)\n        )\n    ) {\n        if (isImage) {\n            AsyncImage(\n                model = uri,\n                contentDescription = \"Image Preview\",\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .heightIn(max = 400.dp)\n                    .let {\n                        if (aspectRatio != null && aspectRatio > 0f) {\n                            it.aspectRatio(aspectRatio)\n                        } else {\n                            it.height(180.dp)\n                        }\n                    }\n                    .clip(RoundedCornerShape(16.dp)),\n                contentScale = ContentScale.Crop\n            )\n        } else {\n            Row(\n                modifier = Modifier.padding(16.dp),\n                verticalAlignment = Alignment.CenterVertically\n            ) {\n                Icon(\n                    imageVector = Icons.Outlined.AttachFile,\n                    contentDescription = \"File Icon\",\n                    modifier = Modifier.size(48.dp),\n                    tint = MaterialTheme.colorScheme.primary\n                )\n\n                Spacer(modifier = Modifier.width(16.dp))\n\n                Column(modifier = Modifier.weight(1f)) {\n                    Text(\n                        text = fileName,\n                        style = MaterialTheme.typography.bodyLarge,\n                        fontWeight = FontWeight.Bold,\n                        maxLines = 2,\n                        overflow = TextOverflow.Ellipsis\n                    )\n                    Text(\n                        text = fileSize,\n                        style = MaterialTheme.typography.bodyMedium,\n                        color = MaterialTheme.colorScheme.onSurfaceVariant\n                    )\n                }\n            }\n        }\n    }\n}\n\n@Composable\nprivate fun AttachmentOptions(\n    isUploading: Boolean,\n    modifier: Modifier = Modifier,\n    onDismissAfterPick: () -> Unit,\n    onStyleClick: () -> Unit,\n    onKeyClick: () -> Unit,\n) {\n    val context = LocalContext.current\n\n    Column(\n        modifier = modifier.fillMaxWidth(),\n        verticalArrangement = Arrangement.spacedBy(16.dp)\n    ) {\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            horizontalArrangement = Arrangement.spacedBy(16.dp)\n        ) {\n            SendOptionItem(\n                icon = Icons.Outlined.Collections,\n                label = \"相册\",\n                enabled = !isUploading,\n                onClick = {\n                    val intent = Intent(context, AttachmentPickerActivity::class.java).apply {\n                        putExtra(\n                            AttachmentPickerActivity.EXTRA_PICK_TYPE,\n                            AttachmentPickerActivity.TYPE_MEDIA\n                        )\n                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n                    }\n                    context.startActivity(intent)\n                    onDismissAfterPick()\n                }\n            )\n\n            SendOptionItem(\n                icon = Icons.Outlined.FileOpen,\n                label = \"文件\",\n                enabled = !isUploading,\n                onClick = {\n                    val intent = Intent(context, AttachmentPickerActivity::class.java).apply {\n                        putExtra(\n                            AttachmentPickerActivity.EXTRA_PICK_TYPE,\n                            AttachmentPickerActivity.TYPE_FILE\n                        )\n                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n                    }\n                    context.startActivity(intent)\n                    onDismissAfterPick()\n                }\n            )\n        }\n\n        Row(\n            modifier = Modifier.fillMaxWidth(),\n            horizontalArrangement = Arrangement.spacedBy(16.dp)\n        ) {\n            SendOptionItem(\n                icon = Icons.Outlined.Translate,\n                label = \"语种\",\n                enabled = !isUploading,\n                onClick = onStyleClick\n            )\n\n            SendOptionItem(\n                icon = Icons.Filled.VpnKey,\n                label = \"密钥\",\n                enabled = !isUploading,\n                onClick = onKeyClick\n            )\n        }\n    }\n}\n\n@Composable\nprivate fun InlineSingleChoicePanel(\n    title: String,\n    items: List<Pair<String, String>>,\n    selectedKey: String,\n    onItemClick: (String) -> Unit,\n) {\n    val scrollState = rememberScrollState()\n\n    Card(\n        modifier = Modifier.fillMaxWidth(),\n        shape = RoundedCornerShape(16.dp),\n        colors = CardDefaults.cardColors(\n            containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)\n        ),\n        border = BorderStroke(\n            1.dp,\n            MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)\n        )\n    ) {\n        Column(modifier = Modifier.padding(10.dp)) {\n            Text(\n                text = title,\n                style = MaterialTheme.typography.titleMedium,\n                fontWeight = FontWeight.Bold\n            )\n\n            Spacer(modifier = Modifier.height(6.dp))\n\n            Column(\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .heightIn(max = 300.dp)\n                    .verticalScroll(scrollState),\n                verticalArrangement = Arrangement.spacedBy(4.dp)\n            ) {\n                items.forEach { (key, label) ->\n                    Surface(\n                        modifier = Modifier\n                            .fillMaxWidth()\n                            .clip(RoundedCornerShape(12.dp))\n                            .clickable { onItemClick(key) },\n                        shape = RoundedCornerShape(12.dp),\n                        color = if (key == selectedKey) {\n                            MaterialTheme.colorScheme.primaryContainer\n                        } else {\n                            MaterialTheme.colorScheme.surface\n                        }\n                    ) {\n                        Row(\n                            modifier = Modifier\n                                .fillMaxWidth()\n                                .padding(horizontal = 10.dp, vertical = 6.dp),\n                            verticalAlignment = Alignment.CenterVertically\n                        ) {\n                            RadioButton(\n                                selected = key == selectedKey,\n                                onClick = { onItemClick(key) },\n                                modifier = Modifier.size(18.dp)\n                            )\n                            Spacer(modifier = Modifier.width(8.dp))\n                            Text(\n                                text = label,\n                                style = MaterialTheme.typography.bodyMedium\n                            )\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n@Composable\nprivate fun RowScope.SendOptionItem(\n    icon: ImageVector,\n    label: String,\n    onClick: () -> Unit,\n    modifier: Modifier = Modifier,\n    enabled: Boolean = true,\n) {\n    val shape = RoundedCornerShape(12.dp)\n\n    Surface(\n        modifier = modifier\n            .weight(1f)\n            .clip(shape)\n            .clickable(\n                onClick = onClick,\n                enabled = enabled,\n            ),\n        shape = shape,\n        color = if (enabled) {\n            MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)\n        } else {\n            MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f)\n        },\n        border = BorderStroke(\n            1.dp,\n            MaterialTheme.colorScheme.outline.copy(alpha = if (enabled) 0.2f else 0.1f)\n        )\n    ) {\n        Column(\n            modifier = Modifier.padding(vertical = 16.dp),\n            horizontalAlignment = Alignment.CenterHorizontally,\n            verticalArrangement = Arrangement.Center\n        ) {\n            Icon(\n                imageVector = icon,\n                contentDescription = label,\n                modifier = Modifier.size(32.dp),\n                tint = if (enabled) {\n                    MaterialTheme.colorScheme.primary\n                } else {\n                    MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)\n                }\n            )\n            Spacer(modifier = Modifier.height(8.dp))\n            Text(\n                text = label,\n                style = MaterialTheme.typography.bodyMedium,\n                color = if (enabled) {\n                    MaterialTheme.colorScheme.onSurface\n                } else {\n                    MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)\n                }\n            )\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/FilePreviewDialog.kt",
    "content": "package me.wjz.nekocrypt.ui.dialog\n\nimport android.net.Uri\nimport androidx.compose.animation.AnimatedContent\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.Spring\nimport androidx.compose.animation.core.animateFloatAsState\nimport androidx.compose.animation.core.spring\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.animation.scaleOut\nimport androidx.compose.animation.togetherWith\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.Launch\nimport androidx.compose.material.icons.filled.AddPhotoAlternate\nimport androidx.compose.material.icons.filled.Check\nimport androidx.compose.material.icons.filled.Download\nimport androidx.compose.material.icons.filled.FilePresent\nimport androidx.compose.material.icons.filled.Image\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.CircularProgressIndicator\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.ProgressIndicatorDefaults\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.draw.shadow\nimport androidx.compose.ui.layout.ContentScale\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextOverflow\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport coil.compose.AsyncImage\nimport kotlinx.coroutines.delay\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.ui.theme.NekoCryptTheme\nimport me.wjz.nekocrypt.util.NCFileProtocol\nimport me.wjz.nekocrypt.util.NCFileType\nimport me.wjz.nekocrypt.util.formatFileSize\n\n/**\n * ✨ 全新改造的文件详情对话框 UI\n */\n@Composable\nfun FilePreviewDialog(\n    fileInfo: NCFileProtocol,\n    downloadProgress: Int?,\n    downloadedFileUri: Uri?,\n    isImageSavedThisTime : Boolean,\n    onDismissRequest: () -> Unit,\n    onDownloadRequest: (NCFileProtocol) -> Unit,\n    onOpenRequest: (Uri) -> Unit,\n    onSaveToGalleryRequest: (Uri) -> Unit // ✨ 新增：保存到相册的回调\n) {\n    val coroutineScope = rememberCoroutineScope()\n    var isVisible by remember { mutableStateOf(false) }\n    val isDownloading = downloadProgress != null // ✨ 判断当前是否正在下载\n\n    // 出现动画，并且判断如果是图片且未缓存，直接下载。\n    LaunchedEffect(Unit) {\n        isVisible = true\n        if(fileInfo.type == NCFileType.IMAGE && downloadedFileUri == null){\n            onDownloadRequest(fileInfo)\n        }\n    }\n\n    // 带动画的关闭逻辑\n    fun dismissWithAnimation() {\n        coroutineScope.launch {\n            isVisible = false\n            delay(300) // 等待动画播放完毕\n            onDismissRequest()\n        }\n    }\n\n    NekoCryptTheme(darkTheme = false) {\n        Box(modifier = Modifier.padding(16.dp)) {\n            AnimatedVisibility(\n                visible = isVisible,\n                enter = scaleIn(\n                    animationSpec = spring(\n                        dampingRatio = Spring.DampingRatioLowBouncy,\n                        stiffness = Spring.StiffnessLow\n                    )\n                ) + fadeIn(animationSpec = tween(200)),\n                exit = scaleOut(animationSpec = tween(300)) + fadeOut(\n                    animationSpec = tween(300)\n                )\n            ) {\n                Card(\n                    modifier = Modifier\n                        .shadow(elevation = 8.dp, shape = RoundedCornerShape(24.dp)),\n                    shape = RoundedCornerShape(24.dp),\n                    colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)\n                ) {\n                    Column(\n                        modifier = Modifier.padding(16.dp),\n                        horizontalAlignment = Alignment.CenterHorizontally)\n                    {\n                        // 标题\n                        Row(\n                            modifier = Modifier.fillMaxWidth(),\n                            horizontalArrangement = Arrangement.Center,\n                            verticalAlignment = Alignment.CenterVertically\n                        ) {\n                            Text(\n                                text = if(fileInfo.type == NCFileType.FILE) stringResource(R.string.dialog_download_file_file_info) else fileInfo.name,\n                                style = MaterialTheme.typography.headlineSmall,\n                                fontWeight = FontWeight.Bold,\n                                color = MaterialTheme.colorScheme.primary\n                            )\n                        }\n\n                        Spacer(modifier = Modifier.height(8.dp))\n\n                        // 核心预览区\n                        Card(\n                            modifier = Modifier\n                                .fillMaxWidth()\n                                .heightIn(max = 600.dp),\n                            shape = RoundedCornerShape(16.dp),\n                            colors = CardDefaults.cardColors(contentColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))\n                        ) {\n                            AnimatedContent(\n                                targetState = downloadedFileUri,\n                                label = \"preview_animation\",\n                            ) { uri ->\n                                if(uri !=null && fileInfo.type == NCFileType.IMAGE){\n                                    // 如果是图片并且已经有缓存文件，直接展示图片\n                                    AsyncImage(\n                                        model = uri,\n                                        contentDescription = \"image preview\",\n                                        modifier = Modifier\n                                            .fillMaxWidth()\n                                            .clip(RoundedCornerShape(2.dp)),\n                                        contentScale = ContentScale.FillWidth\n                                    )\n                                } else {\n                                    // 图片下载未完成，或者根本不是图片格式，就展示普通的样式\n                                    Box(\n                                        modifier = Modifier\n                                            .fillMaxWidth()\n                                            .height(200.dp)\n                                            .padding(16.dp),\n                                        contentAlignment = Alignment.Center\n                                    ){\n                                        if (isDownloading) DownloadProgressIndicator(progress = downloadProgress)\n                                        else InitialFileInfo(fileInfo = fileInfo)\n                                    }\n                                }\n                            }\n                        }\n\n                        Spacer(modifier = Modifier.height(16.dp))\n\n                        // --- ✨ 智能操作按钮 ---\n                        Row(\n                            modifier = Modifier.fillMaxWidth(),\n                            horizontalArrangement = Arrangement.End\n                        ) {\n                            // 关闭按钮\n                            TextButton(onClick = { dismissWithAnimation() }, enabled = !isDownloading) {\n                                Text(stringResource(R.string.cancel))\n                            }\n                            Spacer(modifier = Modifier.width(8.dp))\n\n                            AnimatedContent(\n                                targetState = downloadedFileUri != null,\n                                transitionSpec = {\n                                    fadeIn(animationSpec = tween(200)) togetherWith\n                                            fadeOut(animationSpec = tween(200))\n                                },\n                                label = \"ActionButtonAnimation\"\n                            ) { isDownloaded ->\n                                if (isDownloaded) {\n                                    // --- 已下载完成 ---\n                                    if (fileInfo.type == NCFileType.FILE) {\n                                        // 文件类型：显示“打开文件”\n                                        Button(onClick = {\n                                            // ✨ 核心修正2：先调用打开逻辑，再触发带动画的关闭\n                                            onOpenRequest(downloadedFileUri!!)\n                                            dismissWithAnimation()\n                                        }) {\n                                            Icon(Icons.AutoMirrored.Filled.Launch, contentDescription = null, modifier = Modifier.size(18.dp))\n                                            Spacer(modifier = Modifier.width(8.dp))\n                                            Text(stringResource(R.string.open_file))\n                                        }\n                                    } else {\n                                        // 图片类型：显示带动画的“保存”或“已保存”按钮\n                                        Button(\n                                            onClick = { onSaveToGalleryRequest(downloadedFileUri!!) },\n                                            enabled = !isImageSavedThisTime\n                                        ) {\n                                            AnimatedContent(\n                                                targetState = isImageSavedThisTime,\n                                                transitionSpec = {\n                                                    fadeIn(tween(300)) togetherWith fadeOut(tween(300))\n                                                },\n                                                label = \"SaveButtonAnimation\"\n                                            ) { saved ->\n                                                Row(verticalAlignment = Alignment.CenterVertically) {\n                                                    if (saved) {\n                                                        Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp))\n                                                        Spacer(modifier = Modifier.width(8.dp))\n                                                        Text(stringResource(R.string.image_saved_to_gallery_saved))\n                                                    } else {\n                                                        Icon(Icons.Default.AddPhotoAlternate, contentDescription = null, modifier = Modifier.size(18.dp))\n                                                        Spacer(modifier = Modifier.width(8.dp))\n                                                        Text(stringResource(R.string.save_to_gallery))\n                                                    }\n                                                }\n                                            }\n                                        }\n                                    }\n                                } else {\n                                    // --- 未下载 ---\n                                    Button(onClick = { onDownloadRequest(fileInfo) }, enabled = !isDownloading) {\n                                        Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp))\n                                        Spacer(modifier = Modifier.width(8.dp))\n                                        Text(stringResource(R.string.download))\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\n\n\n/**\n * ✨ 改造后的初始文件信息布局\n * 用于显示大图标、文件名和大小。\n */\n@Composable\nprivate fun InitialFileInfo(fileInfo: NCFileProtocol) {\n    Column(\n        horizontalAlignment = Alignment.CenterHorizontally,\n        verticalArrangement = Arrangement.Center,\n        modifier = Modifier.fillMaxSize()\n    ) {\n        val icon = when(fileInfo.type){\n            NCFileType.FILE -> Icons.Default.FilePresent\n            NCFileType.IMAGE -> Icons.Default.Image\n        }\n\n        Icon(\n            imageVector = icon,\n            contentDescription = \"文件类型\",\n            modifier = Modifier.size(96.dp), // 变大了！\n            tint = MaterialTheme.colorScheme.primary\n        )\n        Spacer(modifier = Modifier.height(8.dp))\n        // 文件名\n        Text(\n            text = fileInfo.name,\n            style = MaterialTheme.typography.titleMedium,\n            fontWeight = FontWeight.Bold,\n            fontSize = 24.sp,\n            color = MaterialTheme.colorScheme.onSurface,\n            maxLines = 2,\n            overflow = TextOverflow.Ellipsis\n        )\n        Spacer(modifier = Modifier.height(8.dp))\n        // 文件大小\n        Text(\n            text = fileInfo.size.formatFileSize(),\n            style = MaterialTheme.typography.bodyMedium,\n            color = MaterialTheme.colorScheme.onSurfaceVariant,\n            fontSize = 16.sp\n        )\n    }\n}\n\n@Composable\nprivate fun DownloadProgressIndicator(progress: Int?) {\n    Column(horizontalAlignment = Alignment.CenterHorizontally) {\n        val animatedProgress by animateFloatAsState(\n            targetValue = (progress ?: 0) / 100f,\n            animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec,\n            label = \"download_progress_animation\"\n        )\n        CircularProgressIndicator(progress = { animatedProgress })\n        Spacer(Modifier.height(16.dp))\n        Text(\n            text = \"${stringResource(R.string.downloading)} ${progress ?: 0}%\",\n            style = MaterialTheme.typography.bodyMedium,\n            color = MaterialTheme.colorScheme.primary\n        )\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/KeyManagementDialog.kt",
    "content": "package me.wjz.nekocrypt.ui.dialog\n\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.animateContentSize\nimport androidx.compose.animation.core.animateDpAsState\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.scaleIn\nimport androidx.compose.foundation.BorderStroke\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.PaddingValues\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.lazy.items\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.text.KeyboardActions\nimport androidx.compose.foundation.text.KeyboardOptions\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Add\nimport androidx.compose.material.icons.filled.Delete\nimport androidx.compose.material.icons.filled.VpnKey\nimport androidx.compose.material.icons.outlined.CheckCircle\nimport androidx.compose.material.icons.outlined.ContentCopy\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.ButtonDefaults\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.material3.TextField\nimport androidx.compose.material3.TextFieldDefaults\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateListOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.runtime.snapshots.SnapshotStateList\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.focus.FocusRequester\nimport androidx.compose.ui.focus.focusRequester\nimport androidx.compose.ui.focus.onFocusChanged\nimport androidx.compose.ui.graphics.Color\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.platform.LocalConfiguration\nimport androidx.compose.ui.platform.LocalFocusManager\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.input.ImeAction\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.times\nimport androidx.compose.ui.window.Dialog\nimport androidx.compose.ui.window.DialogProperties\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.Constant\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.data.LocalDataStoreManager\nimport me.wjz.nekocrypt.data.rememberKeyArrayState\nimport me.wjz.nekocrypt.hook.rememberDataStoreState\n\n\n@Composable\nfun KeyManagementDialog(onDismissRequest: () -> Unit) {\n    // 状态管理\n    val dataStoreManager = LocalDataStoreManager.current\n    val coroutineScope = rememberCoroutineScope()\n\n    val keysFromDataStore: Array<String> by rememberKeyArrayState()\n    val keys = remember { mutableStateListOf<String>() }\n\n    // 数据库中key改变，同步到compose中\n    LaunchedEffect(keysFromDataStore) {\n        if(keys.toList()!=keysFromDataStore.toList()){\n            keys.clear()\n            keys.addAll(keysFromDataStore)\n        }\n    }\n\n    // 当前正在使用的密钥\n    var activeKey by rememberDataStoreState(SettingKeys.CURRENT_KEY, Constant.DEFAULT_SECRET_KEY)\n    // 我们不再需要 showAddKeyDialog，而是用一个新的状态来控制“添加模式”\n    var isAddingNewKey by remember { mutableStateOf(false) }\n\n    var keyToDelete by remember { mutableStateOf<String?>(null) }\n    val clipboardManager = LocalClipboardManager.current\n\n    fun saveKeys(updatedKeys: SnapshotStateList<String>) {\n        coroutineScope.launch {\n            dataStoreManager.saveKeyArray(updatedKeys.toTypedArray())\n        }\n    }\n\n    Dialog(\n        onDismissRequest = {\n            onDismissRequest()\n        },\n        // ✨ 2. [核心修正] 告诉Dialog不要使用平台默认的窄宽度\n        properties = DialogProperties(usePlatformDefaultWidth = false)\n    ) {\n        Card(\n            modifier = Modifier\n                .fillMaxWidth(0.90f), // ✨ 设置宽度为屏幕可用宽度的90%\n            shape = RoundedCornerShape(24.dp),\n        ) {\n            Column(modifier = Modifier.padding(12.dp)) {\n                Text(\n                    text = stringResource(R.string.key_management_dialog_key_management),\n                    color = MaterialTheme.colorScheme.primary,\n                    style = MaterialTheme.typography.headlineMedium,\n                    fontWeight = FontWeight.Bold\n                )\n                Spacer(modifier = Modifier.height(16.dp))\n                LazyColumn(\n                    modifier = Modifier.heightIn(max = LocalConfiguration.current.screenHeightDp * 0.5.dp),\n                    contentPadding = PaddingValues(vertical = 8.dp),\n                    verticalArrangement = Arrangement.spacedBy(12.dp),\n                    horizontalAlignment = Alignment.CenterHorizontally\n                ) {\n                    // 密钥列表item\n                    items(keys) { key ->\n                        KeyItem(\n                            keyText = key,\n                            isActive = key == activeKey,\n                            onSetAsActive = { activeKey = key },\n                            onCopy = { clipboardManager.setText(AnnotatedString(key)) },\n                            onDelete = { keyToDelete = key }\n                        )\n                    }\n                    // 密钥添加item\n                    item {\n                        AnimatedVisibility(visible = isAddingNewKey) {\n                            KeyEditItem( // ✨ 命名已更新\n                                onAddKey = { newKey ->\n                                    if (newKey.isNotBlank() && !keys.contains(newKey)) {\n                                        keys.add(newKey)\n                                        saveKeys(keys)\n                                    }\n                                    isAddingNewKey = false // 完成后关闭输入模式\n                                }\n                            )\n                        }\n                    }\n\n                    // +号按钮，用来添加密钥。\n                    item {\n                        AnimatedVisibility(visible = !isAddingNewKey) {\n                            AddNewKeyButton(onClick = { isAddingNewKey = true })\n                        }\n                    }\n                }\n                Spacer(modifier = Modifier.height(16.dp))\n                Row(\n                    modifier = Modifier.fillMaxWidth(),\n                    horizontalArrangement = Arrangement.End\n                ) {\n                    Button(onClick = onDismissRequest) {\n                        Text(stringResource(R.string.cancel))\n                    }\n                }\n            }\n        }\n    }\n\n    if (keyToDelete != null) {\n        DeleteConfirmDialog(\n            onDismiss = { keyToDelete = null },\n            onConfirm = {\n                keys.remove(keyToDelete)\n                if (activeKey == keyToDelete && keys.isNotEmpty()) {\n                    activeKey = keys.first()\n                }\n                // ✨ 5. [核心] 删除后，立刻保存\n                saveKeys(keys)\n                keyToDelete = null\n            }\n        )\n    }\n}\n\n/**\n * 列表里的单个密钥UI\n */\n@Composable\nprivate fun KeyItem(\n    keyText: String,\n    isActive: Boolean,\n    onSetAsActive: () -> Unit,\n    onCopy: () -> Unit,\n    onDelete: () -> Unit\n){\n    Card(\n        modifier = Modifier\n            .fillMaxWidth()\n            .animateContentSize(),\n        shape = RoundedCornerShape(24.dp),\n        border = if (isActive) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,\n        colors = CardDefaults.cardColors(\n            containerColor = if(isActive) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)\n            else MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.1f)\n        )\n    ){\n        Row(\n            modifier = Modifier\n                .clickable(onClick = onSetAsActive)\n                .padding(horizontal = 16.dp, vertical = 8.dp),\n            verticalAlignment = Alignment.CenterVertically\n        ) {\n\n            Icon(\n                imageVector = Icons.Default.VpnKey,\n                contentDescription = \"密钥图标\",\n                tint = MaterialTheme.colorScheme.primary\n            )\n            Spacer(modifier = Modifier.width(16.dp))\n            Text(\n                text = keyText,\n                modifier = Modifier.weight(1f),\n                style = MaterialTheme.typography.bodyLarge,\n                fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal\n            )\n            AnimatedVisibility(\n                visible = isActive,\n                enter = scaleIn() + fadeIn(),\n                exit = fadeOut()\n            ) {\n                Icon(\n                    imageVector = Icons.Outlined.CheckCircle,\n                    contentDescription = \"当前活动密钥\",\n                    tint = MaterialTheme.colorScheme.primary\n                )\n            }\n            Spacer(modifier = Modifier.width(8.dp))\n            IconButton(onClick = onCopy) {\n                Icon(Icons.Outlined.ContentCopy, contentDescription = \"复制密钥\",tint = MaterialTheme.colorScheme.primary)\n            }\n            IconButton(onClick = onDelete) {\n                Icon(Icons.Default.Delete, contentDescription = \"删除密钥\", tint = MaterialTheme.colorScheme.secondary)\n            }\n        }\n    }\n}\n\n@Composable\nprivate fun AddNewKeyButton(onClick: () -> Unit) {\n    val shape = RoundedCornerShape(32.dp)\n    Card(\n        modifier = Modifier\n            .fillMaxWidth(0.25f)\n            .padding(vertical = 4.dp)\n            .clip(shape)\n            .clickable(onClick = onClick),\n        shape = shape,\n        colors = CardDefaults.cardColors(\n            containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f)\n        ),\n        border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary)\n    ) {\n        Row(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(vertical = 12.dp),\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.Center\n        ) {\n            Icon(\n                imageVector = Icons.Default.Add,\n                contentDescription = stringResource(R.string.key_management_dialog_key_add),\n                tint = MaterialTheme.colorScheme.primary\n            )\n        }\n    }\n}\n\n@Composable\nprivate fun KeyEditItem(\n    onAddKey: (String) -> Unit\n) {\n    var newKeyText by remember { mutableStateOf(\"\") }\n    val focusRequester = remember { FocusRequester() }\n    val focusManager = LocalFocusManager.current\n    val elevation by animateDpAsState(targetValue = 6.dp, label = \"\")\n    // 当输入框出现时，自动请求焦点\n    LaunchedEffect(Unit) {\n        focusRequester.requestFocus()\n    }\n\n    Card(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(vertical = 4.dp),\n        shape = RoundedCornerShape(24.dp),\n        elevation = CardDefaults.cardElevation(defaultElevation = elevation),\n        border = BorderStroke(1.5.dp, MaterialTheme.colorScheme.secondary),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)\n    ) {\n        // ✨ 核心修正：我们用一个普通的 TextField，并把它变成“隐形”的！\n        TextField(\n            value = newKeyText,\n            onValueChange = { newKeyText = it },\n            modifier = Modifier\n                .fillMaxWidth()\n                .focusRequester(focusRequester)\n                .onFocusChanged { focusState ->\n                    // 当输入框失去焦点时，自动保存\n                    if (!focusState.isFocused && newKeyText.isNotBlank()) {\n                        onAddKey(newKeyText)\n                    }\n                },\n            // 用 placeholder 感觉比 label 更适合这里的UI\n            placeholder = { Text(stringResource(R.string.key_screen_new_key_placeholder)) },\n            singleLine = true,\n            keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),\n            keyboardActions = KeyboardActions(onDone = {\n                onAddKey(newKeyText)\n                focusManager.clearFocus() // 清除焦点，触发 onFocusChanged\n            }),\n            // ✨ 魔法在这里！我们把所有背景和边框颜色都设置为透明！\n            colors = TextFieldDefaults.colors(\n                focusedContainerColor = Color.Transparent,\n                unfocusedContainerColor = Color.Transparent,\n                disabledContainerColor = Color.Transparent,\n                focusedIndicatorColor = Color.Transparent,\n                unfocusedIndicatorColor = Color.Transparent,\n                disabledIndicatorColor = Color.Transparent,\n            )\n        )\n    }\n}\n\n/**\n * 删除确认对话框\n */\n@Composable\nprivate fun DeleteConfirmDialog(\n    onDismiss: () -> Unit,\n    onConfirm: () -> Unit\n) {\n    AlertDialog(\n        onDismissRequest = onDismiss,\n        title = { Text(\"确认删除\") },\n        text = { Text(\"确定要删除这个密钥吗？此操作不可撤销。\") },\n        confirmButton = {\n            Button(\n                onClick = onConfirm,\n                colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)\n            ) {\n                Text(\"删除\")\n            }\n        },\n        dismissButton = {\n            TextButton(onClick = onDismiss) {\n                Text(\"取消\")\n            }\n        }\n    )\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/NCDialog.kt",
    "content": "package me.wjz.nekocrypt.ui.dialog\n\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.graphics.vector.ImageVector\n\n/**\n * 弹窗对话栏\n */\ninterface NCDialog {\n    val icon: ImageVector\n    val title: String\n    val text: String\n    val onDismiss: () -> Unit\n    val onConfirm: () -> Unit\n    @Composable\n    fun Content()\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/PermissionDialog.kt",
    "content": "\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport androidx.compose.ui.res.stringResource\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.ui.dialog.NCDialog\n\n/**\n * 专门用于请求权限的对话框实现。\n * 它在构造时接收所有必要信息，并直接在 Content() 方法中定义自己的UI。\n */\nclass PermissionDialog(\n    private val dialogIcon: ImageVector,\n    private val dialogTitle: String,\n    private val dialogText: String,\n    private val onDismissRequest: () -> Unit,\n    private val onConfirmRequest: () -> Unit\n) : NCDialog {\n\n    // 将接口属性映射到构造函数参数\n    override val icon: ImageVector get() = dialogIcon\n    override val title: String get() = dialogTitle\n    override val text: String get() = dialogText\n    override val onDismiss: () -> Unit get() = onDismissRequest\n    override val onConfirm: () -> Unit get() = onConfirmRequest\n\n    @Composable\n    override fun Content() {\n        AlertDialog(\n            onDismissRequest = onDismiss,\n            icon = { Icon(imageVector = icon, contentDescription = title) },\n            title = { Text(text = title) },\n            text = { Text(text = text) },\n            confirmButton = {\n                TextButton(onClick = onConfirm) {\n                    Text(stringResource(R.string.permission_go_to_settings))\n                }\n            },\n            dismissButton = {\n                TextButton(onClick = onDismiss) {\n                    Text(stringResource(R.string.cancel))\n                }\n            }\n        )\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/dialog/ScannerDialog.kt",
    "content": "package me.wjz.nekocrypt.ui.dialog\n\nimport android.widget.Toast\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.foundation.border\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.heightIn\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.selection.selectable\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.HelpOutline\nimport androidx.compose.material.icons.rounded.Info\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.Button\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Surface\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.derivedStateOf\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.platform.LocalConfiguration\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.semantics.Role\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.compose.ui.window.Dialog\nimport androidx.compose.ui.window.DialogProperties\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.ui.activity.FoundNodeInfo\nimport me.wjz.nekocrypt.ui.activity.MessageListScanResult\nimport me.wjz.nekocrypt.ui.activity.ScanResult\n\n/**\n * 用于封装用户最终选择的节点信息的数据类。\n */\ndata class ScanSelections(\n    val inputNode: FoundNodeInfo,\n    val sendBtnNode: FoundNodeInfo,\n    val messageList: FoundNodeInfo,\n    val messageText: FoundNodeInfo\n)\n\n/**\n * 悬浮扫描按钮点击后显示的对话框 Composable (V3 协同版)。\n */\n@Composable\nfun ScannerDialog(\n    scanResult: ScanResult,\n    onDismissRequest: () -> Unit,\n    onConfirm: (ScanSelections,ScanResult) -> Unit\n) {\n    val configuration = LocalConfiguration.current\n    val screenHeight = configuration.screenHeightDp.dp\n    //  记住用户的选择\n    var selectedInput by remember { mutableStateOf<FoundNodeInfo?>(null) }\n    var selectedSendBtn by remember { mutableStateOf<FoundNodeInfo?>(null) }\n    var selectedList by remember { mutableStateOf<MessageListScanResult?>(null) }\n    var selectedMessageText by remember { mutableStateOf<FoundNodeInfo?>(null) }\n\n    //  helpDialog\n    var showHelpDialog by remember { mutableStateOf(false) }\n    // --- 2. 衍生状态：只有当所有选项都选了，确认按钮才能点击 ---\n    val isConfirmEnabled by remember(selectedInput, selectedSendBtn, selectedList, selectedMessageText) {\n        derivedStateOf {\n            selectedInput != null && selectedSendBtn != null && selectedList != null && selectedMessageText != null\n        }\n    }\n\n    Dialog(\n        onDismissRequest = onDismissRequest,\n        properties = DialogProperties(usePlatformDefaultWidth = false)\n    ) {\n        Card(\n            modifier = Modifier\n                .fillMaxWidth(0.95f) // 使用屏幕宽度的90%\n                .padding(16.dp)\n                .heightIn(max = screenHeight * 0.85f), // 最大高度为屏幕的85%\n            shape = RoundedCornerShape(16.dp)\n        ) {\n            Column(modifier = Modifier.padding(16.dp)) {\n                // --- 核心修改：总标题和一个可点击的帮助图标 ---\n                Row(\n                    modifier = Modifier.fillMaxWidth(),\n                    horizontalArrangement = Arrangement.SpaceBetween,\n                    verticalAlignment = Alignment.CenterVertically\n                ) {\n                    Text(\n                        text = stringResource(R.string.scanner_dialog_title),\n                        style = MaterialTheme.typography.headlineSmall\n                    )\n                    IconButton(onClick = { showHelpDialog = true }) {\n                        Icon(\n                            imageVector = Icons.Outlined.HelpOutline,\n                            contentDescription = \"instruction\"\n                        )\n                    }\n                }\n\n                // 显示当前应用的包名和名称\n                Text(\n                    text = \"${scanResult.name} (${scanResult.packageName})\",\n                    style = MaterialTheme.typography.bodyMedium,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n                Spacer(modifier = Modifier.height(16.dp))\n\n                // 使用可滚动的 LazyColumn 来展示所有区块\n                LazyColumn(modifier = Modifier.weight(1f, fill = false)) {\n                    item {\n                        SelectableSection(\n                            title = stringResource(R.string.scanner_dialog_section_input),\n                            nodes = scanResult.foundInputNodes,\n                            selectedNode = selectedInput,\n                            onNodeSelected = {\n                                selectedInput = if (selectedInput == it) null else it\n                            }\n                        )\n                    }\n                    item {\n                        SelectableSection(\n                            title = stringResource(R.string.scanner_dialog_section_send_btn),\n                            nodes = scanResult.foundSendBtnNodes,\n                            selectedNode = selectedSendBtn,\n                            onNodeSelected = { selectedSendBtn = if (selectedSendBtn == it) null else it}\n                        )\n                    }\n                    item {\n                        MessageListSelectionSection(\n                            title = stringResource(R.string.scanner_dialog_section_msg_list),\n                            lists = scanResult.foundMessageLists,\n                            selectedList = selectedList,\n                            selectedText = selectedMessageText,\n                            onListSelected = {\n                                selectedList = if(selectedList == it) null else it\n\n                                selectedMessageText = null // ✨ 切换列表时，重置消息文本的选择\n                            },\n                            onTextSelected = { selectedMessageText =if(selectedMessageText == it)null else it }\n                        )\n                    }\n                }\n\n                Spacer(modifier = Modifier.height(16.dp))\n\n                // --- 底部按钮 ---\n                Row(\n                    modifier = Modifier.fillMaxWidth(),\n                    horizontalArrangement = Arrangement.End\n                ) {\n                    TextButton(onClick = onDismissRequest) {\n                        Text(stringResource(R.string.cancel))\n                    }\n                    Spacer(modifier = Modifier.width(8.dp))\n                    Button(\n                        onClick = {\n                            // ✨ 点击确认时，把所有选择打包成 ScanSelections 并回传\n                            onConfirm(\n                                ScanSelections(\n                                    inputNode = selectedInput!!,\n                                    sendBtnNode = selectedSendBtn!!,\n                                    messageList = selectedList!!.listContainerInfo,\n                                    messageText = selectedMessageText!!\n                                ),scanResult\n                            )\n                        },\n                        enabled = isConfirmEnabled // ✨ 绑定按钮的可用状态\n                    ) {\n                        Text(stringResource(R.string.accept))\n                    }\n                }\n            }\n        }\n    }\n\n    if (showHelpDialog) {\n        ScannerHelpDialog(onDismissRequest = { showHelpDialog = false })\n    }\n}\n\n/**\n * ✨ 全新：用于选择“消息列表”和“消息文本”的复合区块\n */\n@Composable\nprivate fun MessageListSelectionSection(\n    title: String,\n    lists: List<MessageListScanResult>,\n    selectedList: MessageListScanResult?,\n    selectedText: FoundNodeInfo?,\n    onListSelected: (MessageListScanResult) -> Unit,\n    onTextSelected: (FoundNodeInfo) -> Unit\n) {\n    if (lists.isNotEmpty()) {\n        Column(modifier = Modifier.padding(bottom = 12.dp)) {\n            Text(text = \"$title (${lists.size})\", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)\n            Spacer(modifier = Modifier.height(8.dp))\n\n            lists.forEach { listResult ->\n                // 1. 先把每个列表容器作为可选项显示\n                SelectableNodeInfoCard(\n                    nodeInfo = listResult.listContainerInfo,\n                    isSelected = listResult == selectedList,\n                    onSelected = { onListSelected(listResult) }\n                )\n\n                // 2. 如果当前列表被选中了，就“展开”它内部的消息文本作为下一级选项\n\n                Column(modifier = Modifier.padding(start = 16.dp, top = 8.dp)) {\n                    Text(\n                        text = \"└─ ${stringResource(R.string.scanner_dialog_section_msg_text)} (${listResult.messageTexts.size})\",\n                        style = MaterialTheme.typography.titleSmall,\n                        color = MaterialTheme.colorScheme.secondary\n                    )\n                    Spacer(modifier = Modifier.height(4.dp))\n                    // ✨ 核心修改：只有当父列表被选中时，才动态展示详细的子节点卡片\n                    AnimatedVisibility(visible = listResult == selectedList) {\n                        Column {\n                            listResult.messageTexts.forEach { textNode ->\n                                SelectableNodeInfoCard(\n                                    nodeInfo = textNode,\n                                    isSelected = textNode == selectedText,\n                                    onSelected = { onTextSelected(textNode) }\n                                )\n                                Spacer(modifier = Modifier.height(8.dp))\n                            }\n                        }\n                    }\n                }\n\n                Spacer(modifier = Modifier.height(8.dp))\n            }\n        }\n    }\n}\n\n/**\n * ✨ 全新：通用的、用于单选的区块（用于输入框和发送按钮）\n */\n@Composable\nprivate fun SelectableSection(\n    title: String,\n    nodes: List<FoundNodeInfo>,\n    selectedNode: FoundNodeInfo?,\n    onNodeSelected: (FoundNodeInfo) -> Unit\n) {\n    if (nodes.isNotEmpty()) {\n        Column(modifier = Modifier.padding(bottom = 16.dp)) {\n            Text(text = \"$title (${nodes.size})\", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)\n            Spacer(modifier = Modifier.height(8.dp))\n            nodes.forEach { node ->\n                SelectableNodeInfoCard(\n                    nodeInfo = node,\n                    isSelected = node == selectedNode,\n                    onSelected = { onNodeSelected(node) }\n                )\n                Spacer(modifier = Modifier.height(8.dp))\n            }\n        }\n    }\n}\n\n/**\n * ✨ 全新：带单选按钮的节点信息卡片\n */\n@Composable\nprivate fun SelectableNodeInfoCard(\n    nodeInfo: FoundNodeInfo,\n    isSelected: Boolean,\n    onSelected: () -> Unit\n) {\n    val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)\n    Card(\n        modifier = Modifier\n            .fillMaxWidth()\n            .border(2.dp, borderColor, RoundedCornerShape(12.dp))\n            .selectable(\n                selected = isSelected,\n                onClick = onSelected,\n                role = Role.RadioButton\n            ),\n        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)\n    ) {\n        Row(\n            modifier = Modifier.padding(12.dp),\n            verticalAlignment = Alignment.CenterVertically\n        ) {\n            Column {\n                if (nodeInfo.resourceId?.isNotBlank()==true) InfoRow(label = stringResource(R.string.scanner_dialog_card_id), value = nodeInfo.resourceId)\n                InfoRow(label = stringResource(R.string.scanner_dialog_card_class), value = nodeInfo.className)\n                if (nodeInfo.text?.isNotBlank()==true) InfoRow(label = stringResource(R.string.scanner_dialog_card_text), value = nodeInfo.text)\n                if (nodeInfo.contentDescription?.isNotBlank()==true) InfoRow(label = stringResource(R.string.scanner_dialog_card_desc), value = nodeInfo.contentDescription)\n            }\n        }\n    }\n}\n\n/**\n * 用于在卡片内显示一行“标签: 内容”信息，并支持点击复制。\n */\n@Composable\nprivate fun InfoRow(label: String, value: String) {\n    val clipboardManager = LocalClipboardManager.current\n    val context = LocalContext.current\n    val hasCopyHint = stringResource(R.string.has_copy)\n\n    Row(\n        modifier = Modifier\n            .fillMaxWidth()\n            .padding(vertical = 4.dp),\n        verticalAlignment = Alignment.CenterVertically\n    ) {\n        // 标签\n        Text(\n            text = \"$label:\",\n            fontWeight = FontWeight.Bold,\n            fontSize = 14.sp,\n            modifier = Modifier.width(48.dp) // 给标签一个固定宽度，让内容对齐\n        )\n        // 内容\n        Surface(\n            onClick = {\n                if (value.isNotEmpty()) {\n                    clipboardManager.setText(AnnotatedString(value))\n                    Toast.makeText(context, \"'$value' $hasCopyHint\", Toast.LENGTH_SHORT).show()\n                }\n            },\n            modifier = Modifier.weight(1f),\n            shape = RoundedCornerShape(8.dp),\n            color = MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.5f)\n        ) {\n            Text(\n                text = value,\n                style = MaterialTheme.typography.bodyMedium,\n                fontFamily = FontFamily.Monospace,\n                modifier = Modifier\n                    .fillMaxWidth()\n                    .padding(horizontal = 12.dp, vertical = 8.dp)\n            )\n        }\n    }\n}\n\n@Composable\nprivate fun ScannerHelpDialog(onDismissRequest: () -> Unit) {\n    AlertDialog(\n        onDismissRequest = onDismissRequest,\n        icon = { Icon(Icons.Rounded.Info, contentDescription = null) },\n        title = { Text(text = stringResource(R.string.scanner_help_dialog_title)) },\n        text = {\n            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {\n                Text(stringResource(R.string.scanner_help_dialog_intro))\n                Text(stringResource(R.string.scanner_help_dialog_instruction))\n            }\n        },\n        confirmButton = {\n            TextButton(onClick = onDismissRequest) {\n                Text(stringResource(R.string.accept))\n            }\n        }\n    )\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/screen/CryptoScreen.kt",
    "content": "package me.wjz.nekocrypt.ui.screen\n\nimport android.content.ContentValues\nimport android.content.Context\nimport android.content.Intent\nimport android.net.Uri\nimport android.os.Build\nimport android.os.Environment\nimport android.provider.MediaStore\nimport android.util.Log\nimport android.webkit.MimeTypeMap\nimport android.widget.Toast\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.foundation.clickable\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.height\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.rememberScrollState\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.foundation.verticalScroll\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.automirrored.filled.ArrowForward\nimport androidx.compose.material.icons.automirrored.rounded.Notes\nimport androidx.compose.material.icons.filled.ContentCopy\nimport androidx.compose.material.icons.filled.ContentPaste\nimport androidx.compose.material.icons.filled.Key\nimport androidx.compose.material.icons.rounded.Clear\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.DropdownMenuItem\nimport androidx.compose.material3.ExperimentalMaterial3Api\nimport androidx.compose.material3.ExposedDropdownMenuBox\nimport androidx.compose.material3.ExposedDropdownMenuDefaults\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.IconButton\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.OutlinedTextField\nimport androidx.compose.material3.OutlinedTextFieldDefaults\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableIntStateOf\nimport androidx.compose.runtime.mutableLongStateOf\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.platform.LocalClipboardManager\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.AnnotatedString\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.dp\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\nimport me.wjz.nekocrypt.Constant.DEFAULT_SECRET_KEY\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys.CIPHERTEXT_STYLE\nimport me.wjz.nekocrypt.SettingKeys.CURRENT_KEY\nimport me.wjz.nekocrypt.data.rememberKeyArrayState\nimport me.wjz.nekocrypt.hook.rememberDataStoreState\nimport me.wjz.nekocrypt.ui.dialog.FilePreviewDialog\nimport me.wjz.nekocrypt.ui.dialog.KeyManagementDialog\nimport me.wjz.nekocrypt.util.CiphertextStyleType\nimport me.wjz.nekocrypt.util.CryptoDownloader\nimport me.wjz.nekocrypt.util.CryptoManager\nimport me.wjz.nekocrypt.util.CryptoManager.applyCiphertextStyle\nimport me.wjz.nekocrypt.util.CryptoManager.containsCiphertext\nimport me.wjz.nekocrypt.util.NCFileProtocol\nimport me.wjz.nekocrypt.util.getCacheFileFor\nimport me.wjz.nekocrypt.util.getUriForFile\nimport java.io.IOException\n\n@Composable\nfun CryptoScreen(modifier: Modifier = Modifier) {\n    var inputText by remember { mutableStateOf(\"\") }\n    var outputText by remember { mutableStateOf(\"\") }\n    val clipboardManager = LocalClipboardManager.current\n    val context = LocalContext.current\n    var isEncryptMode by remember { mutableStateOf(true) }//当前是加密or解密\n    //  获取当前密钥，用于加密。没有就是默认密钥\n    val secretKey: String by rememberDataStoreState(CURRENT_KEY, DEFAULT_SECRET_KEY)\n    //  这里还要拿密钥列表，for循环遍历解密\n    val secretKeyList by rememberKeyArrayState()\n\n    //  当前的密文风格类型\n    var ciphertextStyleType by rememberDataStoreState(CIPHERTEXT_STYLE, CiphertextStyleType.NEKO.toString())\n\n    val decryptFailed = stringResource(id = R.string.crypto_decrypt_fail)//解密错误的text。\n    var isDecryptFailed by remember { mutableStateOf(false) }\n    // 新增：用于统计的状态\n    var charCount by remember { mutableIntStateOf(0) }\n    var elapsedTime by remember { mutableLongStateOf(0L) }\n    // 新增一个状态，用来控制密钥管理对话框的显示和隐藏\n    var showKeyDialog by remember { mutableStateOf(false) }\n\n    //  管理文件弹窗和下载相关\n    var fileInfoToShow by remember { mutableStateOf<NCFileProtocol?>(null) }\n    var downloadProgress by remember { mutableStateOf<Int?>(null) }\n    var downloadedFileUri by remember { mutableStateOf<Uri?>(null) }\n    var isImageSavedThisTime by remember { mutableStateOf(false) }\n\n    //自动加解密\n    LaunchedEffect(inputText, secretKey) {\n        if (inputText.isEmpty()) {\n            outputText = \"\"\n            fileInfoToShow = null // 清空文件信息\n            charCount = 0\n            elapsedTime = 0L\n            return@LaunchedEffect\n        }\n\n        val startTime = System.currentTimeMillis()\n        var ciphertextCharCount = 0\n\n        // 先判断是不是密文\n        if (inputText.containsCiphertext()) {\n            isEncryptMode = false\n            ciphertextCharCount = inputText.length\n            var decryptedText:String? = null\n            //  执行解密\n            for( key in secretKeyList){\n                decryptedText = CryptoManager.decrypt(inputText, key)\n                if(decryptedText!=null) break\n            }\n\n            // 再判断解密后的内容是不是文件协议\n            val fileInfo = decryptedText?.let { NCFileProtocol.fromString(it) }\n\n            if (fileInfo != null) {\n                // --- 是文件！准备显示弹窗 ---\n                outputText = \"\" // 清空普通文本输出\n                isDecryptFailed = false\n                // 检查文件是否已缓存\n                val targetFile = getCacheFileFor(context, fileInfo)\n                if (targetFile.exists() && targetFile.length() == fileInfo.size) {\n                    downloadedFileUri = getUriForFile(context, targetFile)\n                    downloadProgress = null\n                } else {\n                    downloadedFileUri = null\n                    downloadProgress = null\n                }\n                isImageSavedThisTime = false // 重置保存状态\n                fileInfoToShow = fileInfo // ✨ 触发弹窗显示！\n            } else {\n                // --- 是普通文本 ---\n                fileInfoToShow = null // 确保文件弹窗不显示\n                isDecryptFailed = decryptedText == null\n                outputText = decryptedText ?: decryptFailed\n            }\n        } else {\n            // --- 是原文，执行加密 ---\n            isEncryptMode = true\n            fileInfoToShow = null\n            val ciphertext = CryptoManager.encrypt(inputText, secretKey).applyCiphertextStyle()\n            ciphertextCharCount = ciphertext.length\n            outputText = ciphertext\n        }\n\n        val endTime = System.currentTimeMillis()\n        elapsedTime = endTime - startTime\n        charCount = ciphertextCharCount\n    }\n\n    Column(\n        modifier = modifier\n            .fillMaxSize()\n            .padding(16.dp).verticalScroll(rememberScrollState()),\n        horizontalAlignment = Alignment.CenterHorizontally\n    ) {\n        // 1. 密钥选择器\n        KeySelector(\n            selectedKeyName = secretKey,\n            onClick = {\n                // 当点击时，将状态设置为true，以显示对话框\n                showKeyDialog = true\n            }\n        )\n        Spacer(modifier = Modifier.height(16.dp))\n\n        CiphertextStyleSelector(\n            selectedStyle = CiphertextStyleType.fromName(ciphertextStyleType),\n            onStyleSelected = { newStyle ->\n                // ✨ 核心修正：直接赋值即可！\n                // 我们的 DataStoreStateDelegate 会自动处理保存逻辑和UI更新。\n                ciphertextStyleType = newStyle.toString()\n            }\n        )\n\n        Spacer(modifier = Modifier.height(16.dp))\n\n        // 2. 输入文本框\n        OutlinedTextField(\n            value = inputText,\n            onValueChange = { inputText = it },\n            modifier = Modifier\n                .fillMaxWidth()\n//                .height(180.dp),这里不设置固定高度\n                ,\n            minLines = 1,//控制默认的最小行数\n            maxLines = 6,//控制最大行数\n            label = { Text(stringResource(id = R.string.crypto_input_label)) },\n            placeholder = { Text(stringResource(id = R.string.crypto_input_placeholder)) },\n            leadingIcon = {\n                Icon(\n                    Icons.AutoMirrored.Rounded.Notes,\n                    contentDescription = stringResource(id = R.string.crypto_input_icon_desc)\n                )\n            },\n            // 右方的辅助按钮\n            trailingIcon = {\n                Row {\n                    // 粘贴按钮\n                    IconButton(onClick = {\n                        clipboardManager.getText()?.text?.let { inputText += it }//这里+=\n                        Toast.makeText(\n                            context,\n                            context.getString(R.string.crypto_pasted_from_clipboard),\n                            Toast.LENGTH_SHORT\n                        ).show()\n                    }) {\n                        Icon(\n                            Icons.Default.ContentPaste,\n                            contentDescription = stringResource(id = R.string.crypto_paste_icon_desc)\n                        )\n                    }\n                    // 清空按钮，仅在有输入时显示\n                    AnimatedVisibility(visible = inputText.isNotEmpty()) {\n                        IconButton(onClick = { inputText = \"\" }) {\n                            Icon(\n                                Icons.Rounded.Clear,\n                                contentDescription = stringResource(id = R.string.crypto_clear_input_icon_desc)\n                            )\n                        }\n                    }\n                }\n            },\n            shape = RoundedCornerShape(16.dp),\n            colors = OutlinedTextFieldDefaults.colors(\n                focusedBorderColor = MaterialTheme.colorScheme.primary,\n                unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)\n            )\n        )\n\n        Spacer(modifier = Modifier.height(16.dp))\n\n        // 输出结果区域\n        // 使用 AnimatedVisibility，当有输出时，这个区域会平滑地淡入\n        AnimatedVisibility(\n            visible = outputText.isNotEmpty(),\n            enter = fadeIn(animationSpec = tween(500)),\n            exit = fadeOut(animationSpec = tween(500))\n        ) {\n            OutlinedTextField(\n                value = outputText,\n                onValueChange = {}, // 输出框通常是只读的\n                readOnly = true,\n                modifier = Modifier\n                    .fillMaxWidth(),\n//                    .height(180.dp),\n                minLines = 1,\n                maxLines = 6,\n                isError = isDecryptFailed,\n                label = { Text(stringResource(if (isEncryptMode) R.string.crypto_result_label_encrypted else R.string.crypto_result_label_decrypted)) },\n                // 右下角的复制按钮\n                trailingIcon = {\n                    IconButton(onClick = {\n                        clipboardManager.setText(AnnotatedString(outputText))\n                        Toast.makeText(\n                            context,\n                            context.getString(R.string.crypto_copied_to_clipboard),\n                            Toast.LENGTH_SHORT\n                        ).show()\n                    }) {\n                        Icon(\n                            Icons.Default.ContentCopy,\n                            contentDescription = stringResource(id = R.string.crypto_copy_result_icon_desc)\n                        )\n                    }\n                },\n                shape = RoundedCornerShape(16.dp),\n                colors = OutlinedTextFieldDefaults.colors(\n                    focusedBorderColor = MaterialTheme.colorScheme.secondary,\n                    unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)\n                )\n            )\n        }\n\n        Spacer(modifier = Modifier.height(16.dp))\n\n        // 统计信息区域\n        AnimatedVisibility(\n            visible = outputText.isNotEmpty(),\n            enter = fadeIn(animationSpec = tween(300)),\n            exit = fadeOut(animationSpec = tween(300))\n        ) {\n            CryptoStats(\n                charCount = charCount,\n                elapsedTime = elapsedTime\n            )\n        }\n\n        if(showKeyDialog){\n            KeyManagementDialog(onDismissRequest = { showKeyDialog = false })\n        }\n\n        //  加上文件展示dialog\n        fileInfoToShow?.let { fileInfo ->\n            val scope= rememberCoroutineScope()\n            FilePreviewDialog(\n                fileInfo = fileInfo,\n                downloadProgress = downloadProgress,\n                downloadedFileUri = downloadedFileUri,\n                isImageSavedThisTime = isImageSavedThisTime,\n                onDismissRequest = { fileInfoToShow = null },\n                onDownloadRequest = { info ->\n                    // 在协程中启动下载\n                    scope.launch {\n                        val targetFile = getCacheFileFor(context, info)\n                        downloadProgress = 0\n                        val result = CryptoDownloader.download(\n                            fileInfo = info,\n                            targetFile = targetFile,\n                            onProgress = { progress -> downloadProgress = progress }\n                        )\n                        if (result.isSuccess) {\n                            downloadedFileUri = getUriForFile(context, result.getOrThrow())\n                        } else {\n                            Toast.makeText(context, \"下载失败: ${result.exceptionOrNull()?.message}\", Toast.LENGTH_SHORT).show()\n                        }\n                        downloadProgress = null\n                    }\n                },\n                onOpenRequest = { uri -> openFile(context, scope,uri, fileInfo) },\n                onSaveToGalleryRequest = { uri ->\n                    scope.launch { isImageSavedThisTime = saveImageToGallery(context, uri, fileInfo) }\n                }\n            )\n        }\n    }\n}\n\n\n/**\n * 一个用于展示统计信息（字符数和耗时）的组件。\n */\n@Composable\nprivate fun CryptoStats(\n    charCount: Int,\n    elapsedTime: Long,\n    modifier: Modifier = Modifier\n) {\n    Card(\n        modifier = modifier.fillMaxWidth(),\n        shape = RoundedCornerShape(16.dp),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)),\n        elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) // 无阴影，更轻量\n    ) {\n        Row(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(vertical = 12.dp, horizontal = 16.dp),\n            horizontalArrangement = Arrangement.SpaceAround,\n            verticalAlignment = Alignment.CenterVertically\n        ) {\n            // 字符总数统计\n            Column(horizontalAlignment = Alignment.CenterHorizontally) {\n                Text(\n                    text = stringResource(id = R.string.crypto_stats_char_count),\n                    style = MaterialTheme.typography.bodyMedium,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n                Spacer(modifier = Modifier.height(4.dp))\n                Text(\n                    text = charCount.toString(),\n                    style = MaterialTheme.typography.titleLarge,\n                    fontWeight = FontWeight.Bold,\n                    color = MaterialTheme.colorScheme.primary\n                )\n            }\n            // 处理耗时统计\n            Column(horizontalAlignment = Alignment.CenterHorizontally) {\n                Text(\n                    text = stringResource(id = R.string.crypto_stats_time_elapsed),\n                    style = MaterialTheme.typography.bodyMedium,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n                Spacer(modifier = Modifier.height(4.dp))\n                Text(\n                    text = stringResource(id = R.string.crypto_stats_time_ms, elapsedTime),\n                    style = MaterialTheme.typography.titleLarge,\n                    fontWeight = FontWeight.Bold,\n                    color = MaterialTheme.colorScheme.secondary\n                )\n            }\n        }\n    }\n}\n\n/**\n * 一个用于展示和选择密钥的自定义组件。\n */\n@Composable\nfun KeySelector(\n    selectedKeyName: String,\n    onClick: () -> Unit\n) {\n    // 将形状定义为一个变量，方便复用\n    val cardShape = RoundedCornerShape(16.dp)\n\n    Card(\n        modifier = Modifier\n            .fillMaxWidth()\n            .clip(cardShape)\n            .clickable(onClick = onClick),\n        shape = RoundedCornerShape(16.dp),\n        elevation = CardDefaults.cardElevation(defaultElevation = 16.dp),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)\n    ) {\n        Row(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(horizontal = 16.dp, vertical = 12.dp),\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.SpaceBetween\n        ) {\n            Row(verticalAlignment = Alignment.CenterVertically) {\n                Icon(\n                    Icons.Default.Key,\n                    contentDescription = stringResource(id = R.string.crypto_key_icon_desc),\n                    tint = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n                Spacer(modifier = Modifier.width(12.dp))\n                Column {\n                    Text(\n                        text = stringResource(id = R.string.crypto_current_key_label),\n                        style = MaterialTheme.typography.bodyMedium,\n                        color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.9f)\n                    )\n                    Text(\n                        text = selectedKeyName,\n                        style = MaterialTheme.typography.bodyLarge,\n                        fontWeight = FontWeight.Bold,\n                        color = MaterialTheme.colorScheme.onSurfaceVariant\n                    )\n                }\n            }\n            Icon(\n                Icons.AutoMirrored.Filled.ArrowForward,\n                contentDescription = stringResource(id = R.string.crypto_select_key_icon_desc),\n                tint = MaterialTheme.colorScheme.onSurfaceVariant\n            )\n        }\n    }\n}\n\nprivate fun openFile(context: Context, scope: CoroutineScope, uri: Uri, fileInfo: NCFileProtocol){\n    scope.launch {\n        try{\n            // 1. ✨ 从原始文件名中获取文件后缀\n            val extension = fileInfo.name.substringAfterLast('.', \"\")\n            // 2. ✨ 使用 MimeTypeMap 将后缀转换为标准的MIME类型\n            val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())\n                ?: \"*/*\" // 如果找不到，使用通用类型\n\n            val intent = Intent(Intent.ACTION_VIEW).apply {\n                setDataAndType(uri,mimeType)\n                addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)\n                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n            }\n            context.startActivity(intent)\n\n        } catch (e: Exception) {\n            Log.e(NekoCryptApp.TAG, \"打开文件失败\", e)\n            Toast.makeText(context,context.getString(R.string.cannot_open_file),Toast.LENGTH_SHORT).show()\n        }\n    }\n}\n\nprivate suspend fun saveImageToGallery(context: Context,uri: Uri, fileInfo: NCFileProtocol): Boolean {\n\n    val success = withContext(Dispatchers.IO) {\n        runCatching {\n            val extension = fileInfo.name.substringAfterLast('.', \"\")\n            val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)\n\n            // ContentValues 就像一个“档案袋”，我们把新文件的所有信息（元数据）都放进去。\n            val contentValues = ContentValues().apply {\n                put(MediaStore.MediaColumns.DISPLAY_NAME, fileInfo.name)      // 文件在相册里显示的名字。\n                put(MediaStore.MediaColumns.MIME_TYPE, mimeType)         // 文件的mime类型\n                // 档案3 & 4 (仅限 Android 10 及以上)：\n                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n                    // 告诉系统要把这个文件放在公共的“相册”文件夹里。\n                    put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)\n                    // 先把文件标记为“待定”状态。这意味着在文件内容被完全写入之前，\n                    // 其他应用（包括相册自己）是看不到这个文件的，可以防止出现损坏的半成品文件。\n                    put(MediaStore.MediaColumns.IS_PENDING, 1)\n                }\n            }\n\n            // 用我们写好的信息，去申请一个URI\n            val imageUri = context.contentResolver.insert(\n                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,\n                contentValues\n            )\n                ?: throw IOException(\"无法在相册中创建新文件。\")\n\n            // 使用我们新的imageUri，写入文件\n            context.contentResolver.openOutputStream(imageUri).use { outputStream ->\n                context.contentResolver.openInputStream(uri).use { inputStream ->\n                    requireNotNull(inputStream) { \"无法打开缓存文件的输入流\" }\n                    requireNotNull(outputStream) { \"无法打开相册文件的输出流\" }\n                    inputStream.copyTo(outputStream)\n                }\n            }\n\n            // (仅限 Android 10 及以上) 文件内容已经写完，我们再次更新档案，\n            // 把“待定”状态改为0，正式通知系统：“文件已准备就绪，可以对外展示了！”\n            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {\n                contentValues.clear()\n                contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)\n                context.contentResolver.update(imageUri, contentValues, null, null)\n            }\n\n            //顺利完成，返回true\n            true\n        }.onFailure { e ->\n            Log.e(NekoCryptApp.TAG, \"保存图片到相册失败\", e)\n            false // 返回失败\n        }.getOrDefault(false) // 拿不到，默认就返回false\n    }\n    if (success) Toast.makeText(context,context.getString(R.string.image_saved_to_gallery_success),Toast.LENGTH_SHORT).show()\n    else Toast.makeText(context,context.getString(R.string.image_saved_to_gallery_failed),Toast.LENGTH_SHORT).show()\n    return success\n}\n\n/**\n * ✨ 全新：一个用于选择密文伪装风格的下拉菜单组件\n */\n@OptIn(ExperimentalMaterial3Api::class)\n@Composable\nprivate fun CiphertextStyleSelector(\n    selectedStyle: CiphertextStyleType,\n    onStyleSelected: (CiphertextStyleType) -> Unit,\n    modifier: Modifier = Modifier\n) {\n    var expanded by remember { mutableStateOf(false) }\n    // 从枚举中获取所有可选的样式\n    val styles = remember { CiphertextStyleType.entries }\n\n    ExposedDropdownMenuBox(\n        expanded = expanded,\n        onExpandedChange = { expanded = !expanded },\n        modifier = modifier\n    ) {\n        OutlinedTextField(\n            value = stringResource(id = selectedStyle.displayNameResId),\n            onValueChange = {},\n            readOnly = true,\n            label = { Text(stringResource(R.string.crypto_style_selector_label)) },\n            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },\n            modifier = Modifier\n                .menuAnchor() //  这是让下拉菜单能正确定位到输入框的关键！\n                .fillMaxWidth(),\n            shape = RoundedCornerShape(16.dp),\n        )\n        // 真正的下拉菜单\n        ExposedDropdownMenu(\n            expanded = expanded,\n            onDismissRequest = { expanded = false }\n        ) {\n            styles.forEach { style ->\n                DropdownMenuItem(\n                    text = { Text(stringResource(id = style.displayNameResId)) },\n                    onClick = {\n                        onStyleSelected(style)\n                        expanded = false // 选择后收起菜单\n                    }\n                )\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/screen/HomeScreen.kt",
    "content": "package me.wjz.nekocrypt.ui.screen\n\nimport android.content.Context\nimport androidx.compose.animation.AnimatedVisibility\nimport androidx.compose.animation.core.tween\nimport androidx.compose.animation.expandVertically\nimport androidx.compose.animation.fadeIn\nimport androidx.compose.animation.fadeOut\nimport androidx.compose.animation.shrinkVertically\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Box\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.aspectRatio\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.remember\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport me.wjz.nekocrypt.CommonKeys.DECRYPTION_MODE_IMMERSIVE\nimport me.wjz.nekocrypt.CommonKeys.DECRYPTION_MODE_STANDARD\nimport me.wjz.nekocrypt.CommonKeys.ENCRYPTION_MODE_IMMERSIVE\nimport me.wjz.nekocrypt.CommonKeys.ENCRYPTION_MODE_STANDARD\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.hook.rememberDataStoreState\nimport com.dianming.phoneapp.MyAccessibilityService\nimport me.wjz.nekocrypt.ui.InfoDialogIcon\nimport me.wjz.nekocrypt.ui.RadioOption\nimport me.wjz.nekocrypt.ui.SegmentedButtonSetting\nimport me.wjz.nekocrypt.ui.SwitchSettingCard\nimport me.wjz.nekocrypt.ui.component.CatPawButton\nimport me.wjz.nekocrypt.util.openAccessibilitySettings\nimport me.wjz.nekocrypt.util.rememberAccessibilityServiceState\n\n// --- 主屏幕代码 ---\n\n@Composable\nfun HomeScreen(modifier: Modifier = Modifier) {\n    // 1. 获取当前上下文\n    val context: Context = LocalContext.current\n\n    // 2. 使用我们新的 Composable 函数来获取并监听无障碍服务的状态\n    val isAccessibilityEnabled by rememberAccessibilityServiceState(\n        context,\n        MyAccessibilityService::class.java\n    )\n\n    val useAutoEncryption by rememberDataStoreState(SettingKeys.USE_AUTO_ENCRYPTION, false)\n    val useAutoDecryption by rememberDataStoreState(SettingKeys.USE_AUTO_DECRYPTION, false)\n\n    // 使用 Column 作为根布局，以垂直排列组件\n    Column(\n        modifier = modifier.fillMaxSize(),\n        horizontalAlignment = Alignment.CenterHorizontally\n    ) {\n        // 使用带权重的 Column 来包裹原有的猫爪UI，使其占据大部分空间并保持居中\n        Column(\n            modifier = Modifier.weight(1f).fillMaxWidth(),\n            verticalArrangement = Arrangement.Center,\n            horizontalAlignment = Alignment.CenterHorizontally\n        ) {\n            // ✨ 核心修正：用一个 Box 把猫爪按钮包裹起来，并给这个 Box 一个“方形模具”\n            Box(\n                modifier = Modifier\n                    .fillMaxWidth() // 让 Box 尽可能宽\n                    .padding(52.dp) // 给猫爪留出一些呼吸空间\n                    .aspectRatio(1f), // ✨ 魔法！强制这个 Box 的高度等于它的宽度，永远保持正方形\n                contentAlignment = Alignment.Center\n            ) {\n                CatPawButton(\n                    modifier = Modifier.fillMaxSize(), // 让猫爪按钮填满这个完美的正方形\n                    isEnabled = isAccessibilityEnabled,\n                    statusText = if (isAccessibilityEnabled)\n                        stringResource(id = R.string.accessibility_service_enabled)\n                    else\n                        stringResource(id = R.string.accessibility_service_disabled),\n                    onClick = { openAccessibilitySettings(context) }\n                )\n            }\n        }\n\n        // 在底部添加我们的设置卡片\n\n        // 加密选项\n        Card(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(horizontal = 16.dp, vertical = 8.dp),\n            shape = RoundedCornerShape(16.dp),\n            colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),\n            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)\n        ) {\n            Column {\n                SwitchSettingCard(\n                    key = SettingKeys.USE_AUTO_ENCRYPTION,\n                    defaultValue = false,\n                    title = stringResource(id = R.string.setting_encrypt_on_send_title),\n                    subtitle = stringResource(id = R.string.setting_encrypt_on_send_subtitle)\n                )\n\n                // 2. 模式选择\n                AnimatedVisibility(\n                    visible = useAutoEncryption,\n                    enter = expandVertically(animationSpec = tween(400)) + fadeIn(),\n                    exit = shrinkVertically(animationSpec = tween(400)) + fadeOut()\n                ) {\n                    val modeStandardText = stringResource(R.string.mode_standard)\n                    val modeImmersiveText = stringResource(R.string.mode_immersive)\n\n                    val encryptionModeOptions = remember {\n                        listOf(\n                            RadioOption(ENCRYPTION_MODE_STANDARD, modeStandardText),\n                            RadioOption(ENCRYPTION_MODE_IMMERSIVE, modeImmersiveText)\n                        )\n                    }\n                    SegmentedButtonSetting(\n                        settingKey = SettingKeys.ENCRYPTION_MODE,\n                        defaultOptionKey = ENCRYPTION_MODE_STANDARD,\n                        title = stringResource(id = R.string.setting_encryption_mode_info_title),\n                        options = encryptionModeOptions,\n                        titleExtraContent = {   // 标题旁边的额外内容。\n                            InfoDialogIcon(\n                                title = stringResource(R.string.setting_encryption_mode_info_text),\n                                text = stringResource(R.string.setting_encryption_mode_info_desc),\n                                contentDescription = stringResource(R.string.setting_encryption_mode_info_desc)\n                            )\n                        }\n                    )\n                }\n            }\n        }\n\n        // 解密选项\n        Card(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(horizontal = 16.dp, vertical = 8.dp),\n            shape = RoundedCornerShape(16.dp),\n            colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),\n            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)\n        ) {\n            Column {\n                //解密开关\n                SwitchSettingCard(\n                    key = SettingKeys.USE_AUTO_DECRYPTION,\n                    defaultValue = false,\n                    title = stringResource(id = R.string.setting_decrypt_immersive_mod_title),\n                    subtitle = stringResource(id = R.string.setting_decrypt_immersive_mod_subtitle),\n                )\n                AnimatedVisibility(\n                    visible = useAutoDecryption,\n                    enter = expandVertically(animationSpec = tween(400)) + fadeIn(),\n                    exit = shrinkVertically(animationSpec = tween(400)) + fadeOut()\n                ) {\n                    val decryptionModeOptions = remember {\n                        listOf(\n                            RadioOption(DECRYPTION_MODE_STANDARD, \"标准模式\"),\n                            RadioOption(DECRYPTION_MODE_IMMERSIVE, \"沉浸模式\")\n                        )\n                    }\n                    SegmentedButtonSetting(\n                        settingKey = SettingKeys.DECRYPTION_MODE,\n                        defaultOptionKey = DECRYPTION_MODE_STANDARD,\n                        title = stringResource(id = R.string.setting_decryption_mode_info_title),\n                        options = decryptionModeOptions,\n                        titleExtraContent = {\n                            // ✨ 看！之前所有复杂的 TooltipBox 代码，\n                            // 现在都变成了这一行极其清晰的调用！\n                            InfoDialogIcon(\n                                title = stringResource(R.string.setting_decryption_mode_info_text),\n                                text = stringResource(R.string.setting_decryption_mode_info_desc),\n                                contentDescription = stringResource(R.string.setting_decryption_mode_info_desc)\n                            )\n                        }\n                    )\n                }\n            }\n\n        }\n        Spacer(Modifier.padding(4.dp))\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/screen/KeyScreen.kt",
    "content": "package me.wjz.nekocrypt.ui.screen\n\nimport android.content.Intent\nimport android.content.pm.PackageManager\nimport android.graphics.drawable.Drawable\nimport android.provider.Settings\nimport android.util.Log\nimport android.widget.Toast\nimport androidx.compose.foundation.Image\nimport androidx.compose.foundation.background\nimport androidx.compose.foundation.layout.Arrangement\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.Row\nimport androidx.compose.foundation.layout.Spacer\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.fillMaxWidth\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.layout.size\nimport androidx.compose.foundation.layout.width\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.foundation.shape.RoundedCornerShape\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.HelpOutline\nimport androidx.compose.material.icons.filled.Info\nimport androidx.compose.material.icons.filled.MyLocation\nimport androidx.compose.material3.Card\nimport androidx.compose.material3.CardDefaults\nimport androidx.compose.material3.DividerDefaults\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.Switch\nimport androidx.compose.material3.Text\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Alignment\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.draw.clip\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.text.style.TextAlign\nimport androidx.compose.ui.unit.dp\nimport androidx.compose.ui.unit.sp\nimport androidx.core.net.toUri\nimport androidx.datastore.preferences.core.booleanPreferencesKey\nimport com.dianming.phoneapp.MyAccessibilityService\nimport com.google.accompanist.drawablepainter.rememberDrawablePainter\nimport kotlinx.coroutines.launch\nimport me.wjz.nekocrypt.AppRegistry\nimport me.wjz.nekocrypt.Constant.DEFAULT_SECRET_KEY\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys.CURRENT_KEY\nimport me.wjz.nekocrypt.SettingKeys.SCAN_BTN_ACTIVE\nimport me.wjz.nekocrypt.data.LocalDataStoreManager\nimport me.wjz.nekocrypt.data.rememberCustomAppListState\nimport me.wjz.nekocrypt.hook.rememberDataStoreState\nimport me.wjz.nekocrypt.service.handler.ChatAppHandler\nimport me.wjz.nekocrypt.ui.SettingsHeader\nimport me.wjz.nekocrypt.ui.SwitchSettingItem\nimport me.wjz.nekocrypt.ui.dialog.AppHandlerInfoDialog\nimport me.wjz.nekocrypt.ui.dialog.KeyManagementDialog\nimport me.wjz.nekocrypt.util.PermissionUtil.isAccessibilityServiceEnabled\nimport me.wjz.nekocrypt.util.openAccessibilitySettings\n\n@Composable\nfun KeyScreen(modifier: Modifier = Modifier) {\n    // 状态管理\n    var currentKey by rememberDataStoreState(CURRENT_KEY, DEFAULT_SECRET_KEY)\n    var showKeyDialog by remember { mutableStateOf(false) }     //控制密钥管理对话框的显示和隐藏\n    // 自定义APP列表\n    val customApps by rememberCustomAppListState()\n    val context = LocalContext.current\n    val dataStoreManager = LocalDataStoreManager.current\n    val scope = rememberCoroutineScope() // 获取协程作用域，用于执行删除操作\n    //  进入UI时做一些判断逻辑\n    LaunchedEffect(Unit) {\n        if (!isAccessibilityServiceEnabled(context) || !Settings.canDrawOverlays(context)) {\n            dataStoreManager.saveSetting(SCAN_BTN_ACTIVE, false)\n            Log.d(NekoCryptApp.TAG, \"权限不足，已强制关闭扫描开关。\")\n        }\n    }\n\n    LazyColumn(\n        modifier = modifier\n            .fillMaxSize()\n            .padding(16.dp),\n        verticalArrangement = Arrangement.spacedBy(16.dp)\n    ) {\n        // 1. 密钥选择器\n        item {\n            KeySelector(\n                selectedKeyName = currentKey,\n                onClick = {\n                    // 当点击时，将状态设置为true，以显示对话框\n                    showKeyDialog = true\n                }\n            )\n        }\n\n        // 支持的应用\n        item {\n            SettingsHeader(stringResource(R.string.key_screen_supported_app))\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n\n        item{\n            Card(\n                modifier = Modifier.fillMaxWidth(),\n                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),\n                shape = RoundedCornerShape(16.dp),\n            ) {\n                // 在 Card 内部使用 Column 来垂直排列我们的 App 列表项\n                Column(\n                    modifier = Modifier.padding(vertical = 16.dp, horizontal = 12.dp), // 给上下一点内边距\n                    verticalArrangement = Arrangement.spacedBy(12.dp) // 垂直项间距\n                ) {\n                    AppRegistry.allHandlers.forEach { handler ->\n                        SupportedAppItem(handler = handler)\n                    }\n                }\n            }\n        }\n        // 说明文本\n        item {\n          Row(\n              modifier=Modifier.fillMaxWidth()\n                  .padding(horizontal = 8.dp)\n                  .clip(RoundedCornerShape(8.dp))\n                  .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))\n                  .padding(horizontal = 12.dp, vertical = 8.dp), // 设置内边距\n              verticalAlignment = Alignment.CenterVertically\n          ){\n              // 左侧的小图标\n              Icon(\n                  imageVector = Icons.Default.Info,\n                  contentDescription = null, // 图标是纯装饰性的\n                  tint = MaterialTheme.colorScheme.onTertiaryContainer,\n                  modifier = Modifier.size(18.dp)\n              )\n              Spacer(modifier = Modifier.width(8.dp))\n              // 右侧的说明文字\n              Text(\n                  text = stringResource(R.string.key_screen_supported_app_description),\n                  style = MaterialTheme.typography.bodyMedium,\n                  color = MaterialTheme.colorScheme.onTertiaryContainer\n              )\n          }\n        }\n        // 自定义应用\n        item {\n            SettingsHeader(stringResource(R.string.key_screen_custom_app))\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        //  自定义APP列表\n        item {\n            Card(\n                modifier = Modifier.fillMaxWidth(),\n                elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),\n                shape = RoundedCornerShape(16.dp),\n            ) {\n                Column(\n                    modifier = Modifier.padding(vertical = 16.dp, horizontal = 12.dp),\n                    verticalArrangement = Arrangement.spacedBy(12.dp),\n                    horizontalAlignment = Alignment.CenterHorizontally\n                ) {\n                    // 根据列表是否为空，显示不同的内容\n                    if (customApps.isEmpty()) {\n                        Text(\n                            text = stringResource(R.string.key_screen_no_custom_app_configured),\n                            // 为了让单行文本在卡片内居中，我们给它一个 Modifier\n                            modifier = Modifier\n                                .fillMaxWidth()\n                                .padding(vertical = 8.dp) // 给一点垂直padding，避免太贴近按钮\n                                .align(Alignment.CenterHorizontally),\n                            style = MaterialTheme.typography.bodyLarge,\n                            color = MaterialTheme.colorScheme.onSurfaceVariant,\n                            textAlign = TextAlign.Center\n                        )\n                    } else {\n                        // 遍历自定义应用列表，显示每一项\n                        customApps.forEach { customHandler ->\n                            SupportedAppItem(handler = customHandler,\n                                onDelete = {\n                                    scope.launch {\n                                        dataStoreManager.deleteCustomApp(customHandler.packageName)\n                                        Toast.makeText(context, context.getString(R.string.key_screen_delete_config_toast), Toast.LENGTH_SHORT).show()\n                                    }\n                                })\n                        }\n                    }\n                }\n            }\n        }\n\n        item{\n            SwitchSettingItem(\n                key = SCAN_BTN_ACTIVE,\n                defaultValue = false,\n                title = stringResource(R.string.enable_scanner_mode),\n                subtitle = stringResource(R.string.enable_scanner_mode_description),\n                icon = { Icon(imageVector = Icons.Default.MyLocation, contentDescription = stringResource(R.string.enable_scanner_mode)) },\n                // ✨ 1. 这里是我们的“门卫”，负责所有权限检查\n                onCheckValidated = { desiredState ->\n                    // 如果是想关闭开关，永远允许\n                    if (!desiredState) return@SwitchSettingItem true\n\n                    // --- 下面都是想打开开关时的检查 ---\n                    // 检查无障碍权限\n                    if (!isAccessibilityServiceEnabled(context)) {\n                        Toast.makeText(context, context.getString(R.string.please_grant_accessibility_service_permission), Toast.LENGTH_LONG).show()\n                        // 最好再加一个跳转，方便用户开启\n                        openAccessibilitySettings(context)\n                        return@SwitchSettingItem false // 验证不通过，拦截！\n                    }\n\n                    // 检查悬浮窗权限\n                    if (!Settings.canDrawOverlays(context)) {\n                        Toast.makeText(context, context.getString(R.string.please_grant_overlay_permission), Toast.LENGTH_LONG).show()\n                        val intent = Intent(\n                            Settings.ACTION_MANAGE_OVERLAY_PERMISSION,\n                            \"package:${context.packageName}\".toUri()\n                        )\n                        context.startActivity(intent)\n                        return@SwitchSettingItem false // 验证不通过，拦截！\n                    }\n\n                    // 所有检查都通过了，放行！\n                    return@SwitchSettingItem true\n                },\n\n                // ✨ 2. 这里是我们的“执行官”，只在状态成功改变后执行\n                onStateChanged = { isEnabled ->\n                    // 根据最终的状态，发送不同的指令给服务\n                    val action = if (isEnabled) {\n                        MyAccessibilityService.ACTION_SHOW_SCANNER\n                    } else {\n                        MyAccessibilityService.ACTION_HIDE_SCANNER\n                    }\n                    val intent = Intent(context, MyAccessibilityService::class.java).apply {\n                        this.action = action\n                    }\n                    context.startService(intent)\n                }\n            )\n        }\n    }\n\n    if(showKeyDialog){\n        KeyManagementDialog(onDismissRequest = { showKeyDialog = false })\n    }\n}\n\n@Composable\nfun SupportedAppItem(handler: ChatAppHandler, onDelete: (() -> Unit)? = null){\n    var isEnabled by rememberDataStoreState(booleanPreferencesKey(\"app_enabled_${handler.packageName}\"),\n        defaultValue = true\n    )\n    var showHandlerInfoDialog by remember { mutableStateOf(false) }    //控制是否展示handler详细信息\n\n    val context = LocalContext.current\n    var appIcon by remember { mutableStateOf<Drawable?>(null) }\n    var isAppInstalled by remember { mutableStateOf(false) }\n    var appName by remember { mutableStateOf(\"\") }\n    // 尝试获取应用图标和名称\n    LaunchedEffect(handler.packageName) {\n        try{\n            val pm = context.packageManager\n            // 1. 先获取应用的“身份证” (ApplicationInfo)\n            val appInfo = pm.getApplicationInfo(handler.packageName, 0)\n            // 2. 从“身份证”里同时获取“照片”和“姓名”\n            appIcon = pm.getApplicationIcon(appInfo)\n            appName = pm.getApplicationLabel(appInfo).toString()\n\n            isAppInstalled = true\n        }catch (e: PackageManager.NameNotFoundException) {\n            appIcon = null\n            isAppInstalled = false\n            Log.e(NekoCryptApp.TAG, e.toString())\n        }\n    }\n\n    if (showHandlerInfoDialog) {\n        AppHandlerInfoDialog(\n            appName=appName,\n            handler = handler,\n            onDismissRequest = { showHandlerInfoDialog = false },\n            onDeleteRequest = onDelete\n        )\n    }\n\n    Card(\n        onClick = { showHandlerInfoDialog = true },\n        modifier = Modifier.fillMaxWidth(),\n        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),\n        colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),\n        shape = RoundedCornerShape(16.dp),\n    ) {\n        Row(\n            modifier = Modifier\n                .fillMaxWidth()\n                .padding(horizontal = 16.dp, vertical = 8.dp),\n            verticalAlignment = Alignment.CenterVertically,\n            horizontalArrangement = Arrangement.SpaceBetween\n        ) {\n            if(isAppInstalled&& appIcon!=null){\n                Image(\n                    // 用Google的Accompanist\n                    painter = rememberDrawablePainter(drawable = appIcon),\n                    contentDescription = \"$appName 图标\", // ✨ 使用 handler.name\n                    modifier = Modifier.size(48.dp)\n                )\n            } else {\n                Icon(\n                    imageVector = Icons.Default.HelpOutline,\n                    contentDescription = \"app not install\", modifier = Modifier.size(48.dp),\n                    tint = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n            }\n            Spacer(modifier = Modifier.width(16.dp))\n            // 然后放APP名和包名\n            Column(modifier = Modifier.weight(1f)) {\n                Text(\n                    appName + if (!isAppInstalled) \" — ${stringResource(R.string.not_installed)}\" else \"\",\n                    fontWeight = FontWeight.SemiBold,\n                    fontSize = 16.sp,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant\n                )\n                Text(\n                    handler.packageName,\n                    fontSize = 12.sp,\n                    color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.5f)\n                )\n            }\n            Spacer(modifier = Modifier.width(16.dp))\n            // 右边放开关\n            Switch(\n                checked = isEnabled,\n                onCheckedChange = {\n                    isEnabled = it\n                    //Log.d(NekoCryptApp.TAG, \"包${handler.packageName}监听状态：$it\")\n                },\n                // ✨ 如果App没安装，开关就禁用\n                enabled = isAppInstalled\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/screen/Screen.kt",
    "content": "package me.wjz.nekocrypt.ui.screen\n\nimport androidx.annotation.StringRes\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Home\nimport androidx.compose.material.icons.outlined.Key\nimport androidx.compose.material.icons.outlined.Lock\nimport androidx.compose.material.icons.outlined.Settings\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.graphics.vector.ImageVector\nimport me.wjz.nekocrypt.R\n\n/**\n * App的所有页面来源。\n */\nsealed class Screen (\n    val route: String,\n    @StringRes val titleResId: Int,\n    val icon: ImageVector,\n    val content: @Composable () -> Unit\n){\n    //  主页\n    data object Home : Screen(\n        route = \"home\",\n        titleResId = R.string.home,\n        icon = Icons.Outlined.Home,\n        content = { HomeScreen() }\n    )\n    // 加解密页\n    data object Crypto : Screen(\n        route = \"crypto\",\n        titleResId = R.string.crypto,\n        icon = Icons.Outlined.Lock,\n        content = { CryptoScreen() }\n    )\n\n    // 密钥管理页\n    data object Key : Screen(\n        route = \"key\",\n        titleResId = R.string.key,\n        icon = Icons.Outlined.Key,\n        content = { KeyScreen() }\n    )\n\n    // 设置页\n    data object Setting : Screen(\n        route = \"setting\",\n        titleResId = R.string.settings,\n        icon = Icons.Outlined.Settings,\n        content = { SettingsScreen() }\n    )\n    companion object {\n        val allScreens: List<Screen> = listOf(Home, Crypto, Key, Setting)\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/screen/SettingsScreen.kt",
    "content": "package me.wjz.nekocrypt.ui.screen\n\nimport android.content.Context\nimport android.content.Intent\nimport android.util.Log\nimport android.widget.Toast\nimport androidx.compose.foundation.layout.Column\nimport androidx.compose.foundation.layout.fillMaxSize\nimport androidx.compose.foundation.layout.padding\nimport androidx.compose.foundation.lazy.LazyColumn\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.filled.Build\nimport androidx.compose.material.icons.filled.Info\nimport androidx.compose.material.icons.filled.Link\nimport androidx.compose.material.icons.outlined.GraphicEq\nimport androidx.compose.material.icons.outlined.Timer\nimport androidx.compose.material3.AlertDialog\nimport androidx.compose.material3.DividerDefaults\nimport androidx.compose.material3.HorizontalDivider\nimport androidx.compose.material3.Icon\nimport androidx.compose.material3.Text\nimport androidx.compose.material3.TextButton\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.rememberCoroutineScope\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.Modifier\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.compose.ui.res.stringResource\nimport androidx.compose.ui.unit.dp\nimport androidx.core.net.toUri\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.launch\nimport kotlinx.coroutines.withContext\nimport kotlinx.serialization.SerialName\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.json.Json\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.ui.ClickableSettingItem\nimport me.wjz.nekocrypt.ui.ColorSettingItem\nimport me.wjz.nekocrypt.ui.RangeSliderSettingItem\nimport me.wjz.nekocrypt.ui.SettingsHeader\nimport me.wjz.nekocrypt.ui.SliderSettingItem\nimport java.io.BufferedReader\nimport java.net.HttpURLConnection\nimport java.net.URL\n\n@Composable\nfun SettingsScreen(modifier: Modifier = Modifier) {\n    var showAboutDialog by remember { mutableStateOf(false) }\n    val context = LocalContext.current\n    val scope = rememberCoroutineScope() // 获取协程作用域，用于执行异步任务\n    LazyColumn(\n        modifier = modifier.fillMaxSize(),\n        // verticalArrangement = Arrangement.spacedBy(16.dp), 选项之间不需要间隔\n    ) {\n        // 第一个分组：加解密设置\n        item {\n            SettingsHeader(stringResource(R.string.crypto_settings))\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            SliderSettingItem(  //  长按发送密文所需时间\n                key = SettingKeys.ENCRYPTION_LONG_PRESS_DELAY,\n                defaultValue = 500L, // 默认 500 毫秒\n                icon = { Icon(Icons.Outlined.Timer, contentDescription = \"Long Press Delay\") },\n                title = stringResource(R.string.decryption_long_press_delay),\n                subtitle = stringResource(R.string.decryption_long_press_delay_desc),\n                valueRange = 50L..1000L, // 允许用户在 200ms 到 1500ms 之间选择\n                step = 50L //每50ms一个挡位\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            SliderSettingItem( //   点击密文解密的所需时间。\n                key = SettingKeys.DECRYPTION_WINDOW_SHOW_TIME,\n                defaultValue = 500L, // 默认 500 毫秒\n                icon = { Icon(Icons.Outlined.Timer, contentDescription = \"Long Press Delay\") },\n                title = stringResource(R.string.decryption_window_show_time),\n                subtitle = stringResource(R.string.decryption_window_show_time_desc),\n                valueRange = 500L..3000L,\n                step = 250L // 单步步长\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            // 区间选择器\n            RangeSliderSettingItem(\n                minKey = SettingKeys.CIPHERTEXT_STYLE_LENGTH_MIN,\n                maxKey = SettingKeys.CIPHERTEXT_STYLE_LENGTH_MAX,\n                defaultMin = 3,\n                defaultMax = 7,\n                icon = { Icon(Icons.Outlined.GraphicEq, contentDescription = \"Ciphertext Length\") },\n                title = stringResource(R.string.ciphertext_length_title),\n                subtitle = stringResource(R.string.ciphertext_length_subtitle),\n                valueRange = 1..10,\n                step = 1\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        // ————————————————————————————————\n\n        // 第二个分组，界面相关设置\n        item {\n            SettingsHeader(stringResource(R.string.crypto_ui_settings))\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            SliderSettingItem( //   沉浸式下，密文位置更新间隔\n                key = SettingKeys.DECRYPTION_WINDOW_POSITION_UPDATE_DELAY,\n                defaultValue = 250L, // 默认 250\n                icon = { Icon(Icons.Outlined.Timer, contentDescription = \"position update delay\") },\n                title = stringResource(R.string.decryption_window_position_update_delay),\n                subtitle = stringResource(R.string.decryption_window_position_update_delay_desc),\n                valueRange = 0L..1000L,\n                step = 50L // 单步步长\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        // ————————————————————————————————\n        item {\n            ColorSettingItem(\n                key = SettingKeys.SEND_BTN_OVERLAY_COLOR,\n                defaultValue = \"#5066ccff\",\n                title = stringResource(R.string.send_btn_overlay_color),\n                subtitle = stringResource(R.string.send_btn_overlay_color_desc)\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            SliderSettingItem(  //  控制拉起附件发送悬浮窗的时间间隔。\n                key = SettingKeys.SHOW_ATTACHMENT_VIEW_DOUBLE_CLICK_THRESHOLD,\n                defaultValue = 250L,\n                icon = { Icon(Icons.Outlined.Timer, contentDescription = \"Long Press Delay\") },\n                title = stringResource(R.string.double_click_threshold),\n                subtitle = stringResource(R.string.double_click_threshold_desc),\n                valueRange = 250L..1000L, // 允许用户在 200ms 到 1500ms 之间选择\n                step = 250L //每50ms一个挡位\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n\n        // 第二个分组：关于\n        item {\n            SettingsHeader(\"关于\")\n        }\n        item {\n            ClickableSettingItem(\n                icon = { Icon(Icons.Default.Info, contentDescription = \"About App\") },\n                title = \"关于 NekoCrypt\",\n                onClick = { showAboutDialog=true }\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            val versionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName\n            ClickableSettingItem(\n                icon = { Icon(Icons.Default.Build, contentDescription = \"Version\") },\n                // ✨ 在 title 的 Composable 槽位里，自定义我们的布局！\n                title = stringResource(R.string.version,versionName?:\"unknown\"),\n                onClick = { handleCheckUpdate(context, scope, versionName?:\"N/A\") }\n            )\n        }\n        item {\n            HorizontalDivider(\n                modifier = Modifier.padding(horizontal = 16.dp),\n                thickness = DividerDefaults.Thickness,\n                color = DividerDefaults.color\n            )\n        }\n        item {\n            ClickableSettingItem(\n                icon = { Icon(Icons.Default.Link, contentDescription = \"GitHub Link\") },\n                title = stringResource(R.string.github),\n                onClick = {\n                    val intent = Intent(Intent.ACTION_VIEW, \"https://github.com/WJZ-P/NekoCrypt\".toUri())\n                    context.startActivity(intent)\n                }\n            )\n        }\n\n    }\n\n    if(showAboutDialog){\n        AboutDialog(onDismissRequest = {showAboutDialog = false})\n    }\n}\n\n/**\n * 用于显示关于信息的对话框\n */\n@Composable\nprivate fun AboutDialog(onDismissRequest: () -> Unit) {\n    AlertDialog(\n        onDismissRequest = onDismissRequest,\n        icon = { Icon(Icons.Default.Info, contentDescription = null) },\n        title = { Text(text = stringResource(R.string.about_dialog_title)) },\n        text = {\n            Column {\n                Text(stringResource(R.string.about_dialog_content))\n                // 你可以在这里添加更多信息，比如版本号、作者、开源链接等\n            }\n        },\n        confirmButton = {\n            TextButton(onClick = onDismissRequest) {\n                Text(stringResource(R.string.accept))\n            }\n        }\n    )\n}\n\n/**\n * 一个用于解析 GitHub API /releases/latest 端点返回的 JSON 的数据类。\n * @Serializable 注解让它可以被 kotlinx.serialization 库处理。\n * @SerialName 注解用于将 JSON 中的 snake_case 字段名映射到我们的 camelCase 属性名。\n */\n@Serializable\ndata class GitHubRelease(\n    @SerialName(\"tag_name\")\n    val tagName: String, // 版本标签，例如 \"v1.1.0\"\n\n    @SerialName(\"html_url\")\n    val htmlUrl: String, // 该发布页面的网址\n)\n\nprivate fun handleCheckUpdate(context: Context, scope: CoroutineScope, versionName: String) {\n    scope.launch {\n        withContext(Dispatchers.Main) {\n            Toast.makeText(context, context.getString(R.string.checking_for_update), Toast.LENGTH_SHORT).show()\n        }\n\n        // 切换到 IO 线程执行网络请求\n        val latestRelease: GitHubRelease? = withContext(Dispatchers.IO) {\n            try {\n                val url = URL(\"https://api.github.com/repos/WJZ-P/NekoCrypt/releases/latest\")\n                val connection = url.openConnection() as HttpURLConnection\n                val jsonText = connection.inputStream.bufferedReader().use(BufferedReader::readText)\n                Log.d(NekoCryptApp.TAG,jsonText)\n\n                // 使用 kotlinx.serialization 解析 JSON\n                Json { ignoreUnknownKeys = true }.decodeFromString<GitHubRelease>(jsonText)\n\n            } catch (e: Exception) {\n                e.printStackTrace()\n                null\n            }\n        }\n\n        // 回到主线程更新 UI\n        withContext(Dispatchers.Main) {\n            if (latestRelease == null) {\n                Toast.makeText(context, context.getString(R.string.check_for_update_failed), Toast.LENGTH_SHORT).show()\n                return@withContext\n            }\n\n            // 比较版本号（简单地移除 'v' 前缀进行比较）\n            val latestVersionName = latestRelease.tagName.removePrefix(\"v\")\n\n            if (versionName != \"N/A\" && latestVersionName > versionName) {\n                Toast.makeText(context, context.getString(R.string.check_for_update_failed,latestRelease.tagName), Toast.LENGTH_SHORT).show()\n                // 引导用户去发布页面查看\n                val intent = Intent(Intent.ACTION_VIEW, latestRelease.htmlUrl.toUri())\n                context.startActivity(intent)\n            } else {\n                Toast.makeText(context, context.getString(R.string.is_newest_version), Toast.LENGTH_SHORT).show()\n            }\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/theme/Color.kt",
    "content": "package me.wjz.nekocrypt.ui.theme\n\nimport androidx.compose.ui.graphics.Color\n\nval Purple80 = Color(0xFFD0BCFF)\nval PurpleGrey80 = Color(0xFFCCC2DC)\nval Pink80 = Color(0xFFEFB8C8)\n\nval Purple40 = Color(0xFF6650a4)\nval PurpleGrey40 = Color(0xFF625b71)\nval Pink40 = Color(0xFF7D5260)"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/theme/Theme.kt",
    "content": "package me.wjz.nekocrypt.ui.theme\n\nimport android.os.Build\nimport androidx.compose.foundation.isSystemInDarkTheme\nimport androidx.compose.material3.MaterialTheme\nimport androidx.compose.material3.darkColorScheme\nimport androidx.compose.material3.dynamicDarkColorScheme\nimport androidx.compose.material3.dynamicLightColorScheme\nimport androidx.compose.material3.lightColorScheme\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.platform.LocalContext\n\nprivate val DarkColorScheme = darkColorScheme(\n    primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80\n)\n\nprivate val LightColorScheme = lightColorScheme(\n    primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40\n\n    /* Other default colors to override\n    background = Color(0xFFFFFBFE),\n    surface = Color(0xFFFFFBFE),\n    onPrimary = Color.White,\n    onSecondary = Color.White,\n    onTertiary = Color.White,\n    onBackground = Color(0xFF1C1B1F),\n    onSurface = Color(0xFF1C1B1F),\n    */\n)\n\n@Composable\nfun NekoCryptTheme(\n    darkTheme: Boolean = isSystemInDarkTheme(),\n    // Dynamic color is available on Android 12+\n    dynamicColor: Boolean = true,\n    content: @Composable () -> Unit\n) {\n    //通过when决定什么时候用哪种调色盘\n\n    val colorScheme = when {\n        // 条件1: 如果“动态颜色”开启了，并且安卓版本大于等于12 (S)\n        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {\n            // LocalContext.current 用来获取当前App的上下文环境，是安卓开发里的老朋友啦\n            val context = LocalContext.current\n            if (darkTheme) dynamicDarkColorScheme(context)\n            else dynamicLightColorScheme(context)\n        }\n        // 条件2: 如果不满足条件1，但用户开启了深色模式\n        darkTheme -> DarkColorScheme\n        // 条件3: 否则（也就是浅色模式）\n        else -> LightColorScheme\n    }\n\n    MaterialTheme(\n        colorScheme = colorScheme, typography = Typography, content = content\n    )\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/ui/theme/Type.kt",
    "content": "package me.wjz.nekocrypt.ui.theme\n\nimport androidx.compose.material3.Typography\nimport androidx.compose.ui.text.TextStyle\nimport androidx.compose.ui.text.font.FontFamily\nimport androidx.compose.ui.text.font.FontWeight\nimport androidx.compose.ui.unit.sp\n\n// 这个对象控制着整个APP的文字样式\nval Typography = Typography(\n    // 我们在这里定义 bodyLarge (大号正文) 的具体样式\n    bodyLarge = TextStyle(\n        fontFamily = FontFamily.Default,\n        fontWeight = FontWeight.Normal,\n        fontSize = 16.sp,\n        lineHeight = 24.sp,\n        letterSpacing = 0.5.sp// letterSpacing: 字间距\n    )\n    /* Other default text styles to override\n    titleLarge = TextStyle(\n        fontFamily = FontFamily.Default,\n        fontWeight = FontWeight.Normal,\n        fontSize = 22.sp,\n        lineHeight = 28.sp,\n        letterSpacing = 0.sp\n    ),\n    labelSmall = TextStyle(\n        fontFamily = FontFamily.Default,\n        fontWeight = FontWeight.Medium,\n        fontSize = 11.sp,\n        lineHeight = 16.sp,\n        letterSpacing = 0.5.sp\n    )\n    */\n)"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/AccessibilityManager.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.accessibilityservice.AccessibilityService\nimport android.content.Context\nimport android.content.Intent\nimport android.provider.Settings\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.DisposableEffect\nimport androidx.compose.runtime.State\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleEventObserver\nimport androidx.lifecycle.compose.LocalLifecycleOwner\nimport me.wjz.nekocrypt.util.PermissionUtil.isAccessibilityServiceEnabled\n\n/**\n * 一个 Composable 函数，用于记住并监听无障碍服务的开启状态。\n * 当应用从后台返回前台时（例如，用户在设置页开启权限后返回），它会自动刷新状态。\n *\n * @param context 上下文环境。\n * @param serviceClass 你的无障碍服务的类名，例如：MyAccessibilityService::class.java。\n * @return 一个 State<Boolean> 对象，实时代表着服务是否开启。\n */\n@Composable\nfun rememberAccessibilityServiceState(\n    context: Context,\n    serviceClass: Class<out AccessibilityService>\n): State<Boolean> {\n    val accessibilityState= remember{ mutableStateOf(isAccessibilityServiceEnabled(context)) }\n    // 2. 获取当前 Composable 的生命周期所有者\n    val lifecycleOwner = LocalLifecycleOwner.current\n    // 3. 使用 DisposableEffect 来添加和移除生命周期观察者，防止内存泄漏\n    DisposableEffect(lifecycleOwner) {\n        // 创建一个观察者\n        val observer = LifecycleEventObserver { _, event ->\n            // 当生命周期事件为 ON_RESUME (恢复) 时，说明界面回到了前台\n            if (event == Lifecycle.Event.ON_RESUME) {\n                // 重新检查一次无障碍权限的状态，并更新 state\n                accessibilityState.value = isAccessibilityServiceEnabled(context)\n            }\n        }\n\n        // 将观察者添加到生命周期中\n        lifecycleOwner.lifecycle.addObserver(observer)\n\n        // onDispose 会在 Composable 离开屏幕时被调用\n        onDispose {\n            // 从生命周期中移除观察者，避免内存泄漏\n            lifecycleOwner.lifecycle.removeObserver(observer)\n        }\n    }\n    // 4. 返回这个 state，UI 可以订阅它的变化\n    return accessibilityState\n}\n\n/**\n * 创建一个意图(Intent)并跳转到系统的无障碍功能设置页面。\n *\n * @param context 上下文环境。\n */\nfun openAccessibilitySettings(context: Context) {\n    val intent = Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)\n    // 确保在 Activity 栈外启动新的任务\n    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)\n    context.startActivity(intent)\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/CryptoDownloader.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.withContext\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport java.io.File\nimport java.io.FilterInputStream\nimport java.io.IOException\nimport java.io.InputStream\nimport java.util.concurrent.TimeUnit\n\n/**\n * 专门用于下载并解密文件的工具类\n */\nobject CryptoDownloader {\n    private val client = OkHttpClient.Builder()\n        .connectTimeout(30, TimeUnit.SECONDS)\n        .readTimeout(30, TimeUnit.SECONDS)\n        .build()\n\n\n    suspend fun download(\n        fileInfo: NCFileProtocol,\n        targetFile: File, // ✨ 接收一个目标文件\n        onProgress: (Int) -> Unit,\n    ): Result<File> =\n        withContext(Dispatchers.IO) {\n            runCatching {\n                val url = fileInfo.url\n                val encryptionKey = fileInfo.encryptionKey\n\n                val request = Request.Builder().url(url).build()\n\n                // execute 会 suspend\n                client.newCall(request).execute().use { response ->\n                    if (!response.isSuccessful) throw IOException(\"下载失败，响应码: ${response.code}\")\n\n                    ProgressInputStream(response.body.byteStream()) { bytesRead ->\n                        // 这里处理下载回调\n                        val estimatedTotalRead = (bytesRead - CryptoUploader.SINGLE_PIXEL_GIF_BUFFER.size).coerceAtLeast(0)\n                        val progress = (estimatedTotalRead * 100 / fileInfo.size).toInt()\n                        onProgress(progress.coerceIn(0, 100))\n\n                    }.use { networkStream ->\n                        // 使用循环确保跳过完整的GIF头\n                        val skipSize = CryptoUploader.SINGLE_PIXEL_GIF_BUFFER.size.toLong()\n                        var skipped = 0L\n                        while (skipped < skipSize) {\n                            // 这里用while循环是因为这个skip可能会跳过少于预期的字节数量。\n                            val n = networkStream.skip(skipSize - skipped)\n                            if (n <= 0) throw IOException(\"无法跳过GIF头部，文件可能已损坏。\")\n                            skipped += n\n                        }\n                        // 流式解密 && 下载\n                        targetFile.outputStream().use { outputStream ->\n                            CryptoManager.decryptStream(networkStream, outputStream, encryptionKey)\n                        }\n                    }\n                }\n                targetFile\n            }\n        }\n}\n\n/**\n * 用来追踪 InputStream读取进度的辅助类\n * 它通过包裹一个现有的 InputStream，来监听数据的读取过程。\n */\nclass ProgressInputStream(\n    inStream: InputStream,\n    private val onProgress: (Long) -> Unit,\n) : FilterInputStream(inStream) {\n\n    private var bytesRead: Long = 0 // 已经读取的字节数\n\n    /**\n     * 只重写这一个 read 方法就足够了。\n     * 因为单字节的 read() 在内部会自动调用这个方法，\n     * 这样可以避免重复计算进度。\n     */\n    override fun read(b: ByteArray, off: Int, len: Int): Int {\n        val read = super.read(b, off, len)\n        if (read > 0) {\n            bytesRead += read\n            // 安全地调用监听器，报告当前的进度\n            onProgress(bytesRead)\n        }\n        return read\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/CryptoManager.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport kotlinx.coroutines.CoroutineScope\nimport kotlinx.coroutines.Dispatchers\nimport kotlinx.coroutines.SupervisorJob\nimport me.wjz.nekocrypt.NekoCryptApp\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.SettingKeys\nimport me.wjz.nekocrypt.data.DataStoreManager\nimport me.wjz.nekocrypt.hook.observeAsState\nimport java.io.InputStream\nimport java.io.OutputStream\nimport java.math.BigInteger\nimport java.security.MessageDigest\nimport java.security.SecureRandom\nimport java.util.Locale.getDefault\nimport javax.crypto.AEADBadTagException\nimport javax.crypto.Cipher\nimport javax.crypto.KeyGenerator\nimport javax.crypto.SecretKey\nimport javax.crypto.spec.GCMParameterSpec\nimport javax.crypto.spec.SecretKeySpec\nimport kotlin.random.Random\n\n/**\n * 加密工具类，有相关的加密算法。\n */\nobject CryptoManager {\n    val dataStoreManager: DataStoreManager by lazy { NekoCryptApp.instance.dataStoreManager }\n    val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())\n\n    //  当前使用的密文语种\n    val ciphertextStyleType: String by scope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.CIPHERTEXT_STYLE, CiphertextStyleType.NEKO.toString())\n    },initialValue = CiphertextStyleType.NEKO.toString())\n    //  密文长度词组最小值\n    val ciphertextStyleLengthMin by scope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.CIPHERTEXT_STYLE_LENGTH_MIN, 3)\n    },initialValue = 1)\n    //  密文长度词组最大值\n    val ciphertextStyleLengthMax by scope.observeAsState(flowProvider = {\n        dataStoreManager.getSettingFlow(SettingKeys.CIPHERTEXT_STYLE_LENGTH_MAX, 7)\n    },initialValue = 1)\n\n    private const val ALGORITHM = \"AES\"\n    const val TRANSFORMATION = \"AES/GCM/NoPadding\"\n    private const val KEY_SIZE_BITS = 256 // AES-256\n    const val IV_LENGTH_BYTES = 16  // GCM 推荐的IV长度是12，为了该死的兼容改成16\n    const val TAG_LENGTH_BITS = 128 // GCM 推荐的认证标签长度\n\n    // 下面是一些映射表\n    private val STEALTH_ALPHABET = (0xFE00..0xFE0F).map { it.toChar() }.joinToString(\"\")\n\n    /**\n     * 为了高效解码，预先创建一个从“猫语”字符到其在字母表中索引位置的映射。\n     * 这是一个关键的性能优化。\n     */\n    private val STEALTH_CHAR_TO_INDEX_MAP = STEALTH_ALPHABET.withIndex().associate { (index, char) -> char to index }\n\n    /**\n     * 生成一个符合 AES-256 要求的随机密钥。\n     *\n     * @return 一个 SecretKey 对象，包含了256位的密钥数据。\n     */\n    fun generateKey(): SecretKey {\n        val keyGenerator = KeyGenerator.getInstance(ALGORITHM)\n        keyGenerator.init(KEY_SIZE_BITS)\n        return keyGenerator.generateKey()\n    }\n\n    /**\n     * 加密一个消息，使用给定的密钥，返回的直接是隐写字符串\n     */\n    fun encrypt(message: String, key: String): String {\n        val plaintextBytes = message.toByteArray(Charsets.UTF_8)\n        val encryptedBytes = encryptBytes(plaintextBytes, key)\n        return baseNEncode(encryptedBytes)\n    }\n    // 提供一个重载\n    fun encrypt(data: ByteArray, key: String): ByteArray {\n        return encryptBytes(data, key)\n    }\n\n    //消息解密，智能地从含密文的混合字符串中解密\n    fun decrypt(stealthCiphertext: String, key: String): String? {\n        val combinedBytes = baseNDecode(stealthCiphertext)\n        val decryptedBytes = decryptBytes(combinedBytes, key)\n        return decryptedBytes?.toString(Charsets.UTF_8)\n    }\n\n    fun decrypt(data: ByteArray, key: String): ByteArray? {\n        return decryptBytes(data, key)\n    }\n\n    /**\n     * ✨ [私有核心] 真正执行加密操作的函数\n     */\n    private fun encryptBytes(plaintextBytes: ByteArray, key: String): ByteArray {\n        val iv = ByteArray(IV_LENGTH_BYTES)\n        SecureRandom().nextBytes(iv)    //填充随机内容\n        val cipher = Cipher.getInstance(TRANSFORMATION)\n        val parameterSpec = GCMParameterSpec(TAG_LENGTH_BITS, iv)\n        cipher.init(Cipher.ENCRYPT_MODE, deriveKeyFromString(key), parameterSpec)\n        val ciphertextBytes = cipher.doFinal(plaintextBytes)\n        // 返回拼接了IV和密文的完整数据\n        return iv + ciphertextBytes\n    }\n\n    /**\n     * ✨ [私有核心] 真正执行解密操作的函数\n     */\n    private fun decryptBytes(combinedBytes: ByteArray, key: String): ByteArray? {\n        try {\n            if (combinedBytes.size < IV_LENGTH_BYTES) return null\n\n            val iv = combinedBytes.copyOfRange(0, IV_LENGTH_BYTES)\n            val ciphertextBytes = combinedBytes.copyOfRange(IV_LENGTH_BYTES, combinedBytes.size)\n            val cipher = Cipher.getInstance(TRANSFORMATION)\n            val parameterSpec = GCMParameterSpec(TAG_LENGTH_BITS, iv)\n            cipher.init(Cipher.DECRYPT_MODE, deriveKeyFromString(key), parameterSpec)\n\n            return cipher.doFinal(ciphertextBytes)\n        } catch (e: AEADBadTagException) {\n            println(\"解密失败：数据认证失败，可能已被篡改或密钥错误。\\n\" + e.message)\n            return null\n        } catch (e: Exception) {\n            println(\"解密时发生未知错误: ${e.message}\")\n            return null\n        }\n    }\n\n    /**\n     * 判断给定字符串是否包含密文\n     */\n    fun String.containsCiphertext(): Boolean{\n        return this.any { STEALTH_CHAR_TO_INDEX_MAP.containsKey(it) }\n    }\n\n    fun deriveKeyFromString(keyString: String): SecretKey {\n        val digest = MessageDigest.getInstance(\"SHA-256\")\n        val keyBytes = digest.digest(keyString.toByteArray(Charsets.UTF_8))\n        return SecretKeySpec(keyBytes, ALGORITHM)\n    }\n\n    // -----------------关键的baseN方法---------------------\n\n    /**\n     * 将字节数组编码为我们自定义的 BaseN 字符串。\n     * 算法核心：通过大数运算，将 Base256 的数据转换为 BaseN。\n     * @param data 原始二进制数据。\n     * @return 编码后的“猫语”字符串。\n     */\n    private fun baseNEncode(data: ByteArray): String {\n        if (data.isEmpty()) return \"\"\n        // 使用 BigInteger 来处理任意长度的二进制数据，避免溢出。\n        // 构造函数 `BigInteger(1, data)` 确保数字被解释为正数。\n        var bigInt = BigInteger(1, data)\n        val base = BigInteger.valueOf(STEALTH_ALPHABET.length.toLong())\n        val builder = StringBuilder()\n        while (bigInt > BigInteger.ZERO) {\n            // 除基取余法\n            val (quotient, remainder) = bigInt.divideAndRemainder(base)\n            bigInt = quotient\n            builder.append(STEALTH_ALPHABET[remainder.toInt()])\n        }\n        // 因为是从低位开始添加的，所以需要反转得到正确的顺序\n        return builder.reverse().toString()\n    }\n\n    /**\n     * 将我们自定义的 BaseN 字符串解码回字节数组。\n     * 算法核心：通过大数运算，将 BaseN 的数据转换回 Base256。\n     * @param encodedString 编码后的“猫语”字符串，可能混杂有其他字符。\n     * @return 原始二进制数据。\n     */\n    private fun baseNDecode(encodedString: String): ByteArray {\n        var bigInt = BigInteger.ZERO\n        val base = BigInteger.valueOf(STEALTH_ALPHABET.length.toLong())\n        // 遍历字符串，只处理在“猫语字典”中存在的字符\n        // 乘基加权法。\n        encodedString.forEach { char ->\n            val index = STEALTH_CHAR_TO_INDEX_MAP[char]\n            if (index != null) {\n                // 核心算法: result = result * base + index\n                bigInt = bigInt.multiply(base).add(BigInteger.valueOf(index.toLong()))\n            }\n        }\n        // 如果解码结果为0，直接返回空数组\n        if (bigInt == BigInteger.ZERO) return ByteArray(0)\n\n        // BigInteger.toByteArray() 可能会在开头添加一个0字节来表示正数，我们需要去掉它\n        val bytes = bigInt.toByteArray()\n        return if (bytes[0].toInt() == 0) {\n            bytes.copyOfRange(1, bytes.size)\n        } else { bytes }\n    }\n\n    // -- 通过inputStream和outputStream来流式解密 --\n    /**\n     * 为 AES/GCM 实现的、真正安全的流式解密方法\n     * 它会从输入流中读取加密数据，解密后写入输出流。\n     * @param inputStream 包含加密数据的输入流 (必须是已经跳过GIF头的数据)\n     * @param outputStream 用于写入解密后数据的输出流\n     * @param key 用于解密的密钥\n     */\n    fun decryptStream(inputStream: InputStream, outputStream: OutputStream, key: String){\n        val iv = ByteArray(IV_LENGTH_BYTES)\n        require(inputStream.read(iv) == IV_LENGTH_BYTES) {\n            \"输入流太短，无法读取IV。\"\n        }\n\n        // 2. 初始化 Cipher\n        val cipher = Cipher.getInstance(TRANSFORMATION).apply {\n            val spec = GCMParameterSpec(TAG_LENGTH_BITS, iv)\n            init(Cipher.DECRYPT_MODE, deriveKeyFromString(key), spec)\n        }\n\n        // 3. 边读边解密边写\n        val buffer = ByteArray(8 * 1024)\n        while (true) {\n            val read = inputStream.read(buffer)\n            if (read == -1) break\n            cipher.update(buffer, 0, read)?.let { outputStream.write(it) }\n        }\n\n        // 4. 关键！在所有数据都处理完后，调用 doFinal 来验证“防伪标签”\n        try {\n            cipher.doFinal()?.let { outputStream.write(it) } // 验证通过后，就doFinal做检验，校验不过抛出错误。\n        } catch (e: AEADBadTagException) {\n            throw SecurityException(\"解密失败，数据可能被篡改或密钥错误\", e)\n        }\n    }\n\n    /**\n     * ✨ 全新：根据用户设置，为密文应用伪装文本样式。\n     *\n     * @return 伪装后的、包含随机语言和真实密文的最终字符串。\n     */\n    fun String.applyCiphertextStyle(): String {\n        // 拿到对应枚举类\n        val styleType: CiphertextStyleType = CiphertextStyleType.fromName(ciphertextStyleType)\n        // 获取该风格下的所有可用词组\n        val content = styleType.content\n\n        // 管理最大最小值\n        val finalMin = minOf(ciphertextStyleLengthMin, ciphertextStyleLengthMax)\n        val finalMax = maxOf(ciphertextStyleLengthMin, ciphertextStyleLengthMax)\n\n        // 盲文模式下，词组数量翻倍\n        val actualMin = if (styleType == CiphertextStyleType.BRAILLE) finalMin * 2 else finalMin\n        val actualMax = if (styleType == CiphertextStyleType.BRAILLE) finalMax * 2 else finalMax\n\n        // 如果 actualMin 和 actualMax 相等，直接取这个值，否则在范围内取随机数\n        val count = if (actualMin == actualMax) actualMin else Random.nextInt(actualMin, actualMax + 1)\n\n        // 先随机挑选词组\n        val selectedParts = List(count) { content.random() }\n\n        // 按“词组边界”拆成前后两半，避免切断补充平面字符\n        val middleIndex = selectedParts.size / 2\n        val prefix = selectedParts.take(middleIndex).joinToString(\"\")\n        val suffix = selectedParts.drop(middleIndex).joinToString(\"\")\n\n        return prefix + this + suffix\n    }\n\n}\n\nenum class CiphertextStyleType(val displayNameResId:Int,val content:List<String>){\n    NEKO(\n        displayNameResId = R.string.cipher_style_neko,  // 猫娘语\n        content = listOf(\"嗷呜!\", \"咕噜~\", \"喵~\", \"喵咕~\", \"喵喵~\", \"喵?\", \"喵喵！\", \"哈！\", \"喵呜...\", \"咪咪喵！\", \"咕咪?\")\n    ),\n    BANGBOO(\n        displayNameResId = R.string.cipher_style_bangboo, // 邦布语\n        content = listOf(\"嗯呢...\", \"哇哒！\", \"嗯呢！\", \"嗯呢哒！\", \"嗯呐呐！\", \"嗯哒！\", \"嗯呢呢！\")\n    ),\n    HILICHURLIAN(\n        displayNameResId = R.string.cipher_style_Hilichurlian, //丘丘语\n        content = listOf(\"Muhe ye!\", \"Ye dada!\", \"Ya yika!\", \"Biat ye！\", \"Dala si？\", \"Yaya ika！\", \"Mi? Dada!\",\n            \"ye pupu!\", \"gusha dada!\",\"Dala？\",\"Mosi mita！\",\"Mani ye！\",\"Biat ye！\",\"Todo yo.\",\"tiga mitono!\",\"Biat, gusha!\",\"Unu dada!\",\"Mimi movo!\")\n    ),\n    NIER(\n    displayNameResId = R.string.cipher_style_nier, // 尼尔语\n    content = listOf(\n    \"Ee \", \"ser \", \"les \", \"hii \", \"san \", \"mia \", \"ni \", \"Escalei \", \"lu \", \"push \", \"to \", \"lei \",\n    \"Schmosh \", \"juna \", \"wu \", \"ria \", \"e \", \"je \", \"cho \", \"no \",\n    \"Nasico \", \"whosh \", \"pier \", \"wa \", \"nei \", \"Wananba \", \"he \", \"na \", \"qua \", \"lei \",\n    \"Sila \", \"schmer \", \"ya \", \"pi \", \"pa \", \"lu \", \"Un \", \"schen \", \"ta \", \"tii \", \"pia \", \"pa \", \"ke \", \"lo \")\n    ),\n    MANBO(\n        displayNameResId = R.string.cipher_style_manbo, //  曼波！\n        content = listOf(\"曼波~\",\"哈吉米~\",\"哈吉米咩那咩路多~\",\"曼波!\",\"曼波...\",\"欧码叽哩，曼波！\",\"叮咚鸡！\",\"哈压库！\",\"哈压库~\",\"哈吉米！\",\"哦耶~\",\"duang~\")\n    ),\n    BRAILLE(\n        displayNameResId = R.string.cipher_style_braille, // 盲文点阵\n        content = (0x2800..0x28FF).map { it.toChar().toString() } // U+2800 ~ U+28FF，共 256 个盲文 Unicode\n    ),\n    MAGICSPELL(\n        displayNameResId = R.string.cipher_style_magicspell, // 魔法咒语\n        content = listOf(\n            \"𝓐 \", \"𝓔 \", \"𝓘 \", \"𝓞 \", \"𝓤 \",\n            \"𝓓𝓲 \", \"𝓘𝓸 \", \"𝓜𝓮 \", \"𝓣𝓮 \", \"𝓣𝓾 \",\n            \"𝓛𝓾𝔁 \", \"𝓝𝓸𝔁 \", \"𝓟𝓪𝔁 \", \"𝓢𝓸𝓵 \", \"𝓡𝓸𝓼 \",\n            \"𝓐𝓾𝓻𝓪 \", \"𝓛𝓾𝓷𝓪 \", \"𝓡𝓸𝓼𝓪 \", \"𝓐𝓶𝓸𝓻 \", \"𝓘𝓻𝓲𝓼 \",\n            \"𝓐𝓷𝓲𝓶𝓪 \", \"𝓘𝓰𝓷𝓲𝓼 \", \"𝓤𝓶𝓫𝓻𝓪 \", \"𝓝𝓲𝓽𝓸𝓻 \", \"𝓢𝓲𝓭𝓾𝓼 \",\n            \"𝓐𝓾𝓻𝓸𝓻𝓪 \", \"𝓒𝓪𝓮𝓵𝓾𝓶 \", \"𝓢𝓽𝓮𝓵𝓵𝓪 \", \"𝓥𝓮𝓼𝓹𝓮𝓻 \", \"𝓝𝓮𝓬𝓽𝓪𝓻 \",\n            \"𝓐𝓻𝓬𝓪𝓷𝓾𝓶 \", \"𝓓𝓾𝓵𝓬𝓮𝓭𝓸 \", \"𝓛𝓪𝓬𝓻𝓲𝓶𝓪 \", \"𝓛𝓾𝓬𝓮𝓻𝓷𝓪 \", \"𝓥𝓸𝓵𝓾𝓬𝓮𝓻 \",\n            \"𝓥𝓮𝓷𝓾𝓼𝓽𝓪𝓼 \", \"𝓒𝓵𝓪𝓻𝓲𝓽𝓪𝓼 \", \"𝓢𝓲𝓭𝓮𝓻𝓮𝓾𝓼 \", \"𝓥𝓸𝓵𝓾𝓬𝓻𝓲𝓼 \", \"𝓢𝓾𝓪𝓿𝓲𝓽𝓪𝓼 \",\n            \"𝓢𝓮𝓻𝓮𝓷𝓲𝓽𝓪𝓼 \", \"𝓢𝓲𝓵𝓮𝓷𝓽𝓲𝓾𝓶 \", \"𝓜𝓲𝓻𝓪𝓬𝓾𝓵𝓾𝓶 \", \"𝓒𝓪𝓮𝓵𝓲𝓬𝓸𝓵𝓪 \", \"𝓒𝓪𝓮𝓵𝓮𝓼𝓽𝓲𝓼 \",\n            \"𝓐𝓮𝓽𝓮𝓻𝓷𝓲𝓽𝓪𝓼 \", \"𝓒𝓸𝓻𝓾𝓼𝓬𝓪𝓽𝓲𝓸 \", \"𝓛𝓲𝓺𝓾𝓮𝓼𝓬𝓮𝓻𝓮 \", \"𝓜𝓮𝓵𝓵𝓲𝓯𝓵𝓾𝓾𝓼 \", \"𝓕𝓻𝓪𝓰𝓻𝓪𝓷𝓽𝓲𝓪 \")\n    );\n    companion object{\n        //  辅助函数\n        fun fromName(name:String): CiphertextStyleType{\n            return entries.find { it.name == name.uppercase(getDefault()) } ?:NEKO\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/CryptoUploader.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.net.Uri\nimport android.util.Base64\nimport android.util.Log\nimport kotlinx.coroutines.ExperimentalCoroutinesApi\nimport kotlinx.coroutines.suspendCancellableCoroutine\nimport kotlinx.serialization.json.Json\nimport kotlinx.serialization.json.jsonObject\nimport kotlinx.serialization.json.jsonPrimitive\nimport me.wjz.nekocrypt.NekoCryptApp\nimport okhttp3.MediaType.Companion.toMediaTypeOrNull\nimport okhttp3.MultipartBody\nimport okhttp3.OkHttpClient\nimport okhttp3.Request\nimport okhttp3.RequestBody\nimport okhttp3.RequestBody.Companion.toRequestBody\nimport okio.BufferedSink\nimport java.io.IOException\nimport java.security.MessageDigest\nimport java.util.concurrent.TimeUnit\nimport kotlin.coroutines.resumeWithException\n\n/**\n * 封装图片、视频和文件的加密上传逻辑\n * 采用两步 OSS 上传：1. getParamsByAccount 获取凭证 → 2. POST 到 OSS\n */\nobject CryptoUploader {\n    private const val TAG = \"CryptoUploader\"\n\n    // ============ OSS 上传配置 ============\n    // 第一步：获取上传凭证的 API\n    private const val GET_PARAMS_URL =\n        \"https://api-takumi.mihoyogift.com/upload/outer/getParamsByAccount\"\n    // OSS 默认上传地址（API 返回 host 时优先使用 API 的）\n    private const val DEFAULT_OSS_HOST =\n        \"https://plat-sh-operation-prod-upload-ugc.cn-shanghai.oss.aliyuncs.com/\"\n    // 上传成功后拼接文件外链的基础 URL\n    private const val IMG_SRC_URL = \"https://operation-upload.mihoyo.com\"\n    // 业务类型\n    private const val BIZ = \"mall-im-user\"\n\n    // 最大文件大小 50MB\n    const val MAX_FILE_SIZE = 50L * 1024 * 1024\n\n    // Cookie（写死，与测试脚本一致）\n    const val COOKIE_STR = \"_MHYUUID=5f763e16-2067-448a-9b34-7d96ef2442a9; MIHOYO_LOGIN_PLATFORM_LIFECYCLE_ID=92b4c88e90; DEVICEFP_SEED_ID=89a4c724ec8fdce7; DEVICEFP_SEED_TIME=1777346563865; DEVICEFP=38d817dba05f7; cookie_token_v2=v2_bgnw4o4ZdBI-3dBB3hxa3HCp_d_nKAQK-bb7ufa-_QUnFcPMiDWRLwArsDtU4EO7KEplSkZKS2NVuoe68Km3xohnCjPJrK41HBIeVDLwH3L6qNLGq59C6cTotYobzMYMNc5DypCvnOaGlZ_nPvfD.CAE=; account_mid_v2=0pc3m4rki2_mhy; account_id_v2=282706094; ltoken_v2=v2_OYQJoc9KRCiBE34GSim5h3gtGEgokSQGC-wiM5F2WEKxa5ugDGyE8qhhPjcG9blvmSFD5Nrwxh6HVpfg6D0ZlQ57wRdgdMPFTlE8U3so8GI3yF7b_r4dP8qro-FcwxaJJVwt2p36m4razyZbS52q.CAE=; ltmid_v2=0pc3m4rki2_mhy; ltuid_v2=282706094; cookie_token=92pot53yPHwWtgzgQoBLZYMzmdlj84ai8Ilo5w9O; account_id=282706094; ltoken=ZpkZC6nlSW7KtHZeR4eDGRVA7M64XhcoFaDTPPSG; ltuid=282706094; aliyungf_tc=340fe61fe20ba7dfb8b764396451a570555896311546ea33c3a7db4d76eda80f\"\n\n    // 1像素 GIF 伪装头\n    val SINGLE_PIXEL_GIF_BUFFER: ByteArray =\n        Base64.decode(\"R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=\", Base64.DEFAULT)\n\n    private val client = OkHttpClient.Builder()\n        .connectTimeout(1, TimeUnit.MINUTES)\n        .readTimeout(5, TimeUnit.MINUTES)\n        .writeTimeout(5, TimeUnit.MINUTES)\n        .build()\n\n    // ============ 第一步：获取上传凭证 ============\n\n    /**\n     * 调用 getParamsByAccount 接口，获取 OSS 上传所需的凭证\n     */\n    private fun getUploadParams(fileMd5: String, ext: String): Map<String, Any?> {\n        Log.d(TAG, \"[Step1] 开始获取上传凭证, md5=$fileMd5, ext=$ext, biz=$BIZ\")\n\n        val jsonBody = \"\"\"{\"md5\":\"$fileMd5\",\"ext\":\"$ext\",\"biz\":\"$BIZ\",\"support_content_type\":true}\"\"\"\n        Log.d(TAG, \"[Step1] 请求体: $jsonBody\")\n\n        val request = Request.Builder()\n            .url(GET_PARAMS_URL)\n            .header(\"Content-Type\", \"application/json\")\n            .header(\"Cookie\", COOKIE_STR)\n            .header(\"Origin\", \"https://webstatic.mihoyogift.com\")\n            .header(\"Referer\", \"https://webstatic.mihoyogift.com/\")\n            .header(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0\")\n            .post(jsonBody.toRequestBody(\"application/json\".toMediaTypeOrNull()))\n            .build()\n\n        val response = client.newCall(request).execute()\n        val bodyString = response.body.string() ?: throw IOException(\"获取凭证响应为空\")\n\n        val jsonObject = Json.parseToJsonElement(bodyString).jsonObject\n        val retcode = jsonObject[\"retcode\"]?.jsonPrimitive?.content?.toInt()\n        if (retcode != 0) {\n            Log.e(TAG, \"[Step1] 获取凭证失败! retcode=$retcode, message=${jsonObject[\"message\"]}\")\n            throw IOException(\"获取凭证失败: retcode=$retcode, message=${jsonObject[\"message\"]}\")\n        }\n\n        val data = jsonObject[\"data\"]?.jsonObject ?: throw IOException(\"响应中缺少 data 字段\")\n\n        // 提取关键字段\n        val oss = data[\"oss\"]?.jsonObject ?: throw IOException(\"响应中缺少 data.oss 字段\")\n        val fileName = data[\"file_name\"]?.jsonPrimitive?.content\n            ?: throw IOException(\"响应中缺少 data.file_name 字段\")\n\n        val host = oss[\"host\"]?.jsonPrimitive?.content ?: DEFAULT_OSS_HOST\n        val policy = oss[\"policy\"]?.jsonPrimitive?.content\n        val signature = oss[\"signature\"]?.jsonPrimitive?.content\n        val accessid = oss[\"accessid\"]?.jsonPrimitive?.content\n\n        Log.d(TAG, \"[Step1] 凭证获取成功!\")\n//        Log.d(TAG, \"[Step1]   host: $host\")\n//        Log.d(TAG, \"[Step1]   key: $fileName\")\n//        Log.d(TAG, \"[Step1]   policy: $policy\")\n//        Log.d(TAG, \"[Step1]   signature: $signature\")\n//        Log.d(TAG, \"[Step1]   accessid: $accessid\")\n\n        // 解码 policy 看过期时间和限制条件\n        try {\n            val policyJson = String(Base64.decode(policy, Base64.DEFAULT))\n            Log.d(TAG, \"[Step1]   policy解码: $policyJson\")\n        } catch (_: Exception) {}\n\n        return mapOf(\n            \"host\" to host,\n            \"policy\" to policy,\n            \"signature\" to signature,\n            \"accessid\" to accessid,\n            // callback 不传，和测试脚本保持一致\n            // \"callback\" to oss[\"callback\"]?.jsonPrimitive?.content,\n            \"file_name\" to fileName,\n        )\n    }\n\n    // ============ 第二步：上传到 OSS ============\n\n    /**\n     * 将文件通过 multipart POST 上传到 OSS\n     */\n    private fun uploadToOss(\n        params: Map<String, Any?>,\n        fileData: ByteArray,\n        fileName: String,\n        ext: String,\n        onProcess: (Int) -> Unit,\n    ): String {\n        val host = params[\"host\"] as String\n        val key = params[\"file_name\"] as String\n\n//        Log.d(TAG, \"[Step2] 开始上传到 OSS\")\n//        Log.d(TAG, \"[Step2]   目标: $host\")\n//        Log.d(TAG, \"[Step2]   key: $key\")\n//        Log.d(TAG, \"[Step2]   文件名: $fileName\")\n//        Log.d(TAG, \"[Step2]   数据大小: ${fileData.size} bytes\")\n\n        // 构造 multipart 表单\n        val multipartBuilder = MultipartBody.Builder()\n            .setType(MultipartBody.FORM)\n            .addFormDataPart(\"key\", key)\n            .addFormDataPart(\"policy\", params[\"policy\"] as String)\n            .addFormDataPart(\"signature\", params[\"signature\"] as String)\n            .addFormDataPart(\"OSSAccessKeyId\", params[\"accessid\"] as String)\n            .addFormDataPart(\"success_action_status\", \"200\")\n            .addFormDataPart(\"x-oss-content-type\", \"image/$ext\")\n\n        // callback 不传，和测试脚本保持一致\n        // (params[\"callback\"] as? String)?.let { callback ->\n        //     if (callback.isNotBlank()) {\n        //         multipartBuilder.addFormDataPart(\"callback\", callback)\n        //     }\n        // }\n\n        // 文件部分，带进度\n        val fileRequestBody = ProgressRequestBody(\n            data = fileData,\n            contentType = \"image/$ext\".toMediaTypeOrNull(),\n            onProcess = onProcess\n        )\n        multipartBuilder.addFormDataPart(\"file\", fileName, fileRequestBody)\n\n        val request = Request.Builder()\n            .url(host)\n            .header(\"Origin\", \"https://webstatic.mihoyogift.com\")\n            .header(\"Referer\", \"https://webstatic.mihoyogift.com/\")\n            .header(\"User-Agent\", \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/147.0.0.0\")\n            .post(multipartBuilder.build())\n            .build()\n\n        Log.d(TAG, \"[Step2] 正在发送 POST 请求到 OSS...\")\n        val response = client.newCall(request).execute()\n        val respBody = response.body?.string()\n\n        if (!response.isSuccessful) {\n            Log.e(TAG, \"[Step2] OSS 上传失败! 状态码: ${response.code}, 响应: $respBody\")\n            throw IOException(\"OSS 上传失败，状态码: ${response.code}，响应: $respBody\")\n        }\n\n        // 上传成功，拼接文件 URL\n        val fileUrl = \"$IMG_SRC_URL/$key\"\n        Log.d(TAG, \"[Step2] 上传成功! 状态码: ${response.code}\")\n        Log.d(TAG, \"[Step2] 响应体: $respBody\")\n        Log.d(TAG, \"[Step2] 文件外链: $fileUrl\")\n        return fileUrl\n    }\n\n    // ============ 对外接口 ============\n\n    /**\n     * 上传字节数组（当前主用版本）\n     * 流程：加密 → GIF伪装 → 计算MD5 → 获取凭证 → 上传OSS → 返回URL\n     *\n     * 进度分配：加密 0→10%，获取凭证 10→20%，上传OSS 20→100%\n     */\n    @OptIn(ExperimentalCoroutinesApi::class)\n    suspend fun upload(\n        fileBytes: ByteArray,\n        fileName: String = \"\",\n        encryptionKey: String,\n        onProcess: (progress: Int) -> Unit,\n    ): NCFileProtocol {\n        Log.d(TAG, \"========== 开始上传(字节数组) ==========\")\n        Log.d(TAG, \"原始文件: $fileName, 大小: ${fileBytes.size} bytes, 类型: ${if (fileBytes.isImage()) \"图片\" else \"文件\"}\")\n\n        return suspendCancellableCoroutine { continuation ->\n            try {\n                // 1. 加密 + GIF 伪装 (进度 0→10%)\n                onProcess(0)\n                Log.d(TAG, \"加密中...\")\n                val encryptedBytes = CryptoManager.encrypt(fileBytes, encryptionKey)\n                val payload = SINGLE_PIXEL_GIF_BUFFER + encryptedBytes\n                onProcess(10)\n                Log.d(TAG, \"加密完成, 伪装后大小: ${payload.size} bytes (含GIF头 ${SINGLE_PIXEL_GIF_BUFFER.size} bytes)\")\n\n                // 2. 计算 MD5\n                val fileMd5 = computeMd5(payload)\n                Log.d(TAG, \"payload MD5: $fileMd5\")\n\n                // 3. 获取上传凭证 (进度 10→20%)\n                onProcess(10)\n                val params = getUploadParams(fileMd5, \"gif\")\n                onProcess(20)\n\n                // 4. 上传到 OSS (进度 20→100%，由 ProgressRequestBody 驱动)\n                val uploadFileName = \"$fileName.gif\"\n                // 将 0-100 的内部进度映射到 20-100 的外部进度\n                val ossOnProcess: (Int) -> Unit = { internalProgress ->\n                    onProcess(20 + (internalProgress * 80 / 100))\n                }\n                val fileUrl = uploadToOss(params, payload, uploadFileName, \"gif\", ossOnProcess)\n\n                // 5. 构造返回结果\n                if (fileUrl.isNotBlank()) {\n                    val result = NCFileProtocol(\n                        url = fileUrl,\n                        size = fileBytes.size.toLong(),\n                        name = fileName,\n                        encryptionKey = encryptionKey,\n                        type = if (fileBytes.isImage()) NCFileType.IMAGE else NCFileType.FILE\n                    )\n                    Log.d(TAG, \"========== 上传完成 ==========\")\n                    Log.d(TAG, \"结果: url=${result.url}, name=${result.name}, size=${result.size}, type=${result.type}\")\n                    continuation.resume(result, null)\n                } else {\n                    Log.e(TAG, \"构造文件 URL 失败: fileUrl 为空\")\n                    continuation.resumeWithException(IOException(\"构造文件 URL 失败\"))\n                }\n            } catch (e: Exception) {\n                Log.e(TAG, \"========== 上传失败 ==========\", e)\n                if (continuation.isActive) continuation.resumeWithException(e)\n            }\n        }\n    }\n\n    /**\n     * 上传 Uri 文件（流式版本，目前未使用但保留）\n     * 进度分配：读取+加密 0→10%，获取凭证 10→20%，上传OSS 20→100%\n     */\n    @OptIn(ExperimentalCoroutinesApi::class)\n    suspend fun upload(\n        uri: Uri,\n        fileName: String,\n        encryptionKey: String,\n        onProcess: (progress: Int) -> Unit,\n    ): NCFileProtocol {\n        val fileSize = getFileSize(uri)\n        Log.d(TAG, \"========== 开始上传(Uri) ==========\")\n        Log.d(TAG, \"URI: $uri, 文件名: $fileName, 大小: $fileSize bytes\")\n\n        onProcess(0)\n\n        val inputStream = NekoCryptApp.instance.contentResolver.openInputStream(uri)\n            ?: throw IOException(\"Failed to open input stream from URI\")\n\n        // 先读取到内存（因为需要先计算MD5再上传）\n        val fileBytes = inputStream.use { it.readBytes() }\n        Log.d(TAG, \"文件读取完成, 实际大小: ${fileBytes.size} bytes\")\n\n        // 加密 + GIF 伪装 (进度 0→10%)\n        val encryptedBytes = CryptoManager.encrypt(fileBytes, encryptionKey)\n        val payload = SINGLE_PIXEL_GIF_BUFFER + encryptedBytes\n        onProcess(10)\n\n        // 计算 MD5\n        val fileMd5 = computeMd5(payload)\n\n        // 获取凭证 (进度 10→20%)\n        val params = getUploadParams(fileMd5, \"gif\")\n        onProcess(20)\n\n        // 上传到 OSS (进度 20→100%)\n        val uploadFileName = \"$fileName.gif\"\n        val ossOnProcess: (Int) -> Unit = { internalProgress ->\n            onProcess(20 + (internalProgress * 80 / 100))\n        }\n\n        return suspendCancellableCoroutine { continuation ->\n            try {\n                val fileUrl = uploadToOss(params, payload, uploadFileName, \"gif\", ossOnProcess)\n\n                if (fileUrl.isNotBlank()) {\n                    val result = NCFileProtocol(\n                        url = fileUrl,\n                        size = fileSize,\n                        name = fileName,\n                        encryptionKey = encryptionKey,\n                        type = if (isFileImage(uri)) NCFileType.IMAGE else NCFileType.FILE\n                    )\n                    Log.d(TAG, \"========== 上传完成 ==========\")\n                    Log.d(TAG, \"结果: url=${result.url}, name=${result.name}, size=${result.size}, type=${result.type}\")\n                    continuation.resume(result, null)\n                } else {\n                    Log.e(TAG, \"构造文件 URL 失败: fileUrl 为空\")\n                    continuation.resumeWithException(IOException(\"构造文件 URL 失败\"))\n                }\n            } catch (e: Exception) {\n                Log.e(TAG, \"========== 上传失败 ==========\", e)\n                if (continuation.isActive) continuation.resumeWithException(e)\n            }\n        }\n    }\n\n    // ============ 工具方法 ============\n\n    /**\n     * 计算字节数组的 MD5 哈希（小写十六进制）\n     */\n    private fun computeMd5(data: ByteArray): String {\n        val digest = MessageDigest.getInstance(\"MD5\")\n        return digest.digest(data).joinToString(\"\") { \"%02x\".format(it) }\n    }\n}\n\n/**\n * 带进度回调的 RequestBody\n */\nprivate class ProgressRequestBody(\n    private val data: ByteArray,\n    private val contentType: okhttp3.MediaType?,\n    private val onProcess: (Int) -> Unit,\n) : RequestBody() {\n    override fun contentType(): okhttp3.MediaType? = contentType\n    override fun contentLength(): Long = data.size.toLong()\n\n    override fun writeTo(sink: BufferedSink) {\n        val totalBytes = contentLength()\n        var bytesWritten = 0L\n        val bufferSize = 8 * 1024\n\n        data.inputStream().use { inputStream ->\n            val buffer = ByteArray(bufferSize)\n            var read: Int\n            while (inputStream.read(buffer).also { read = it } != -1) {\n                sink.write(buffer, 0, read)\n                bytesWritten += read\n                val progress = (100 * bytesWritten / totalBytes).toInt()\n                onProcess(progress)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/LifecycleOwnerProvider.kt",
    "content": "// 文件路径: me/wjz/nekocrypt/util/LifecycleOwnerProvider.kt\npackage me.wjz.nekocrypt.util\n\nimport androidx.lifecycle.Lifecycle\nimport androidx.lifecycle.LifecycleOwner\nimport androidx.lifecycle.LifecycleRegistry\nimport androidx.lifecycle.ViewModelStore\nimport androidx.lifecycle.ViewModelStoreOwner\nimport androidx.savedstate.SavedStateRegistry\nimport androidx.savedstate.SavedStateRegistryController\nimport androidx.savedstate.SavedStateRegistryOwner\n/**\n * 这是一个文档注释，用来解释这个类的作用。\n * 它是一个实现了所有生命周期相关接口的“便携式电源包”。\n * 它可以为任何没有自带生命周期的View（比如添加到WindowManager的View）提供一个完整的、可控的生命周期。\n */\n// --- 类声明 ---\n// 定义一个名为 LifecycleOwnerProvider 的类。\n// 它通过冒号\":\"实现了三个重要的接口，意味着它承诺会提供这三个接口所要求的所有功能。\nclass LifecycleOwnerProvider : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner {\n\n    // --- “发电机”部分：提供 Lifecycle ---\n\n    // 创建一个 LifecycleRegistry 实例。这是真正管理生命周期状态（CREATED, STARTED, RESUMED...）的核心引擎。\n    // `this` 指的是 LifecycleOwnerProvider 本身，因为它实现了 LifecycleOwner 接口。\n    private val lifecycleRegistry = LifecycleRegistry(this)\n\n    // 这是对 LifecycleOwner 接口的实现。当外部需要一个 Lifecycle 对象时，我们把内部的 lifecycleRegistry 提供给它。\n    // `override` 关键字表示我们正在重写父接口的方法/属性。\n    override val lifecycle: Lifecycle get() = lifecycleRegistry\n\n    // --- “遥控器中枢”部分：提供 ViewModelStore ---\n\n    // 创建一个 ViewModelStore 实例。这是真正存放所有 ViewModel 的“容器”或“仓库”。\n    // `_viewModelStore` 的下划线是Kotlin的惯例，表示这是一个私有的、用于支持公开属性的“幕后字段”。\n    private val _viewModelStore = ViewModelStore()\n\n    // 这是对 ViewModelStoreOwner 接口的实现。它对外提供一个只读的 viewModelStore。\n    // 当外部代码（比如ViewModelProvider）需要存储ViewModel时，就会访问这个属性。\n    override val viewModelStore: ViewModelStore get() = _viewModelStore\n\n    // --- “信号源”部分：提供 SavedStateRegistry ---\n\n    // 创建一个 SavedStateRegistryController 实例，它是管理状态保存和恢复的“总控制器”。\n    // `SavedStateRegistryController.create(this)` 将这个控制器与我们这个 Owner 绑定。\n    private val savedStateRegistryController = SavedStateRegistryController.create(this)\n\n    // 这是对 SavedStateRegistryOwner 接口的实现。它对外提供一个用于注册和读取状态的 SavedStateRegistry。\n    override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry\n\n    /**\n     * 初始化“电源包”，把它和自己的各个部件连接起来。\n     */\n    // `init` 块是类的构造函数的一部分。当一个 LifecycleOwnerProvider 实例被创建时，这里的代码会立刻执行。\n    init {\n        // 调用控制器的 performRestore 方法，尝试从之前保存的状态中恢复数据。\n        // 在我们这个场景下，因为是凭空创建，所以通常没有状态可恢复，传入 `null` 即可。\n        // 这是完成初始化所必需的步骤。\n        savedStateRegistryController.performRestore(null)\n    }\n\n    /**\n     * 手动“开机”！将生命周期推进到 RESUMED 状态。\n     */\n    // 这是一个我们自己定义的公共方法，用于手动启动生命周期。\n    fun resume() {\n        // 通过 handleLifecycleEvent 方法，手动将生命周期状态依次推进。\n        // 这三行代码模拟了一个Activity/Fragment从创建到完全可见的完整过程。\n        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)\n        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START)\n        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)\n    }\n\n    /**\n     * 手动“关机”！将生命周期推进到 DESTROYED 状态，并清理所有资源。\n     */\n    // 这是一个我们自己定义的公共方法，用于手动销毁生命周期并释放资源。\n    fun destroy() {\n        // 将生命周期状态反向推进，模拟一个Activity/Fragment从可见到完全销毁的过程。\n        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)\n        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP)\n        lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)\n\n        // 这是至关重要的一步！当生命周期结束时，清空 ViewModelStore 中的所有 ViewModel。\n        // 如果没有这一步，所有创建的 ViewModel 都会永远留在内存中，造成严重的内存泄漏。\n        _viewModelStore.clear()\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/NCFileProtocol.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.util.Log\nimport kotlinx.serialization.Serializable\nimport kotlinx.serialization.encodeToString\nimport kotlinx.serialization.json.Json\nimport me.wjz.nekocrypt.util.CryptoManager.applyCiphertextStyle\nimport org.json.JSONException\n\nenum class NCFileType{\n    IMAGE,FILE;\n}\n\nconst val NC_FILE_PROTOCOL_PREFIX = \"NCFile://\"\n\n@Serializable\ndata class NCFileProtocol(\n    val url: String,\n    val size: Long,\n    val name: String,\n    val type: NCFileType,\n    val encryptionKey: String\n) {\n    companion object {\n        /**\n         * ✨ [反序列化] (使用Fastjson)\n         * @return 如果解密和解析成功，返回NCFileProtocol对象；否则返回null。\n         */\n        fun fromString(decryptedString: String): NCFileProtocol? {\n            return try {\n                if (!decryptedString.startsWith(NC_FILE_PROTOCOL_PREFIX)) return null\n\n                val jsonPayload = decryptedString.substringAfter(NC_FILE_PROTOCOL_PREFIX)\n                // ✨ 使用Fastjson进行解析\n                Json.decodeFromString<NCFileProtocol>(jsonPayload)\n            } catch (e: JSONException) {\n                // Fastjson解析失败\n                null\n            } catch (e: Exception) {\n                // 捕获其他所有可能的异常（如枚举转换失败）\n                null\n            }\n        }\n    }\n\n    /**\n     * ✨ [加密 & 序列化] (使用Fastjson)\n     * 将当前的NCFileProtocol对象，转换为一个完整的、加密的协议字符串。\n     * @param encryptionKey 用于加密的密钥。\n     * @return 格式为 \"NCFile://[加密并隐写编码后的JSON载荷]\" 的字符串。\n     */\n    fun toEncryptedString(encryptionKey: String): String {\n        Log.d(\"NekoAccessibility\", \"protocol本身结果结果：$this\")\n        val payloadJson = Json.encodeToString(this)\n        Log.d(\"NekoAccessibility\", \"protocol转json结果：$payloadJson\")\n        return CryptoManager.encrypt(NC_FILE_PROTOCOL_PREFIX + payloadJson, encryptionKey).applyCiphertextStyle()\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/NCWindowManager.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.animation.ValueAnimator\nimport android.content.Context\nimport android.graphics.PixelFormat\nimport android.graphics.Rect\nimport android.os.Build\nimport android.view.Gravity\nimport android.view.MotionEvent\nimport android.view.View\nimport android.view.ViewConfiguration\nimport android.view.WindowManager\nimport android.view.animation.DecelerateInterpolator\nimport androidx.compose.runtime.Composable\nimport androidx.compose.ui.platform.ComposeView\nimport androidx.compose.ui.platform.ViewCompositionStrategy\nimport androidx.lifecycle.setViewTreeLifecycleOwner\nimport androidx.lifecycle.setViewTreeViewModelStoreOwner\nimport androidx.savedstate.setViewTreeSavedStateRegistryOwner\nimport kotlin.math.abs\n\n/**\n * 一个通用的、用于在 WindowManager 上显示 Compose UI 的弹窗工具类。\n * 它封装了所有创建、显示和销毁悬浮窗的复杂逻辑。\n *\n * @param context 上下文环境。\n * @param onDismissRequest 当弹窗被请求关闭时（例如，通过代码调用dismiss）的回调。\n * @param content 要在弹窗中显示的 Composable 内容。\n */\nclass NCWindowManager(\n    private val context: Context,\n    private val onDismissRequest: () -> Unit = {},\n    private val anchorRect: Rect? = null,\n    private val isDraggable: Boolean = false,\n    private val content: @Composable () -> Unit,\n) {\n    private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager\n    private var popupView: View? = null\n    private var lifecycleOwnerProvider: LifecycleOwnerProvider? = null\n    private var positionAnimator: ValueAnimator? = null\n\n    /**\n     * 显示弹窗。\n     * @param anchorRect 一个可选的矩形，用于定位弹窗。如果为null，弹窗会居中。\n     */\n    fun show() {\n        //  防止重复显示\n        if (popupView != null) return\n        //  创建并启动生命周期\n        lifecycleOwnerProvider = LifecycleOwnerProvider().also { it.resume() }\n\n        //  创建ComposeView并设置内容\n        popupView = ComposeView(context).apply {\n            // ✨ 在设置内容之前，先给它“通上电”！\n            setViewTreeLifecycleOwner(lifecycleOwnerProvider)\n            setViewTreeViewModelStoreOwner(lifecycleOwnerProvider)\n            setViewTreeSavedStateRegistryOwner(lifecycleOwnerProvider)\n            // 不要裁剪子视图，避免子视图做动画时超出边界被裁剪\n            clipChildren = false\n            clipToPadding = false\n\n            // 使用最安全通用的生命周期策略\n            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)\n            setContent(content)\n        }\n        if (isDraggable) {\n            popupView?.setOnTouchListener(createDragTouchListener())\n        }\n        // 创建 WindowManager 参数\n        val params = createLayoutParams(anchorRect)\n        // 添加到窗口\n        windowManager.addView(popupView, params)\n    }\n\n    /**\n     * 关闭并销毁弹窗。\n     */\n    fun dismiss() {\n        if (popupView != null) {\n            try {\n                windowManager.removeView(popupView)\n            } catch (e: Exception) {\n                // 忽略窗口已经不存在等异常\n            } finally {\n                popupView = null\n                lifecycleOwnerProvider?.destroy()\n                lifecycleOwnerProvider = null\n                // 调用外部传入的关闭回调\n                onDismissRequest()\n            }\n        }\n    }\n\n    private fun createLayoutParams(anchorRect: Rect?): WindowManager.LayoutParams {\n        // 这个accessibility不需要用户授权，比application的好\n        val layoutFlag = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY\n\n        val params = WindowManager.LayoutParams(\n            WindowManager.LayoutParams.WRAP_CONTENT,\n            WindowManager.LayoutParams.WRAP_CONTENT,\n            layoutFlag,\n            // ✨ 核心修正：加上 FLAG_LAYOUT_IN_SCREEN 这句关键的“咒语”！\n            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,// 这句是关键\n            PixelFormat.TRANSLUCENT\n        )\n\n        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {\n            params.blurBehindRadius = 30\n        }\n\n        if (anchorRect != null) {\n            params.gravity = Gravity.TOP or Gravity.START\n            params.x = anchorRect.left\n            params.y = anchorRect.top\n        }\n        return params\n    }\n\n    // 带有平滑效果的位置更新函数\n    fun updatePosition(targetRect: Rect) {\n        // 如果视图不存在直接返回\n        val view = popupView ?: return\n        // 取消正在进行的任何旧动画\n        positionAnimator?.cancel()\n\n        val currentParams = view.layoutParams as? WindowManager.LayoutParams ?: return\n        val startX = currentParams.x\n        val startY = currentParams.y\n        val endX = targetRect.left\n        val endY = targetRect.top\n\n        // 如果位置没有变化，就没必要执行动画\n        if (startX == endX && startY == endY) return\n\n        positionAnimator = ValueAnimator.ofFloat(0f, 1f).apply {\n            duration = 250 //动画时长\n            interpolator = DecelerateInterpolator() // 减速插值器，动画效果更自然\n            // 根据动画进度计算当前帧的x,y坐标\n            addUpdateListener { animation ->\n                val fraction = animation.animatedFraction\n                // 根据动画进度计算当前帧的x, y坐标\n                currentParams.x = (startX + (endX - startX) * fraction).toInt()\n                currentParams.y = (startY + (endY - startY) * fraction).toInt()\n\n                // 只有当视图还附着在窗口上时才更新布局\n                if (view.isAttachedToWindow) {\n                    windowManager.updateViewLayout(view, currentParams)\n                }\n            }\n            start()\n        }\n    }\n\n    /**\n     * ✨ 全新函数：创建一个处理拖动逻辑的 OnTouchListener。\n     * 它能智能地区分用户的“轻点”和“拖动”手势。\n     */\n    private fun createDragTouchListener():View.OnTouchListener{\n        // 手指按下时的初始位置\n        var initialX = 0\n        var initialY = 0\n        var initialTouchX = 0f\n        var initialTouchY = 0f\n        val touchSlop = ViewConfiguration.get(context).scaledTouchSlop\n        var isDragging = false\n\n        return View.OnTouchListener{ view, event ->\n            val params = view.layoutParams as? WindowManager.LayoutParams ?: return@OnTouchListener false\n\n            when (event.action) {\n                MotionEvent.ACTION_DOWN -> {\n                    isDragging = false // 每次按下都重置拖动状态\n                    // 记录下当前窗口的位置\n                    initialX = params.x\n                    initialY = params.y\n                    // 记录下手指在屏幕上的位置\n                    initialTouchX = event.rawX\n                    initialTouchY = event.rawY\n                    true // 返回 true，表示我们要继续处理后续的 MOVE 和 UP 事件\n                }\n                MotionEvent.ACTION_MOVE -> {\n                    // 计算手指滑动的距离\n                    val dx = event.rawX - initialTouchX\n                    val dy = event.rawY - initialTouchY\n\n                    // 如果还没开始拖动，并且滑动的距离已经超过了系统阈值，那么就判定为开始拖动\n                    if (!isDragging && (abs(dx) > touchSlop || abs(dy) > touchSlop)) {\n                        isDragging = true\n                    }\n\n                    // 如果正在拖动，就更新窗口的位置\n                    if (isDragging) {\n                        params.x = (initialX + dx).toInt()\n                        params.y = (initialY + dy).toInt()\n                        windowManager.updateViewLayout(view, params)\n                    }\n                    true\n                }\n                MotionEvent.ACTION_UP -> {\n                    // 如果手指抬起时，我们判定这并非一次拖动...\n                    if (!isDragging) {\n                        // ...那就当它是一次点击！\n                        view.performClick()\n                    }\n                    true\n                }\n                else -> false // 其他事件我们不关心\n            }\n        }\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/NekoNotification.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.app.Notification\nimport android.app.NotificationChannel\nimport android.app.NotificationManager\nimport android.content.Context\nimport androidx.core.app.NotificationCompat\nimport me.wjz.nekocrypt.R\n\n// 用于创建通知的对象\nobject NekoNotification {\n\n    const val NEKO_NOTIFICATION_ID = 20040821\n    private const val CHANNEL_ID = \"NekoCryptKeepAlive\"\n    private const val CHANNEL_NAME = \"NekoCrypt 服务状态\"\n\n    /**\n     * 创建通知渠道（仅在Android 8.0+需要）。\n     */\n    fun createChannel(context: Context) {\n        val channel = NotificationChannel(\n            CHANNEL_ID,\n            CHANNEL_NAME,\n            NotificationManager.IMPORTANCE_MIN // 设置为最低重要性，用户不会被打扰\n        ).apply {\n            description = \"用于保持NekoCrypt服务在后台稳定运行\"\n            setShowBadge(false) // 不在桌面图标上显示角标\n        }\n        val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager\n        manager.createNotificationChannel(channel)\n    }\n\n    /**\n     * 构建前台服务的常驻通知。\n     */\n    fun build(context: Context): Notification {\n        return NotificationCompat.Builder(context, CHANNEL_ID)\n            .setContentTitle(\"NekoCrypt 正在守护中\")\n            .setContentText(\"加密服务正在后台运行...\")\n            .setSmallIcon(R.drawable.ic_launcher_foreground) // ✨ 请确保你有一个图标资源\n            .setPriority(NotificationCompat.PRIORITY_MIN) // 设置为最低优先级\n            .setOngoing(true) // 设置为常驻通知，用户无法划掉，被划掉了会被降级。\n            .build()\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/NodeFinder.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.util.Log\nimport android.view.accessibility.AccessibilityNodeInfo\nimport me.wjz.nekocrypt.NekoCryptApp\n\nprivate const val TAG = NekoCryptApp.TAG\n\n/**\n * 检查节点是否仍然有效，这是操作缓存节点前的“金标准”。\n * @param node 要检查的节点。\n * @return 如果节点有效则返回 true，否则返回 false。\n */\nfun isNodeValid(node: AccessibilityNodeInfo?): Boolean {\n    return node?.refresh() ?: false\n}\n\n/**\n * ✨ [核心] 查找符合所有指定条件的第一个节点。\n *\n * @param rootNode 查找的起始节点。\n * @param viewId 节点的资源ID (e.g., \"com.tencent.mobileqq:id/input\")。\n * @param className 节点的类名 (e.g., \"android.widget.EditText\")，支持部分匹配。\n * @param text 节点显示的文本，支持部分匹配。\n * @param contentDescription 节点的内容描述，支持部分匹配。\n * @param predicate 一个自定义的检查函数，返回 true 表示匹配。\n * @return 返回第一个匹配的 AccessibilityNodeInfo，如果找不到则返回 null。\n */\nfun findSingleNode(\n    rootNode: AccessibilityNodeInfo,\n    viewId: String? = null,\n    className: String? = null,\n    text: String? = null,\n    contentDescription: String? = null,\n    predicate: ((AccessibilityNodeInfo) -> Boolean)? = null\n): AccessibilityNodeInfo? {\n    // 策略1: 如果提供了viewId，以此为主要查找方式，因为最高效。\n    if (!viewId.isNullOrEmpty()) {\n        val candidates = rootNode.findAccessibilityNodeInfosByViewId(viewId)\n        // 在通过ID找到的候选中，进一步筛选出符合所有其他条件的第一个\n        return candidates.firstOrNull { node ->\n            matchesAllConditions(node, className, text, contentDescription, predicate)\n        }\n    }\n\n    // 策略2: 如果没有提供 viewId，则进行递归查找。\n    // 递归查找时，必须提供至少一个其他条件，以防止错误地匹配到根节点。\n    if (className != null || text != null || contentDescription != null || predicate != null) {\n        return findNodeRecursively(rootNode) { node ->\n            matchesAllConditions(node, className, text, contentDescription, predicate)\n        }\n    }\n\n    // 如果只提供了rootNode而没有其他任何条件，直接返回null，防止出错。\n    Log.w(TAG, \"NodeFinder: 查找条件不足，已跳过搜索。\")\n    return null\n}\n\n/**\n * ✨ [核心] 查找符合所有指定条件的全部节点。\n *\n * @return 返回所有匹配的 AccessibilityNodeInfo 列表，可能为空。\n */\nfun findMultipleNodes(\n    rootNode: AccessibilityNodeInfo,\n    viewId: String? = null,\n    className: String? = null,\n    text: String? = null,\n    contentDescription: String? = null,\n    predicate: ((AccessibilityNodeInfo) -> Boolean)? = null\n): List<AccessibilityNodeInfo> {\n    val results = mutableListOf<AccessibilityNodeInfo>()\n\n    // 策略1: 如果提供了viewId，以此为主要查找方式。\n    if (!viewId.isNullOrEmpty()) {\n        val candidates = rootNode.findAccessibilityNodeInfosByViewId(viewId)\n        // 筛选出所有符合其他条件的节点\n        candidates.filterTo(results) { node ->\n            matchesAllConditions(node, className, text, contentDescription, predicate)\n        }\n        // 找到后直接返回，不再进行递归。\n        return results\n    }\n\n    // 策略2: 如果没有提供 viewId，则进行递归查找。\n    if (className != null || text != null || contentDescription != null || predicate != null) {\n        findAllNodesRecursively(rootNode, results) { node ->\n            matchesAllConditions(node, className, text, contentDescription, predicate)\n        }\n    }\n\n    return results\n}\n\n\n/**\n * 🎯 核心匹配逻辑：检查一个节点是否满足所有非null的条件。\n * @return 如果所有提供的条件都满足，则返回 true。\n */\nprivate fun matchesAllConditions(\n    node: AccessibilityNodeInfo,\n    className: String?,\n    text: String?,\n    contentDescription: String?,\n    predicate: ((AccessibilityNodeInfo) -> Boolean)?\n): Boolean {\n    // 这种写法保证了只有所有非null的条件都为true时，最终结果才为true。\n    return (className == null || node.className?.toString()?.contains(className, ignoreCase = true) == true) &&\n            (text == null || node.text?.toString()?.contains(text, ignoreCase = true) == true) &&\n            (contentDescription == null || node.contentDescription?.toString()?.contains(contentDescription, ignoreCase = true) == true) &&\n            (predicate == null || predicate(node))\n}\n\n/**\n * 🔍 递归查找第一个满足条件的节点。\n * @param node 当前遍历的节点。\n * @param condition 匹配条件的函数。\n * @return 找到的节点或null。\n */\nprivate fun findNodeRecursively(\n    node: AccessibilityNodeInfo,\n    condition: (AccessibilityNodeInfo) -> Boolean\n): AccessibilityNodeInfo? {\n    // 检查当前节点\n    if (condition(node)) {\n        return node\n    }\n    // 递归检查子节点\n    for (i in 0 until node.childCount) {\n        val child = node.getChild(i) ?: continue\n        val found = findNodeRecursively(child, condition)\n        if (found != null) {\n            // 一旦找到，立刻层层返回，停止搜索\n            return found\n        }\n    }\n    return null\n}\n\n/**\n * 🔍 递归查找所有满足条件的节点。\n * @param node 当前遍历的节点。\n * @param results 用于存储结果的列表。\n * @param condition 匹配条件的函数。\n */\nprivate fun findAllNodesRecursively(\n    node: AccessibilityNodeInfo,\n    results: MutableList<AccessibilityNodeInfo>,\n    condition: (AccessibilityNodeInfo) -> Boolean\n) {\n    // 检查当前节点\n    if (condition(node)) {\n        results.add(node)\n    }\n    // 递归检查子节点\n    for (i in 0 until node.childCount) {\n        val child = node.getChild(i) ?: continue\n        findAllNodesRecursively(child, results, condition)\n    }\n}\n\n\n/**\n * 🐾 调试用：打印节点树结构\n */\nfun debugNodeTree(\n    node: AccessibilityNodeInfo?,\n    maxDepth: Int = 5,\n    currentDepth: Int = 0,\n) {\n    if (node == null || currentDepth > maxDepth) return\n\n    val indent = \"  \".repeat(currentDepth)\n    val className = node.className?.toString() ?: \"null\"\n    val text = node.text?.toString()?.take(20) ?: \"\"\n    val desc = node.contentDescription?.toString()?.take(20) ?: \"\"\n\n    Log.d(TAG, \"$indent[$currentDepth] $className | ID: ${node.viewIdResourceName}\")\n    if (text.isNotEmpty()) Log.d(TAG, \"$indent    文本: '$text'\")\n    if (desc.isNotEmpty()) Log.d(TAG, \"$indent    描述: '$desc'\")\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/PermissionGuard.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport PermissionDialog\nimport android.content.Intent\nimport android.provider.Settings\nimport androidx.activity.compose.rememberLauncherForActivityResult\nimport androidx.activity.result.contract.ActivityResultContracts\nimport androidx.compose.material.icons.Icons\nimport androidx.compose.material.icons.outlined.Layers\nimport androidx.compose.material.icons.outlined.SyncProblem\nimport androidx.compose.runtime.Composable\nimport androidx.compose.runtime.LaunchedEffect\nimport androidx.compose.runtime.getValue\nimport androidx.compose.runtime.mutableStateOf\nimport androidx.compose.runtime.remember\nimport androidx.compose.runtime.setValue\nimport androidx.compose.ui.platform.LocalContext\nimport androidx.core.net.toUri\nimport me.wjz.nekocrypt.R\nimport me.wjz.nekocrypt.ui.dialog.NCDialog\n\n@Composable\nfun PermissionGuard(content: @Composable () -> Unit) {\n    val context = LocalContext.current\n    var activeDialog by remember { mutableStateOf<NCDialog?>(null) }\n\n    //  用于跳转到应用详细信息，方便设置权限\n    val appDetailsLauncher = rememberLauncherForActivityResult(\n        contract = ActivityResultContracts.StartActivityForResult()\n    ) {}\n    //  跳转到“显示在其他应用上层”权限设置页面\n    val overlayPermissionLauncher = rememberLauncherForActivityResult(\n        contract = ActivityResultContracts.StartActivityForResult()\n    ) {\n        if (!PermissionUtil.isOverlayPermissionGranted(context)) {\n            // 弹出对话引导用户跳转到权限设置页面\n            activeDialog = PermissionDialog(\n                dialogIcon = Icons.Outlined.SyncProblem,\n                dialogTitle = context.getString(R.string.permission_still_missing_title),\n                dialogText = context.getString(R.string.permission_still_missing_text),\n                onDismissRequest = { activeDialog = null },\n                onConfirmRequest = {\n                    appDetailsLauncher.launch(\n                        Intent(\n                            Settings.ACTION_APPLICATION_DETAILS_SETTINGS,\n                            \"package:${context.packageName}\".toUri()\n                        )\n                    )\n                    activeDialog = null\n                }\n            )\n        }\n    }\n\n    // UI首次加载的时候做一次权限检查\n    LaunchedEffect(Unit) {\n        if (!PermissionUtil.isOverlayPermissionGranted(context)) {\n            // 弹出对话引导用户跳转到权限设置页面\n            activeDialog = PermissionDialog(\n                dialogIcon = Icons.Outlined.Layers,\n                dialogTitle = context.getString(R.string.permission_overlay_title),\n                dialogText = context.getString(R.string.permission_overlay_text),\n                onDismissRequest = { activeDialog = null },\n                onConfirmRequest = {\n                    overlayPermissionLauncher.launch(\n                        Intent(\n                            Settings.ACTION_MANAGE_OVERLAY_PERMISSION,\n                            \"package:${context.packageName}\".toUri()\n                        )\n                    )\n                    activeDialog = null\n                }\n\n            )\n        }\n    }\n\n    content()// 渲染传入的页面\n\n    activeDialog?.Content()// 根据权限状态拉起对话框。\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/PermissionUtil.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.content.Context\nimport android.provider.Settings\nimport android.text.TextUtils\nimport com.dianming.phoneapp.MyAccessibilityService\n\nobject PermissionUtil {\n    /**\n     * 检查“显示在其他应用上层”（悬浮窗）权限是否已授予。\n     */\n    fun isOverlayPermissionGranted(context: Context): Boolean {\n        return Settings.canDrawOverlays(context)\n    }\n\n    /**\n     * 检查我们的无障碍服务是否已启用。\n     */\n    fun isAccessibilityServiceEnabled(context: Context): Boolean {\n        val serviceName = context.packageName + \"/\" + MyAccessibilityService::class.java.name\n        try {\n            val enabledServices = Settings.Secure.getString(\n                context.contentResolver,\n                Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES\n            )\n            val stringColonSplitter = TextUtils.SimpleStringSplitter(':')\n            stringColonSplitter.setString(enabledServices)\n            while (stringColonSplitter.hasNext()) {\n                val componentName = stringColonSplitter.next()\n                if (componentName.equals(serviceName, ignoreCase = true)) {\n                    return true\n                }\n            }\n        } catch (e: Exception) {\n            // 忽略异常\n        }\n        return false\n    }\n}\n"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/ResultRelay.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.net.Uri\nimport kotlinx.coroutines.flow.MutableSharedFlow\nimport kotlinx.coroutines.flow.asSharedFlow\n\nobject ResultRelay {\n    // replay=1：确保 handler 被临时反激活后重新激活时，仍能收到最近一次发送的 URI\n    private val _flow = MutableSharedFlow<Uri>(replay = 1)\n    val flow = _flow.asSharedFlow()\n\n    suspend fun send(uri: Uri) {\n        _flow.emit(uri)\n    }\n\n    /**\n     * 在 handler 消费完 URI 后调用，防止 replay 导致重复处理\n     */\n    fun consumeLast() {\n        _flow.resetReplayCache()\n    }\n}"
  },
  {
    "path": "app/src/main/java/me/wjz/nekocrypt/util/helper.kt",
    "content": "package me.wjz.nekocrypt.util\n\nimport android.content.Context\nimport android.graphics.BitmapFactory\nimport android.net.Uri\nimport android.provider.OpenableColumns\nimport android.view.accessibility.AccessibilityNodeInfo\nimport androidx.compose.ui.graphics.Color\nimport androidx.core.content.FileProvider\nimport me.wjz.nekocrypt.NekoCryptApp\nimport java.io.File\nimport java.io.IOException\nimport java.util.Locale\nimport kotlin.math.log10\nimport kotlin.math.pow\n\n// 取反色\nfun Color.inverse(): Color {\n    return Color(\n        red = 1.0f - this.red,\n        green = 1.0f - this.green,\n        blue = 1.0f - this.blue,\n        alpha = this.alpha\n    )\n}\n\n/**\n * 根据uri查询文件大小。-1表示未知。\n */\nfun getFileSize(uri: Uri): Long {\n    NekoCryptApp.instance.contentResolver.query(\n        uri,\n        null,                                       // ④ projection = null → 返回所有列\n        null, null, null   // ⑤ selection/args/sortOrder 均不需要\n    )?.use {\n        if (it.moveToFirst()) {\n            val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)\n            return if (it.isNull(sizeIndex)) -1 else it.getLong(sizeIndex)\n        }\n    }\n    return -1\n}\n\n/**\n * 获取文件名\n */\nfun getFileName(uri: Uri): String {\n    // file:// URI 直接从路径取文件名\n    if (uri.scheme == \"file\") {\n        val name = uri.lastPathSegment\n        if (!name.isNullOrBlank()) return name\n    }\n    var fileName = \"unknown\"\n    NekoCryptApp.instance.contentResolver.query(uri, null, null, null, null)?.use { cursor ->\n        if (cursor.moveToFirst()) {\n            val displayNameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)\n            if (displayNameColumn != -1) { // 检查 DISPLAY_NAME 列是否存在\n                fileName = cursor.getString(displayNameColumn)\n            }\n        }\n    }\n    return fileName\n}\n\n/**\n * 检查给定的包名是否属于系统应用。\n * @param packageName 需要检查的应用包名。\n * @return 如果是系统应用或核心应用，则返回 true，否则返回 false。\n */\nfun isSystemApp(packageName: String?): Boolean {\n    if (packageName.isNullOrBlank()) {\n        return false\n    }\n    // 相册属于前者，文件选择器属于后者。\n    return packageName.startsWith(\"com.android.providers\") || packageName.startsWith(\"com.google.android\")\n}\n\n/**\n * 格式化文件大小，入参单位为bytes\n */\nfun Long.formatFileSize(): String {\n    if (this <= 0) return \"0 B\"\n    val units = arrayOf(\"B\", \"KB\", \"MB\", \"GB\", \"TB\")\n    val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt()\n    return String.format(\n        Locale.US,\n        \"%.1f %s\",\n        this / 1024.0.pow(digitGroups.toDouble()),\n        units[digitGroups]\n    )\n}\n\n// 定义图片文件的常见扩展名\nval imageExtensions =\n    setOf(\"jpg\", \"jpeg\", \"png\", \"gif\", \"bmp\", \"webp\", \"heic\", \"svg\", \"tiff\", \"psdng\")\n\nfun isFileImage(uri: Uri): Boolean {\n    val fileName = getFileName(uri)\n    // 获取文件的扩展名\n    val extension = fileName.substringAfterLast(\".\").lowercase()\n    // 检查扩展名是否在图片扩展名集合中\n    return imageExtensions.contains(extension)\n}\n\nprivate val IMAGE_HEADERS = setOf(\n    \"FFD8\",                 // JPEG\n    \"89504E47\",             // PNG\n    \"47494638\",             // GIF\n    \"49492A00\", \"4D4D002A\", // TIFF 两种字节序\n    \"424D\",                 // BMP\n    \"52494646\",             // WEBP 的前 4 字节\n    \"000000\"                // ICO\n)\n\nfun ByteArray.isImage(): Boolean {\n    if (isEmpty()) return false\n    // 前 8 字节足够覆盖上面所有魔数\n    val prefix = take(8)\n        .joinToString(\"\") { \"%02X\".format(it) }\n    return IMAGE_HEADERS.any { prefix.startsWith(it) }\n}\n\n/**\n * ✨ [新增] 专门获取图片宽高比的辅助函数\n * 它只解码图片的边界信息，不加载整个图片到内存，因此非常高效。\n * @param uri 图片的Uri\n * @return Float类型的宽高比，如果无法获取则返回null\n */\nfun getImageAspectRatio(uri: Uri): Float? {\n    val currentService = NekoCryptApp.instance\n    return try {\n        // 使用 contentResolver 打开输入流\n        currentService.contentResolver.openInputStream(uri)?.use { inputStream ->\n            // 创建一个 BitmapFactory.Options 对象\n            val options = BitmapFactory.Options().apply {\n                // 设置 inJustDecodeBounds = true 是关键！\n                // 这会告诉解码器只解析图片的元数据（包括尺寸），而不真正加载像素数据到内存。\n                inJustDecodeBounds = true\n            }\n            // 执行解码操作（实际上只解码了边界）\n            BitmapFactory.decodeStream(inputStream, null, options)\n\n            // 检查是否成功获取了有效的宽度和高度\n            if (options.outWidth > 0 && options.outHeight > 0) {\n                // 计算并返回宽高比\n                options.outWidth.toFloat() / options.outHeight.toFloat()\n            } else {\n                // 如果尺寸无效，返回null\n                null\n            }\n        }\n    } catch (e: IOException) {\n        print(e.stackTraceToString())\n        null\n    }\n}\n\n/**\n * 判断节点是否为空，为空依据：无子节点｜无包名｜无viewIdResourceName\n  */\nfun AccessibilityNodeInfo.isEmpty(): Boolean {\n    return this.packageName == null || this.viewIdResourceName == null\n}\n\n//  获取文件缓存\nfun getCacheFileFor(context: Context, fileInfo: NCFileProtocol): File{\n    // 总是用外部缓存，方便分享之类的操作\n    val baseDir = context.externalCacheDir ?: context.cacheDir\n    val downloadDir = File(baseDir,\"download\").apply { mkdirs() }\n    // 用唯一文件名，避免重名之类的\n    val fileName = fileInfo.name.let { name ->\n        val dot = name.lastIndexOf('.')\n        if (dot == -1) \"${name}-${fileInfo.url.hashCode()}\"\n        else \"${name.substring(0, dot)}-${fileInfo.url.hashCode()}${name.substring(dot)}\"\n    }\n    return File(downloadDir,fileName)\n}\n\n//  根据File拿Uri\nfun getUriForFile(context: Context, file: File):Uri{\n    return FileProvider.getUriForFile(\n        context,\n        \"${context.packageName}.provider\",  // 这个地方的authority一定要和manifest里面配置的一样\n        file\n    )\n}"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_background.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"108\"\n    android:viewportHeight=\"108\">\n  <path\n      android:pathData=\"M0,0h108v108h-108z\">\n    <aapt:attr name=\"android:fillColor\">\n      <gradient \n          android:startX=\"0\"\n          android:startY=\"0\"\n          android:endX=\"108\"\n          android:endY=\"108\"\n          android:type=\"linear\">\n        <item android:offset=\"0\" android:color=\"#FF3B82F6\"/>\n        <item android:offset=\"1\" android:color=\"#FF66CCFF\"/>\n      </gradient>\n    </aapt:attr>\n  </path>\n</vector>\n"
  },
  {
    "path": "app/src/main/res/drawable/ic_launcher_foreground.xml",
    "content": "<vector xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    xmlns:aapt=\"http://schemas.android.com/aapt\"\n    android:width=\"108dp\"\n    android:height=\"108dp\"\n    android:viewportWidth=\"100\"\n    android:viewportHeight=\"100\">\n  <path\n      android:pathData=\"M0,0h100v100h-100z\">\n    <aapt:attr name=\"android:fillColor\">\n      <gradient \n          android:startX=\"0\"\n          android:startY=\"0\"\n          android:endX=\"0\"\n          android:endY=\"100\"\n          android:type=\"linear\">\n        <item android:offset=\"0\" android:color=\"#FF3B82F6\"/>\n        <item android:offset=\"1\" android:color=\"#FF66CCFF\"/>\n      </gradient>\n    </aapt:attr>\n  </path>\n  <path\n      android:pathData=\"M12,78L35,20L50,50L65,20L88,78M40,63L50,73L60,63\"\n      android:strokeLineJoin=\"round\"\n      android:strokeWidth=\"7\"\n      android:fillColor=\"#00000000\"\n      android:strokeColor=\"#FFFFFF\"\n      android:strokeLineCap=\"round\"/>\n</vector>\n"
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <background android:drawable=\"@drawable/ic_launcher_background\"/>\n    <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n</adaptive-icon>"
  },
  {
    "path": "app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n    <background android:drawable=\"@drawable/ic_launcher_background\"/>\n    <foreground android:drawable=\"@drawable/ic_launcher_foreground\"/>\n</adaptive-icon>"
  },
  {
    "path": "app/src/main/res/values/colors.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n    <color name=\"purple_200\">#FFBB86FC</color>\n    <color name=\"purple_500\">#FF6200EE</color>\n    <color name=\"purple_700\">#FF3700B3</color>\n    <color name=\"teal_200\">#FF03DAC5</color>\n    <color name=\"teal_700\">#FF018786</color>\n    <color name=\"black\">#FF000000</color>\n    <color name=\"white\">#FFFFFFFF</color>\n</resources>"
  },
  {
    "path": "app/src/main/res/values/strings.xml",
    "content": "<resources>\n    <string name=\"app_name\">NekoCrypt</string>\n\n    <!--    闲杂信息-->\n    <string name=\"ok\">原来是这样，现在我完全搞懂了</string>\n    <string name=\"cancel\">关闭</string>\n    <string name=\"send\">发送</string>\n    <string name=\"accept\">确认</string>\n    <string name=\"file_size\">文件大小</string>\n    <string name=\"url\">URL</string>\n    <string name=\"download\">下载</string>\n    <string name=\"finish\">完成</string>\n    <string name=\"delete\">删除</string>\n    <string name=\"downloading\">下载中</string>\n    <string name=\"not_installed\">未安装</string>\n    <string name=\"open_file\">打开文件</string>\n    <string name=\"cannot_open_file\">打开文件失败</string>\n    <string name=\"checking_for_update\">检查更新中...</string>\n    <string name=\"check_for_update_failed\">检查更新失败，请检查网络连接</string>\n    <string name=\"is_newest_version\">\"已经是最新版本啦 (ฅ´ω`ฅ)\"</string>\n    <string name=\"find_new_version\">发现新版本: %s！</string>\n    <string name=\"save_to_gallery\">保存到相册</string>\n    <string name=\"image_saved_to_gallery_success\">成功保存到相册</string>\n    <string name=\"image_saved_to_gallery_failed\">保存到相册失败</string>\n    <string name=\"image_saved_to_gallery_saved\">已保存到相册</string>\n    <string name=\"has_copy\">已复制到剪切板</string>\n    <string name=\"please_grant_overlay_permission\">请打开悬浮窗权限！</string>\n    <string name=\"please_grant_accessibility_service_permission\">请开启无障碍服务！</string>\n\n    <!--    bottomBar的label-->\n    <string name=\"home\">主页</string>\n    <string name=\"crypto\">加密</string>\n    <string name=\"key\">密钥</string>\n    <string name=\"settings\">设置</string>\n\n\n\n    <!--    加解密模式-->\n    <string name=\"mode_standard\">标准模式</string>\n    <string name=\"mode_immersive\">沉浸模式</string>\n    <!--    CryptoScreen -->\n    <string name=\"crypto_input_label\">输入原文或密文</string>\n    <string name=\"crypto_input_placeholder\">在此处输入或粘贴文本…</string>\n    <string name=\"crypto_decrypt_fail\">解密失败！密文被篡改或密钥错误.\\n ｡ﾟ(ﾟ´Д｀ﾟ)ﾟ｡</string>\n    <string name=\"crypto_input_icon_desc\">输入图标</string>\n    <string name=\"crypto_pasted_from_clipboard\">已从剪贴板粘贴</string>\n    <string name=\"crypto_paste_icon_desc\">粘贴</string>\n    <string name=\"crypto_clear_input_icon_desc\">清空输入</string>\n    <string name=\"crypto_encrypt_icon_desc\">加密图标</string>\n    <string name=\"crypto_swap_icon_desc\">交换输入与输出</string>\n    <string name=\"crypto_decrypt_button\">解密</string>\n    <string name=\"crypto_decrypt_icon_desc\">解密图标</string>\n    <string name=\"crypto_result_label_encrypted\">加密结果</string>\n    <string name=\"crypto_result_label_decrypted\">解密结果</string>\n    <string name=\"crypto_copied_to_clipboard\">已复制到剪贴板</string>\n    <string name=\"crypto_copy_result_icon_desc\">复制结果</string>\n    <string name=\"crypto_key_icon_desc\">密钥图标</string>\n    <string name=\"crypto_current_key_label\">当前密钥</string>\n    <string name=\"crypto_select_key_icon_desc\">选择密钥</string>\n    <!-- CryptoScreen 统计信息 -->\n    <string name=\"crypto_stats_char_count\">密文字符数</string>\n    <string name=\"crypto_stats_time_elapsed\">总处理耗时</string>\n    <string name=\"crypto_stats_time_ms\">%1$d ms</string>\n    <!--    无障碍页面信息-->\n    <string name=\"accessibility_service_about\">喵密服务，用于智能加解密消息</string>\n    <string name=\"accessibility_service_description\">为保证功能正常使用，需要申请无障碍权限哦 (๑•̀ㅂ•́)و✧</string>\n    <!--    Home界面的一些文本-->\n    <string name=\"accessibility_service_enabled\">已获取无障碍权限</string>\n    <string name=\"accessibility_service_disabled\">点击开启无障碍权限</string>\n    <string name=\"setting_encrypt_on_send_title\">加密开关</string>\n    <string name=\"setting_encrypt_on_send_subtitle\">开启喵密世界的大门。</string>\n    <string name=\"setting_decrypt_immersive_mod_title\">解密开关</string>\n    <string name=\"setting_decrypt_immersive_mod_subtitle\">读懂猫咪语言的必选项！</string>\n    <string name=\"setting_encryption_mode_info_title\">设置加密模式</string>\n    <string name=\"setting_encryption_mode_info_text\">加密模式说明</string>\n    <string name=\"setting_encryption_mode_info_desc\">标准模式：\\n长按发送按钮才会发送密文。\\n\\n沉浸模式：\\n点按直接发送密文。</string>\n    <string name=\"setting_decryption_mode_info_title\">设置解密模式</string>\n    <string name=\"setting_decryption_mode_info_text\">解密模式说明</string>\n    <string name=\"setting_decryption_mode_info_desc\">标准模式：\\n点击密文弹出悬浮窗显示解密结果。\\n\\n沉浸模式：\\n无需点击，直接以悬浮窗显示解密后的密文，但会增加一定耗电量。</string>\n\n    <!--    通知栏显示文本-->\n    <string name=\"notification_title\">NekoCrypt 喵密服务运行中</string>\n    <string name=\"set_text_failed\">密文设置失败喵！可能是字数过多喵… (｡◕ˇ‸ˇ◕｡)：%d 字</string>\n\n    <!--    权限检查相关-->\n    <string name=\"permission_go_to_settings\">前往设置</string>\n    <string name=\"permission_overlay_title\">需要悬浮窗权限</string>\n    <string name=\"permission_overlay_text\">为了功能正常使用，请您手动授予权限。\\n\\n在设置页面，请寻找并开启“显示在其他应用上层”选项。\\n\\n在部分手机(如小米、华为)上，还可能额外有“悬浮窗权限”，请一同在应用的权限管理中寻找并开启它。</string>\n    <string name=\"permission_still_missing_title\">仍未检测到悬浮窗权限</string>\n    <string name=\"permission_still_missing_text\">喵密检测到悬浮窗权限仍未开启。别担心，这是因为各大手机厂商有更高的悬浮窗权限控制。\\n\\n请在接下来打开的应用设置页面中，手动找到并开启“悬浮窗”或“显示在其他应用上层”的相关权限。</string>\n    <!--    下面这两条暂时用不着-->\n    <string name=\"permission_accessibility_title\">需要无障碍服务</string>\n    <string name=\"permission_accessibility_text\">为了实现自动加解密和读取聊天内容，请在设置列表中找到“NekoCrypt”并开启它的无障碍服务。</string>\n    <!--    设置-->\n    <string name=\"crypto_settings\">加解密设置</string>\n    <string name=\"decryption_long_press_delay\">长按解密延时</string>\n    <string name=\"decryption_long_press_delay_desc\">设置触发长按加密所需的时间</string>\n    <string name=\"decryption_window_show_time\">解密密文展示时长</string>\n    <string name=\"decryption_window_show_time_desc\">设置解密密文悬浮窗的显示时间</string>\n    <string name=\"about\">关于</string>\n    <string name=\"about_dialog_title\">关于NekoCrypt</string>\n    <string name=\"version\">软件版本：%s</string>\n    <string name=\"github\">Github 项目地址</string>\n    <string name=\"about_dialog_content\">NekoCrypt (喵密) 是一款方便好用的全局APP加解密软件！(ฅ´ω`ฅ)\\n\\n双击输入框可以拉起附件发送界面哦！✨\\n\\n如果您喜欢本软件，请到Github项目主页为我点上一个Star吧！ପ(๑•̀ㅂ•́)و✧\\n\\n本项目仅供学习交流，任何法律问题均需用户自行负责哦~ ( •̀ ω •́ )✧ 使用NekoCrypt即表示您同意该条款。\\n\\n    ——Made by WJZ_P with love❤️.</string>\n    <!--    设置：界面相关-->\n    <string name=\"crypto_ui_settings\">功能界面设置</string>\n    <string name=\"decryption_window_position_update_delay\">弹窗位置更新延时</string>\n    <string name=\"decryption_window_position_update_delay_desc\">在沉浸式解密下，密文弹窗位置更新的时间间隔，越短越实时，但可能导致卡顿</string>\n    <string name=\"pick_color\">选择颜色</string>\n    <string name=\"send_btn_overlay_color\">按钮遮罩颜色</string>\n    <string name=\"send_btn_overlay_color_desc\">支持ARGB和RGB格式，用于设置盖在发送按钮上遮罩的颜色。最后一个是纯透明</string>\n    <string name=\"double_click_threshold\">拉起多媒体弹窗的双击间隔</string>\n    <string name=\"double_click_threshold_desc\">在间隔时间内双击输入框，会拉起发送多媒体消息的弹窗</string>\n    <!--    附件发送页面-->\n    <string name=\"crypto_attachment_title\">发送加密附件</string>\n    <string name=\"crypto_attachment_media\">相册</string>\n    <string name=\"crypto_attachment_file\">文件</string>\n    <string name=\"crypto_attachment_upload_failed\">文件上传失败：%s</string>\n    <string name=\"crypto_attachment_file_not_found\">文件不存在！</string>\n    <string name=\"crypto_attachment_file_not_accessible\">文件无法访问！</string>\n    <string name=\"crypto_attachment_file_too_large\">文件过大！当前最大支持：%d MB</string>\n    <string name=\"crypto_attachment_send_failed_node_not_found\">输入框或发送按钮节点未找到！</string>\n    <string name=\"crypto_attachment_chosen_path\">已选择: %s</string>\n    <string name=\"crypto_attachment_uploading\">\"正在加密上传...\"</string>\n\n    <!--    图片文件下载、展示页面-->\n    <string name=\"dialog_download_file_download_failed\">文件下载失败：%s</string>\n    <string name=\"dialog_download_file_file_info\">文件详情</string>\n\n    <!--    密钥管理相关-->\n    <string name=\"key_management_dialog_key_management\">密钥管理</string>\n    <string name=\"key_management_dialog_key_add\">添加</string>\n    <string name=\"key_screen_supported_app\">默认支持应用</string>\n    <string name=\"key_screen_supported_app_description\">可能失效，推荐手动扫描添加</string>\n    <string name=\"key_screen_custom_app\">自定义应用</string>\n    <string name=\"key_screen_no_custom_app_configured\">暂未添加自定义APP</string>\n    <string name=\"key_screen_supported_app_input_id\">输入框 ID：</string>\n    <string name=\"key_screen_supported_app_send_btn_id\">发送按钮 ID：</string>\n    <string name=\"key_screen_supported_app_message_text_id\">消息气泡 ID：</string>\n    <string name=\"key_screen_supported_app_message_list_class_name\">消息列表类名：</string>\n    <string name=\"add_custom_app\">添加自定义APP</string>\n    <string name=\"enable_scanner_mode\">扫描模式</string>\n    <string name=\"enable_scanner_mode_description\">屏幕上会出现用于扫描当前界面的按钮</string>\n    <string name=\"key_screen_delete_config_toast\">APP配置已删除</string>\n    <string name=\"key_screen_new_key_placeholder\">回车以保存新密钥...</string>\n\n    <!--    扫描界面相关-->\n    <string name=\"scanner_dialog_title\">扫描结果</string>\n    <string name=\"scanner_get_result_fail\">获取扫描结果失败！</string>\n    <string name=\"scanner_dialog_section_input\">输入框</string>\n    <string name=\"scanner_dialog_section_send_btn\">发送按钮</string>\n    <string name=\"scanner_dialog_section_msg_list\">消息列表</string>\n    <string name=\"scanner_dialog_section_msg_text\">文本节点</string>\n    <string name=\"scanner_dialog_card_id\">ID</string>\n    <string name=\"scanner_dialog_card_class\">类名</string>\n    <string name=\"scanner_dialog_card_text\">文本</string>\n    <string name=\"scanner_dialog_card_desc\">描述</string>\n    <string name=\"scanner_scanning\">描述</string>\n    <string name=\"scanner_help_dialog_title\">扫描功能介绍</string>\n    <string name=\"scanner_help_dialog_intro\">必须选择正确的输入框、发送按钮、消息列表、消息节点。</string>\n    <string name=\"scanner_help_dialog_instruction\">点击即可选中，只有四种全部选择完才可以点击确认，之后就自动保存当前软件的节点信息用于加解密。可通过节点的具体内容确认是否是所需节点。特别注意的是消息节点，必须正确选择包含正确消息内容的节点！</string>\n    <string name=\"scanner_config_saved_toast\">扫描配置已保存</string>\n\n    <!--    密文风格相关-->\n    <string name=\"cipher_style_title\">密文语种选择</string>\n    <string name=\"cipher_style_description\">加密后的文本可以选择不同风格！</string>\n    <string name=\"cipher_style_neko\">猫娘语</string>\n    <string name=\"cipher_style_nier\">尼尔语</string>\n    <string name=\"cipher_style_manbo\">曼波语</string>\n    <string name=\"cipher_style_bangboo\">邦布语</string>\n    <string name=\"cipher_style_Hilichurlian\">丘丘语</string>\n    <string name=\"cipher_style_braille\">盲文点阵</string>\n    <string name=\"cipher_style_magicspell\">魔法咒语</string>\n\n    <string name=\"crypto_style_selector_label\">密文语种选择</string>\n    <string name=\"ciphertext_length_title\">密文长度选择</string>\n    <string name=\"ciphertext_length_subtitle\">密文词组数为闭区间内的随机值</string>\n\n</resources>\n"
  },
  {
    "path": "app/src/main/res/values/themes.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n\n    <style name=\"Theme.NekoCrypt\" parent=\"android:Theme.Material.Light.NoActionBar\" />\n</resources>"
  },
  {
    "path": "app/src/main/res/xml/accessibility_service_config.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<accessibility-service xmlns:android=\"http://schemas.android.com/apk/res/android\"\n    android:accessibilityEventTypes=\"typeAllMask\"\n    android:accessibilityFeedbackType=\"feedbackAllMask\"\n    android:accessibilityFlags=\"flagDefault|flagIncludeNotImportantViews|flagReportViewIds|flagRetrieveInteractiveWindows|flagRequestEnhancedWebAccessibility\"\n    android:canPerformGestures=\"true\"\n    android:canRequestEnhancedWebAccessibility=\"true\"\n    android:canRequestFilterKeyEvents=\"true\"\n    android:canRequestTouchExplorationMode=\"true\"\n    android:canRetrieveWindowContent=\"true\"\n    android:isAccessibilityTool=\"true\"\n    android:settingsActivity=\"me.wjz.nekocrypt.MainActivity\"\n    android:description=\"@string/accessibility_service_description\"\n    android:notificationTimeout=\"100\"\n    android:summary=\"@string/accessibility_service_about\"/>\n"
  },
  {
    "path": "app/src/main/res/xml/backup_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample backup rules file; uncomment and customize as necessary.\n   See https://developer.android.com/guide/topics/data/autobackup\n   for details.\n   Note: This file is ignored for devices older than API 31\n   See https://developer.android.com/about/versions/12/backup-restore\n-->\n<full-backup-content>\n    <!--\n   <include domain=\"sharedpref\" path=\".\"/>\n   <exclude domain=\"sharedpref\" path=\"device.xml\"/>\n-->\n</full-backup-content>"
  },
  {
    "path": "app/src/main/res/xml/data_extraction_rules.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?><!--\n   Sample data extraction rules file; uncomment and customize as necessary.\n   See https://developer.android.com/about/versions/12/backup-restore#xml-changes\n   for details.\n-->\n<data-extraction-rules>\n    <cloud-backup>\n        <!-- TODO: Use <include> and <exclude> to control what is backed up.\n        <include .../>\n        <exclude .../>\n        -->\n    </cloud-backup>\n    <!--\n    <device-transfer>\n        <include .../>\n        <exclude .../>\n    </device-transfer>\n    -->\n</data-extraction-rules>"
  },
  {
    "path": "app/src/main/res/xml/provider_paths.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<paths>\n    <!-- 这个标签授权分享内部缓存 (context.cacheDir) -->\n    <cache-path\n        name=\"internal_cache\"\n        path=\".\" />\n\n    <!-- ✨ 这是关键！这个标签授权分享外部缓存 (context.externalCacheDir) ✨ -->\n    <external-cache-path\n        name=\"external_cache\"\n        path=\".\" />\n</paths>"
  },
  {
    "path": "build.gradle.kts",
    "content": "// Top-level build file where you can add configuration options common to all sub-projects/modules.\nplugins {\n    alias(libs.plugins.android.application) apply false\n    alias(libs.plugins.kotlin.android) apply false\n    alias(libs.plugins.kotlin.compose) apply false\n}"
  },
  {
    "path": "gradle/libs.versions.toml",
    "content": "[versions]\nagp = \"8.11.0\"\nkotlin = \"2.2.0\"\ncoreKtx = \"1.10.1\"\njunit = \"4.13.2\"\njunitVersion = \"1.1.5\"\nespressoCore = \"3.5.1\"\nlifecycleRuntimeKtx = \"2.6.1\"\nactivityCompose = \"1.8.0\"\ncomposeBom = \"2024.09.00\"\ncompiler = \"3.2.0-alpha11\"\n\n[libraries]\nandroidx-core-ktx = { group = \"androidx.core\", name = \"core-ktx\", version.ref = \"coreKtx\" }\njunit = { group = \"junit\", name = \"junit\", version.ref = \"junit\" }\nandroidx-junit = { group = \"androidx.test.ext\", name = \"junit\", version.ref = \"junitVersion\" }\nandroidx-espresso-core = { group = \"androidx.test.espresso\", name = \"espresso-core\", version.ref = \"espressoCore\" }\nandroidx-lifecycle-runtime-ktx = { group = \"androidx.lifecycle\", name = \"lifecycle-runtime-ktx\", version.ref = \"lifecycleRuntimeKtx\" }\nandroidx-activity-compose = { group = \"androidx.activity\", name = \"activity-compose\", version.ref = \"activityCompose\" }\nandroidx-compose-bom = { group = \"androidx.compose\", name = \"compose-bom\", version.ref = \"composeBom\" }\nandroidx-ui = { group = \"androidx.compose.ui\", name = \"ui\" }\nandroidx-ui-graphics = { group = \"androidx.compose.ui\", name = \"ui-graphics\" }\nandroidx-ui-tooling = { group = \"androidx.compose.ui\", name = \"ui-tooling\" }\nandroidx-ui-tooling-preview = { group = \"androidx.compose.ui\", name = \"ui-tooling-preview\" }\nandroidx-ui-test-manifest = { group = \"androidx.compose.ui\", name = \"ui-test-manifest\" }\nandroidx-ui-test-junit4 = { group = \"androidx.compose.ui\", name = \"ui-test-junit4\" }\nandroidx-material3 = { group = \"androidx.compose.material3\", name = \"material3\" }\nandroidx-compiler = { group = \"androidx.databinding\", name = \"compiler\", version.ref = \"compiler\" }\n\n[plugins]\nandroid-application = { id = \"com.android.application\", version.ref = \"agp\" }\nkotlin-android = { id = \"org.jetbrains.kotlin.android\", version.ref = \"kotlin\" }\nkotlin-compose = { id = \"org.jetbrains.kotlin.plugin.compose\", version.ref = \"kotlin\" }\n\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "#Fri Jun 27 23:34:49 CST 2025\ndistributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.13-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "# Project-wide Gradle settings.\n# IDE (e.g. Android Studio) users:\n# Gradle settings configured through the IDE *will override*\n# any settings specified in this file.\n# For more details on how to configure your build environment visit\n# http://www.gradle.org/docs/current/userguide/build_environment.html\n# Specifies the JVM arguments used for the daemon process.\n# The setting is particularly useful for tweaking memory settings.\norg.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8\n# When configured, Gradle will run in incubating parallel mode.\n# This option should only be used with decoupled projects. For more details, visit\n# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects\n# org.gradle.parallel=true\n# AndroidX package structure to make it clearer which packages are bundled with the\n# Android operating system, and which are packaged with your app's APK\n# https://developer.android.com/topic/libraries/support-library/androidx-rn\nandroid.useAndroidX=true\n# Kotlin code style for this project: \"official\" or \"obsolete\":\nkotlin.code.style=official\n# Enables namespacing of each library's R class so that its R class includes only the\n# resources declared in the library itself and none from the library's dependencies,\n# thereby reducing the size of the R class for that library\nandroid.nonTransitiveRClass=true"
  },
  {
    "path": "gradlew",
    "content": "#!/usr/bin/env sh\n\n#\n# Copyright 2015 the original author or authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##############################################################################\n##\n##  Gradle start up script for UN*X\n##\n##############################################################################\n\n# Attempt to set APP_HOME\n# Resolve links: $0 may be a link\nPRG=\"$0\"\n# Need this for relative symlinks.\nwhile [ -h \"$PRG\" ] ; do\n    ls=`ls -ld \"$PRG\"`\n    link=`expr \"$ls\" : '.*-> \\(.*\\)$'`\n    if expr \"$link\" : '/.*' > /dev/null; then\n        PRG=\"$link\"\n    else\n        PRG=`dirname \"$PRG\"`\"/$link\"\n    fi\ndone\nSAVED=\"`pwd`\"\ncd \"`dirname \\\"$PRG\\\"`/\" >/dev/null\nAPP_HOME=\"`pwd -P`\"\ncd \"$SAVED\" >/dev/null\n\nAPP_NAME=\"Gradle\"\nAPP_BASE_NAME=`basename \"$0\"`\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=\"maximum\"\n\nwarn () {\n    echo \"$*\"\n}\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n}\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"`uname`\" in\n  CYGWIN* )\n    cygwin=true\n    ;;\n  Darwin* )\n    darwin=true\n    ;;\n  MINGW* )\n    msys=true\n    ;;\n  NONSTOP* )\n    nonstop=true\n    ;;\nesac\n\nCLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=\"$JAVA_HOME/jre/sh/java\"\n    else\n        JAVACMD=\"$JAVA_HOME/bin/java\"\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=\"java\"\n    which java >/dev/null 2>&1 || die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\nfi\n\n# Increase the maximum file descriptors if we can.\nif [ \"$cygwin\" = \"false\" -a \"$darwin\" = \"false\" -a \"$nonstop\" = \"false\" ] ; then\n    MAX_FD_LIMIT=`ulimit -H -n`\n    if [ $? -eq 0 ] ; then\n        if [ \"$MAX_FD\" = \"maximum\" -o \"$MAX_FD\" = \"max\" ] ; then\n            MAX_FD=\"$MAX_FD_LIMIT\"\n        fi\n        ulimit -n $MAX_FD\n        if [ $? -ne 0 ] ; then\n            warn \"Could not set maximum file descriptor limit: $MAX_FD\"\n        fi\n    else\n        warn \"Could not query maximum file descriptor limit: $MAX_FD_LIMIT\"\n    fi\nfi\n\n# For Darwin, add options to specify how the application appears in the dock\nif $darwin; then\n    GRADLE_OPTS=\"$GRADLE_OPTS \\\"-Xdock:name=$APP_NAME\\\" \\\"-Xdock:icon=$APP_HOME/media/gradle.icns\\\"\"\nfi\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif [ \"$cygwin\" = \"true\" -o \"$msys\" = \"true\" ] ; then\n    APP_HOME=`cygpath --path --mixed \"$APP_HOME\"`\n    CLASSPATH=`cygpath --path --mixed \"$CLASSPATH\"`\n\n    JAVACMD=`cygpath --unix \"$JAVACMD\"`\n\n    # We build the pattern for arguments to be converted via cygpath\n    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`\n    SEP=\"\"\n    for dir in $ROOTDIRSRAW ; do\n        ROOTDIRS=\"$ROOTDIRS$SEP$dir\"\n        SEP=\"|\"\n    done\n    OURCYGPATTERN=\"(^($ROOTDIRS))\"\n    # Add a user-defined pattern to the cygpath arguments\n    if [ \"$GRADLE_CYGPATTERN\" != \"\" ] ; then\n        OURCYGPATTERN=\"$OURCYGPATTERN|($GRADLE_CYGPATTERN)\"\n    fi\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    i=0\n    for arg in \"$@\" ; do\n        CHECK=`echo \"$arg\"|egrep -c \"$OURCYGPATTERN\" -`\n        CHECK2=`echo \"$arg\"|egrep -c \"^-\"`                                 ### Determine if an option\n\n        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition\n            eval `echo args$i`=`cygpath --path --ignore --mixed \"$arg\"`\n        else\n            eval `echo args$i`=\"\\\"$arg\\\"\"\n        fi\n        i=`expr $i + 1`\n    done\n    case $i in\n        0) set -- ;;\n        1) set -- \"$args0\" ;;\n        2) set -- \"$args0\" \"$args1\" ;;\n        3) set -- \"$args0\" \"$args1\" \"$args2\" ;;\n        4) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" ;;\n        5) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" ;;\n        6) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" ;;\n        7) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" ;;\n        8) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" ;;\n        9) set -- \"$args0\" \"$args1\" \"$args2\" \"$args3\" \"$args4\" \"$args5\" \"$args6\" \"$args7\" \"$args8\" ;;\n    esac\nfi\n\n# Escape application args\nsave () {\n    for i do printf %s\\\\n \"$i\" | sed \"s/'/'\\\\\\\\''/g;1s/^/'/;\\$s/\\$/' \\\\\\\\/\" ; done\n    echo \" \"\n}\nAPP_ARGS=`save \"$@\"`\n\n# Collect all arguments for the java command, following the shell quoting and substitution rules\neval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \"\\\"-Dorg.gradle.appname=$APP_BASE_NAME\\\"\" -classpath \"\\\"$CLASSPATH\\\"\" org.gradle.wrapper.GradleWrapperMain \"$APP_ARGS\"\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\n@rem Copyright 2015 the original author or authors.\n@rem\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\n@rem you may not use this file except in compliance with the License.\n@rem You may obtain a copy of the License at\n@rem\n@rem      https://www.apache.org/licenses/LICENSE-2.0\n@rem\n@rem Unless required by applicable law or agreed to in writing, software\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n@rem See the License for the specific language governing permissions and\n@rem limitations under the License.\n@rem\n\n@if \"%DEBUG%\" == \"\" @echo off\n@rem ##########################################################################\n@rem\n@rem  Gradle startup script for Windows\n@rem\n@rem ##########################################################################\n\n@rem Set local scope for the variables with windows NT shell\nif \"%OS%\"==\"Windows_NT\" setlocal\n\nset DIRNAME=%~dp0\nif \"%DIRNAME%\" == \"\" set DIRNAME=.\nset APP_BASE_NAME=%~n0\nset APP_HOME=%DIRNAME%\n\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\n\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\n\n@rem Find java.exe\nif defined JAVA_HOME goto findJavaFromJavaHome\n\nset JAVA_EXE=java.exe\n%JAVA_EXE% -version >NUL 2>&1\nif \"%ERRORLEVEL%\" == \"0\" goto execute\n\necho.\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\necho.\necho Please set the JAVA_HOME variable in your environment to match the\necho location of your Java installation.\n\ngoto fail\n\n:findJavaFromJavaHome\nset JAVA_HOME=%JAVA_HOME:\"=%\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\n\nif exist \"%JAVA_EXE%\" goto execute\n\necho.\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%\necho.\necho Please set the JAVA_HOME variable in your environment to match the\necho location of your Java installation.\n\ngoto fail\n\n:execute\n@rem Setup the command line\n\nset CLASSPATH=%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\n\n\n@rem Execute Gradle\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -classpath \"%CLASSPATH%\" org.gradle.wrapper.GradleWrapperMain %*\n\n:end\n@rem End local scope for the variables with windows NT shell\nif \"%ERRORLEVEL%\"==\"0\" goto mainEnd\n\n:fail\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\nrem the _cmd.exe /c_ return code!\nif  not \"\" == \"%GRADLE_EXIT_CONSOLE%\" exit 1\nexit /b 1\n\n:mainEnd\nif \"%OS%\"==\"Windows_NT\" endlocal\n\n:omega\n"
  },
  {
    "path": "settings.gradle.kts",
    "content": "pluginManagement {\n    repositories {\n        google {\n            content {\n                includeGroupByRegex(\"com\\\\.android.*\")\n                includeGroupByRegex(\"com\\\\.google.*\")\n                includeGroupByRegex(\"androidx.*\")\n            }\n        }\n        mavenCentral()\n        gradlePluginPortal()\n    }\n}\ndependencyResolutionManagement {\n    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n    repositories {\n        google()\n        mavenCentral()\n    }\n}\n\nrootProject.name = \"NekoCrypt\"\ninclude(\":app\")\n"
  }
]