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"
+ }
+ }
+ }
+}