feat: add Ghostgram features
- Anti-Delete: save deleted messages locally - Ghost Mode: hide online status, read receipts - Voice Morpher: audio processing effects - Device Spoof: spoof device info - Custom GhostIcon app icon - User Notes: personal notes for contacts - Misc settings and controllers - GPLv2 License
@@ -0,0 +1,338 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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.
|
||||
|
||||
<signature of Moe Ghoul>, 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.
|
||||
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 623 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 623 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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."
|
||||
@@ -0,0 +1,36 @@
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String> = []
|
||||
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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
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
|
||||
@@ -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<NSNumber *, NSString *> *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<NSNumber *, NSString *> *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
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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<Data, Error>) -> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||