diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9efa6fbc --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@1024x1024.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@1024x1024.png new file mode 100644 index 00000000..23cc6761 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@1024x1024.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@120x120.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@120x120.png new file mode 100644 index 00000000..bc3738e3 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@120x120.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@152x152.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@152x152.png new file mode 100644 index 00000000..b7d12ca8 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@152x152.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@167x167.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@167x167.png new file mode 100644 index 00000000..a514afb2 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@167x167.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@180x180.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@180x180.png new file mode 100644 index 00000000..336e7bd2 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@180x180.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@20x20.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@20x20.png new file mode 100644 index 00000000..3ad57dfa Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@20x20.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@29x29.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@29x29.png new file mode 100644 index 00000000..840c5956 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@29x29.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@40x40.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@40x40.png new file mode 100644 index 00000000..7425a650 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@40x40.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@58x58.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@58x58.png new file mode 100644 index 00000000..b54ab402 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@58x58.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@60x60.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@60x60.png new file mode 100644 index 00000000..bf575501 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@60x60.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@76x76.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@76x76.png new file mode 100644 index 00000000..88823054 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@76x76.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@80x80.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@80x80.png new file mode 100644 index 00000000..4a76bc85 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@80x80.png differ diff --git a/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@87x87.png b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@87x87.png new file mode 100644 index 00000000..0b4de296 Binary files /dev/null and b/Telegram/Telegram-iOS/DefaultAppIcon.xcassets/AppIconLLC.appiconset/GhostIcon@87x87.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIcon@2x.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIcon@2x.png new file mode 100644 index 00000000..bc3738e3 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIcon@3x.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIcon@3x.png new file mode 100644 index 00000000..336e7bd2 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIcon@3x.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconIpad.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconIpad.png new file mode 100644 index 00000000..88823054 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconIpad.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconIpad@2x.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconIpad@2x.png new file mode 100644 index 00000000..b7d12ca8 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconLargeIpad@2x.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconLargeIpad@2x.png new file mode 100644 index 00000000..a514afb2 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramIconLargeIpad@2x.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon.png new file mode 100644 index 00000000..3ad57dfa Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon@2x.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon@2x.png new file mode 100644 index 00000000..7425a650 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon@2x.png differ diff --git a/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon@3x.png b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon@3x.png new file mode 100644 index 00000000..bf575501 Binary files /dev/null and b/Telegram/Telegram-iOS/GhostgramIcon.alticon/GhostgramNotificationIcon@3x.png differ diff --git a/fix-swiftmodule-dirs.sh b/fix-swiftmodule-dirs.sh new file mode 100755 index 00000000..655e2639 --- /dev/null +++ b/fix-swiftmodule-dirs.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Universal fix for ALL framework module copy phase errors +# Creates Modules/ModuleName.swiftmodule/Project directories for all frameworks + +DERIVED_DATA_BASE="$HOME/Library/Developer/Xcode/DerivedData" +TELEGRAM_DD=$(find "$DERIVED_DATA_BASE" -maxdepth 1 -type d -name "Telegram-*" 2>/dev/null | head -1) + +if [ -z "$TELEGRAM_DD" ]; then + echo "No Telegram DerivedData found" + exit 0 +fi + +echo "Found: $TELEGRAM_DD" + +# Fix permissions first +chmod -R u+w "$TELEGRAM_DD/Build/Products/" 2>/dev/null + +# Find ALL framework directories and create module structures +PRODUCTS_DIR="$TELEGRAM_DD/Build/Products" + +find "$PRODUCTS_DIR" -type d -name "*.framework" 2>/dev/null | while read FW_PATH; do + FW_NAME=$(basename "$FW_PATH" .framework) + + # Extract module name (remove "Framework" suffix if present) + if [[ "$FW_NAME" == *Framework ]]; then + MODULE_NAME="${FW_NAME%Framework}" + else + MODULE_NAME="$FW_NAME" + fi + + # Create the module directories + MODULE_DIR="$FW_PATH/Modules/${MODULE_NAME}.swiftmodule" + PROJECT_DIR="$MODULE_DIR/Project" + + if [ ! -d "$MODULE_DIR" ]; then + mkdir -p "$MODULE_DIR" 2>/dev/null && echo "Created: $MODULE_DIR" + fi + + if [ ! -d "$PROJECT_DIR" ]; then + mkdir -p "$PROJECT_DIR" 2>/dev/null && echo "Created: $PROJECT_DIR" + fi +done + +echo "Done! All framework module directories are ready." diff --git a/submodules/OpusBinding/PublicHeaders/OpusBinding/VoiceMorpherProcessor.h b/submodules/OpusBinding/PublicHeaders/OpusBinding/VoiceMorpherProcessor.h new file mode 100644 index 00000000..214ded36 --- /dev/null +++ b/submodules/OpusBinding/PublicHeaders/OpusBinding/VoiceMorpherProcessor.h @@ -0,0 +1,36 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// VoiceMorpherProcessor - Processes OGG/Opus audio with voice effects +/// Decodes OGG -> applies effects -> re-encodes to OGG +@interface VoiceMorpherProcessor : NSObject + +typedef NS_ENUM(NSInteger, VoiceMorpherPreset) { + VoiceMorpherPresetDisabled = 0, + VoiceMorpherPresetAnonymous = 1, + VoiceMorpherPresetFemale = 2, + VoiceMorpherPresetMale = 3, + VoiceMorpherPresetChild = 4, + VoiceMorpherPresetRobot = 5 +}; + +/// Process OGG audio data with voice morphing effect +/// @param inputData Original OGG/Opus audio data +/// @param preset Voice morphing preset to apply +/// @param completion Callback with processed OGG data or error ++ (void)processOggData:(NSData *)inputData + preset:(VoiceMorpherPreset)preset + completion:(void (^)(NSData *_Nullable outputData, + NSError *_Nullable error))completion; + +/// Get pitch shift value for preset ++ (float)pitchShiftForPreset:(VoiceMorpherPreset)preset; + +/// Get rate multiplier for preset ++ (float)rateForPreset:(VoiceMorpherPreset)preset; + +@end + +NS_ASSUME_NONNULL_END diff --git a/submodules/OpusBinding/Sources/VoiceMorpherProcessor.m b/submodules/OpusBinding/Sources/VoiceMorpherProcessor.m new file mode 100644 index 00000000..69f980b7 --- /dev/null +++ b/submodules/OpusBinding/Sources/VoiceMorpherProcessor.m @@ -0,0 +1,289 @@ +#import "VoiceMorpherProcessor.h" +#import "OggOpusReader.h" +#import "TGDataItem.h" +#import "TGOggOpusWriter.h" + +@implementation VoiceMorpherProcessor + ++ (float)pitchShiftForPreset:(VoiceMorpherPreset)preset { + switch (preset) { + case VoiceMorpherPresetDisabled: + return 0; + case VoiceMorpherPresetAnonymous: + return -200; + case VoiceMorpherPresetFemale: + return 600; // More feminine - higher pitch + case VoiceMorpherPresetMale: + return -300; + case VoiceMorpherPresetChild: + return 600; + case VoiceMorpherPresetRobot: + return 0; + } +} + ++ (float)rateForPreset:(VoiceMorpherPreset)preset { + switch (preset) { + case VoiceMorpherPresetDisabled: + return 1.0; + case VoiceMorpherPresetAnonymous: + return 0.95; + case VoiceMorpherPresetFemale: + return 1.08; // Slightly faster for feminine effect + case VoiceMorpherPresetMale: + return 0.95; + case VoiceMorpherPresetChild: + return 1.1; + case VoiceMorpherPresetRobot: + return 1.0; + } +} + ++ (void)processOggData:(NSData *)inputData + preset:(VoiceMorpherPreset)preset + completion: + (void (^)(NSData *_Nullable, NSError *_Nullable))completion { + + if (preset == VoiceMorpherPresetDisabled) { + completion(inputData, nil); + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), + ^{ + NSError *error = nil; + NSData *result = [self processOggDataSync:inputData + preset:preset + error:&error]; + + // Call completion on background thread to avoid deadlock + // when caller uses semaphore on main thread + completion(result, error); + }); +} + ++ (NSData *_Nullable)processOggDataSync:(NSData *)inputData + preset:(VoiceMorpherPreset)preset + error:(NSError **)error { + // Save input OGG to temp file for decoding + NSString *tempInputPath = [NSTemporaryDirectory() + stringByAppendingPathComponent: + [NSString + stringWithFormat:@"vm_in_%lld.ogg", (long long)[[NSDate date] + timeIntervalSince1970] * + 1000]]; + + [inputData writeToFile:tempInputPath atomically:YES]; + + // Decode OGG to PCM + OggOpusReader *reader = [[OggOpusReader alloc] initWithPath:tempInputPath]; + if (!reader) { + if (error) { + *error = [NSError + errorWithDomain:@"VoiceMorpher" + code:1 + userInfo:@{ + NSLocalizedDescriptionKey : @"Failed to open OGG file" + }]; + } + [[NSFileManager defaultManager] removeItemAtPath:tempInputPath error:nil]; + return nil; + } + + // Opus outputs 16-bit stereo at 48kHz + NSMutableData *pcmData = [[NSMutableData alloc] init]; + int16_t buffer[5760 * 2]; // Max frame size * channels + int32_t samplesRead; + + while ((samplesRead = [reader read:buffer + bufSize:sizeof(buffer) / sizeof(buffer[0])]) > 0) { + [pcmData appendBytes:buffer length:samplesRead * sizeof(int16_t)]; + } + + [[NSFileManager defaultManager] removeItemAtPath:tempInputPath error:nil]; + + if (pcmData.length == 0) { + if (error) { + *error = + [NSError errorWithDomain:@"VoiceMorpher" + code:2 + userInfo:@{ + NSLocalizedDescriptionKey : @"No PCM data decoded" + }]; + } + return nil; + } + + // Apply voice effects using AVAudioEngine + NSData *processedPcm = [self applyEffectsToPcmData:pcmData + preset:preset + error:error]; + if (!processedPcm) { + return nil; + } + + // Encode processed PCM back to OGG + TGDataItem *dataItem = [[TGDataItem alloc] init]; + TGOggOpusWriter *writer = [[TGOggOpusWriter alloc] init]; + + if (![writer beginWithDataItem:dataItem]) { + if (error) { + *error = [NSError + errorWithDomain:@"VoiceMorpher" + code:4 + userInfo:@{ + NSLocalizedDescriptionKey : @"Failed to begin OGG encoding" + }]; + } + return nil; + } + + // Write PCM data in frames (960 samples = 20ms at 48kHz) + const int frameSize = 960 * sizeof(int16_t); + const uint8_t *bytes = processedPcm.bytes; + NSUInteger remaining = processedPcm.length; + NSUInteger offset = 0; + + while (remaining >= frameSize) { + [writer writeFrame:(uint8_t *)(bytes + offset) frameByteCount:frameSize]; + offset += frameSize; + remaining -= frameSize; + } + + if (remaining > 0) { + uint8_t lastFrame[frameSize]; + memset(lastFrame, 0, frameSize); + memcpy(lastFrame, bytes + offset, remaining); + [writer writeFrame:lastFrame frameByteCount:frameSize]; + } + + return [dataItem data]; +} + ++ (NSData *_Nullable)applyEffectsToPcmData:(NSData *)pcmData + preset:(VoiceMorpherPreset)preset + error:(NSError **)error { + NSUInteger sampleCount = pcmData.length / sizeof(int16_t); + const int16_t *int16Samples = (const int16_t *)pcmData.bytes; + + float *floatSamples = (float *)malloc(sampleCount * sizeof(float)); + if (!floatSamples) { + if (error) { + *error = [NSError + errorWithDomain:@"VoiceMorpher" + code:5 + userInfo:@{ + NSLocalizedDescriptionKey : @"Memory allocation failed" + }]; + } + return nil; + } + + // Convert int16 to float (-1.0 to 1.0 range) + for (NSUInteger i = 0; i < sampleCount; i++) { + floatSamples[i] = (float)int16Samples[i] / 32768.0f; + } + + // Create audio format (mono, 48kHz, float) + AVAudioFormat *format = + [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatFloat32 + sampleRate:48000 + channels:1 + interleaved:NO]; + + AVAudioFrameCount frameCount = (AVAudioFrameCount)sampleCount; + AVAudioPCMBuffer *inputBuffer = + [[AVAudioPCMBuffer alloc] initWithPCMFormat:format + frameCapacity:frameCount]; + inputBuffer.frameLength = frameCount; + + memcpy(inputBuffer.floatChannelData[0], floatSamples, + sampleCount * sizeof(float)); + free(floatSamples); + + // Create engine and nodes + AVAudioEngine *engine = [[AVAudioEngine alloc] init]; + AVAudioPlayerNode *playerNode = [[AVAudioPlayerNode alloc] init]; + AVAudioUnitTimePitch *pitchNode = [[AVAudioUnitTimePitch alloc] init]; + + pitchNode.pitch = [self pitchShiftForPreset:preset]; + pitchNode.rate = [self rateForPreset:preset]; + + [engine attachNode:playerNode]; + [engine attachNode:pitchNode]; + [engine connect:playerNode to:pitchNode format:format]; + + AVAudioNode *lastNode = pitchNode; + + if (preset == VoiceMorpherPresetRobot) { + AVAudioUnitDistortion *distortion = [[AVAudioUnitDistortion alloc] init]; + [distortion loadFactoryPreset:AVAudioUnitDistortionPresetSpeechRadioTower]; + distortion.wetDryMix = 40; + [engine attachNode:distortion]; + [engine connect:pitchNode to:distortion format:format]; + lastNode = distortion; + } else if (preset == VoiceMorpherPresetAnonymous) { + AVAudioUnitDistortion *distortion = [[AVAudioUnitDistortion alloc] init]; + [distortion + loadFactoryPreset:AVAudioUnitDistortionPresetSpeechCosmicInterference]; + distortion.wetDryMix = 30; + [engine attachNode:distortion]; + [engine connect:pitchNode to:distortion format:format]; + lastNode = distortion; + } + + [engine connect:lastNode to:engine.mainMixerNode format:format]; + + __block NSMutableData *outputData = [[NSMutableData alloc] init]; + + [engine.mainMixerNode + installTapOnBus:0 + bufferSize:4096 + format:format + block:^(AVAudioPCMBuffer *buffer, AVAudioTime *when) { + float *samples = buffer.floatChannelData[0]; + AVAudioFrameCount count = buffer.frameLength; + + int16_t *int16Buffer = + (int16_t *)malloc(count * sizeof(int16_t)); + for (AVAudioFrameCount i = 0; i < count; i++) { + float sample = samples[i]; + if (sample > 1.0f) + sample = 1.0f; + if (sample < -1.0f) + sample = -1.0f; + int16Buffer[i] = (int16_t)(sample * 32767.0f); + } + + [outputData appendBytes:int16Buffer + length:count * sizeof(int16_t)]; + free(int16Buffer); + }]; + + NSError *startError = nil; + [engine startAndReturnError:&startError]; + if (startError) { + if (error) { + *error = startError; + } + return nil; + } + + [playerNode scheduleBuffer:inputBuffer + atTime:nil + options:0 + completionHandler:nil]; + [playerNode play]; + + float rate = [self rateForPreset:preset]; + NSTimeInterval duration = (double)sampleCount / 48000.0 / rate + 0.5; + [NSThread sleepForTimeInterval:duration]; + + [playerNode stop]; + [engine.mainMixerNode removeTapOnBus:0]; + [engine stop]; + + return outputData; +} + +@end diff --git a/submodules/SettingsUI/Sources/DeletedMessagesController.swift b/submodules/SettingsUI/Sources/DeletedMessagesController.swift new file mode 100644 index 00000000..92154df0 --- /dev/null +++ b/submodules/SettingsUI/Sources/DeletedMessagesController.swift @@ -0,0 +1,197 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import Postbox +import TelegramPresentationData +import ItemListUI +import AccountContext + +// MARK: - Entry Definition + +private enum DeletedMessagesSection: Int32 { + case settings +} + +private enum DeletedMessagesEntry: ItemListNodeEntry { + case enableToggle(PresentationTheme, String, Bool) + case archiveMediaToggle(PresentationTheme, String, Bool) + case settingsInfo(PresentationTheme, String) + + var section: ItemListSectionId { + return DeletedMessagesSection.settings.rawValue + } + + var stableId: Int32 { + switch self { + case .enableToggle: + return 0 + case .archiveMediaToggle: + return 1 + case .settingsInfo: + return 2 + } + } + + static func ==(lhs: DeletedMessagesEntry, rhs: DeletedMessagesEntry) -> Bool { + switch lhs { + case let .enableToggle(lhsTheme, lhsText, lhsValue): + if case let .enableToggle(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .archiveMediaToggle(lhsTheme, lhsText, lhsValue): + if case let .archiveMediaToggle(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .settingsInfo(lhsTheme, lhsText): + if case let .settingsInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + } + } + + static func <(lhs: DeletedMessagesEntry, rhs: DeletedMessagesEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! DeletedMessagesControllerArguments + switch self { + case let .enableToggle(_, text, value): + return ItemListSwitchItem( + presentationData: presentationData, + title: text, + value: value, + sectionId: self.section, + style: .blocks, + updated: { value in + arguments.toggleEnabled(value) + } + ) + case let .archiveMediaToggle(_, text, value): + return ItemListSwitchItem( + presentationData: presentationData, + title: text, + value: value, + sectionId: self.section, + style: .blocks, + updated: { value in + arguments.toggleArchiveMedia(value) + } + ) + case let .settingsInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + } + } +} + +// MARK: - Arguments + +private final class DeletedMessagesControllerArguments { + let toggleEnabled: (Bool) -> Void + let toggleArchiveMedia: (Bool) -> Void + + init( + toggleEnabled: @escaping (Bool) -> Void, + toggleArchiveMedia: @escaping (Bool) -> Void + ) { + self.toggleEnabled = toggleEnabled + self.toggleArchiveMedia = toggleArchiveMedia + } +} + +// MARK: - State + +private struct DeletedMessagesControllerState: Equatable { + var isEnabled: Bool + var archiveMedia: Bool + + static func ==(lhs: DeletedMessagesControllerState, rhs: DeletedMessagesControllerState) -> Bool { + return lhs.isEnabled == rhs.isEnabled && + lhs.archiveMedia == rhs.archiveMedia + } +} + +// MARK: - Entries builder + +private func deletedMessagesControllerEntries( + presentationData: PresentationData, + state: DeletedMessagesControllerState +) -> [DeletedMessagesEntry] { + var entries: [DeletedMessagesEntry] = [] + + entries.append(.enableToggle(presentationData.theme, "Сохранять удалённые сообщения", state.isEnabled)) + entries.append(.archiveMediaToggle(presentationData.theme, "Архивировать медиа", state.archiveMedia)) + entries.append(.settingsInfo(presentationData.theme, "Когда включено, сообщения, удалённые другими пользователями, будут сохраняться локально. Рядом со временем сообщения появится иконка корзины.")) + + return entries +} + +// MARK: - Controller + +public func deletedMessagesController(context: AccountContext) -> ViewController { + let initialState = DeletedMessagesControllerState( + isEnabled: AntiDeleteManager.shared.isEnabled, + archiveMedia: AntiDeleteManager.shared.archiveMedia + ) + + let statePromise = ValuePromise(initialState, ignoreRepeated: true) + let stateValue = Atomic(value: initialState) + let updateState: ((DeletedMessagesControllerState) -> DeletedMessagesControllerState) -> Void = { f in + statePromise.set(stateValue.modify { f($0) }) + } + + let arguments = DeletedMessagesControllerArguments( + toggleEnabled: { value in + AntiDeleteManager.shared.isEnabled = value + updateState { state in + var state = state + state.isEnabled = value + return state + } + }, + toggleArchiveMedia: { value in + AntiDeleteManager.shared.archiveMedia = value + updateState { state in + var state = state + state.archiveMedia = value + return state + } + } + ) + + let signal = combineLatest( + context.sharedContext.presentationData, + statePromise.get() + ) + |> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let entries = deletedMessagesControllerEntries(presentationData: presentationData, state: state) + + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("Удалённые сообщения"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), + animateChanges: false + ) + + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: entries, + style: .blocks, + animateChanges: false + ) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + return controller +} diff --git a/submodules/SettingsUI/Sources/DeviceSpoofController.swift b/submodules/SettingsUI/Sources/DeviceSpoofController.swift new file mode 100644 index 00000000..a6b7f454 --- /dev/null +++ b/submodules/SettingsUI/Sources/DeviceSpoofController.swift @@ -0,0 +1,292 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import ItemListUI +import AccountContext + +// MARK: - Entry Definition + +private enum DeviceSpoofSection: Int32 { + case enable + case profiles + case custom +} + +private enum DeviceSpoofEntry: ItemListNodeEntry { + case enableHeader(PresentationTheme, String) + case enableToggle(PresentationTheme, String, Bool) + case enableInfo(PresentationTheme, String) + case profilesHeader(PresentationTheme, String) + case profile(PresentationTheme, Int, String, Bool) + case customHeader(PresentationTheme, String) + case customDeviceModel(PresentationTheme, String, String) + case customSystemVersion(PresentationTheme, String, String) + case customInfo(PresentationTheme, String) + + var section: ItemListSectionId { + switch self { + case .enableHeader, .enableToggle, .enableInfo: + return DeviceSpoofSection.enable.rawValue + case .profilesHeader, .profile: + return DeviceSpoofSection.profiles.rawValue + case .customHeader, .customDeviceModel, .customSystemVersion, .customInfo: + return DeviceSpoofSection.custom.rawValue + } + } + + var stableId: Int32 { + switch self { + case .enableHeader: return 0 + case .enableToggle: return 1 + case .enableInfo: return 2 + case .profilesHeader: return 3 + case let .profile(_, id, _, _): return 10 + Int32(id) + case .customHeader: return 500 + case .customDeviceModel: return 501 + case .customSystemVersion: return 502 + case .customInfo: return 503 + } + } + + static func ==(lhs: DeviceSpoofEntry, rhs: DeviceSpoofEntry) -> Bool { + switch lhs { + case let .enableHeader(lhsTheme, lhsText): + if case let .enableHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .enableToggle(lhsTheme, lhsText, lhsValue): + if case let .enableToggle(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .enableInfo(lhsTheme, lhsText): + if case let .enableInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .profilesHeader(lhsTheme, lhsText): + if case let .profilesHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .profile(lhsTheme, lhsId, lhsName, lhsSelected): + if case let .profile(rhsTheme, rhsId, rhsName, rhsSelected) = rhs, + lhsTheme === rhsTheme, lhsId == rhsId, lhsName == rhsName, lhsSelected == rhsSelected { + return true + } + return false + case let .customHeader(lhsTheme, lhsText): + if case let .customHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .customDeviceModel(lhsTheme, lhsTitle, lhsValue): + if case let .customDeviceModel(rhsTheme, rhsTitle, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsValue == rhsValue { + return true + } + return false + case let .customSystemVersion(lhsTheme, lhsTitle, lhsValue): + if case let .customSystemVersion(rhsTheme, rhsTitle, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsValue == rhsValue { + return true + } + return false + case let .customInfo(lhsTheme, lhsText): + if case let .customInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + } + } + + static func <(lhs: DeviceSpoofEntry, rhs: DeviceSpoofEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! DeviceSpoofControllerArguments + switch self { + case let .enableHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .enableToggle(_, text, value): + return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleEnabled(value) + }) + case let .enableInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + case let .profilesHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .profile(_, id, name, selected): + return ItemListCheckboxItem(presentationData: presentationData, title: name, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.selectProfile(id) + }) + case let .customHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .customDeviceModel(_, title, value): + return ItemListSingleLineInputItem(presentationData: presentationData, title: NSAttributedString(string: title), text: value, placeholder: "iPhone 14 Pro", sectionId: self.section, textUpdated: { text in + arguments.updateCustomDeviceModel(text) + }, action: {}) + case let .customSystemVersion(_, title, value): + return ItemListSingleLineInputItem(presentationData: presentationData, title: NSAttributedString(string: title), text: value, placeholder: "iOS 17.2", sectionId: self.section, textUpdated: { text in + arguments.updateCustomSystemVersion(text) + }, action: {}) + case let .customInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + } + } +} + +// MARK: - Arguments + +private final class DeviceSpoofControllerArguments { + let toggleEnabled: (Bool) -> Void + let selectProfile: (Int) -> Void + let updateCustomDeviceModel: (String) -> Void + let updateCustomSystemVersion: (String) -> Void + + init( + toggleEnabled: @escaping (Bool) -> Void, + selectProfile: @escaping (Int) -> Void, + updateCustomDeviceModel: @escaping (String) -> Void, + updateCustomSystemVersion: @escaping (String) -> Void + ) { + self.toggleEnabled = toggleEnabled + self.selectProfile = selectProfile + self.updateCustomDeviceModel = updateCustomDeviceModel + self.updateCustomSystemVersion = updateCustomSystemVersion + } +} + +// MARK: - State + +private struct DeviceSpoofControllerState: Equatable { + var isEnabled: Bool + var selectedProfileId: Int + var customDeviceModel: String + var customSystemVersion: String +} + +// MARK: - Entries Builder + +private func deviceSpoofControllerEntries(presentationData: PresentationData, state: DeviceSpoofControllerState) -> [DeviceSpoofEntry] { + var entries: [DeviceSpoofEntry] = [] + + let theme = presentationData.theme + + entries.append(.enableHeader(theme, "ПОДМЕНА УСТРОЙСТВА")) + entries.append(.enableToggle(theme, "Включить подмену", state.isEnabled)) + entries.append(.enableInfo(theme, "Изменяет информацию об устройстве для серверов Telegram. Требуется перезапуск приложения.")) + + entries.append(.profilesHeader(theme, "ВЫБЕРИТЕ УСТРОЙСТВО")) + for profile in DeviceSpoofManager.profiles { + let isSelected = profile.id == state.selectedProfileId + entries.append(.profile(theme, profile.id, profile.name, isSelected)) + } + + // Show custom input fields only when custom profile is selected + if state.selectedProfileId == 100 { + entries.append(.customHeader(theme, "СВОЁ УСТРОЙСТВО")) + entries.append(.customDeviceModel(theme, "Модель: ", state.customDeviceModel)) + entries.append(.customSystemVersion(theme, "Система: ", state.customSystemVersion)) + + // Warning if fields are empty + if state.customDeviceModel.isEmpty || state.customSystemVersion.isEmpty { + entries.append(.customInfo(theme, "⚠️ Заполните оба поля. Пока поля пустые — используется реальное устройство.")) + } else { + entries.append(.customInfo(theme, "Перезапустите приложение для применения.")) + } + } + + return entries +} + +// MARK: - Controller + +public func deviceSpoofController(context: AccountContext) -> ViewController { + let statePromise = ValuePromise( + DeviceSpoofControllerState( + isEnabled: DeviceSpoofManager.shared.isEnabled, + selectedProfileId: DeviceSpoofManager.shared.selectedProfileId, + customDeviceModel: DeviceSpoofManager.shared.customDeviceModel, + customSystemVersion: DeviceSpoofManager.shared.customSystemVersion + ), + ignoreRepeated: true + ) + let stateValue = Atomic(value: DeviceSpoofControllerState( + isEnabled: DeviceSpoofManager.shared.isEnabled, + selectedProfileId: DeviceSpoofManager.shared.selectedProfileId, + customDeviceModel: DeviceSpoofManager.shared.customDeviceModel, + customSystemVersion: DeviceSpoofManager.shared.customSystemVersion + )) + + let updateState: ((inout DeviceSpoofControllerState) -> Void) -> Void = { f in + let result = stateValue.modify { state in + var state = state + f(&state) + return state + } + statePromise.set(result) + } + + let arguments = DeviceSpoofControllerArguments( + toggleEnabled: { value in + DeviceSpoofManager.shared.isEnabled = value + updateState { state in + state.isEnabled = value + } + }, + selectProfile: { id in + DeviceSpoofManager.shared.selectedProfileId = id + updateState { state in + state.selectedProfileId = id + } + }, + updateCustomDeviceModel: { text in + DeviceSpoofManager.shared.customDeviceModel = text + updateState { state in + state.customDeviceModel = text + } + }, + updateCustomSystemVersion: { text in + DeviceSpoofManager.shared.customSystemVersion = text + updateState { state in + state.customSystemVersion = text + } + } + ) + + let signal = combineLatest( + context.sharedContext.presentationData, + statePromise.get() + ) + |> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let entries = deviceSpoofControllerEntries(presentationData: presentationData, state: state) + + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("Подмена устройства"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), + animateChanges: false + ) + + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: entries, + style: .blocks, + animateChanges: false + ) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + return controller +} diff --git a/submodules/SettingsUI/Sources/GhostModeController.swift b/submodules/SettingsUI/Sources/GhostModeController.swift new file mode 100644 index 00000000..a52f7357 --- /dev/null +++ b/submodules/SettingsUI/Sources/GhostModeController.swift @@ -0,0 +1,331 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import ItemListUI +import AccountContext + +// MARK: - Entry Definition + +private enum GhostModeSection: Int32 { + case master + case features +} + +private enum GhostModeEntry: ItemListNodeEntry { + case masterHeader(PresentationTheme, String) + case masterToggle(PresentationTheme, String, Bool, Int, Int) // title, isOn, activeCount, totalCount + case masterInfo(PresentationTheme, String) + case featuresHeader(PresentationTheme, String) + case hideReadReceipts(PresentationTheme, String, Bool) + case hideStoryViews(PresentationTheme, String, Bool) + case hideOnlineStatus(PresentationTheme, String, Bool) + case hideTypingIndicator(PresentationTheme, String, Bool) + case forceOffline(PresentationTheme, String, Bool) + + var section: ItemListSectionId { + switch self { + case .masterHeader, .masterToggle, .masterInfo: + return GhostModeSection.master.rawValue + case .featuresHeader, .hideReadReceipts, .hideStoryViews, .hideOnlineStatus, .hideTypingIndicator, .forceOffline: + return GhostModeSection.features.rawValue + } + } + + var stableId: Int32 { + switch self { + case .masterHeader: return 0 + case .masterToggle: return 1 + case .masterInfo: return 2 + case .featuresHeader: return 3 + case .hideReadReceipts: return 4 + case .hideStoryViews: return 5 + case .hideOnlineStatus: return 6 + case .hideTypingIndicator: return 7 + case .forceOffline: return 8 + } + } + + static func ==(lhs: GhostModeEntry, rhs: GhostModeEntry) -> Bool { + switch lhs { + case let .masterHeader(lhsTheme, lhsText): + if case let .masterHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .masterToggle(lhsTheme, lhsText, lhsValue, lhsActive, lhsTotal): + if case let .masterToggle(rhsTheme, rhsText, rhsValue, rhsActive, rhsTotal) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue, lhsActive == rhsActive, lhsTotal == rhsTotal { + return true + } + return false + case let .masterInfo(lhsTheme, lhsText): + if case let .masterInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .featuresHeader(lhsTheme, lhsText): + if case let .featuresHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .hideReadReceipts(lhsTheme, lhsText, lhsValue): + if case let .hideReadReceipts(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .hideStoryViews(lhsTheme, lhsText, lhsValue): + if case let .hideStoryViews(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .hideOnlineStatus(lhsTheme, lhsText, lhsValue): + if case let .hideOnlineStatus(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .hideTypingIndicator(lhsTheme, lhsText, lhsValue): + if case let .hideTypingIndicator(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .forceOffline(lhsTheme, lhsText, lhsValue): + if case let .forceOffline(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + } + } + + static func <(lhs: GhostModeEntry, rhs: GhostModeEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! GhostModeControllerArguments + switch self { + case let .masterHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .masterToggle(_, text, value, activeCount, totalCount): + let title = "\(text) \(activeCount)/\(totalCount)" + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleMaster(value) + }) + case let .masterInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + case let .featuresHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .hideReadReceipts(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleHideReadReceipts() + }) + case let .hideStoryViews(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleHideStoryViews() + }) + case let .hideOnlineStatus(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleHideOnlineStatus() + }) + case let .hideTypingIndicator(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleHideTypingIndicator() + }) + case let .forceOffline(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleForceOffline() + }) + } + } +} + +// MARK: - Arguments + +private final class GhostModeControllerArguments { + let toggleMaster: (Bool) -> Void + let toggleHideReadReceipts: () -> Void + let toggleHideStoryViews: () -> Void + let toggleHideOnlineStatus: () -> Void + let toggleHideTypingIndicator: () -> Void + let toggleForceOffline: () -> Void + + init( + toggleMaster: @escaping (Bool) -> Void, + toggleHideReadReceipts: @escaping () -> Void, + toggleHideStoryViews: @escaping () -> Void, + toggleHideOnlineStatus: @escaping () -> Void, + toggleHideTypingIndicator: @escaping () -> Void, + toggleForceOffline: @escaping () -> Void + ) { + self.toggleMaster = toggleMaster + self.toggleHideReadReceipts = toggleHideReadReceipts + self.toggleHideStoryViews = toggleHideStoryViews + self.toggleHideOnlineStatus = toggleHideOnlineStatus + self.toggleHideTypingIndicator = toggleHideTypingIndicator + self.toggleForceOffline = toggleForceOffline + } +} + +// MARK: - State + +private struct GhostModeControllerState: Equatable { + var isEnabled: Bool + var hideReadReceipts: Bool + var hideStoryViews: Bool + var hideOnlineStatus: Bool + var hideTypingIndicator: Bool + var forceOffline: Bool + + static func ==(lhs: GhostModeControllerState, rhs: GhostModeControllerState) -> Bool { + return lhs.isEnabled == rhs.isEnabled && + lhs.hideReadReceipts == rhs.hideReadReceipts && + lhs.hideStoryViews == rhs.hideStoryViews && + lhs.hideOnlineStatus == rhs.hideOnlineStatus && + lhs.hideTypingIndicator == rhs.hideTypingIndicator && + lhs.forceOffline == rhs.forceOffline + } +} + +// MARK: - Entries Builder + +private func ghostModeControllerEntries(presentationData: PresentationData, state: GhostModeControllerState) -> [GhostModeEntry] { + var entries: [GhostModeEntry] = [] + + let theme = presentationData.theme + + // Count active features + var activeCount = 0 + if state.hideReadReceipts { activeCount += 1 } + if state.hideStoryViews { activeCount += 1 } + if state.hideOnlineStatus { activeCount += 1 } + if state.hideTypingIndicator { activeCount += 1 } + if state.forceOffline { activeCount += 1 } + + // Master section + entries.append(.masterHeader(theme, "РЕЖИМ ПРИЗРАКА")) + entries.append(.masterToggle(theme, "Режим призрака", state.isEnabled, activeCount, 5)) + entries.append(.masterInfo(theme, "Когда включен, выбранные функции приватности будут активны.")) + + // Features section + entries.append(.featuresHeader(theme, "ФУНКЦИИ")) + entries.append(.hideReadReceipts(theme, "Не читать сообщения", state.hideReadReceipts)) + entries.append(.hideStoryViews(theme, "Не читать истории", state.hideStoryViews)) + entries.append(.hideOnlineStatus(theme, "Не отправлять «онлайн»", state.hideOnlineStatus)) + entries.append(.hideTypingIndicator(theme, "Не отправлять «печатает»", state.hideTypingIndicator)) + entries.append(.forceOffline(theme, "Автоматический «офлайн»", state.forceOffline)) + + return entries +} + +// MARK: - Controller + +public func ghostModeController(context: AccountContext) -> ViewController { + let statePromise = ValuePromise( + GhostModeControllerState( + isEnabled: GhostModeManager.shared.isEnabled, + hideReadReceipts: GhostModeManager.shared.hideReadReceipts, + hideStoryViews: GhostModeManager.shared.hideStoryViews, + hideOnlineStatus: GhostModeManager.shared.hideOnlineStatus, + hideTypingIndicator: GhostModeManager.shared.hideTypingIndicator, + forceOffline: GhostModeManager.shared.forceOffline + ), + ignoreRepeated: true + ) + let stateValue = Atomic(value: GhostModeControllerState( + isEnabled: GhostModeManager.shared.isEnabled, + hideReadReceipts: GhostModeManager.shared.hideReadReceipts, + hideStoryViews: GhostModeManager.shared.hideStoryViews, + hideOnlineStatus: GhostModeManager.shared.hideOnlineStatus, + hideTypingIndicator: GhostModeManager.shared.hideTypingIndicator, + forceOffline: GhostModeManager.shared.forceOffline + )) + + let updateState: ((inout GhostModeControllerState) -> Void) -> Void = { f in + let result = stateValue.modify { state in + var state = state + f(&state) + return state + } + statePromise.set(result) + } + + let arguments = GhostModeControllerArguments( + toggleMaster: { value in + GhostModeManager.shared.isEnabled = value + updateState { state in + state.isEnabled = value + } + }, + toggleHideReadReceipts: { + let newValue = !GhostModeManager.shared.hideReadReceipts + GhostModeManager.shared.hideReadReceipts = newValue + updateState { state in + state.hideReadReceipts = newValue + } + }, + toggleHideStoryViews: { + let newValue = !GhostModeManager.shared.hideStoryViews + GhostModeManager.shared.hideStoryViews = newValue + updateState { state in + state.hideStoryViews = newValue + } + }, + toggleHideOnlineStatus: { + let newValue = !GhostModeManager.shared.hideOnlineStatus + GhostModeManager.shared.hideOnlineStatus = newValue + updateState { state in + state.hideOnlineStatus = newValue + } + }, + toggleHideTypingIndicator: { + let newValue = !GhostModeManager.shared.hideTypingIndicator + GhostModeManager.shared.hideTypingIndicator = newValue + updateState { state in + state.hideTypingIndicator = newValue + } + }, + toggleForceOffline: { + let newValue = !GhostModeManager.shared.forceOffline + GhostModeManager.shared.forceOffline = newValue + updateState { state in + state.forceOffline = newValue + } + } + ) + + let signal = combineLatest( + context.sharedContext.presentationData, + statePromise.get() + ) + |> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let entries = ghostModeControllerEntries(presentationData: presentationData, state: state) + + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("Режим призрака"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), + animateChanges: false + ) + + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: entries, + style: .blocks, + animateChanges: true + ) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + return controller +} diff --git a/submodules/SettingsUI/Sources/GhostgramSettingsController.swift b/submodules/SettingsUI/Sources/GhostgramSettingsController.swift new file mode 100644 index 00000000..a045e524 --- /dev/null +++ b/submodules/SettingsUI/Sources/GhostgramSettingsController.swift @@ -0,0 +1,301 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import ItemListUI +import AccountContext + +// MARK: - Entry Definition + +private enum GhostgramSettingsSection: Int32 { + case features +} + +private enum GhostgramSettingsEntry: ItemListNodeEntry { + case deletedMessages(PresentationTheme, String, String) + case ghostMode(PresentationTheme, String, String) + case misc(PresentationTheme, String, String) + case deviceSpoof(PresentationTheme, String, String) + case voiceMorpher(PresentationTheme, String, String) + case info(PresentationTheme, String) + + var section: ItemListSectionId { + return GhostgramSettingsSection.features.rawValue + } + + var stableId: Int32 { + switch self { + case .deletedMessages: + return 0 + case .ghostMode: + return 1 + case .misc: + return 2 + case .deviceSpoof: + return 3 + case .voiceMorpher: + return 4 + case .info: + return 5 + } + } + + static func ==(lhs: GhostgramSettingsEntry, rhs: GhostgramSettingsEntry) -> Bool { + switch lhs { + case let .deletedMessages(lhsTheme, lhsText, lhsValue): + if case let .deletedMessages(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .ghostMode(lhsTheme, lhsText, lhsValue): + if case let .ghostMode(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .misc(lhsTheme, lhsText, lhsValue): + if case let .misc(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .deviceSpoof(lhsTheme, lhsText, lhsValue): + if case let .deviceSpoof(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .voiceMorpher(lhsTheme, lhsText, lhsValue): + if case let .voiceMorpher(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .info(lhsTheme, lhsText): + if case let .info(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + } + } + + static func <(lhs: GhostgramSettingsEntry, rhs: GhostgramSettingsEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! GhostgramSettingsControllerArguments + switch self { + case let .deletedMessages(_, text, value): + return ItemListDisclosureItem( + presentationData: presentationData, + title: text, + label: value, + sectionId: self.section, + style: .blocks, + action: { + arguments.openDeletedMessages() + } + ) + case let .ghostMode(_, text, value): + return ItemListDisclosureItem( + presentationData: presentationData, + title: text, + label: value, + sectionId: self.section, + style: .blocks, + action: { + arguments.openGhostMode() + } + ) + case let .misc(_, text, value): + return ItemListDisclosureItem( + presentationData: presentationData, + title: text, + label: value, + sectionId: self.section, + style: .blocks, + action: { + arguments.openMisc() + } + ) + case let .deviceSpoof(_, text, value): + return ItemListDisclosureItem( + presentationData: presentationData, + title: text, + label: value, + sectionId: self.section, + style: .blocks, + action: { + arguments.openDeviceSpoof() + } + ) + case let .voiceMorpher(_, text, value): + return ItemListDisclosureItem( + presentationData: presentationData, + title: text, + label: value, + sectionId: self.section, + style: .blocks, + action: { + arguments.openVoiceMorpher() + } + ) + case let .info(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + } + } +} + +// MARK: - Arguments + +private final class GhostgramSettingsControllerArguments { + let openDeletedMessages: () -> Void + let openGhostMode: () -> Void + let openMisc: () -> Void + let openDeviceSpoof: () -> Void + let openVoiceMorpher: () -> Void + + init( + openDeletedMessages: @escaping () -> Void, + openGhostMode: @escaping () -> Void, + openMisc: @escaping () -> Void, + openDeviceSpoof: @escaping () -> Void, + openVoiceMorpher: @escaping () -> Void + ) { + self.openDeletedMessages = openDeletedMessages + self.openGhostMode = openGhostMode + self.openMisc = openMisc + self.openDeviceSpoof = openDeviceSpoof + self.openVoiceMorpher = openVoiceMorpher + } +} + +// MARK: - State + +private struct GhostgramSettingsState: Equatable { + var deletedMessagesEnabled: Bool + var ghostModeEnabled: Bool + var ghostModeActiveCount: Int + var miscEnabled: Bool + var miscActiveCount: Int + var deviceSpoofEnabled: Bool + var voiceMorpherEnabled: Bool + + static func current() -> GhostgramSettingsState { + return GhostgramSettingsState( + deletedMessagesEnabled: AntiDeleteManager.shared.isEnabled, + ghostModeEnabled: GhostModeManager.shared.isEnabled, + ghostModeActiveCount: GhostModeManager.shared.activeFeatureCount, + miscEnabled: MiscSettingsManager.shared.isEnabled, + miscActiveCount: MiscSettingsManager.shared.activeFeatureCount, + deviceSpoofEnabled: DeviceSpoofManager.shared.isEnabled, + voiceMorpherEnabled: VoiceMorpherManager.shared.isEnabled + ) + } +} + +// MARK: - Entries builder + +private func ghostgramSettingsControllerEntries( + presentationData: PresentationData, + state: GhostgramSettingsState +) -> [GhostgramSettingsEntry] { + var entries: [GhostgramSettingsEntry] = [] + + // Deleted Messages + let deletedStatus = state.deletedMessagesEnabled ? "Вкл" : "Выкл" + entries.append(.deletedMessages(presentationData.theme, "Удалённые сообщения", deletedStatus)) + + // Ghost Mode + let ghostModeStatus = state.ghostModeEnabled ? "\(state.ghostModeActiveCount)/5" : "Выкл" + entries.append(.ghostMode(presentationData.theme, "Режим призрака", ghostModeStatus)) + + // Misc + let miscStatus = state.miscEnabled ? "\(state.miscActiveCount)/5" : "Выкл" + entries.append(.misc(presentationData.theme, "Прочее", miscStatus)) + + // Device Spoofing + let deviceSpoofStatus = state.deviceSpoofEnabled ? "Вкл" : "Выкл" + entries.append(.deviceSpoof(presentationData.theme, "Подмена устройства", deviceSpoofStatus)) + + // Voice Morpher + let voiceMorpherStatus = state.voiceMorpherEnabled ? VoiceMorpherManager.shared.selectedPreset.name : "Выкл" + entries.append(.voiceMorpher(presentationData.theme, "Голосовой двойник", voiceMorpherStatus)) + + // Info + entries.append(.info(presentationData.theme, "Функции конфиденциальности Ghostgram. Скрытые отметки о прочтении, обход исчезающих сообщений, обход защиты от пересылки и другое.")) + + return entries +} + +// MARK: - Controller + +public func ghostgramSettingsController(context: AccountContext) -> ViewController { + var pushControllerImpl: ((ViewController, Bool) -> Void)? + + let stateValue = Atomic(value: GhostgramSettingsState.current()) + let statePromise = ValuePromise(GhostgramSettingsState.current(), ignoreRepeated: true) + + let arguments = GhostgramSettingsControllerArguments( + openDeletedMessages: { + pushControllerImpl?(deletedMessagesController(context: context), true) + }, + openGhostMode: { + pushControllerImpl?(ghostModeController(context: context), true) + }, + openMisc: { + pushControllerImpl?(miscController(context: context), true) + }, + openDeviceSpoof: { + pushControllerImpl?(deviceSpoofController(context: context), true) + }, + openVoiceMorpher: { + pushControllerImpl?(voiceMorpherController(context: context), true) + } + ) + + let signal = combineLatest( + context.sharedContext.presentationData, + statePromise.get() + ) + |> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let entries = ghostgramSettingsControllerEntries(presentationData: presentationData, state: state) + + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("Ghostgram"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), + animateChanges: true + ) + + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: entries, + style: .blocks, + animateChanges: true + ) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + + // Refresh state when view appears + controller.visibleBottomContentOffsetChanged = { _ in } + controller.didAppear = { _ in + let newState = GhostgramSettingsState.current() + let _ = stateValue.modify { _ in newState } + statePromise.set(newState) + } + + pushControllerImpl = { [weak controller] c, animated in + controller?.push(c) + } + return controller +} diff --git a/submodules/SettingsUI/Sources/MiscController.swift b/submodules/SettingsUI/Sources/MiscController.swift new file mode 100644 index 00000000..e541f666 --- /dev/null +++ b/submodules/SettingsUI/Sources/MiscController.swift @@ -0,0 +1,328 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import ItemListUI +import AccountContext + +// MARK: - Entry Definition + +private enum MiscSection: Int32 { + case master + case features +} + +private enum MiscEntry: ItemListNodeEntry { + case masterHeader(PresentationTheme, String) + case masterToggle(PresentationTheme, String, Bool, Int, Int) + case masterInfo(PresentationTheme, String) + case featuresHeader(PresentationTheme, String) + case bypassCopyProtection(PresentationTheme, String, Bool) + case disableViewOnceAutoDelete(PresentationTheme, String, Bool) + case bypassScreenshotProtection(PresentationTheme, String, Bool) + case blockAds(PresentationTheme, String, Bool) + case alwaysOnline(PresentationTheme, String, Bool) + + var section: ItemListSectionId { + switch self { + case .masterHeader, .masterToggle, .masterInfo: + return MiscSection.master.rawValue + case .featuresHeader, .bypassCopyProtection, .disableViewOnceAutoDelete, .bypassScreenshotProtection, .blockAds, .alwaysOnline: + return MiscSection.features.rawValue + } + } + + var stableId: Int32 { + switch self { + case .masterHeader: return 0 + case .masterToggle: return 1 + case .masterInfo: return 2 + case .featuresHeader: return 3 + case .bypassCopyProtection: return 4 + case .disableViewOnceAutoDelete: return 5 + case .bypassScreenshotProtection: return 6 + case .blockAds: return 7 + case .alwaysOnline: return 8 + } + } + + static func ==(lhs: MiscEntry, rhs: MiscEntry) -> Bool { + switch lhs { + case let .masterHeader(lhsTheme, lhsText): + if case let .masterHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .masterToggle(lhsTheme, lhsText, lhsValue, lhsActive, lhsTotal): + if case let .masterToggle(rhsTheme, rhsText, rhsValue, rhsActive, rhsTotal) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue, lhsActive == rhsActive, lhsTotal == rhsTotal { + return true + } + return false + case let .masterInfo(lhsTheme, lhsText): + if case let .masterInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .featuresHeader(lhsTheme, lhsText): + if case let .featuresHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .bypassCopyProtection(lhsTheme, lhsText, lhsValue): + if case let .bypassCopyProtection(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .disableViewOnceAutoDelete(lhsTheme, lhsText, lhsValue): + if case let .disableViewOnceAutoDelete(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .bypassScreenshotProtection(lhsTheme, lhsText, lhsValue): + if case let .bypassScreenshotProtection(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .blockAds(lhsTheme, lhsText, lhsValue): + if case let .blockAds(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .alwaysOnline(lhsTheme, lhsText, lhsValue): + if case let .alwaysOnline(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + } + } + + static func <(lhs: MiscEntry, rhs: MiscEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! MiscControllerArguments + switch self { + case let .masterHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .masterToggle(_, text, value, activeCount, totalCount): + let title = "\(text) \(activeCount)/\(totalCount)" + return ItemListSwitchItem(presentationData: presentationData, title: title, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleMaster(value) + }) + case let .masterInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + case let .featuresHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .bypassCopyProtection(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleBypassCopyProtection() + }) + case let .disableViewOnceAutoDelete(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleDisableViewOnceAutoDelete() + }) + case let .bypassScreenshotProtection(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleBypassScreenshotProtection() + }) + case let .blockAds(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleBlockAds() + }) + case let .alwaysOnline(_, text, value): + return ItemListCheckboxItem(presentationData: presentationData, title: text, style: .left, checked: value, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.toggleAlwaysOnline() + }) + } + } +} + +// MARK: - Arguments + +private final class MiscControllerArguments { + let toggleMaster: (Bool) -> Void + let toggleBypassCopyProtection: () -> Void + let toggleDisableViewOnceAutoDelete: () -> Void + let toggleBypassScreenshotProtection: () -> Void + let toggleBlockAds: () -> Void + let toggleAlwaysOnline: () -> Void + + init( + toggleMaster: @escaping (Bool) -> Void, + toggleBypassCopyProtection: @escaping () -> Void, + toggleDisableViewOnceAutoDelete: @escaping () -> Void, + toggleBypassScreenshotProtection: @escaping () -> Void, + toggleBlockAds: @escaping () -> Void, + toggleAlwaysOnline: @escaping () -> Void + ) { + self.toggleMaster = toggleMaster + self.toggleBypassCopyProtection = toggleBypassCopyProtection + self.toggleDisableViewOnceAutoDelete = toggleDisableViewOnceAutoDelete + self.toggleBypassScreenshotProtection = toggleBypassScreenshotProtection + self.toggleBlockAds = toggleBlockAds + self.toggleAlwaysOnline = toggleAlwaysOnline + } +} + +// MARK: - State + +private struct MiscControllerState: Equatable { + var isEnabled: Bool + var bypassCopyProtection: Bool + var disableViewOnceAutoDelete: Bool + var bypassScreenshotProtection: Bool + var blockAds: Bool + var alwaysOnline: Bool + + static func ==(lhs: MiscControllerState, rhs: MiscControllerState) -> Bool { + return lhs.isEnabled == rhs.isEnabled && + lhs.bypassCopyProtection == rhs.bypassCopyProtection && + lhs.disableViewOnceAutoDelete == rhs.disableViewOnceAutoDelete && + lhs.bypassScreenshotProtection == rhs.bypassScreenshotProtection && + lhs.blockAds == rhs.blockAds && + lhs.alwaysOnline == rhs.alwaysOnline + } +} + +// MARK: - Entries Builder + +private func miscControllerEntries(presentationData: PresentationData, state: MiscControllerState) -> [MiscEntry] { + var entries: [MiscEntry] = [] + + let theme = presentationData.theme + + var activeCount = 0 + if state.bypassCopyProtection { activeCount += 1 } + if state.disableViewOnceAutoDelete { activeCount += 1 } + if state.bypassScreenshotProtection { activeCount += 1 } + if state.blockAds { activeCount += 1 } + if state.alwaysOnline { activeCount += 1 } + + entries.append(.masterHeader(theme, "РАСШИРЕННЫЕ ВОЗМОЖНОСТИ")) + entries.append(.masterToggle(theme, "Misc", state.isEnabled, activeCount, 5)) + entries.append(.masterInfo(theme, "Когда включено, выбранные функции обхода ограничений будут активны.")) + + entries.append(.featuresHeader(theme, "ФУНКЦИИ")) + entries.append(.bypassCopyProtection(theme, "Разрешить пересылку", state.bypassCopyProtection)) + entries.append(.disableViewOnceAutoDelete(theme, "Сохранять View Once", state.disableViewOnceAutoDelete)) + entries.append(.bypassScreenshotProtection(theme, "Разрешить скриншоты", state.bypassScreenshotProtection)) + entries.append(.blockAds(theme, "Блокировать рекламу", state.blockAds)) + entries.append(.alwaysOnline(theme, "Вечный онлайн", state.alwaysOnline)) + + return entries +} + +// MARK: - Controller + +public func miscController(context: AccountContext) -> ViewController { + let statePromise = ValuePromise( + MiscControllerState( + isEnabled: MiscSettingsManager.shared.isEnabled, + bypassCopyProtection: MiscSettingsManager.shared.bypassCopyProtection, + disableViewOnceAutoDelete: MiscSettingsManager.shared.disableViewOnceAutoDelete, + bypassScreenshotProtection: MiscSettingsManager.shared.bypassScreenshotProtection, + blockAds: MiscSettingsManager.shared.blockAds, + alwaysOnline: MiscSettingsManager.shared.alwaysOnline + ), + ignoreRepeated: true + ) + let stateValue = Atomic(value: MiscControllerState( + isEnabled: MiscSettingsManager.shared.isEnabled, + bypassCopyProtection: MiscSettingsManager.shared.bypassCopyProtection, + disableViewOnceAutoDelete: MiscSettingsManager.shared.disableViewOnceAutoDelete, + bypassScreenshotProtection: MiscSettingsManager.shared.bypassScreenshotProtection, + blockAds: MiscSettingsManager.shared.blockAds, + alwaysOnline: MiscSettingsManager.shared.alwaysOnline + )) + + let updateState: ((inout MiscControllerState) -> Void) -> Void = { f in + let result = stateValue.modify { state in + var state = state + f(&state) + return state + } + statePromise.set(result) + } + + let arguments = MiscControllerArguments( + toggleMaster: { value in + MiscSettingsManager.shared.isEnabled = value + updateState { state in + state.isEnabled = value + } + }, + toggleBypassCopyProtection: { + let newValue = !MiscSettingsManager.shared.bypassCopyProtection + MiscSettingsManager.shared.bypassCopyProtection = newValue + updateState { state in + state.bypassCopyProtection = newValue + } + }, + toggleDisableViewOnceAutoDelete: { + let newValue = !MiscSettingsManager.shared.disableViewOnceAutoDelete + MiscSettingsManager.shared.disableViewOnceAutoDelete = newValue + updateState { state in + state.disableViewOnceAutoDelete = newValue + } + }, + toggleBypassScreenshotProtection: { + let newValue = !MiscSettingsManager.shared.bypassScreenshotProtection + MiscSettingsManager.shared.bypassScreenshotProtection = newValue + updateState { state in + state.bypassScreenshotProtection = newValue + } + }, + toggleBlockAds: { + let newValue = !MiscSettingsManager.shared.blockAds + MiscSettingsManager.shared.blockAds = newValue + updateState { state in + state.blockAds = newValue + } + }, + toggleAlwaysOnline: { + let newValue = !MiscSettingsManager.shared.alwaysOnline + MiscSettingsManager.shared.alwaysOnline = newValue + updateState { state in + state.alwaysOnline = newValue + } + } + ) + + let signal = combineLatest( + context.sharedContext.presentationData, + statePromise.get() + ) + |> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let entries = miscControllerEntries(presentationData: presentationData, state: state) + + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("Misc"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), + animateChanges: false + ) + + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: entries, + style: .blocks, + animateChanges: true + ) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + return controller +} diff --git a/submodules/SettingsUI/Sources/VoiceMorpherController.swift b/submodules/SettingsUI/Sources/VoiceMorpherController.swift new file mode 100644 index 00000000..e07c9ca3 --- /dev/null +++ b/submodules/SettingsUI/Sources/VoiceMorpherController.swift @@ -0,0 +1,211 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import TelegramCore +import TelegramPresentationData +import ItemListUI +import AccountContext + +// MARK: - Entry Definition + +private enum VoiceMorpherSection: Int32 { + case enable + case presets +} + +private enum VoiceMorpherEntry: ItemListNodeEntry { + case enableHeader(PresentationTheme, String) + case enableToggle(PresentationTheme, String, Bool) + case enableInfo(PresentationTheme, String) + case presetsHeader(PresentationTheme, String) + case preset(PresentationTheme, Int, String, String, Bool) // id, name, description, selected + + var section: ItemListSectionId { + switch self { + case .enableHeader, .enableToggle, .enableInfo: + return VoiceMorpherSection.enable.rawValue + case .presetsHeader, .preset: + return VoiceMorpherSection.presets.rawValue + } + } + + var stableId: Int32 { + switch self { + case .enableHeader: return 0 + case .enableToggle: return 1 + case .enableInfo: return 2 + case .presetsHeader: return 3 + case let .preset(_, id, _, _, _): return 10 + Int32(id) + } + } + + static func ==(lhs: VoiceMorpherEntry, rhs: VoiceMorpherEntry) -> Bool { + switch lhs { + case let .enableHeader(lhsTheme, lhsText): + if case let .enableHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .enableToggle(lhsTheme, lhsText, lhsValue): + if case let .enableToggle(rhsTheme, rhsText, rhsValue) = rhs, + lhsTheme === rhsTheme, lhsText == rhsText, lhsValue == rhsValue { + return true + } + return false + case let .enableInfo(lhsTheme, lhsText): + if case let .enableInfo(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .presetsHeader(lhsTheme, lhsText): + if case let .presetsHeader(rhsTheme, rhsText) = rhs, lhsTheme === rhsTheme, lhsText == rhsText { + return true + } + return false + case let .preset(lhsTheme, lhsId, lhsName, lhsDesc, lhsSelected): + if case let .preset(rhsTheme, rhsId, rhsName, rhsDesc, rhsSelected) = rhs, + lhsTheme === rhsTheme, lhsId == rhsId, lhsName == rhsName, lhsDesc == rhsDesc, lhsSelected == rhsSelected { + return true + } + return false + } + } + + static func <(lhs: VoiceMorpherEntry, rhs: VoiceMorpherEntry) -> Bool { + return lhs.stableId < rhs.stableId + } + + func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem { + let arguments = arguments as! VoiceMorpherControllerArguments + switch self { + case let .enableHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .enableToggle(_, text, value): + return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, sectionId: self.section, style: .blocks, updated: { value in + arguments.toggleEnabled(value) + }) + case let .enableInfo(_, text): + return ItemListTextItem(presentationData: presentationData, text: .plain(text), sectionId: self.section) + case let .presetsHeader(_, text): + return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section) + case let .preset(_, id, name, _, selected): + return ItemListCheckboxItem(presentationData: presentationData, title: name, style: .left, checked: selected, zeroSeparatorInsets: false, sectionId: self.section, action: { + arguments.selectPreset(id) + }) + } + } +} + +// MARK: - Arguments + +private final class VoiceMorpherControllerArguments { + let toggleEnabled: (Bool) -> Void + let selectPreset: (Int) -> Void + + init( + toggleEnabled: @escaping (Bool) -> Void, + selectPreset: @escaping (Int) -> Void + ) { + self.toggleEnabled = toggleEnabled + self.selectPreset = selectPreset + } +} + +// MARK: - State + +private struct VoiceMorpherControllerState: Equatable { + var isEnabled: Bool + var selectedPresetId: Int +} + +// MARK: - Entries Builder + +private func voiceMorpherControllerEntries(presentationData: PresentationData, state: VoiceMorpherControllerState) -> [VoiceMorpherEntry] { + var entries: [VoiceMorpherEntry] = [] + + let theme = presentationData.theme + + entries.append(.enableHeader(theme, "ИЗМЕНЕНИЕ ГОЛОСА")) + entries.append(.enableToggle(theme, "Включить Voice Morpher", state.isEnabled)) + entries.append(.enableInfo(theme, "Изменяет твой голос при записи голосовых сообщений. Использует встроенные аудио-эффекты iOS.")) + + entries.append(.presetsHeader(theme, "ВЫБЕРИТЕ ЭФФЕКТ")) + + // Add all presets except disabled (it's controlled by toggle) + for preset in VoiceMorpherManager.VoicePreset.allCases where preset != .disabled { + let isSelected = preset.rawValue == state.selectedPresetId + entries.append(.preset(theme, preset.rawValue, preset.name, preset.description, isSelected)) + } + + return entries +} + +// MARK: - Controller + +public func voiceMorpherController(context: AccountContext) -> ViewController { + let statePromise = ValuePromise( + VoiceMorpherControllerState( + isEnabled: VoiceMorpherManager.shared.isEnabled, + selectedPresetId: VoiceMorpherManager.shared.selectedPresetId == 0 ? 1 : VoiceMorpherManager.shared.selectedPresetId + ), + ignoreRepeated: true + ) + let stateValue = Atomic(value: VoiceMorpherControllerState( + isEnabled: VoiceMorpherManager.shared.isEnabled, + selectedPresetId: VoiceMorpherManager.shared.selectedPresetId == 0 ? 1 : VoiceMorpherManager.shared.selectedPresetId + )) + + let updateState: ((inout VoiceMorpherControllerState) -> Void) -> Void = { f in + let result = stateValue.modify { state in + var state = state + f(&state) + return state + } + statePromise.set(result) + } + + let arguments = VoiceMorpherControllerArguments( + toggleEnabled: { value in + VoiceMorpherManager.shared.isEnabled = value + updateState { state in + state.isEnabled = value + } + }, + selectPreset: { id in + VoiceMorpherManager.shared.selectedPresetId = id + updateState { state in + state.selectedPresetId = id + } + } + ) + + let signal = combineLatest( + context.sharedContext.presentationData, + statePromise.get() + ) + |> map { presentationData, state -> (ItemListControllerState, (ItemListNodeState, Any)) in + let entries = voiceMorpherControllerEntries(presentationData: presentationData, state: state) + + let controllerState = ItemListControllerState( + presentationData: ItemListPresentationData(presentationData), + title: .text("Голосовой двойник"), + leftNavigationButton: nil, + rightNavigationButton: nil, + backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), + animateChanges: false + ) + + let listState = ItemListNodeState( + presentationData: ItemListPresentationData(presentationData), + entries: entries, + style: .blocks, + animateChanges: false + ) + + return (controllerState, (listState, arguments)) + } + + let controller = ItemListController(context: context, state: signal) + return controller +} diff --git a/submodules/TelegramCore/Sources/AntiDelete/AntiDeleteManager.swift b/submodules/TelegramCore/Sources/AntiDelete/AntiDeleteManager.swift new file mode 100644 index 00000000..30614454 --- /dev/null +++ b/submodules/TelegramCore/Sources/AntiDelete/AntiDeleteManager.swift @@ -0,0 +1,241 @@ +import Foundation + +/// Менеджер для сохранения удалённых сообщений +/// Перехватывает сообщения перед удалением и архивирует их локально +public final class AntiDeleteManager { + + public static let shared = AntiDeleteManager() + + // MARK: - Settings + + private let defaults = UserDefaults.standard + private let enabledKey = "antiDelete.enabled" + private let archiveMediaKey = "antiDelete.archiveMedia" + private let archiveKey = "antiDelete.archive" + private let deletedIdsKey = "antiDelete.deletedIds" + + /// Включено ли сохранение удалённых сообщений + public var isEnabled: Bool { + get { defaults.bool(forKey: enabledKey) } + set { defaults.set(newValue, forKey: enabledKey) } + } + + /// Сохранять ли медиа-контент + public var archiveMedia: Bool { + get { defaults.bool(forKey: archiveMediaKey) } + set { defaults.set(newValue, forKey: archiveMediaKey) } + } + + // MARK: - Deleted Message IDs Storage + + private var deletedMessageIds: Set = [] + private let deletedIdsLock = NSLock() + + /// Пометить сообщение как удалённое + public func markAsDeleted(peerId: Int64, messageId: Int32) { + let key = "\(peerId)_\(messageId)" + deletedIdsLock.lock() + deletedMessageIds.insert(key) + deletedIdsLock.unlock() + saveDeletedIds() + } + + /// Проверить, является ли сообщение удалённым + public func isMessageDeleted(peerId: Int64, messageId: Int32) -> Bool { + guard isEnabled else { return false } + let key = "\(peerId)_\(messageId)" + deletedIdsLock.lock() + defer { deletedIdsLock.unlock() } + return deletedMessageIds.contains(key) + } + + /// Проверить, является ли сообщение удалённым (по тексту - legacy) + public func isMessageDeleted(text: String) -> Bool { + guard isEnabled else { return false } + // Legacy: проверяем наличие дефолтного префикса для обратной совместимости + let defaultPrefix = "🗑️ " + return text.hasPrefix(defaultPrefix) + } + + private func saveDeletedIds() { + deletedIdsLock.lock() + let ids = Array(deletedMessageIds) + deletedIdsLock.unlock() + defaults.set(ids, forKey: deletedIdsKey) + } + + private func loadDeletedIds() { + if let ids = defaults.stringArray(forKey: deletedIdsKey) { + deletedIdsLock.lock() + deletedMessageIds = Set(ids) + deletedIdsLock.unlock() + } + } + + // MARK: - Archived Messages Storage + + /// Структура архивированного сообщения + public struct ArchivedMessage: Codable { + public let globalId: Int32 + public let peerId: Int64 + public let messageId: Int32 + public let timestamp: Int32 + public let deletedAt: Int32 + public let authorId: Int64? + public let text: String + public let forwardAuthorId: Int64? + public let mediaDescription: String? + + public init( + globalId: Int32, + peerId: Int64, + messageId: Int32, + timestamp: Int32, + deletedAt: Int32, + authorId: Int64?, + text: String, + forwardAuthorId: Int64?, + mediaDescription: String? + ) { + self.globalId = globalId + self.peerId = peerId + self.messageId = messageId + self.timestamp = timestamp + self.deletedAt = deletedAt + self.authorId = authorId + self.text = text + self.forwardAuthorId = forwardAuthorId + self.mediaDescription = mediaDescription + } + } + + private var archivedMessages: [ArchivedMessage] = [] + private let archiveLock = NSLock() + + private init() { + // Set default values + if defaults.object(forKey: enabledKey) == nil { + defaults.set(true, forKey: enabledKey) + } + if defaults.object(forKey: archiveMediaKey) == nil { + defaults.set(true, forKey: archiveMediaKey) + } + loadArchive() + loadDeletedIds() + } + + // MARK: - Archive Operations + + /// Архивировать сообщение перед удалением + /// - Parameters: + /// - globalId: Глобальный ID сообщения + /// - peerId: ID чата + /// - messageId: Локальный ID сообщения + /// - timestamp: Время отправки + /// - authorId: ID автора + /// - text: Текст сообщения + /// - forwardAuthorId: ID автора пересланного сообщения + /// - mediaDescription: Описание медиа (тип, размер) + public func archiveMessage( + globalId: Int32, + peerId: Int64, + messageId: Int32, + timestamp: Int32, + authorId: Int64?, + text: String, + forwardAuthorId: Int64? = nil, + mediaDescription: String? = nil + ) { + guard isEnabled else { return } + + let archived = ArchivedMessage( + globalId: globalId, + peerId: peerId, + messageId: messageId, + timestamp: timestamp, + deletedAt: Int32(Date().timeIntervalSince1970), + authorId: authorId, + text: text, + forwardAuthorId: forwardAuthorId, + mediaDescription: mediaDescription + ) + + archiveLock.lock() + defer { archiveLock.unlock() } + + // Avoid duplicates + if !archivedMessages.contains(where: { $0.globalId == globalId }) { + archivedMessages.append(archived) + saveArchive() + } + } + + /// Получить все архивированные сообщения + public func getAllArchivedMessages() -> [ArchivedMessage] { + archiveLock.lock() + defer { archiveLock.unlock() } + return archivedMessages.sorted { $0.deletedAt > $1.deletedAt } + } + + /// Получить архивированные сообщения для конкретного чата + /// - Parameter peerId: ID чата + public func getArchivedMessages(forPeerId peerId: Int64) -> [ArchivedMessage] { + archiveLock.lock() + defer { archiveLock.unlock() } + return archivedMessages + .filter { $0.peerId == peerId } + .sorted { $0.deletedAt > $1.deletedAt } + } + + /// Количество архивированных сообщений + public var archivedCount: Int { + archiveLock.lock() + defer { archiveLock.unlock() } + return archivedMessages.count + } + + /// Получить данные архивированных сообщений для удаления из диалогов + /// Возвращает массив (peerId, messageId) + public func getArchivedMessageData() -> [(peerId: Int64, messageId: Int32)] { + archiveLock.lock() + defer { archiveLock.unlock() } + return archivedMessages.map { (peerId: $0.peerId, messageId: $0.messageId) } + } + + /// Очистить архив + public func clearArchive() { + archiveLock.lock() + defer { archiveLock.unlock() } + archivedMessages.removeAll() + saveArchive() + } + + /// Удалить конкретное сообщение из архива + public func removeFromArchive(globalId: Int32) { + archiveLock.lock() + defer { archiveLock.unlock() } + archivedMessages.removeAll { $0.globalId == globalId } + saveArchive() + } + + // MARK: - Persistence + + private func saveArchive() { + do { + let data = try JSONEncoder().encode(archivedMessages) + defaults.set(data, forKey: archiveKey) + } catch { + print("[AntiDelete] Failed to save archive: \(error)") + } + } + + private func loadArchive() { + guard let data = defaults.data(forKey: archiveKey) else { return } + do { + archivedMessages = try JSONDecoder().decode([ArchivedMessage].self, from: data) + } catch { + print("[AntiDelete] Failed to load archive: \(error)") + archivedMessages = [] + } + } +} diff --git a/submodules/TelegramCore/Sources/AntiDelete/DeletedMessageAttribute.swift b/submodules/TelegramCore/Sources/AntiDelete/DeletedMessageAttribute.swift new file mode 100644 index 00000000..7e80867d --- /dev/null +++ b/submodules/TelegramCore/Sources/AntiDelete/DeletedMessageAttribute.swift @@ -0,0 +1,47 @@ +import Foundation +import Postbox + +// MARK: - DeletedMessageAttribute +// This attribute marks a message as "deleted but visible" in the chat +// When anti-delete is enabled, messages are not removed but marked with this attribute + +public class DeletedMessageAttribute: MessageAttribute, Equatable { + public let deletedAt: Int32 + public let deletedByPeerId: Int64? + + public var associatedMessageIds: [MessageId] { return [] } + public var associatedPeerIds: [PeerId] { return [] } + public var automaticTimestampBasedAttribute: (UInt32, Int32)? { return nil } + + public init(deletedAt: Int32, deletedByPeerId: Int64? = nil) { + self.deletedAt = deletedAt + self.deletedByPeerId = deletedByPeerId + } + + public required init(decoder: PostboxDecoder) { + self.deletedAt = decoder.decodeInt32ForKey("d", orElse: 0) + self.deletedByPeerId = decoder.decodeOptionalInt64ForKey("p") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeInt32(self.deletedAt, forKey: "d") + if let peerId = self.deletedByPeerId { + encoder.encodeInt64(peerId, forKey: "p") + } + } + + public static func ==(lhs: DeletedMessageAttribute, rhs: DeletedMessageAttribute) -> Bool { + return lhs.deletedAt == rhs.deletedAt && lhs.deletedByPeerId == rhs.deletedByPeerId + } +} + +// MARK: - Helper extension for Message +public extension Message { + var isDeletedButVisible: Bool { + return self.attributes.contains(where: { $0 is DeletedMessageAttribute }) + } + + var deletedMessageAttribute: DeletedMessageAttribute? { + return self.attributes.first(where: { $0 is DeletedMessageAttribute }) as? DeletedMessageAttribute + } +} diff --git a/submodules/TelegramCore/Sources/AntiDelete/EditHistoryManager.swift b/submodules/TelegramCore/Sources/AntiDelete/EditHistoryManager.swift new file mode 100644 index 00000000..7180c5a9 --- /dev/null +++ b/submodules/TelegramCore/Sources/AntiDelete/EditHistoryManager.swift @@ -0,0 +1,111 @@ +import Foundation + + +/// Manages edit history for messages +/// GHOSTGRAM: Stores original message text before edits for history viewing +public final class EditHistoryManager { + public static let shared = EditHistoryManager() + + private let historyKey = "ghostgram_edit_history" + private var editHistory: [String: [EditRecord]] = [:] + private let lock = NSLock() + + public struct EditRecord: Codable, Equatable { + public let text: String + public let editDate: Int32 + + public init(text: String, editDate: Int32) { + self.text = text + self.editDate = editDate + } + } + + private init() { + loadHistory() + } + + /// Creates a unique key for message identification + private func messageKey(peerId: Int64, messageId: Int32) -> String { + return "\(peerId)_\(messageId)" + } + + /// Saves the original text before an edit + /// Call this BEFORE the message is updated with new text + public func saveOriginalText(peerId: Int64, messageId: Int32, originalText: String, editDate: Int32) { + lock.lock() + defer { lock.unlock() } + + let key = messageKey(peerId: peerId, messageId: messageId) + + // Don't save empty text + guard !originalText.isEmpty else { return } + + // Get existing history or create new + var history = editHistory[key] ?? [] + + // Check if this exact text already exists (avoid duplicates) + if history.last?.text != originalText { + let record = EditRecord(text: originalText, editDate: editDate) + history.append(record) + editHistory[key] = history + saveHistory() + } + } + + /// Gets edit history for a message + public func getEditHistory(peerId: Int64, messageId: Int32) -> [EditRecord] { + lock.lock() + defer { lock.unlock() } + + let key = messageKey(peerId: peerId, messageId: messageId) + return editHistory[key] ?? [] + } + + /// Checks if a message has edit history + public func hasEditHistory(peerId: Int64, messageId: Int32) -> Bool { + lock.lock() + defer { lock.unlock() } + + let key = messageKey(peerId: peerId, messageId: messageId) + return !(editHistory[key]?.isEmpty ?? true) + } + + /// Clears history for a specific message + public func clearHistory(peerId: Int64, messageId: Int32) { + lock.lock() + defer { lock.unlock() } + + let key = messageKey(peerId: peerId, messageId: messageId) + editHistory.removeValue(forKey: key) + saveHistory() + } + + /// Clears all edit history + public func clearAllHistory() { + lock.lock() + defer { lock.unlock() } + + editHistory.removeAll() + saveHistory() + } + + // MARK: - Persistence + + private func saveHistory() { + do { + let data = try JSONEncoder().encode(editHistory) + UserDefaults.standard.set(data, forKey: historyKey) + } catch { + // Silent fail - non-critical feature + } + } + + private func loadHistory() { + guard let data = UserDefaults.standard.data(forKey: historyKey) else { return } + do { + editHistory = try JSONDecoder().decode([String: [EditRecord]].self, from: data) + } catch { + editHistory = [:] + } + } +} diff --git a/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofBridge.h b/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofBridge.h new file mode 100644 index 00000000..4d9a188f --- /dev/null +++ b/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofBridge.h @@ -0,0 +1,22 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Objective-C bridge for DeviceSpoofManager (Swift) +/// Provides access to spoofed device information from Objective-C code +@interface DeviceSpoofBridge : NSObject + +/// Returns YES if device spoofing is enabled ++ (BOOL)isEnabled; + +/// Returns the spoofed device model, or nil if spoofing is disabled or using +/// real device ++ (NSString *_Nullable)spoofedDeviceModel; + +/// Returns the spoofed system version, or nil if spoofing is disabled or using +/// real device ++ (NSString *_Nullable)spoofedSystemVersion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofBridge.m b/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofBridge.m new file mode 100644 index 00000000..981ec412 --- /dev/null +++ b/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofBridge.m @@ -0,0 +1,100 @@ +#import "DeviceSpoofBridge.h" + +// Access to UserDefaults to get spoofing settings +// This mirrors DeviceSpoofManager logic but in pure Objective-C +// to avoid Swift/ObjC bridging complexities in MtProtoKit + +static NSString *const kDeviceSpoofIsEnabled = @"DeviceSpoof.isEnabled"; +static NSString *const kDeviceSpoofSelectedProfileId = + @"DeviceSpoof.selectedProfileId"; +static NSString *const kDeviceSpoofCustomDeviceModel = + @"DeviceSpoof.customDeviceModel"; +static NSString *const kDeviceSpoofCustomSystemVersion = + @"DeviceSpoof.customSystemVersion"; + +@implementation DeviceSpoofBridge + ++ (BOOL)isEnabled { + return + [[NSUserDefaults standardUserDefaults] boolForKey:kDeviceSpoofIsEnabled]; +} + ++ (NSString *)spoofedDeviceModel { + if (![self isEnabled]) { + return nil; + } + + NSInteger profileId = [[NSUserDefaults standardUserDefaults] + integerForKey:kDeviceSpoofSelectedProfileId]; + + // Profile ID 0 = real device + if (profileId == 0) { + return nil; + } + + // Profile ID 100 = custom + if (profileId == 100) { + NSString *custom = [[NSUserDefaults standardUserDefaults] + stringForKey:kDeviceSpoofCustomDeviceModel]; + if (custom.length > 0) { + return custom; + } + return nil; + } + + // Preset profiles + NSDictionary *profiles = @{ + @1 : @"iPhone 14 Pro", + @2 : @"iPhone 15 Pro Max", + @3 : @"Samsung SM-S918B", + @4 : @"Google Pixel 8 Pro", + @5 : @"PC 64bit", + @6 : @"MacBook Pro", + @7 : @"Web", + @8 : @"HUAWEI MNA-LX9", + @9 : @"Xiaomi 2311DRK48G" + }; + + return profiles[@(profileId)]; +} + ++ (NSString *)spoofedSystemVersion { + if (![self isEnabled]) { + return nil; + } + + NSInteger profileId = [[NSUserDefaults standardUserDefaults] + integerForKey:kDeviceSpoofSelectedProfileId]; + + // Profile ID 0 = real device + if (profileId == 0) { + return nil; + } + + // Profile ID 100 = custom + if (profileId == 100) { + NSString *custom = [[NSUserDefaults standardUserDefaults] + stringForKey:kDeviceSpoofCustomSystemVersion]; + if (custom.length > 0) { + return custom; + } + return nil; + } + + // Preset profiles + NSDictionary *versions = @{ + @1 : @"iOS 17.2", + @2 : @"iOS 17.4", + @3 : @"Android 14", + @4 : @"Android 14", + @5 : @"Windows 11", + @6 : @"macOS 14.3", + @7 : @"Chrome 121", + @8 : @"HarmonyOS 4.0", + @9 : @"Android 14" + }; + + return versions[@(profileId)]; +} + +@end diff --git a/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofManager.swift b/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofManager.swift new file mode 100644 index 00000000..73d8d5ff --- /dev/null +++ b/submodules/TelegramCore/Sources/DeviceSpoof/DeviceSpoofManager.swift @@ -0,0 +1,141 @@ +import Foundation + +/// DeviceSpoofManager - Manages device identity spoofing +/// Allows changing device model and system version reported to Telegram servers +public final class DeviceSpoofManager { + public static let shared = DeviceSpoofManager() + + // MARK: - Device Profile + + public struct DeviceProfile: Equatable { + public let id: Int + public let name: String + public let deviceModel: String + public let systemVersion: String + + public init(id: Int, name: String, deviceModel: String, systemVersion: String) { + self.id = id + self.name = name + self.deviceModel = deviceModel + self.systemVersion = systemVersion + } + } + + // MARK: - Preset Profiles + + public static let profiles: [DeviceProfile] = [ + DeviceProfile(id: 0, name: "Реальное устройство", deviceModel: "", systemVersion: ""), + DeviceProfile(id: 1, name: "iPhone 14 Pro", deviceModel: "iPhone 14 Pro", systemVersion: "iOS 17.2"), + DeviceProfile(id: 2, name: "iPhone 15 Pro Max", deviceModel: "iPhone 15 Pro Max", systemVersion: "iOS 17.4"), + DeviceProfile(id: 3, name: "Samsung Galaxy S23", deviceModel: "Samsung SM-S918B", systemVersion: "Android 14"), + DeviceProfile(id: 4, name: "Google Pixel 8", deviceModel: "Google Pixel 8 Pro", systemVersion: "Android 14"), + DeviceProfile(id: 5, name: "Desktop Windows", deviceModel: "PC 64bit", systemVersion: "Windows 11"), + DeviceProfile(id: 6, name: "Desktop macOS", deviceModel: "MacBook Pro", systemVersion: "macOS 14.3"), + DeviceProfile(id: 7, name: "Telegram Web", deviceModel: "Web", systemVersion: "Chrome 121"), + DeviceProfile(id: 8, name: "Huawei P60 Pro", deviceModel: "HUAWEI MNA-LX9", systemVersion: "HarmonyOS 4.0"), + DeviceProfile(id: 9, name: "Xiaomi 14", deviceModel: "Xiaomi 2311DRK48G", systemVersion: "Android 14"), + DeviceProfile(id: 100, name: "Своё устройство", deviceModel: "", systemVersion: "") + ] + + // MARK: - Keys + + private enum Keys { + static let isEnabled = "DeviceSpoof.isEnabled" + static let selectedProfileId = "DeviceSpoof.selectedProfileId" + static let customDeviceModel = "DeviceSpoof.customDeviceModel" + static let customSystemVersion = "DeviceSpoof.customSystemVersion" + } + + private let defaults = UserDefaults.standard + + // MARK: - Properties + + /// Whether device spoofing is enabled + public var isEnabled: Bool { + get { defaults.bool(forKey: Keys.isEnabled) } + set { + defaults.set(newValue, forKey: Keys.isEnabled) + notifyChanged() + } + } + + /// Selected profile ID (0 = real device, 100 = custom) + public var selectedProfileId: Int { + get { defaults.integer(forKey: Keys.selectedProfileId) } + set { + defaults.set(newValue, forKey: Keys.selectedProfileId) + notifyChanged() + } + } + + /// Custom device model (when profile ID = 100) + public var customDeviceModel: String { + get { defaults.string(forKey: Keys.customDeviceModel) ?? "" } + set { + defaults.set(newValue, forKey: Keys.customDeviceModel) + notifyChanged() + } + } + + /// Custom system version (when profile ID = 100) + public var customSystemVersion: String { + get { defaults.string(forKey: Keys.customSystemVersion) ?? "" } + set { + defaults.set(newValue, forKey: Keys.customSystemVersion) + notifyChanged() + } + } + + // MARK: - Computed + + /// Get the currently effective device model + public var effectiveDeviceModel: String? { + guard isEnabled else { return nil } + + if selectedProfileId == 100 { + // Custom profile + let custom = customDeviceModel.trimmingCharacters(in: .whitespacesAndNewlines) + return custom.isEmpty ? nil : custom + } + + if let profile = Self.profiles.first(where: { $0.id == selectedProfileId }), profile.id != 0 { + return profile.deviceModel.isEmpty ? nil : profile.deviceModel + } + + return nil + } + + /// Get the currently effective system version + public var effectiveSystemVersion: String? { + guard isEnabled else { return nil } + + if selectedProfileId == 100 { + // Custom profile + let custom = customSystemVersion.trimmingCharacters(in: .whitespacesAndNewlines) + return custom.isEmpty ? nil : custom + } + + if let profile = Self.profiles.first(where: { $0.id == selectedProfileId }), profile.id != 0 { + return profile.systemVersion.isEmpty ? nil : profile.systemVersion + } + + return nil + } + + /// Get selected profile + public var selectedProfile: DeviceProfile? { + return Self.profiles.first(where: { $0.id == selectedProfileId }) + } + + // MARK: - Notification + + public static let settingsChangedNotification = Notification.Name("DeviceSpoofSettingsChanged") + + private func notifyChanged() { + NotificationCenter.default.post(name: Self.settingsChangedNotification, object: nil) + } + + // MARK: - Init + + private init() {} +} diff --git a/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift b/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift new file mode 100644 index 00000000..d3ea16d7 --- /dev/null +++ b/submodules/TelegramCore/Sources/GhostMode/GhostModeManager.swift @@ -0,0 +1,164 @@ +import Foundation + +/// GhostModeManager - Central manager for Ghost Mode privacy settings +/// Controls all privacy features: hide read receipts, typing indicator, online status, story views +public final class GhostModeManager { + + // MARK: - Singleton + + public static let shared = GhostModeManager() + + // MARK: - UserDefaults Keys + + private enum Keys { + static let isEnabled = "GhostMode.isEnabled" + static let hideReadReceipts = "GhostMode.hideReadReceipts" + static let hideStoryViews = "GhostMode.hideStoryViews" + static let hideOnlineStatus = "GhostMode.hideOnlineStatus" + static let hideTypingIndicator = "GhostMode.hideTypingIndicator" + static let forceOffline = "GhostMode.forceOffline" + } + + // MARK: - Settings Storage + + private let defaults = UserDefaults.standard + + // MARK: - Properties + + /// Master toggle for Ghost Mode + public var isEnabled: Bool { + get { defaults.bool(forKey: Keys.isEnabled) } + set { + defaults.set(newValue, forKey: Keys.isEnabled) + notifySettingsChanged() + } + } + + /// Don't send read receipts (blue checkmarks) + public var hideReadReceipts: Bool { + get { defaults.bool(forKey: Keys.hideReadReceipts) } + set { + defaults.set(newValue, forKey: Keys.hideReadReceipts) + notifySettingsChanged() + } + } + + /// Don't send story view notifications + public var hideStoryViews: Bool { + get { defaults.bool(forKey: Keys.hideStoryViews) } + set { + defaults.set(newValue, forKey: Keys.hideStoryViews) + notifySettingsChanged() + } + } + + /// Don't send online status + public var hideOnlineStatus: Bool { + get { defaults.bool(forKey: Keys.hideOnlineStatus) } + set { + defaults.set(newValue, forKey: Keys.hideOnlineStatus) + notifySettingsChanged() + } + } + + /// Don't send typing indicator + public var hideTypingIndicator: Bool { + get { defaults.bool(forKey: Keys.hideTypingIndicator) } + set { + defaults.set(newValue, forKey: Keys.hideTypingIndicator) + notifySettingsChanged() + } + } + + /// Always appear as offline + public var forceOffline: Bool { + get { defaults.bool(forKey: Keys.forceOffline) } + set { + defaults.set(newValue, forKey: Keys.forceOffline) + notifySettingsChanged() + } + } + + // MARK: - Computed Properties + + /// Check if read receipts should be hidden (master + individual toggle) + public var shouldHideReadReceipts: Bool { + return isEnabled && hideReadReceipts + } + + /// Check if story views should be hidden + public var shouldHideStoryViews: Bool { + return isEnabled && hideStoryViews + } + + /// Check if online status should be hidden + public var shouldHideOnlineStatus: Bool { + return isEnabled && hideOnlineStatus + } + + /// Check if typing indicator should be hidden + public var shouldHideTypingIndicator: Bool { + return isEnabled && hideTypingIndicator + } + + /// Check if should force offline + public var shouldForceOffline: Bool { + return isEnabled && forceOffline + } + + /// Count of active features (e.g., "5/5") + public var activeFeatureCount: Int { + var count = 0 + if hideReadReceipts { count += 1 } + if hideStoryViews { count += 1 } + if hideOnlineStatus { count += 1 } + if hideTypingIndicator { count += 1 } + if forceOffline { count += 1 } + return count + } + + /// Total number of features + public static let totalFeatureCount = 5 + + // MARK: - Initialization + + private init() { + // Set default values if not set + if !defaults.bool(forKey: "GhostMode.initialized") { + defaults.set(true, forKey: "GhostMode.initialized") + // Default: all features enabled when ghost mode is on + defaults.set(true, forKey: Keys.hideReadReceipts) + defaults.set(true, forKey: Keys.hideStoryViews) + defaults.set(true, forKey: Keys.hideOnlineStatus) + defaults.set(true, forKey: Keys.hideTypingIndicator) + defaults.set(true, forKey: Keys.forceOffline) + // Ghost mode itself is off by default + defaults.set(false, forKey: Keys.isEnabled) + } + } + + // MARK: - Enable All + + /// Enable all ghost mode features + public func enableAll() { + hideReadReceipts = true + hideStoryViews = true + hideOnlineStatus = true + hideTypingIndicator = true + forceOffline = true + isEnabled = true + } + + /// Disable all ghost mode features + public func disableAll() { + isEnabled = false + } + + // MARK: - Notifications + + public static let settingsChangedNotification = Notification.Name("GhostModeSettingsChanged") + + private func notifySettingsChanged() { + NotificationCenter.default.post(name: GhostModeManager.settingsChangedNotification, object: nil) + } +} diff --git a/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift b/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift new file mode 100644 index 00000000..6a4e95a4 --- /dev/null +++ b/submodules/TelegramCore/Sources/MiscSettings/MiscSettingsManager.swift @@ -0,0 +1,147 @@ +import Foundation + +/// MiscSettingsManager - Central manager for Misc privacy settings +/// Handles: Forward bypass, View-Once persistence, Screenshot bypass +public final class MiscSettingsManager { + public static let shared = MiscSettingsManager() + + private enum Keys { + static let isEnabled = "MiscSettings.isEnabled" + static let bypassCopyProtection = "MiscSettings.bypassCopyProtection" + static let disableViewOnceAutoDelete = "MiscSettings.disableViewOnceAutoDelete" + static let bypassScreenshotProtection = "MiscSettings.bypassScreenshotProtection" + static let blockAds = "MiscSettings.blockAds" + static let alwaysOnline = "MiscSettings.alwaysOnline" + } + + private let defaults = UserDefaults.standard + + // MARK: - Main Toggle + + public var isEnabled: Bool { + get { defaults.bool(forKey: Keys.isEnabled) } + set { + defaults.set(newValue, forKey: Keys.isEnabled) + notifySettingsChanged() + } + } + + // MARK: - Individual Features + + /// Allow forwarding/copying from protected channels and chats + public var bypassCopyProtection: Bool { + get { defaults.bool(forKey: Keys.bypassCopyProtection) } + set { + defaults.set(newValue, forKey: Keys.bypassCopyProtection) + notifySettingsChanged() + } + } + + /// Keep View-Once media visible (don't auto-delete after viewing) + public var disableViewOnceAutoDelete: Bool { + get { defaults.bool(forKey: Keys.disableViewOnceAutoDelete) } + set { + defaults.set(newValue, forKey: Keys.disableViewOnceAutoDelete) + notifySettingsChanged() + } + } + + /// Allow screenshots in secret chats and protected content + public var bypassScreenshotProtection: Bool { + get { defaults.bool(forKey: Keys.bypassScreenshotProtection) } + set { + defaults.set(newValue, forKey: Keys.bypassScreenshotProtection) + notifySettingsChanged() + } + } + + /// Block all sponsored messages (ads) in channels + public var blockAds: Bool { + get { defaults.bool(forKey: Keys.blockAds) } + set { + defaults.set(newValue, forKey: Keys.blockAds) + notifySettingsChanged() + } + } + + /// Keep online status always active + public var alwaysOnline: Bool { + get { defaults.bool(forKey: Keys.alwaysOnline) } + set { + defaults.set(newValue, forKey: Keys.alwaysOnline) + notifySettingsChanged() + } + } + + // MARK: - Computed Properties (considers master toggle) + + public var shouldBypassCopyProtection: Bool { + return isEnabled && bypassCopyProtection + } + + public var shouldDisableViewOnceAutoDelete: Bool { + return isEnabled && disableViewOnceAutoDelete + } + + public var shouldBypassScreenshotProtection: Bool { + return isEnabled && bypassScreenshotProtection + } + + public var shouldBlockAds: Bool { + return isEnabled && blockAds + } + + public var shouldAlwaysBeOnline: Bool { + return isEnabled && alwaysOnline + } + + // MARK: - Utility + + public var activeFeatureCount: Int { + var count = 0 + if bypassCopyProtection { count += 1 } + if disableViewOnceAutoDelete { count += 1 } + if bypassScreenshotProtection { count += 1 } + if blockAds { count += 1 } + if alwaysOnline { count += 1 } + return count + } + + public func enableAll() { + bypassCopyProtection = true + disableViewOnceAutoDelete = true + bypassScreenshotProtection = true + blockAds = true + alwaysOnline = true + } + + public func disableAll() { + bypassCopyProtection = false + disableViewOnceAutoDelete = false + bypassScreenshotProtection = false + blockAds = false + alwaysOnline = false + } + + // MARK: - Notification + + public static let settingsChangedNotification = Notification.Name("MiscSettingsChanged") + + private func notifySettingsChanged() { + NotificationCenter.default.post(name: MiscSettingsManager.settingsChangedNotification, object: nil) + } + + // MARK: - Init + + private init() { + // Set default values if first launch + if !defaults.bool(forKey: "MiscSettings.initialized") { + defaults.set(true, forKey: "MiscSettings.initialized") + defaults.set(false, forKey: Keys.isEnabled) + defaults.set(true, forKey: Keys.bypassCopyProtection) + defaults.set(true, forKey: Keys.disableViewOnceAutoDelete) + defaults.set(true, forKey: Keys.bypassScreenshotProtection) + defaults.set(true, forKey: Keys.blockAds) + } + } +} diff --git a/submodules/TelegramCore/Sources/UserNotes/UserNotesManager.swift b/submodules/TelegramCore/Sources/UserNotes/UserNotesManager.swift new file mode 100644 index 00000000..e57b746c --- /dev/null +++ b/submodules/TelegramCore/Sources/UserNotes/UserNotesManager.swift @@ -0,0 +1,90 @@ +import Foundation + +/// UserNotesManager - Local storage for personal notes about users +/// Notes are stored ONLY on device, never synced to Telegram servers +public final class UserNotesManager { + public static let shared = UserNotesManager() + + private enum Keys { + static let notesPrefix = "UserNotes.note." + static let updatedAtPrefix = "UserNotes.updatedAt." + } + + private let defaults = UserDefaults.standard + + // MARK: - Public API + + /// Get note for a specific user by their peerId + public func getNote(for peerId: Int64) -> String? { + return defaults.string(forKey: Keys.notesPrefix + String(peerId)) + } + + /// Set note for a specific user (pass nil or empty string to delete) + public func setNote(_ note: String?, for peerId: Int64) { + let key = Keys.notesPrefix + String(peerId) + let dateKey = Keys.updatedAtPrefix + String(peerId) + + if let note = note, !note.isEmpty { + defaults.set(note, forKey: key) + defaults.set(Date(), forKey: dateKey) + } else { + defaults.removeObject(forKey: key) + defaults.removeObject(forKey: dateKey) + } + + notifyNoteChanged(peerId: peerId) + } + + /// Check if user has a note + public func hasNote(for peerId: Int64) -> Bool { + guard let note = getNote(for: peerId) else { return false } + return !note.isEmpty + } + + /// Get last update date for a note + public func getUpdatedAt(for peerId: Int64) -> Date? { + return defaults.object(forKey: Keys.updatedAtPrefix + String(peerId)) as? Date + } + + /// Get all peerIds that have notes + public func getAllNotedPeerIds() -> [Int64] { + let allKeys = defaults.dictionaryRepresentation().keys + return allKeys + .filter { $0.hasPrefix(Keys.notesPrefix) } + .compactMap { key -> Int64? in + let peerIdString = String(key.dropFirst(Keys.notesPrefix.count)) + return Int64(peerIdString) + } + } + + /// Delete all notes + public func deleteAllNotes() { + let peerIds = getAllNotedPeerIds() + for peerId in peerIds { + defaults.removeObject(forKey: Keys.notesPrefix + String(peerId)) + defaults.removeObject(forKey: Keys.updatedAtPrefix + String(peerId)) + } + NotificationCenter.default.post(name: UserNotesManager.notesChangedNotification, object: nil) + } + + /// Get notes count + public var notesCount: Int { + return getAllNotedPeerIds().count + } + + // MARK: - Notification + + public static let notesChangedNotification = Notification.Name("UserNotesChanged") + + private func notifyNoteChanged(peerId: Int64) { + NotificationCenter.default.post( + name: UserNotesManager.notesChangedNotification, + object: nil, + userInfo: ["peerId": peerId] + ) + } + + // MARK: - Init + + private init() {} +} diff --git a/submodules/TelegramCore/Sources/VoiceMorpher/VoiceMorpherManager.swift b/submodules/TelegramCore/Sources/VoiceMorpher/VoiceMorpherManager.swift new file mode 100644 index 00000000..c7cf7812 --- /dev/null +++ b/submodules/TelegramCore/Sources/VoiceMorpher/VoiceMorpherManager.swift @@ -0,0 +1,130 @@ +import Foundation +import AVFoundation + +/// VoiceMorpherManager - Manages voice morphing presets and settings +public final class VoiceMorpherManager { + public static let shared = VoiceMorpherManager() + + // MARK: - Voice Preset + + public enum VoicePreset: Int, CaseIterable { + case disabled = 0 + case anonymous = 1 + case female = 2 + case male = 3 + case child = 4 + case robot = 5 + + public var name: String { + switch self { + case .disabled: return "Выключено" + case .anonymous: return "Аноним" + case .female: return "Женский" + case .male: return "Мужской" + case .child: return "Ребёнок" + case .robot: return "Робот" + } + } + + public var description: String { + switch self { + case .disabled: return "Голос без изменений" + case .anonymous: return "Искаженный голос (как в новостях)" + case .female: return "Повышенный питч + форманты" + case .male: return "Пониженный питч + форманты" + case .child: return "Высокий детский голос" + case .robot: return "Металлический эффект" + } + } + + /// Pitch multiplier (1.0 = normal, >1 = higher, <1 = lower) + public var pitchShift: Float { + switch self { + case .disabled: return 0 + case .anonymous: return -200 // semitones down slightly + case .female: return 600 // More feminine - higher pitch + case .male: return -300 // semitones down + case .child: return 600 // high pitch + case .robot: return 0 // no pitch change for robot + } + } + + /// Rate adjustment + public var rate: Float { + switch self { + case .disabled: return 1.0 + case .anonymous: return 0.95 + case .female: return 1.08 // Slightly faster for feminine effect + case .male: return 0.95 + case .child: return 1.1 + case .robot: return 1.0 + } + } + + /// Distortion preset for robot effect + public var useDistortion: Bool { + return self == .robot || self == .anonymous + } + + /// Reverb amount (0-100) + public var reverbAmount: Float { + switch self { + case .anonymous: return 20 + case .robot: return 30 + default: return 0 + } + } + } + + // MARK: - Keys + + private enum Keys { + static let isEnabled = "VoiceMorpher.isEnabled" + static let selectedPreset = "VoiceMorpher.selectedPreset" + } + + private let defaults = UserDefaults.standard + + // MARK: - Properties + + /// Whether voice morphing is enabled + public var isEnabled: Bool { + get { defaults.bool(forKey: Keys.isEnabled) } + set { + defaults.set(newValue, forKey: Keys.isEnabled) + notifyChanged() + } + } + + /// Selected preset ID + public var selectedPresetId: Int { + get { defaults.integer(forKey: Keys.selectedPreset) } + set { + defaults.set(newValue, forKey: Keys.selectedPreset) + notifyChanged() + } + } + + /// Get selected preset + public var selectedPreset: VoicePreset { + return VoicePreset(rawValue: selectedPresetId) ?? .disabled + } + + /// Get effective preset (returns disabled if not enabled) + public var effectivePreset: VoicePreset { + guard isEnabled else { return .disabled } + return selectedPreset + } + + // MARK: - Notification + + public static let settingsChangedNotification = Notification.Name("VoiceMorpherSettingsChanged") + + private func notifyChanged() { + NotificationCenter.default.post(name: Self.settingsChangedNotification, object: nil) + } + + // MARK: - Init + + private init() {} +} diff --git a/submodules/TelegramUI/Sources/VoiceMorpher/VoiceMorpherEngine.swift b/submodules/TelegramUI/Sources/VoiceMorpher/VoiceMorpherEngine.swift new file mode 100644 index 00000000..8ae89350 --- /dev/null +++ b/submodules/TelegramUI/Sources/VoiceMorpher/VoiceMorpherEngine.swift @@ -0,0 +1,99 @@ +import Foundation +import AVFoundation +import OpusBinding +import TelegramCore + +/// VoiceMorpherEngine - Swift wrapper for OGG voice morphing +/// Uses VoiceMorpherProcessor (Obj-C) for OGG decode → effects → encode +public final class VoiceMorpherEngine { + + public static let shared = VoiceMorpherEngine() + + private init() {} + + // MARK: - Process OGG Data + + /// Process OGG/Opus audio data with current voice morpher preset + /// - Parameters: + /// - inputData: Original OGG/Opus audio data + /// - completion: Callback with processed OGG data or error + public func processOggData( + _ inputData: Data, + completion: @escaping (Result) -> Void + ) { + let preset = VoiceMorpherManager.shared.effectivePreset + + // If disabled, return original data + guard preset != .disabled else { + completion(.success(inputData)) + return + } + + // Convert Swift preset to ObjC preset + let objcPreset: VoiceMorpherPreset + switch preset { + case .disabled: + objcPreset = .disabled + case .anonymous: + objcPreset = .anonymous + case .female: + objcPreset = .female + case .male: + objcPreset = .male + case .child: + objcPreset = .child + case .robot: + objcPreset = .robot + } + + VoiceMorpherProcessor.processOggData(inputData, preset: objcPreset) { outputData, error in + if let error = error { + completion(.failure(error)) + } else if let outputData = outputData { + completion(.success(outputData)) + } else { + completion(.failure(VoiceMorpherError.processingFailed)) + } + } + } + + /// Synchronous version for use in existing pipelines + public func processOggDataSync(_ inputData: Data) -> Data { + let preset = VoiceMorpherManager.shared.effectivePreset + + guard preset != .disabled else { + return inputData + } + + // Use semaphore for sync call + let semaphore = DispatchSemaphore(value: 0) + var result = inputData + + processOggData(inputData) { processingResult in + switch processingResult { + case .success(let data): + result = data + case .failure: + // On error, return original + result = inputData + } + semaphore.signal() + } + + _ = semaphore.wait(timeout: .now() + 30) + return result + } + + // MARK: - Errors + + public enum VoiceMorpherError: Error, LocalizedError { + case processingFailed + + public var errorDescription: String? { + switch self { + case .processingFailed: + return "Voice morphing processing failed" + } + } + } +}