Version 11.9

This commit is contained in:
Kylmakalle 2024-07-02 19:58:37 +03:00
parent e5762bd9c8
commit 46c160e25e
797 changed files with 40410 additions and 2891 deletions

View File

@ -1,8 +1,8 @@
name: CI name: CI
on: on:
push: # push:
branches: [ master ] # branches: [ master ]
workflow_dispatch: workflow_dispatch:

6
.gitignore vendored
View File

@ -1,3 +1,9 @@
submodules/**/.build/*
swiftgram-scripts
Swiftgram/Playground/custom_bazel_path.bzl
Swiftgram/Playground/codesigning
buildServer.json
fastlane/README.md fastlane/README.md
fastlane/report.xml fastlane/report.xml
fastlane/test_output/* fastlane/test_output/*

5
.gitmodules vendored
View File

@ -1,7 +1,6 @@
[submodule "submodules/rlottie/rlottie"] [submodule "submodules/rlottie/rlottie"]
path = submodules/rlottie/rlottie path = submodules/rlottie/rlottie
url=../rlottie.git url=https://github.com/TelegramMessenger/rlottie.git
[submodule "build-system/bazel-rules/rules_apple"] [submodule "build-system/bazel-rules/rules_apple"]
path = build-system/bazel-rules/rules_apple path = build-system/bazel-rules/rules_apple
url=https://github.com/ali-fareed/rules_apple.git url=https://github.com/ali-fareed/rules_apple.git
@ -13,7 +12,7 @@ url=https://github.com/bazelbuild/rules_swift.git
url = https://github.com/bazelbuild/apple_support.git url = https://github.com/bazelbuild/apple_support.git
[submodule "submodules/TgVoipWebrtc/tgcalls"] [submodule "submodules/TgVoipWebrtc/tgcalls"]
path = submodules/TgVoipWebrtc/tgcalls path = submodules/TgVoipWebrtc/tgcalls
url=../tgcalls.git url=https://github.com/TelegramMessenger/tgcalls.git
[submodule "third-party/libvpx/libvpx"] [submodule "third-party/libvpx/libvpx"]
path = third-party/libvpx/libvpx path = third-party/libvpx/libvpx
url = https://github.com/webmproject/libvpx.git url = https://github.com/webmproject/libvpx.git

View File

@ -1,5 +1,5 @@
{ {
"sweetpad.build.xcodeWorkspacePath": "Telegram/Telegram.xcodeproj/project.xcworkspace", "sweetpad.build.xcodeWorkspacePath": "Telegram/Swiftgram.xcodeproj/project.xcworkspace",
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB", "lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
"lldb.launch.expressions": "native", "lldb.launch.expressions": "native",
"search.followSymlinks": false, "search.followSymlinks": false,

View File

@ -1,3 +1,16 @@
# Swiftgram
Supercharged Telegram fork for iOS
[<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" height="50">](https://apps.apple.com/app/apple-store/id6471879502?pt=126511626&ct=gh&mt=8)
- Download: [App Store](https://apps.apple.com/app/apple-store/id6471879502?pt=126511626&ct=gh&mt=8)
- Telegram channel: https://t.me/swiftgram
- Telegram chat: https://t.me/swiftgramchat
- TestFlight beta, local chats, translations and other [@SwiftgramLinks](https://t.me/s/SwiftgramLinks)
Swiftgram's compilation steps are the same as for the official app. Below you'll find a complete compilation guide based on the official app.
# Telegram iOS Source Code Compilation Guide # Telegram iOS Source Code Compilation Guide
We welcome all developers to use our API and source code to create applications on our platform. We welcome all developers to use our API and source code to create applications on our platform.
@ -16,7 +29,7 @@ There are several things we require from **all developers** for the moment.
## Get the Code ## Get the Code
``` ```
git clone --recursive -j8 https://github.com/TelegramMessenger/Telegram-iOS.git git clone --recursive -j8 https://github.com/Swiftgram/Telegram-iOS.git
``` ```
## Setup Xcode ## Setup Xcode
@ -29,7 +42,7 @@ Install Xcode (directly from https://developer.apple.com/download/applications o
``` ```
openssl rand -hex 8 openssl rand -hex 8
``` ```
2. Create a new Xcode project. Use `Telegram` as the Product Name. Use `org.{identifier from step 1}` as the Organization Identifier. 2. Create a new Xcode project. Use `Swiftgram` as the Product Name. Use `org.{identifier from step 1}` as the Organization Identifier.
3. Open `Keychain Access` and navigate to `Certificates`. Locate `Apple Development: your@email.address (XXXXXXXXXX)` and double tap the certificate. Under `Details`, locate `Organizational Unit`. This is the Team ID. 3. Open `Keychain Access` and navigate to `Certificates`. Locate `Apple Development: your@email.address (XXXXXXXXXX)` and double tap the certificate. Under `Details`, locate `Organizational Unit`. This is the Team ID.
4. Edit `build-system/template_minimal_development_configuration.json`. Use data from the previous steps. 4. Edit `build-system/template_minimal_development_configuration.json`. Use data from the previous steps.

View File

@ -0,0 +1,9 @@
filegroup(
name = "AppleStyleFolders",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
filegroup(
name = "ChatControllerImplExtension",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,225 @@
import SGSimpleSettings
import Foundation
import UIKit
import Postbox
import SwiftSignalKit
import Display
import AsyncDisplayKit
import TelegramCore
import SafariServices
import MobileCoreServices
import Intents
import LegacyComponents
import TelegramPresentationData
import TelegramUIPreferences
import DeviceAccess
import TextFormat
import TelegramBaseController
import AccountContext
import TelegramStringFormatting
import OverlayStatusController
import DeviceLocationManager
import ShareController
import UrlEscaping
import ContextUI
import ComposePollUI
import AlertUI
import PresentationDataUtils
import UndoUI
import TelegramCallsUI
import TelegramNotices
import GameUI
import ScreenCaptureDetection
import GalleryUI
import OpenInExternalAppUI
import LegacyUI
import InstantPageUI
import LocationUI
import BotPaymentsUI
import DeleteChatPeerActionSheetItem
import HashtagSearchUI
import LegacyMediaPickerUI
import Emoji
import PeerAvatarGalleryUI
import PeerInfoUI
import RaiseToListen
import UrlHandling
import AvatarNode
import AppBundle
import LocalizedPeerData
import PhoneNumberFormat
import SettingsUI
import UrlWhitelist
import TelegramIntents
import TooltipUI
import StatisticsUI
import MediaResources
import GalleryData
import ChatInterfaceState
import InviteLinksUI
import Markdown
import TelegramPermissionsUI
import Speak
import TranslateUI
import UniversalMediaPlayer
import WallpaperBackgroundNode
import ChatListUI
import CalendarMessageScreen
import ReactionSelectionNode
import ReactionListContextMenuContent
import AttachmentUI
import AttachmentTextInputPanelNode
import MediaPickerUI
import ChatPresentationInterfaceState
import Pasteboard
import ChatSendMessageActionUI
import ChatTextLinkEditUI
import WebUI
import PremiumUI
import ImageTransparency
import StickerPackPreviewUI
import TextNodeWithEntities
import EntityKeyboard
import ChatTitleView
import EmojiStatusComponent
import ChatTimerScreen
import MediaPasteboardUI
import ChatListHeaderComponent
import ChatControllerInteraction
import FeaturedStickersScreen
import ChatEntityKeyboardInputNode
import StorageUsageScreen
import AvatarEditorScreen
import ChatScheduleTimeController
import ICloudResources
import StoryContainerScreen
import MoreHeaderButton
import VolumeButtons
import ChatAvatarNavigationNode
import ChatContextQuery
import PeerReportScreen
import PeerSelectionController
import SaveToCameraRoll
import ChatMessageDateAndStatusNode
import ReplyAccessoryPanelNode
import TextSelectionNode
import ChatMessagePollBubbleContentNode
import ChatMessageItem
import ChatMessageItemImpl
import ChatMessageItemView
import ChatMessageItemCommon
import ChatMessageAnimatedStickerItemNode
import ChatMessageBubbleItemNode
import ChatNavigationButton
import WebsiteType
import ChatQrCodeScreen
import PeerInfoScreen
import MediaEditorScreen
import WallpaperGalleryScreen
import WallpaperGridScreen
import VideoMessageCameraScreen
import TopMessageReactions
import AudioWaveform
import PeerNameColorScreen
import ChatEmptyNode
import ChatMediaInputStickerGridItem
import AdsInfoScreen
extension ChatControllerImpl {
func forwardMessagesToCloud(messageIds: [MessageId], removeNames: Bool, openCloud: Bool, resetCurrent: Bool = false) {
let _ = (self.context.engine.data.get(EngineDataMap(
messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init)
))
|> deliverOnMainQueue).startStandalone(next: { [weak self] messages in
guard let strongSelf = self else {
return
}
if resetCurrent {
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(nil).withUpdatedForwardOptionsState(nil).withoutSelectionState() }) })
}
let sortedMessages = messages.values.compactMap { $0?._asMessage() }.sorted { lhs, rhs in
return lhs.id < rhs.id
}
var attributes: [MessageAttribute] = []
if removeNames {
attributes.append(ForwardOptionsMessageAttribute(hideNames: true, hideCaptions: false))
}
if !openCloud {
Queue.mainQueue().after(0.88) {
strongSelf.chatDisplayNode.hapticFeedback.success()
}
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: true, text: messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
if case .info = value, let strongSelf = self {
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.context.account.peerId))
|> deliverOnMainQueue).startStandalone(next: { peer in
guard let strongSelf = self, let peer = peer, let navigationController = strongSelf.effectiveNavigationController else {
return
}
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil))
})
return true
}
return false
}), in: .current)
}
let _ = (enqueueMessages(account: strongSelf.context.account, peerId: strongSelf.context.account.peerId, messages: sortedMessages.map { message -> EnqueueMessage in
return .forward(source: message.id, threadId: nil, grouping: .auto, attributes: attributes, correlationId: nil)
})
|> deliverOnMainQueue).startStandalone(next: { messageIds in
guard openCloud else {
return
}
if let strongSelf = self {
let signals: [Signal<Bool, NoError>] = messageIds.compactMap({ id -> Signal<Bool, NoError>? in
guard let id = id else {
return nil
}
return strongSelf.context.account.pendingMessageManager.pendingMessageStatus(id)
|> mapToSignal { status, _ -> Signal<Bool, NoError> in
if status != nil {
return .never()
} else {
return .single(true)
}
}
|> take(1)
})
if strongSelf.shareStatusDisposable == nil {
strongSelf.shareStatusDisposable = MetaDisposable()
}
strongSelf.shareStatusDisposable?.set((combineLatest(signals)
|> deliverOnMainQueue).startStrict(next: { [weak strongSelf] _ in
guard let strongSelf = strongSelf else {
return
}
strongSelf.chatDisplayNode.hapticFeedback.success()
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.context.account.peerId))
|> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] peer in
guard let strongSelf = strongSelf, let peer = peer, let navigationController = strongSelf.effectiveNavigationController else {
return
}
var navigationSubject: ChatControllerSubject? = nil
for messageId in messageIds {
if let messageId = messageId {
navigationSubject = .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false)
break
}
}
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), subject: navigationSubject, keepStack: .always, purposefulAction: {}, peekData: nil))
})
} ))
}
})
})
}
}

0
Swiftgram/FLEX/BUILD Normal file
View File

68
Swiftgram/FLEX/FLEX.BUILD Normal file
View File

@ -0,0 +1,68 @@
objc_library(
name = "FLEX",
module_name = "FLEX",
srcs = glob(
["Classes/**/*"],
exclude = [
"Classes/Info.plist",
"Classes/Utility/APPLE_LICENSE",
"Classes/Network/OSCache/LICENSE.md",
"Classes/Network/PonyDebugger/LICENSE",
"Classes/GlobalStateExplorers/DatabaseBrowser/LICENSE",
"Classes/GlobalStateExplorers/Keychain/SSKeychain_LICENSE",
"Classes/GlobalStateExplorers/SystemLog/LLVM_LICENSE.TXT",
]
),
hdrs = glob([
"Classes/**/*.h"
]),
includes = [
"Classes",
"Classes/Core",
"Classes/Core/Controllers",
"Classes/Core/Views",
"Classes/Core/Views/Cells",
"Classes/Core/Views/Carousel",
"Classes/ObjectExplorers",
"Classes/ObjectExplorers/Sections",
"Classes/ObjectExplorers/Sections/Shortcuts",
"Classes/Network",
"Classes/Network/PonyDebugger",
"Classes/Network/OSCache",
"Classes/Toolbar",
"Classes/Manager",
"Classes/Manager/Private",
"Classes/Editing",
"Classes/Editing/ArgumentInputViews",
"Classes/Headers",
"Classes/ExplorerInterface",
"Classes/ExplorerInterface/Tabs",
"Classes/ExplorerInterface/Bookmarks",
"Classes/GlobalStateExplorers",
"Classes/GlobalStateExplorers/Globals",
"Classes/GlobalStateExplorers/Keychain",
"Classes/GlobalStateExplorers/FileBrowser",
"Classes/GlobalStateExplorers/SystemLog",
"Classes/GlobalStateExplorers/DatabaseBrowser",
"Classes/GlobalStateExplorers/RuntimeBrowser",
"Classes/GlobalStateExplorers/RuntimeBrowser/DataSources",
"Classes/ViewHierarchy",
"Classes/ViewHierarchy/SnapshotExplorer",
"Classes/ViewHierarchy/SnapshotExplorer/Scene",
"Classes/ViewHierarchy/TreeExplorer",
"Classes/Utility",
"Classes/Utility/Runtime",
"Classes/Utility/Runtime/Objc",
"Classes/Utility/Runtime/Objc/Reflection",
"Classes/Utility/Categories",
"Classes/Utility/Categories/Private",
"Classes/Utility/Keyboard"
],
copts = [
"-Wno-deprecated-declarations",
"-Wno-strict-prototypes",
"-Wno-unsupported-availability-guard",
],
deps = [],
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,3 @@
--maxwidth 100
--indent 4
--disable redundantSelf

View File

@ -0,0 +1,87 @@
load("@bazel_skylib//rules:common_settings.bzl",
"bool_flag",
)
load("@build_bazel_rules_apple//apple:ios.bzl", "ios_application")
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
load(
"@rules_xcodeproj//xcodeproj:defs.bzl",
"top_level_targets",
"xcodeproj",
)
load(
"@build_configuration//:variables.bzl", "telegram_bazel_path"
)
bool_flag(
name = "disableProvisioningProfiles",
build_setting_default = False,
visibility = ["//visibility:public"],
)
config_setting(
name = "disableProvisioningProfilesSetting",
flag_values = {
":disableProvisioningProfiles": "True",
},
)
objc_library(
name = "PlaygroundMain",
srcs = [
"Sources/main.m"
],
)
swift_library(
name = "PlaygroundLib",
srcs = glob(["Sources/**/*.swift"]),
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/AsyncDisplayKit:AsyncDisplayKit",
"//submodules/LegacyUI:LegacyUI",
"//submodules/LegacyComponents:LegacyComponents",
"//submodules/MediaPlayer:UniversalMediaPlayer",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
],
data = [
"//Telegram:GeneratedPresentationStrings/Resources/PresentationStrings.data",
],
visibility = ["//visibility:public"],
)
ios_application(
name = "Playground",
bundle_id = "app.swiftgram.ios.Playground",
families = [
"iphone",
"ipad",
],
provisioning_profile = select({
":disableProvisioningProfilesSetting": None,
"//conditions:default": "codesigning/Playground.mobileprovision",
}),
infoplists = ["Resources/Info.plist"],
minimum_os_version = "14.0",
visibility = ["//visibility:public"],
strings = [
"//Telegram:AppStringResources",
],
launch_storyboard = "Resources/LaunchScreen.storyboard",
deps = [":PlaygroundMain", ":PlaygroundLib"],
)
xcodeproj(
bazel_path = telegram_bazel_path,
name = "Playground_xcodeproj",
build_mode = "bazel",
project_name = "Playground",
tags = ["manual"],
top_level_targets = top_level_targets(
labels = [
":Playground",
],
target_environments = ["device", "simulator"],
),
)

View File

@ -0,0 +1,25 @@
# Swiftgram Playground
Small app to quickly iterate on components testing without building an entire messenger.
## (Optional) Setup Codesigning
Create simple `codesigning/Playground.mobileprovision`. It is only required for non-simulator builds and can be skipped with `--disableProvisioningProfiles`.
## Generate Xcode project
Same as main project described in [../../Readme.md](../../Readme.md), but with `--target="Swiftgram/Playground"` parameter.
## Run generated project on simulator
### From root
```shell
./Swiftgram/Playground/launch_on_simulator.py
```
### From current directory
```shell
./launch_on_simulator.py
```

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UILaunchScreen</key>
<dict>
<key>UILaunchScreen</key>
<dict/>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" xcode11CocoaTouchSystemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
</document>

View File

@ -0,0 +1,82 @@
import UIKit
import SwiftUI
import AsyncDisplayKit
import Display
import LegacyUI
let SHOW_SAFE_AREA = false
@objc(AppDelegate)
final class AppDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
private var mainWindow: Window1?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
let statusBarHost = ApplicationStatusBarHost()
let (window, hostView) = nativeWindowHostView()
let mainWindow = Window1(hostView: hostView, statusBarHost: statusBarHost)
self.mainWindow = mainWindow
hostView.containerView.backgroundColor = UIColor.white
self.window = window
let navigationController = NavigationController(
mode: .single,
theme: NavigationControllerTheme(
statusBar: .black,
navigationBar: THEME.navigationBar,
emptyAreaColor: .white
)
)
mainWindow.viewController = navigationController
let rootViewController = mySwiftUIViewController(0)
if SHOW_SAFE_AREA {
// Add insets visualization
rootViewController.view.layoutMargins = .zero
rootViewController.view.subviews.forEach { $0.removeFromSuperview() }
let topInsetView = UIView()
let leftInsetView = UIView()
let rightInsetView = UIView()
let bottomInsetView = UIView()
[topInsetView, leftInsetView, rightInsetView, bottomInsetView].forEach {
$0.backgroundColor = .systemRed
$0.alpha = 0.3
rootViewController.view.addSubview($0)
$0.translatesAutoresizingMaskIntoConstraints = false
}
NSLayoutConstraint.activate([
topInsetView.topAnchor.constraint(equalTo: rootViewController.view.topAnchor),
topInsetView.leadingAnchor.constraint(equalTo: rootViewController.view.leadingAnchor),
topInsetView.trailingAnchor.constraint(equalTo: rootViewController.view.trailingAnchor),
topInsetView.bottomAnchor.constraint(equalTo: rootViewController.view.safeAreaLayoutGuide.topAnchor),
leftInsetView.topAnchor.constraint(equalTo: rootViewController.view.topAnchor),
leftInsetView.leadingAnchor.constraint(equalTo: rootViewController.view.leadingAnchor),
leftInsetView.bottomAnchor.constraint(equalTo: rootViewController.view.bottomAnchor),
leftInsetView.trailingAnchor.constraint(equalTo: rootViewController.view.safeAreaLayoutGuide.leadingAnchor),
rightInsetView.topAnchor.constraint(equalTo: rootViewController.view.topAnchor),
rightInsetView.trailingAnchor.constraint(equalTo: rootViewController.view.trailingAnchor),
rightInsetView.bottomAnchor.constraint(equalTo: rootViewController.view.bottomAnchor),
rightInsetView.leadingAnchor.constraint(equalTo: rootViewController.view.safeAreaLayoutGuide.trailingAnchor),
bottomInsetView.bottomAnchor.constraint(equalTo: rootViewController.view.bottomAnchor),
bottomInsetView.leadingAnchor.constraint(equalTo: rootViewController.view.leadingAnchor),
bottomInsetView.trailingAnchor.constraint(equalTo: rootViewController.view.trailingAnchor),
bottomInsetView.topAnchor.constraint(equalTo: rootViewController.view.safeAreaLayoutGuide.bottomAnchor)
])
}
navigationController.setViewControllers([rootViewController], animated: false)
self.window?.makeKeyAndVisible()
return true
}
}

View File

@ -0,0 +1,100 @@
import UIKit
import SwiftUI
import AsyncDisplayKit
import Display
public func isKeyboardWindow(window: NSObject) -> Bool {
let typeName = NSStringFromClass(type(of: window))
if #available(iOS 9.0, *) {
if typeName.hasPrefix("UI") && typeName.hasSuffix("RemoteKeyboardWindow") {
return true
}
} else {
if typeName.hasPrefix("UI") && typeName.hasSuffix("TextEffectsWindow") {
return true
}
}
return false
}
public func isKeyboardView(view: NSObject) -> Bool {
let typeName = NSStringFromClass(type(of: view))
if typeName.hasPrefix("UI") && typeName.hasSuffix("InputSetHostView") {
return true
}
return false
}
public func isKeyboardViewContainer(view: NSObject) -> Bool {
let typeName = NSStringFromClass(type(of: view))
if typeName.hasPrefix("UI") && typeName.hasSuffix("InputSetContainerView") {
return true
}
return false
}
public class ApplicationStatusBarHost: StatusBarHost {
private let application = UIApplication.shared
public var isApplicationInForeground: Bool {
switch self.application.applicationState {
case .background:
return false
default:
return true
}
}
public var statusBarFrame: CGRect {
return self.application.statusBarFrame
}
public var statusBarStyle: UIStatusBarStyle {
get {
return self.application.statusBarStyle
} set(value) {
self.setStatusBarStyle(value, animated: false)
}
}
public func setStatusBarStyle(_ style: UIStatusBarStyle, animated: Bool) {
if self.shouldChangeStatusBarStyle?(style) ?? true {
self.application.internalSetStatusBarStyle(style, animated: animated)
}
}
public var shouldChangeStatusBarStyle: ((UIStatusBarStyle) -> Bool)?
public func setStatusBarHidden(_ value: Bool, animated: Bool) {
self.application.internalSetStatusBarHidden(value, animation: animated ? .fade : .none)
}
public var keyboardWindow: UIWindow? {
if #available(iOS 16.0, *) {
return UIApplication.shared.internalGetKeyboard()
}
for window in UIApplication.shared.windows {
if isKeyboardWindow(window: window) {
return window
}
}
return nil
}
public var keyboardView: UIView? {
guard let keyboardWindow = self.keyboardWindow else {
return nil
}
for view in keyboardWindow.subviews {
if isKeyboardViewContainer(view: view) {
for subview in view.subviews {
if isKeyboardView(view: subview) {
return subview
}
}
}
}
return nil
}
}

View File

@ -0,0 +1,5 @@
import UIKit
@objc(Application) class Application: UIApplication {
}

View File

@ -0,0 +1,95 @@
import Foundation
import UIKit
import AsyncDisplayKit
import Display
private final class PlaygroundSplashScreenNode: ASDisplayNode {
private let headerBackgroundNode: ASDisplayNode
private let headerCornerNode: ASImageNode
private var isDismissed = false
private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
override init() {
self.headerBackgroundNode = ASDisplayNode()
self.headerBackgroundNode.backgroundColor = .black
self.headerCornerNode = ASImageNode()
self.headerCornerNode.displaysAsynchronously = false
self.headerCornerNode.displayWithoutProcessing = true
self.headerCornerNode.image = generateImage(CGSize(width: 20.0, height: 10.0), rotatedContext: { size, context in
context.setFillColor(UIColor.black.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
context.setBlendMode(.copy)
context.setFillColor(UIColor.clear.cgColor)
context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 20.0, height: 20.0)))
})?.stretchableImage(withLeftCapWidth: 10, topCapHeight: 1)
super.init()
self.backgroundColor = THEME.list.itemBlocksBackgroundColor
self.addSubnode(self.headerBackgroundNode)
self.addSubnode(self.headerCornerNode)
}
func containerLayoutUpdated(layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) {
if self.isDismissed {
return
}
self.validLayout = (layout, navigationHeight)
let headerHeight = navigationHeight + 260.0
transition.updateFrame(node: self.headerBackgroundNode, frame: CGRect(origin: CGPoint(x: -1.0, y: 0), size: CGSize(width: layout.size.width + 2.0, height: headerHeight)))
transition.updateFrame(node: self.headerCornerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: headerHeight), size: CGSize(width: layout.size.width, height: 10.0)))
}
func animateOut(completion: @escaping () -> Void) {
guard let (layout, navigationHeight) = self.validLayout else {
completion()
return
}
self.isDismissed = true
let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring)
let headerHeight = navigationHeight + 260.0
transition.updateFrame(node: self.headerBackgroundNode, frame: CGRect(origin: CGPoint(x: -1.0, y: -headerHeight - 10.0), size: CGSize(width: layout.size.width + 2.0, height: headerHeight)), completion: { _ in
completion()
})
transition.updateFrame(node: self.headerCornerNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -10.0), size: CGSize(width: layout.size.width, height: 10.0)))
}
}
public final class PlaygroundSplashScreen: ViewController {
public init() {
let navigationBarTheme = NavigationBarTheme(buttonColor: .white, disabledButtonColor: .white, primaryTextColor: .white, backgroundColor: .clear, enableBackgroundBlur: true, separatorColor: .clear, badgeBackgroundColor: THEME.navigationBar.badgeBackgroundColor, badgeStrokeColor: THEME.navigationBar.badgeStrokeColor, badgeTextColor: THEME.navigationBar.badgeTextColor)
super.init(navigationBarPresentationData: NavigationBarPresentationData(theme: navigationBarTheme, strings: NavigationBarStrings(back: "", close: "")))
self.statusBar.statusBarStyle = .White
}
required public init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func loadDisplayNode() {
self.displayNode = PlaygroundSplashScreenNode()
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
super.containerLayoutUpdated(layout, transition: transition)
(self.displayNode as! PlaygroundSplashScreenNode).containerLayoutUpdated(layout: layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition)
}
public func animateOut(completion: @escaping () -> Void) {
self.statusBar.statusBarStyle = .Black
(self.displayNode as! PlaygroundSplashScreenNode).animateOut(completion: completion)
}
}

View File

@ -0,0 +1,362 @@
import Foundation
import UIKit
import Display
import SwiftSignalKit
public final class PlaygroundInfoTheme {
public let buttonBackgroundColor: UIColor
public let buttonTextColor: UIColor
public let incomingFundsTitleColor: UIColor
public let outgoingFundsTitleColor: UIColor
public init(
buttonBackgroundColor: UIColor,
buttonTextColor: UIColor,
incomingFundsTitleColor: UIColor,
outgoingFundsTitleColor: UIColor
) {
self.buttonBackgroundColor = buttonBackgroundColor
self.buttonTextColor = buttonTextColor
self.incomingFundsTitleColor = incomingFundsTitleColor
self.outgoingFundsTitleColor = outgoingFundsTitleColor
}
}
public final class PlaygroundTransactionTheme {
public let descriptionBackgroundColor: UIColor
public let descriptionTextColor: UIColor
public init(
descriptionBackgroundColor: UIColor,
descriptionTextColor: UIColor
) {
self.descriptionBackgroundColor = descriptionBackgroundColor
self.descriptionTextColor = descriptionTextColor
}
}
public final class PlaygroundSetupTheme {
public let buttonFillColor: UIColor
public let buttonForegroundColor: UIColor
public let inputBackgroundColor: UIColor
public let inputPlaceholderColor: UIColor
public let inputTextColor: UIColor
public let inputClearButtonColor: UIColor
public init(
buttonFillColor: UIColor,
buttonForegroundColor: UIColor,
inputBackgroundColor: UIColor,
inputPlaceholderColor: UIColor,
inputTextColor: UIColor,
inputClearButtonColor: UIColor
) {
self.buttonFillColor = buttonFillColor
self.buttonForegroundColor = buttonForegroundColor
self.inputBackgroundColor = inputBackgroundColor
self.inputPlaceholderColor = inputPlaceholderColor
self.inputTextColor = inputTextColor
self.inputClearButtonColor = inputClearButtonColor
}
}
public final class PlaygroundListTheme {
public let itemPrimaryTextColor: UIColor
public let itemSecondaryTextColor: UIColor
public let itemPlaceholderTextColor: UIColor
public let itemDestructiveColor: UIColor
public let itemAccentColor: UIColor
public let itemDisabledTextColor: UIColor
public let plainBackgroundColor: UIColor
public let blocksBackgroundColor: UIColor
public let itemPlainSeparatorColor: UIColor
public let itemBlocksBackgroundColor: UIColor
public let itemBlocksSeparatorColor: UIColor
public let itemHighlightedBackgroundColor: UIColor
public let sectionHeaderTextColor: UIColor
public let freeTextColor: UIColor
public let freeTextErrorColor: UIColor
public let inputClearButtonColor: UIColor
public init(
itemPrimaryTextColor: UIColor,
itemSecondaryTextColor: UIColor,
itemPlaceholderTextColor: UIColor,
itemDestructiveColor: UIColor,
itemAccentColor: UIColor,
itemDisabledTextColor: UIColor,
plainBackgroundColor: UIColor,
blocksBackgroundColor: UIColor,
itemPlainSeparatorColor: UIColor,
itemBlocksBackgroundColor: UIColor,
itemBlocksSeparatorColor: UIColor,
itemHighlightedBackgroundColor: UIColor,
sectionHeaderTextColor: UIColor,
freeTextColor: UIColor,
freeTextErrorColor: UIColor,
inputClearButtonColor: UIColor
) {
self.itemPrimaryTextColor = itemPrimaryTextColor
self.itemSecondaryTextColor = itemSecondaryTextColor
self.itemPlaceholderTextColor = itemPlaceholderTextColor
self.itemDestructiveColor = itemDestructiveColor
self.itemAccentColor = itemAccentColor
self.itemDisabledTextColor = itemDisabledTextColor
self.plainBackgroundColor = plainBackgroundColor
self.blocksBackgroundColor = blocksBackgroundColor
self.itemPlainSeparatorColor = itemPlainSeparatorColor
self.itemBlocksBackgroundColor = itemBlocksBackgroundColor
self.itemBlocksSeparatorColor = itemBlocksSeparatorColor
self.itemHighlightedBackgroundColor = itemHighlightedBackgroundColor
self.sectionHeaderTextColor = sectionHeaderTextColor
self.freeTextColor = freeTextColor
self.freeTextErrorColor = freeTextErrorColor
self.inputClearButtonColor = inputClearButtonColor
}
}
public final class PlaygroundTheme: Equatable {
public let info: PlaygroundInfoTheme
public let transaction: PlaygroundTransactionTheme
public let setup: PlaygroundSetupTheme
public let list: PlaygroundListTheme
public let statusBarStyle: StatusBarStyle
public let navigationBar: NavigationBarTheme
public let keyboardAppearance: UIKeyboardAppearance
public let alert: AlertControllerTheme
public let actionSheet: ActionSheetControllerTheme
private let resourceCache = PlaygroundThemeResourceCache()
public init(info: PlaygroundInfoTheme, transaction: PlaygroundTransactionTheme, setup: PlaygroundSetupTheme, list: PlaygroundListTheme, statusBarStyle: StatusBarStyle, navigationBar: NavigationBarTheme, keyboardAppearance: UIKeyboardAppearance, alert: AlertControllerTheme, actionSheet: ActionSheetControllerTheme) {
self.info = info
self.transaction = transaction
self.setup = setup
self.list = list
self.statusBarStyle = statusBarStyle
self.navigationBar = navigationBar
self.keyboardAppearance = keyboardAppearance
self.alert = alert
self.actionSheet = actionSheet
}
func image(_ key: Int32, _ generate: (PlaygroundTheme) -> UIImage?) -> UIImage? {
return self.resourceCache.image(key, self, generate)
}
public static func ==(lhs: PlaygroundTheme, rhs: PlaygroundTheme) -> Bool {
return lhs === rhs
}
}
private final class PlaygroundThemeResourceCacheHolder {
var images: [Int32: UIImage] = [:]
}
private final class PlaygroundThemeResourceCache {
private let imageCache = Atomic<PlaygroundThemeResourceCacheHolder>(value: PlaygroundThemeResourceCacheHolder())
public func image(_ key: Int32, _ theme: PlaygroundTheme, _ generate: (PlaygroundTheme) -> UIImage?) -> UIImage? {
let result = self.imageCache.with { holder -> UIImage? in
return holder.images[key]
}
if let result = result {
return result
} else {
if let image = generate(theme) {
self.imageCache.with { holder -> Void in
holder.images[key] = image
}
return image
} else {
return nil
}
}
}
}
enum PlaygroundThemeResourceKey: Int32 {
case itemListCornersBoth
case itemListCornersTop
case itemListCornersBottom
case itemListClearInputIcon
case itemListDisclosureArrow
case navigationShareIcon
case transactionLockIcon
case clockMin
case clockFrame
}
func cornersImage(_ theme: PlaygroundTheme, top: Bool, bottom: Bool) -> UIImage? {
if !top && !bottom {
return nil
}
let key: PlaygroundThemeResourceKey
if top && bottom {
key = .itemListCornersBoth
} else if top {
key = .itemListCornersTop
} else {
key = .itemListCornersBottom
}
return theme.image(key.rawValue, { theme in
return generateImage(CGSize(width: 50.0, height: 50.0), rotatedContext: { (size, context) in
let bounds = CGRect(origin: CGPoint(), size: size)
context.setFillColor(theme.list.blocksBackgroundColor.cgColor)
context.fill(bounds)
context.setBlendMode(.clear)
var corners: UIRectCorner = []
if top {
corners.insert(.topLeft)
corners.insert(.topRight)
}
if bottom {
corners.insert(.bottomLeft)
corners.insert(.bottomRight)
}
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0))
context.addPath(path.cgPath)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 25)
})
}
func itemListClearInputIcon(_ theme: PlaygroundTheme) -> UIImage? {
return theme.image(PlaygroundThemeResourceKey.itemListClearInputIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Playground/ClearInput"), color: theme.list.inputClearButtonColor)
})
}
func navigationShareIcon(_ theme: PlaygroundTheme) -> UIImage? {
return theme.image(PlaygroundThemeResourceKey.navigationShareIcon.rawValue, { theme in
generateTintedImage(image: UIImage(bundleImageName: "Playground/NavigationShare"), color: theme.navigationBar.buttonColor)
})
}
func disclosureArrowImage(_ theme: PlaygroundTheme) -> UIImage? {
return theme.image(PlaygroundThemeResourceKey.itemListDisclosureArrow.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Playground/DisclosureArrow"), color: theme.list.itemSecondaryTextColor)
})
}
func clockFrameImage(_ theme: PlaygroundTheme) -> UIImage? {
return theme.image(PlaygroundThemeResourceKey.clockFrame.rawValue, { theme in
let color = theme.list.itemSecondaryTextColor
return generateImage(CGSize(width: 11.0, height: 11.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setStrokeColor(color.cgColor)
context.setFillColor(color.cgColor)
let strokeWidth: CGFloat = 1.0
context.setLineWidth(strokeWidth)
context.strokeEllipse(in: CGRect(x: strokeWidth / 2.0, y: strokeWidth / 2.0, width: size.width - strokeWidth, height: size.height - strokeWidth))
context.fill(CGRect(x: (11.0 - strokeWidth) / 2.0, y: strokeWidth * 3.0, width: strokeWidth, height: 11.0 / 2.0 - strokeWidth * 3.0))
})
})
}
func clockMinImage(_ theme: PlaygroundTheme) -> UIImage? {
return theme.image(PlaygroundThemeResourceKey.clockMin.rawValue, { theme in
let color = theme.list.itemSecondaryTextColor
return generateImage(CGSize(width: 11.0, height: 11.0), contextGenerator: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(color.cgColor)
let strokeWidth: CGFloat = 1.0
context.fill(CGRect(x: (11.0 - strokeWidth) / 2.0, y: (11.0 - strokeWidth) / 2.0, width: 11.0 / 2.0 - strokeWidth, height: strokeWidth))
})
})
}
func PlaygroundTransactionLockIcon(_ theme: PlaygroundTheme) -> UIImage? {
return theme.image(PlaygroundThemeResourceKey.transactionLockIcon.rawValue, { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Playground/EncryptedComment"), color: theme.list.itemSecondaryTextColor)
})
}
public let ACCENT_COLOR = UIColor(rgb: 0x007ee5)
public let NAVIGATION_BAR_THEME = NavigationBarTheme(
buttonColor: ACCENT_COLOR,
disabledButtonColor: UIColor(rgb: 0xd0d0d0),
primaryTextColor: .black,
backgroundColor: UIColor(rgb: 0xf7f7f7),
enableBackgroundBlur: true,
separatorColor: UIColor(rgb: 0xb1b1b1),
badgeBackgroundColor: UIColor(rgb: 0xff3b30),
badgeStrokeColor: UIColor(rgb: 0xff3b30),
badgeTextColor: .white
)
public let THEME = PlaygroundTheme(
info: PlaygroundInfoTheme(
buttonBackgroundColor: UIColor(rgb: 0x32aafe),
buttonTextColor: .white,
incomingFundsTitleColor: UIColor(rgb: 0x00b12c),
outgoingFundsTitleColor: UIColor(rgb: 0xff3b30)
), transaction: PlaygroundTransactionTheme(
descriptionBackgroundColor: UIColor(rgb: 0xf1f1f4),
descriptionTextColor: .black
), setup: PlaygroundSetupTheme(
buttonFillColor: ACCENT_COLOR,
buttonForegroundColor: .white,
inputBackgroundColor: UIColor(rgb: 0xe9e9e9),
inputPlaceholderColor: UIColor(rgb: 0x818086),
inputTextColor: .black,
inputClearButtonColor: UIColor(rgb: 0x7b7b81).withAlphaComponent(0.8)
),
list: PlaygroundListTheme(
itemPrimaryTextColor: .black,
itemSecondaryTextColor: UIColor(rgb: 0x8e8e93),
itemPlaceholderTextColor: UIColor(rgb: 0xc8c8ce),
itemDestructiveColor: UIColor(rgb: 0xff3b30),
itemAccentColor: ACCENT_COLOR,
itemDisabledTextColor: UIColor(rgb: 0x8e8e93),
plainBackgroundColor: .white,
blocksBackgroundColor: UIColor(rgb: 0xefeff4),
itemPlainSeparatorColor: UIColor(rgb: 0xc8c7cc),
itemBlocksBackgroundColor: .white,
itemBlocksSeparatorColor: UIColor(rgb: 0xc8c7cc),
itemHighlightedBackgroundColor: UIColor(rgb: 0xe5e5ea),
sectionHeaderTextColor: UIColor(rgb: 0x6d6d72),
freeTextColor: UIColor(rgb: 0x6d6d72),
freeTextErrorColor: UIColor(rgb: 0xcf3030),
inputClearButtonColor: UIColor(rgb: 0xcccccc)
),
statusBarStyle: .Black,
navigationBar: NAVIGATION_BAR_THEME,
keyboardAppearance: .light,
alert: AlertControllerTheme(
backgroundType: .light,
backgroundColor: .white,
separatorColor: UIColor(white: 0.9, alpha: 1.0),
highlightedItemColor: UIColor(rgb: 0xe5e5ea),
primaryColor: .black,
secondaryColor: UIColor(rgb: 0x5e5e5e),
accentColor: ACCENT_COLOR,
contrastColor: .green,
destructiveColor: UIColor(rgb: 0xff3b30),
disabledColor: UIColor(rgb: 0xd0d0d0),
controlBorderColor: .green,
baseFontSize: 17.0
),
actionSheet: ActionSheetControllerTheme(
dimColor: UIColor(white: 0.0, alpha: 0.4),
backgroundType: .light,
itemBackgroundColor: .white,
itemHighlightedBackgroundColor: UIColor(white: 0.9, alpha: 1.0),
standardActionTextColor: ACCENT_COLOR,
destructiveActionTextColor: UIColor(rgb: 0xff3b30),
disabledActionTextColor: UIColor(rgb: 0xb3b3b3),
primaryTextColor: .black,
secondaryTextColor: UIColor(rgb: 0x5e5e5e),
controlAccentColor: ACCENT_COLOR,
controlColor: UIColor(rgb: 0x7e8791),
switchFrameColor: UIColor(rgb: 0xe0e0e0),
switchContentColor: UIColor(rgb: 0x77d572),
switchHandleColor: UIColor(rgb: 0xffffff),
baseFontSize: 17.0
)
)

View File

@ -0,0 +1,85 @@
import AsyncDisplayKit
import Display
import Foundation
import LegacyUI
import SGSwiftUI
import SwiftUI
import TelegramPresentationData
import UIKit
struct MySwiftUIView: View {
weak var wrapperController: LegacyController?
var num: Int64
var body: some View {
ScrollView {
Text("Hello, World!")
.font(.title)
.foregroundColor(.black)
Spacer(minLength: 0)
Button("Push") {
self.wrapperController?.push(mySwiftUIViewController(num + 1))
}.buttonStyle(AppleButtonStyle())
Spacer()
Button("Modal") {
self.wrapperController?.present(
mySwiftUIViewController(num + 1),
in: .window(.root),
with: ViewControllerPresentationArguments(presentationAnimation: .modalSheet)
)
}.buttonStyle(AppleButtonStyle())
Spacer()
if num > 0 {
Button("Dismiss") {
self.wrapperController?.dismiss()
}.buttonStyle(AppleButtonStyle())
Spacer()
}
ForEach(1..<20, id: \.self) { i in
Button("TAP: \(i)") {
print("Tapped \(i)")
}.buttonStyle(AppleButtonStyle())
}
}
.background(Color.green)
}
}
struct AppleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(minWidth: 0, maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
.scaleEffect(configuration.isPressed ? 0.95 : 1)
.opacity(configuration.isPressed ? 0.9 : 1)
}
}
public func mySwiftUIViewController(_ num: Int64) -> ViewController {
let legacyController = LegacySwiftUIController(
presentation: .modal(animateIn: true),
theme: defaultPresentationTheme,
strings: defaultPresentationStrings
)
legacyController.statusBar.statusBarStyle = defaultPresentationTheme.rootController
.statusBarStyle.style
legacyController.title = "Controller: \(num)"
let swiftUIView = SGSwiftUIView<MySwiftUIView>(
navigationBarHeight: legacyController.navigationBarHeightModel,
containerViewLayout: legacyController.containerViewLayoutModel,
content: { MySwiftUIView(wrapperController: legacyController, num: num) }
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}

View File

@ -0,0 +1,7 @@
#import <UIKit/UIKit.h>
int main(int argc, char *argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, @"Application", @"AppDelegate");
}
}

View File

@ -0,0 +1,78 @@
#!/usr/bin/env python3
from contextlib import contextmanager
import os
import subprocess
import sys
import shutil
import textwrap
# Import the locate_bazel function
sys.path.append(
os.path.join(os.path.dirname(__file__), "..", "..", "build-system", "Make")
)
from BazelLocation import locate_bazel
@contextmanager
def cwd(path):
oldpwd = os.getcwd()
os.chdir(path)
try:
yield
finally:
os.chdir(oldpwd)
def main():
# Get the current script directory
current_script_dir = os.path.dirname(os.path.abspath(__file__))
with cwd(os.path.join(current_script_dir, "..", "..")):
bazel_path = locate_bazel(os.getcwd(), cache_host=None)
# 1. Kill all Xcode processes
subprocess.run(["killall", "Xcode"], check=False)
# 2. Delete xcodeproj.bazelrc if it exists and write a new one
bazelrc_path = os.path.join(current_script_dir, "..", "..", "xcodeproj.bazelrc")
if os.path.exists(bazelrc_path):
os.remove(bazelrc_path)
with open(bazelrc_path, "w") as f:
f.write(
textwrap.dedent(
"""
build --announce_rc
build --features=swift.use_global_module_cache
build --verbose_failures
build --features=swift.enable_batch_mode
build --features=-swift.debug_prefix_map
# build --disk_cache=
build --swiftcopt=-no-warnings-as-errors
build --copt=-Wno-error
"""
)
)
# 3. Delete the Xcode project if it exists
xcode_project_path = os.path.join(current_script_dir, "Playground.xcodeproj")
if os.path.exists(xcode_project_path):
shutil.rmtree(xcode_project_path)
# 4. Write content to generate_project.py
generate_project_path = os.path.join(current_script_dir, "custom_bazel_path.bzl")
with open(generate_project_path, "w") as f:
f.write("def custom_bazel_path():\n")
f.write(f' return "{bazel_path}"\n')
# 5. Run xcodeproj generator
working_dir = os.path.join(current_script_dir, "..", "..")
bazel_command = f'"{bazel_path}" run //Swiftgram/Playground:Playground_xcodeproj'
subprocess.run(bazel_command, shell=True, cwd=working_dir, check=True)
# 5. Open Xcode project
subprocess.run(["open", xcode_project_path], check=True)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
import subprocess
import json
import os
import time
def find_app(start_path):
for root, dirs, _ in os.walk(start_path):
for dir in dirs:
if dir.endswith(".app"):
return os.path.join(root, dir)
return None
def ensure_simulator_booted(device_name) -> str:
# List all devices
devices_json = subprocess.check_output(
["xcrun", "simctl", "list", "devices", "--json"]
).decode()
devices = json.loads(devices_json)
for runtime in devices["devices"]:
for device in devices["devices"][runtime]:
if device["name"] == device_name:
device_udid = device["udid"]
if device["state"] == "Booted":
print(f"Simulator {device_name} is already booted.")
return device_udid
break
if device_udid:
break
if not device_udid:
raise Exception(f"Simulator {device_name} not found")
# Boot the device
print(f"Booting simulator {device_name}...")
subprocess.run(["xcrun", "simctl", "boot", device_udid], check=True)
# Wait for the device to finish booting
print("Waiting for simulator to finish booting...")
while True:
boot_status = subprocess.check_output(
["xcrun", "simctl", "list", "devices"]
).decode()
if f"{device_name} ({device_udid}) (Booted)" in boot_status:
break
time.sleep(0.5)
print(f"Simulator {device_name} is now booted.")
return device_udid
def build_and_run_xcode_project(project_path, scheme_name, destination):
# Change to the directory containing the .xcodeproj file
os.chdir(os.path.dirname(project_path))
# Build the project
build_command = [
"xcodebuild",
"-project",
project_path,
"-scheme",
scheme_name,
"-destination",
destination,
"-sdk",
"iphonesimulator",
"build",
]
try:
subprocess.run(build_command, check=True)
print("Build successful!")
except subprocess.CalledProcessError as e:
print(f"Build failed with error: {e}")
return
# Get the bundle identifier and app path
settings_command = [
"xcodebuild",
"-project",
project_path,
"-scheme",
scheme_name,
"-sdk",
"iphonesimulator",
"-showBuildSettings",
]
try:
result = subprocess.run(
settings_command, capture_output=True, text=True, check=True
)
settings = result.stdout.split("\n")
bundle_id = next(
line.split("=")[1].strip()
for line in settings
if "PRODUCT_BUNDLE_IDENTIFIER" in line
)
build_dir = next(
line.split("=")[1].strip()
for line in settings
if "TARGET_BUILD_DIR" in line
)
app_path = find_app(build_dir)
if not app_path:
print(f"Could not find .app file in {build_dir}")
return
print(f"Found app at: {app_path}")
print(f"Bundle identifier: {bundle_id}")
print(f"App path: {app_path}")
except (subprocess.CalledProcessError, StopIteration) as e:
print(f"Failed to get build settings: {e}")
return
device_udid = ensure_simulator_booted(simulator_name)
# Install the app on the simulator
install_command = ["xcrun", "simctl", "install", device_udid, app_path]
try:
subprocess.run(install_command, check=True)
print("App installed on simulator successfully!")
except subprocess.CalledProcessError as e:
print(f"Failed to install app on simulator: {e}")
return
# List installed apps
try:
listapps_cmd = "/usr/bin/xcrun simctl listapps booted | /usr/bin/plutil -convert json -r -o - -- -"
result = subprocess.run(
listapps_cmd, shell=True, capture_output=True, text=True, check=True
)
apps = json.loads(result.stdout)
if bundle_id in apps:
print(f"App {bundle_id} is installed on the simulator")
else:
print(f"App {bundle_id} is not installed on the simulator")
print("Installed apps:", list(apps.keys()))
except subprocess.CalledProcessError as e:
print(f"Failed to list apps: {e}")
except json.JSONDecodeError as e:
print(f"Failed to parse app list: {e}")
# Focus simulator
subprocess.run(["open", "-a", "Simulator"], check=True)
# Run the project on the simulator
run_command = ["xcrun", "simctl", "launch", "booted", bundle_id]
try:
subprocess.run(run_command, check=True)
print("Application launched in simulator!")
except subprocess.CalledProcessError as e:
print(f"Failed to launch application in simulator: {e}")
# Usage
current_script_dir = os.path.dirname(os.path.abspath(__file__))
project_path = os.path.join(current_script_dir, "Playground.xcodeproj")
scheme_name = "Playground"
simulator_name = "iPhone 15"
destination = f"platform=iOS Simulator,name={simulator_name},OS=latest"
if __name__ == "__main__":
build_and_run_xcode_project(project_path, scheme_name, destination)

View File

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SFSafariViewControllerPlus",
module_name = "SFSafariViewControllerPlus",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,14 @@
import SafariServices
public class SFSafariViewControllerPlusDidFinish: SFSafariViewController, SFSafariViewControllerDelegate {
public var onDidFinish: (() -> Void)?
public override init(url URL: URL, configuration: SFSafariViewController.Configuration = SFSafariViewController.Configuration()) {
super.init(url: URL, configuration: configuration)
self.delegate = self
}
public func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
onDidFinish?()
}
}

25
Swiftgram/SGAPI/BUILD Normal file
View File

@ -0,0 +1,25 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGAPI",
module_name = "SGAPI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGWebAppExtensions:SGWebAppExtensions",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGWebSettingsScheme:SGWebSettingsScheme",
"//Swiftgram/SGRegDateScheme:SGRegDateScheme",
"//Swiftgram/SGRequests:SGRequests",
"//Swiftgram/SGConfig:SGConfig"
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,188 @@
import Foundation
import SwiftSignalKit
import SGConfig
import SGLogging
import SGSimpleSettings
import SGWebAppExtensions
import SGWebSettingsScheme
import SGRequests
import SGRegDateScheme
private let API_VERSION: String = "0"
private func buildApiUrl(_ endpoint: String) -> String {
return "\(SG_CONFIG.apiUrl)/v\(API_VERSION)/\(endpoint)"
}
public let SG_API_AUTHORIZATION_HEADER = "Authorization"
public let SG_API_DEVICE_TOKEN_HEADER = "Device-Token"
private enum HTTPRequestError {
case network
}
public enum SGAPIError {
case generic(String? = nil)
}
public func getSGSettings(token: String) -> Signal<SGWebSettings, SGAPIError> {
return Signal { subscriber in
let url = URL(string: buildApiUrl("settings"))!
let headers = [SG_API_AUTHORIZATION_HEADER: "Token \(token)"]
let completed = Atomic<Bool>(value: false)
var request = URLRequest(url: url)
headers.forEach { key, value in
request.addValue(value, forHTTPHeaderField: key)
}
let downloadSignal = requestsCustom(request: request).start(next: { data, urlResponse in
let _ = completed.swap(true)
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let settings = try decoder.decode(SGWebSettings.self, from: data)
subscriber.putNext(settings)
subscriber.putCompletion()
} catch {
subscriber.putError(.generic("Can't parse user settings: \(error). Response: \(String(data: data, encoding: .utf8) ?? "")"))
}
}, error: { error in
subscriber.putError(.generic("Error requesting user settings: \(String(describing: error))"))
})
return ActionDisposable {
if !completed.with({ $0 }) {
downloadSignal.dispose()
}
}
}
}
public func postSGSettings(token: String, data: [String:Any]) -> Signal<Void, SGAPIError> {
return Signal { subscriber in
let url = URL(string: buildApiUrl("settings"))!
let headers = [SG_API_AUTHORIZATION_HEADER: "Token \(token)"]
let completed = Atomic<Bool>(value: false)
var request = URLRequest(url: url)
headers.forEach { key, value in
request.addValue(value, forHTTPHeaderField: key)
}
request.httpMethod = "POST"
let jsonData = try? JSONSerialization.data(withJSONObject: data, options: [])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
let dataSignal = requestsCustom(request: request).start(next: { data, urlResponse in
let _ = completed.swap(true)
if let httpResponse = urlResponse as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200...299:
subscriber.putCompletion()
default:
subscriber.putError(.generic("Can't update settings: \(httpResponse.statusCode). Response: \(String(data: data, encoding: .utf8) ?? "")"))
}
} else {
subscriber.putError(.generic("Not an HTTP response: \(String(describing: urlResponse))"))
}
}, error: { error in
subscriber.putError(.generic("Error updating settings: \(String(describing: error))"))
})
return ActionDisposable {
if !completed.with({ $0 }) {
dataSignal.dispose()
}
}
}
}
public func getSGAPIRegDate(token: String, deviceToken: String, userId: Int64) -> Signal<RegDate, SGAPIError> {
return Signal { subscriber in
let url = URL(string: buildApiUrl("regdate/\(userId)"))!
let headers = [
SG_API_AUTHORIZATION_HEADER: "Token \(token)",
SG_API_DEVICE_TOKEN_HEADER: deviceToken
]
let completed = Atomic<Bool>(value: false)
var request = URLRequest(url: url)
headers.forEach { key, value in
request.addValue(value, forHTTPHeaderField: key)
}
request.timeoutInterval = 10
let downloadSignal = requestsCustom(request: request).start(next: { data, urlResponse in
let _ = completed.swap(true)
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let settings = try decoder.decode(RegDate.self, from: data)
subscriber.putNext(settings)
subscriber.putCompletion()
} catch {
subscriber.putError(.generic("Can't parse regDate: \(error). Response: \(String(data: data, encoding: .utf8) ?? "")"))
}
}, error: { error in
subscriber.putError(.generic("Error requesting regDate: \(String(describing: error))"))
})
return ActionDisposable {
if !completed.with({ $0 }) {
downloadSignal.dispose()
}
}
}
}
public func postSGReceipt(token: String, deviceToken: String, encodedReceiptData: Data) -> Signal<Void, SGAPIError> {
return Signal { subscriber in
let url = URL(string: buildApiUrl("validate"))!
let headers = [
SG_API_AUTHORIZATION_HEADER: "Token \(token)",
SG_API_DEVICE_TOKEN_HEADER: deviceToken
]
let completed = Atomic<Bool>(value: false)
var request = URLRequest(url: url)
headers.forEach { key, value in
request.addValue(value, forHTTPHeaderField: key)
}
request.httpMethod = "POST"
request.httpBody = encodedReceiptData
let dataSignal = requestsCustom(request: request).start(next: { data, urlResponse in
let _ = completed.swap(true)
if let httpResponse = urlResponse as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200...299:
subscriber.putCompletion()
default:
subscriber.putError(.generic("Error posting Receipt: \(httpResponse.statusCode). Response: \(String(data: data, encoding: .utf8) ?? "")"))
}
} else {
subscriber.putError(.generic("Not an HTTP response: \(String(describing: urlResponse))"))
}
}, error: { error in
subscriber.putError(.generic("Error posting Receipt: \(String(describing: error))"))
})
return ActionDisposable {
if !completed.with({ $0 }) {
dataSignal.dispose()
}
}
}
}

View File

@ -0,0 +1,24 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGAPIToken",
module_name = "SGAPIToken",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/AccountContext:AccountContext",
"//submodules/TelegramCore:TelegramCore",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGWebSettingsScheme:SGWebSettingsScheme",
"//Swiftgram/SGConfig:SGConfig",
"//Swiftgram/SGWebAppExtensions:SGWebAppExtensions",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,133 @@
import Foundation
import SwiftSignalKit
import AccountContext
import TelegramCore
import SGLogging
import SGConfig
import SGWebAppExtensions
private let tokenExpirationTime: TimeInterval = 30 * 60 // 30 minutes
private var tokenCache: [Int64: (token: String, expiration: Date)] = [:]
public enum SGAPITokenError {
case generic(String? = nil)
}
public func getSGApiToken(context: AccountContext, botUsername: String = SG_CONFIG.botUsername) -> Signal<String, SGAPITokenError> {
let userId = context.account.peerId.id._internalGetInt64Value()
if let (token, expiration) = tokenCache[userId], Date() < expiration {
// SGLogger.shared.log("SGAPI", "Using cached token. Expiring at: \(expiration)")
return Signal { subscriber in
subscriber.putNext(token)
subscriber.putCompletion()
return EmptyDisposable
}
}
SGLogger.shared.log("SGAPI", "Requesting new token")
// Workaround for Apple Review
if context.account.testingEnvironment {
return context.account.postbox.transaction { transaction -> String? in
if let testUserPeer = transaction.getPeer(context.account.peerId) as? TelegramUser, let testPhone = testUserPeer.phone {
return testPhone
} else {
return nil
}
}
|> mapToSignalPromotingError { phone -> Signal<String, SGAPITokenError> in
if let phone = phone {
// https://core.telegram.org/api/auth#test-accounts
if phone.starts(with: String(99966)) {
SGLogger.shared.log("SGAPI", "Using demo token")
tokenCache[userId] = (phone, Date().addingTimeInterval(tokenExpirationTime))
return .single(phone)
} else {
return .fail(.generic("Non-demo phone number on test DC"))
}
} else {
return .fail(.generic("Missing test account peer or it's number (how?)"))
}
}
}
return Signal { subscriber in
let getSettingsURLSignal = getSGSettingsURL(context: context, botUsername: botUsername).start(next: { url in
if let hashPart = url.components(separatedBy: "#").last {
let parsedParams = urlParseHashParams(hashPart)
if let token = parsedParams["tgWebAppData"], let token = token {
tokenCache[userId] = (token, Date().addingTimeInterval(tokenExpirationTime))
#if DEBUG
print("[SGAPI]", "API Token: \(token)")
#endif
subscriber.putNext(token)
subscriber.putCompletion()
} else {
subscriber.putError(.generic("Invalid or missing token in response url! \(url)"))
}
} else {
subscriber.putError(.generic("No hash part in URL \(url)"))
}
})
return ActionDisposable {
getSettingsURLSignal.dispose()
}
}
}
public func getSGSettingsURL(context: AccountContext, botUsername: String = SG_CONFIG.botUsername, url: String = SG_CONFIG.webappUrl, themeParams: [String: Any]? = nil) -> Signal<String, SGAPITokenError> {
return Signal { subscriber in
// themeParams = generateWebAppThemeParams(
// context.sharedContext.currentPresentationData.with { $0 }.theme
// )
var requestWebViewSignalDisposable: Disposable? = nil
var requestUpdatePeerIsBlocked: Disposable? = nil
let resolvePeerSignal = (
context.engine.peers.resolvePeerByName(name: botUsername, referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
return .complete()
}
return .single(result)
}).start(next: { botPeer in
if let botPeer = botPeer {
SGLogger.shared.log("SGAPI", "Botpeer found for \(botUsername)")
let requestWebViewSignal = context.engine.messages.requestWebView(peerId: botPeer.id, botId: botPeer.id, url: url, payload: nil, themeParams: themeParams, fromMenu: true, replyToMessageId: nil, threadId: nil)
requestWebViewSignalDisposable = requestWebViewSignal.start(next: { webViewResult in
subscriber.putNext(webViewResult.url)
subscriber.putCompletion()
}, error: { e in
SGLogger.shared.log("SGAPI", "Webview request error, retrying with unblock")
// if e.errorDescription == "YOU_BLOCKED_USER" {
requestUpdatePeerIsBlocked = (context.engine.privacy.requestUpdatePeerIsBlocked(peerId: botPeer.id, isBlocked: false)
|> afterDisposed(
{
requestWebViewSignalDisposable?.dispose()
requestWebViewSignalDisposable = requestWebViewSignal.start(next: { webViewResult in
SGLogger.shared.log("SGAPI", "Webview retry success \(webViewResult)")
subscriber.putNext(webViewResult.url)
subscriber.putCompletion()
}, error: { e in
SGLogger.shared.log("SGAPI", "Webview retry failure \(e)")
subscriber.putError(.generic("Webview retry failure \(e)"))
})
})).start()
// }
})
} else {
SGLogger.shared.log("SGAPI", "Botpeer not found for \(botUsername)")
subscriber.putError(.generic())
}
})
return ActionDisposable {
resolvePeerSignal.dispose()
requestUpdatePeerIsBlocked?.dispose()
requestWebViewSignalDisposable?.dispose()
}
}
}

View File

@ -0,0 +1,23 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGAPIWebSettings",
module_name = "SGAPIWebSettings",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGAPI:SGAPI",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//submodules/AccountContext:AccountContext",
"//submodules/TelegramCore:TelegramCore",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,50 @@
import Foundation
import SGAPIToken
import SGAPI
import SGLogging
import AccountContext
import SGSimpleSettings
import TelegramCore
public func updateSGWebSettingsInteractivelly(context: AccountContext) {
let _ = getSGApiToken(context: context).startStandalone(next: { token in
let _ = getSGSettings(token: token).startStandalone(next: { webSettings in
SGLogger.shared.log("SGAPI", "New SGWebSettings for id \(context.account.peerId.id._internalGetInt64Value()): \(webSettings) ")
SGSimpleSettings.shared.canUseStealthMode = webSettings.global.storiesAvailable
SGSimpleSettings.shared.duckyAppIconAvailable = webSettings.global.duckyAppIconAvailable
let _ = (context.account.postbox.transaction { transaction in
updateAppConfiguration(transaction: transaction, { configuration -> AppConfiguration in
var configuration = configuration
configuration.sgWebSettings = webSettings
return configuration
})
}).startStandalone()
}, error: { e in
if case let .generic(errorMessage) = e, let errorMessage = errorMessage {
SGLogger.shared.log("SGAPI", errorMessage)
}
})
}, error: { e in
if case let .generic(errorMessage) = e, let errorMessage = errorMessage {
SGLogger.shared.log("SGAPI", errorMessage)
}
})
}
public func postSGWebSettingsInteractivelly(context: AccountContext, data: [String: Any]) {
let _ = getSGApiToken(context: context).startStandalone(next: { token in
let _ = postSGSettings(token: token, data: data).startStandalone(error: { e in
if case let .generic(errorMessage) = e, let errorMessage = errorMessage {
SGLogger.shared.log("SGAPI", errorMessage)
}
})
}, error: { e in
if case let .generic(errorMessage) = e, let errorMessage = errorMessage {
SGLogger.shared.log("SGAPI", errorMessage)
}
})
}

View File

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGActionRequestHandlerSanitizer",
module_name = "SGActionRequestHandlerSanitizer",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,15 @@
import Foundation
public func sgActionRequestHandlerSanitizer(_ url: URL) -> URL {
var url = url
if let scheme = url.scheme {
let openInPrefix = "\(scheme)://parseurl?url="
let urlString = url.absoluteString
if urlString.hasPrefix(openInPrefix) {
if let unwrappedUrlString = String(urlString.dropFirst(openInPrefix.count)).removingPercentEncoding, let newUrl = URL(string: unwrappedUrlString) {
url = newUrl
}
}
}
return url
}

View File

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGAppGroupIdentifier",
module_name = "SGAppGroupIdentifier",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,28 @@
import Foundation
let fallbackBaseBundleId: String = "app.swiftgram.ios"
public func sgAppGroupIdentifier() -> String {
let baseBundleId: String
if let bundleId: String = Bundle.main.bundleIdentifier {
if Bundle.main.bundlePath.hasSuffix(".appex") {
if let lastDotRange: Range<String.Index> = bundleId.range(of: ".", options: [.backwards]) {
baseBundleId = String(bundleId[..<lastDotRange.lowerBound])
} else {
baseBundleId = fallbackBaseBundleId
}
} else {
baseBundleId = bundleId
}
} else {
baseBundleId = fallbackBaseBundleId
}
let result: String = "group.\(baseBundleId)"
#if DEBUG
print("APP_GROUP_IDENTIFIER: \(result)")
#endif
return result
}

18
Swiftgram/SGConfig/BUILD Normal file
View File

@ -0,0 +1,18 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGConfig",
module_name = "SGConfig",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/BuildConfig:BuildConfig"
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,21 @@
import Foundation
import BuildConfig
public struct SGConfig: Codable {
public var apiUrl: String = "https://api.swiftgram.app"
public var webappUrl: String = "https://my.swiftgram.app"
public var botUsername: String = "SwiftgramBot"
public var iaps: [String] = []
}
private func parseSGConfig(_ jsonString: String) -> SGConfig {
let jsonData = Data(jsonString.utf8)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return (try? decoder.decode(SGConfig.self, from: jsonData)) ?? SGConfig()
}
private let baseAppBundleId = Bundle.main.bundleIdentifier!
private let buildConfig = BuildConfig(baseAppBundleId: baseAppBundleId)
public let SG_CONFIG: SGConfig = parseSGConfig(buildConfig.sgConfig)
public let SG_API_WEBAPP_URL_PARSED = URL(string: SG_CONFIG.webappUrl)!

View File

@ -0,0 +1,18 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGContentAnalysis",
module_name = "SGContentAnalysis",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,64 @@
import SensitiveContentAnalysis
import SwiftSignalKit
public enum ContentAnalysisError: Error {
case generic(_ message: String)
}
public enum ContentAnalysisMediaType {
case image
case video
}
public func canAnalyzeMedia() -> Bool {
if #available(iOS 17, *) {
let analyzer = SCSensitivityAnalyzer()
let policy = analyzer.analysisPolicy
return policy != .disabled
} else {
return false
}
}
public func analyzeMediaSignal(_ url: URL, mediaType: ContentAnalysisMediaType = .image) -> Signal<Bool, Error> {
return Signal { subscriber in
analyzeMedia(url: url, mediaType: mediaType, completion: { result, error in
if let result = result {
subscriber.putNext(result)
subscriber.putCompletion()
} else if let error = error {
subscriber.putError(error)
} else {
subscriber.putError(ContentAnalysisError.generic("Unknown response"))
}
})
return ActionDisposable {
}
}
}
private func analyzeMedia(url: URL, mediaType: ContentAnalysisMediaType, completion: @escaping (Bool?, Error?) -> Void) {
if #available(iOS 17, *) {
let analyzer = SCSensitivityAnalyzer()
switch mediaType {
case .image:
analyzer.analyzeImage(at: url) { analysisResult, analysisError in
completion(analysisResult?.isSensitive, analysisError)
}
case .video:
Task {
do {
let handler = analyzer.videoAnalysis(forFileAt: url)
let response = try await handler.hasSensitiveContent()
completion(response.isSensitive, nil)
} catch {
completion(nil, error)
}
}
}
} else {
completion(false, nil)
}
}

View File

@ -0,0 +1,9 @@
filegroup(
name = "SGDBReset",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,162 @@
import UIKit
import Foundation
import SGLogging
private let dbResetKey = "sg_db_reset"
private let dbHardResetKey = "sg_db_hard_reset"
public func sgDBResetIfNeeded(databasePath: String, present: ((UIViewController) -> ())?) {
guard UserDefaults.standard.bool(forKey: dbResetKey) else {
return
}
NSLog("[SG.DBReset] Resetting DB with system settings")
let alert = UIAlertController(
title: "Metadata Reset.\nPlease wait...",
message: nil,
preferredStyle: .alert
)
present?(alert)
do {
let _ = try FileManager.default.removeItem(atPath: databasePath)
NSLog("[SG.DBReset] Done. Reset completed")
let successAlert = UIAlertController(
title: "Metadata Reset completed",
message: nil,
preferredStyle: .alert
)
successAlert.addAction(UIAlertAction(title: "Restart App", style: .cancel) { _ in
exit(0)
})
successAlert.addAction(UIAlertAction(title: "OK", style: .default))
alert.dismiss(animated: false) {
present?(successAlert)
}
} catch {
NSLog("[SG.DBReset] ERROR. Failed to reset database: \(error)")
let failAlert = UIAlertController(
title: "ERROR. Failed to Reset database",
message: "\(error)",
preferredStyle: .alert
)
alert.dismiss(animated: false) {
present?(failAlert)
}
}
UserDefaults.standard.set(false, forKey: dbResetKey)
// let semaphore = DispatchSemaphore(value: 0)
// semaphore.wait()
}
public func sgHardReset(dataPath: String, present: ((UIViewController) -> ())?) {
let startAlert = UIAlertController(
title: "ATTENTION",
message: "Confirm RESET ALL?",
preferredStyle: .alert
)
startAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
exit(0)
})
startAlert.addAction(UIAlertAction(title: "RESET", style: .destructive) { _ in
let ensureAlert = UIAlertController(
title: "⚠️ ATTENTION ⚠️",
message: "ARE YOU SURE you want to make a RESET ALL?",
preferredStyle: .alert
)
ensureAlert.addAction(UIAlertAction(title: "Cancel", style: .default) { _ in
exit(0)
})
ensureAlert.addAction(UIAlertAction(title: "RESET NOW", style: .destructive) { _ in
NSLog("[SG.DBReset] Reset All with system settings")
let alert = UIAlertController(
title: "Reset All.\nPlease wait...",
message: nil,
preferredStyle: .alert
)
ensureAlert.dismiss(animated: false) {
present?(alert)
}
do {
let fileManager = FileManager.default
let contents = try fileManager.contentsOfDirectory(atPath: dataPath)
// Filter directories that match our criteria
let accountDirectories = contents.compactMap { filename in
let fullPath = (dataPath as NSString).appendingPathComponent(filename)
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory), isDirectory.boolValue {
if filename.hasPrefix("account-") || filename == "accounts-metadata" {
return fullPath
}
}
return nil
}
NSLog("[SG.DBReset] Found \(accountDirectories.count) account dirs...")
var deletedPostboxCount = 0
for accountDir in accountDirectories {
let accountName = (accountDir as NSString).lastPathComponent
let postboxPath = (accountDir as NSString).appendingPathComponent("postbox")
var isPostboxDir: ObjCBool = false
if fileManager.fileExists(atPath: postboxPath, isDirectory: &isPostboxDir), isPostboxDir.boolValue {
// Delete postbox/db
let dbPath = (postboxPath as NSString).appendingPathComponent("db")
var isDbDir: ObjCBool = false
if fileManager.fileExists(atPath: dbPath, isDirectory: &isDbDir), isDbDir.boolValue {
NSLog("[SG.DBReset] Trying to delete postbox/db in: \(accountName)")
try fileManager.removeItem(atPath: dbPath)
NSLog("[SG.DBReset] OK. Deleted postbox/db directory in: \(accountName)")
}
// Delete postbox/media
let mediaPath = (postboxPath as NSString).appendingPathComponent("media")
var isMediaDir: ObjCBool = false
if fileManager.fileExists(atPath: mediaPath, isDirectory: &isMediaDir), isMediaDir.boolValue {
NSLog("[SG.DBReset] Trying to delete postbox/media in: \(accountName)")
try fileManager.removeItem(atPath: mediaPath)
NSLog("[SG.DBReset] OK. Deleted postbox/media directory in: \(accountName)")
}
deletedPostboxCount += 1
}
}
NSLog("[SG.DBReset] Done. Reset All completed")
let successAlert = UIAlertController(
title: "Reset All completed",
message: nil,
preferredStyle: .alert
)
successAlert.addAction(UIAlertAction(title: "Restart App", style: .cancel) { _ in
exit(0)
})
alert.dismiss(animated: false) {
present?(successAlert)
}
} catch {
NSLog("[SG.DBReset] ERROR. Reset All failed: \(error)")
let failAlert = UIAlertController(
title: "ERROR. Reset All failed",
message: "\(error)",
preferredStyle: .alert
)
alert.dismiss(animated: false) {
present?(failAlert)
}
}
})
ensureAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in
exit(0)
})
present?(ensureAlert)
})
present?(startAlert)
UserDefaults.standard.set(false, forKey: dbHardResetKey)
}

51
Swiftgram/SGDebugUI/BUILD Normal file
View File

@ -0,0 +1,51 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
config_setting(
name = "debug_build",
values = {
"compilation_mode": "dbg",
},
)
flex_dependency = select({
":debug_build": [
"@flex_sdk//:FLEX"
],
"//conditions:default": [],
})
swift_library(
name = "SGDebugUI",
module_name = "SGDebugUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGItemListUI:SGItemListUI",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
"//Swiftgram/SGIAP:SGIAP",
"//Swiftgram/SGPayWall:SGPayWall",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/LegacyUI:LegacyUI",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Postbox:Postbox",
"//submodules/Display:Display",
"//submodules/TelegramCore:TelegramCore",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/UndoUI:UndoUI"
] + flex_dependency,
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,217 @@
import Foundation
import UniformTypeIdentifiers
import SGItemListUI
import UndoUI
import AccountContext
import Display
import TelegramCore
import Postbox
import ItemListUI
import SwiftSignalKit
import TelegramPresentationData
import PresentationDataUtils
import TelegramUIPreferences
// Optional
import SGSimpleSettings
import SGLogging
import SGPayWall
import OverlayStatusController
#if DEBUG
import FLEX
#endif
private enum SGDebugControllerSection: Int32, SGItemListSection {
case base
case notifications
}
private enum SGDebugDisclosureLink: String {
case sessionBackupManager
case messageFilter
case debugIAP
}
private enum SGDebugActions: String {
case flexing
case fileManager
case clearRegDateCache
case restorePurchases
case setIAP
case resetIAP
}
private enum SGDebugToggles: String {
case forceImmediateShareSheet
case legacyNotificationsFix
case inputToolbar
}
private enum SGDebugOneFromManySetting: String {
case pinnedMessageNotifications
case mentionsAndRepliesNotifications
}
private typealias SGDebugControllerEntry = SGItemListUIEntry<SGDebugControllerSection, SGDebugToggles, AnyHashable, SGDebugOneFromManySetting, SGDebugDisclosureLink, SGDebugActions>
private func SGDebugControllerEntries(presentationData: PresentationData) -> [SGDebugControllerEntry] {
var entries: [SGDebugControllerEntry] = []
let id = SGItemListCounter()
#if DEBUG
entries.append(.action(id: id.count, section: .base, actionType: .flexing, text: "FLEX", kind: .generic))
entries.append(.action(id: id.count, section: .base, actionType: .fileManager, text: "FileManager", kind: .generic))
#endif
entries.append(.action(id: id.count, section: .base, actionType: .clearRegDateCache, text: "Clear Regdate cache", kind: .generic))
entries.append(.toggle(id: id.count, section: .base, settingName: .forceImmediateShareSheet, value: SGSimpleSettings.shared.forceSystemSharing, text: "Force System Share Sheet", enabled: true))
entries.append(.action(id: id.count, section: .base, actionType: .restorePurchases, text: "PayWall.RestorePurchases".i18n(presentationData.strings.baseLanguageCode), kind: .generic))
#if DEBUG
entries.append(.action(id: id.count, section: .base, actionType: .setIAP, text: "Set Pro", kind: .generic))
#endif
entries.append(.action(id: id.count, section: .base, actionType: .resetIAP, text: "Reset Pro", kind: .destructive))
entries.append(.toggle(id: id.count, section: .notifications, settingName: .legacyNotificationsFix, value: SGSimpleSettings.shared.legacyNotificationsFix, text: "[OLD] Fix empty notifications", enabled: true))
return entries
}
private func okUndoController(_ text: String, _ presentationData: PresentationData) -> UndoOverlayController {
return UndoOverlayController(presentationData: presentationData, content: .succeed(text: text, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false })
}
public func sgDebugController(context: AccountContext) -> ViewController {
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
let simplePromise = ValuePromise(true, ignoreRepeated: false)
let arguments = SGItemListArguments<SGDebugToggles, AnyHashable, SGDebugOneFromManySetting, SGDebugDisclosureLink, SGDebugActions>(context: context, setBoolValue: { toggleName, value in
switch toggleName {
case .forceImmediateShareSheet:
SGSimpleSettings.shared.forceSystemSharing = value
case .legacyNotificationsFix:
SGSimpleSettings.shared.legacyNotificationsFix = value
SGSimpleSettings.shared.synchronizeShared()
case .inputToolbar:
SGSimpleSettings.shared.inputToolbar = value
}
}, setOneFromManyValue: { setting in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let actionSheet = ActionSheetController(presentationData: presentationData)
let items: [ActionSheetItem] = []
// var items: [ActionSheetItem] = []
// switch (setting) {
// }
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}, openDisclosureLink: { _ in
}, action: { actionType in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch actionType {
case .clearRegDateCache:
SGLogger.shared.log("SGDebug", "Regdate cache cleanup init")
/*
let spinner = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
presentControllerImpl?(spinner, nil)
*/
SGSimpleSettings.shared.regDateCache.drop()
SGLogger.shared.log("SGDebug", "Regdate cache cleanup succesfull")
presentControllerImpl?(okUndoController("OK: Regdate cache cleaned", presentationData), nil)
/*
Queue.mainQueue().async() { [weak spinner] in
spinner?.dismiss()
}
*/
case .flexing:
#if DEBUG
FLEXManager.shared.toggleExplorer()
#endif
case .fileManager:
#if DEBUG
let baseAppBundleId = Bundle.main.bundleIdentifier!
let appGroupName = "group.\(baseAppBundleId)"
let maybeAppGroupUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName)
if let maybeAppGroupUrl = maybeAppGroupUrl {
if let fileManager = FLEXFileBrowserController(path: maybeAppGroupUrl.path) {
FLEXManager.shared.showExplorer()
let flexNavigation = FLEXNavigationController(rootViewController: fileManager)
FLEXManager.shared.presentTool({ return flexNavigation })
}
} else {
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "Empty path", timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
),
nil)
}
#endif
case .restorePurchases:
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "PayWall.Button.Restoring".i18n(args: context.sharedContext.currentPresentationData.with { $0 }.strings.baseLanguageCode), timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
),
nil)
context.sharedContext.SGIAP?.restorePurchases {}
case .setIAP:
#if DEBUG
#endif
case .resetIAP:
let updateSettingsSignal = updateSGStatusInteractively(accountManager: context.sharedContext.accountManager, { status in
var status = status
status.status = SGStatus.default.status
SGSimpleSettings.shared.primaryUserId = ""
return status
})
let _ = (updateSettingsSignal |> deliverOnMainQueue).start(next: {
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "Status reset completed. You can now restore purchases.", timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
),
nil)
})
}
})
let signal = combineLatest(context.sharedContext.presentationData, simplePromise.get())
|> map { presentationData, _ -> (ItemListControllerState, (ItemListNodeState, Any)) in
let entries = SGDebugControllerEntries(presentationData: presentationData)
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Swiftgram Debug"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: /*focusOnItemTag*/ nil, initialScrollToItem: nil /* scrollToItem*/ )
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
pushControllerImpl = { [weak controller] c in
(controller?.navigationController as? NavigationController)?.pushViewController(c)
}
// Workaround
let _ = pushControllerImpl
return controller
}

View File

@ -0,0 +1,18 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGDeviceToken",
module_name = "SGDeviceToken",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,31 @@
import SwiftSignalKit
import DeviceCheck
public enum SGDeviceTokenError {
case unsupportedDevice
case generic(String)
}
public func getDeviceToken() -> Signal<String, SGDeviceTokenError> {
return Signal { subscriber in
let currentDevice = DCDevice.current
if currentDevice.isSupported {
currentDevice.generateToken { (data, error) in
guard error == nil else {
subscriber.putError(.generic(error!.localizedDescription))
return
}
if let tokenData = data {
subscriber.putNext(tokenData.base64EncodedString())
subscriber.putCompletion()
} else {
subscriber.putError(.generic("Empty Token"))
}
}
} else {
subscriber.putError(.unsupportedDevice)
}
return ActionDisposable {
}
}
}

View File

@ -0,0 +1,9 @@
filegroup(
name = "SGDoubleTapMessageAction",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,13 @@
import Foundation
import SGSimpleSettings
import Postbox
import TelegramCore
func sgDoubleTapMessageAction(incoming: Bool, message: Message) -> String {
if incoming {
return SGSimpleSettings.MessageDoubleTapAction.default.rawValue
} else {
return SGSimpleSettings.shared.messageDoubleTapActionOutgoing
}
}

View File

@ -0,0 +1,9 @@
filegroup(
name = "SGEmojiKeyboardDefaultFirst",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,23 @@
import Foundation
func sgPatchEmojiKeyboardItems(_ items: [EmojiPagerContentComponent.ItemGroup]) -> [EmojiPagerContentComponent.ItemGroup] {
var items = items
let staticEmojisIndex = items.firstIndex { item in
if let groupId = item.groupId.base as? String, groupId == "static" {
return true
}
return false
}
let recentEmojisIndex = items.firstIndex { item in
if let groupId = item.groupId.base as? String, groupId == "recent" {
return true
}
return false
}
if let staticEmojisIndex = staticEmojisIndex {
let staticEmojiItem = items.remove(at: staticEmojisIndex)
items.insert(staticEmojiItem, at: (recentEmojisIndex ?? -1) + 1 )
}
return items
}

21
Swiftgram/SGIAP/BUILD Normal file
View File

@ -0,0 +1,21 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGIAP",
module_name = "SGIAP",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGConfig:SGConfig",
"//submodules/AppBundle:AppBundle",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,384 @@
import StoreKit
import SGConfig
import SGLogging
import AppBundle
import Combine
private final class CurrencyFormatterEntry {
public let symbol: String
public let thousandsSeparator: String
public let decimalSeparator: String
public let symbolOnLeft: Bool
public let spaceBetweenAmountAndSymbol: Bool
public let decimalDigits: Int
public init(symbol: String, thousandsSeparator: String, decimalSeparator: String, symbolOnLeft: Bool, spaceBetweenAmountAndSymbol: Bool, decimalDigits: Int) {
self.symbol = symbol
self.thousandsSeparator = thousandsSeparator
self.decimalSeparator = decimalSeparator
self.symbolOnLeft = symbolOnLeft
self.spaceBetweenAmountAndSymbol = spaceBetweenAmountAndSymbol
self.decimalDigits = decimalDigits
}
}
private func getCurrencyExp(currency: String) -> Int {
switch currency {
case "CLF":
return 4
case "BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND":
return 3
case "BIF", "BYR", "CLP", "CVE", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "UGX", "UYI", "VND", "VUV", "XAF", "XOF", "XPF":
return 0
case "MRO":
return 1
default:
return 2
}
}
private func loadCurrencyFormatterEntries() -> [String: CurrencyFormatterEntry] {
guard let filePath = getAppBundle().path(forResource: "currencies", ofType: "json") else {
return [:]
}
guard let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else {
return [:]
}
guard let object = try? JSONSerialization.jsonObject(with: data, options: []), let dict = object as? [String: AnyObject] else {
return [:]
}
var result: [String: CurrencyFormatterEntry] = [:]
for (code, contents) in dict {
if let contentsDict = contents as? [String: AnyObject] {
let entry = CurrencyFormatterEntry(
symbol: contentsDict["symbol"] as! String,
thousandsSeparator: contentsDict["thousandsSeparator"] as! String,
decimalSeparator: contentsDict["decimalSeparator"] as! String,
symbolOnLeft: (contentsDict["symbolOnLeft"] as! NSNumber).boolValue,
spaceBetweenAmountAndSymbol: (contentsDict["spaceBetweenAmountAndSymbol"] as! NSNumber).boolValue,
decimalDigits: getCurrencyExp(currency: code.uppercased())
)
result[code] = entry
result[code.lowercased()] = entry
}
}
return result
}
private let currencyFormatterEntries = loadCurrencyFormatterEntries()
private func fractionalValueToCurrencyAmount(value: Double, currency: String) -> Int64? {
guard let entry = currencyFormatterEntries[currency] ?? currencyFormatterEntries["USD"] else {
return nil
}
var factor: Double = 1.0
for _ in 0 ..< entry.decimalDigits {
factor *= 10.0
}
if value > Double(Int64.max) / factor {
return nil
} else {
return Int64(value * factor)
}
}
public extension Notification.Name {
static let SGIAPHelperPurchaseNotification = Notification.Name("SGIAPPurchaseNotification")
static let SGIAPHelperErrorNotification = Notification.Name("SGIAPErrorNotification")
static let SGIAPHelperProductsUpdatedNotification = Notification.Name("SGIAPProductsUpdatedNotification")
static let SGIAPHelperValidationErrorNotification = Notification.Name("SGIAPValidationErrorNotification")
}
public final class SGIAPManager: NSObject {
private var productRequest: SKProductsRequest?
private var productsRequestCompletion: (([SKProduct]) -> Void)?
private var purchaseCompletion: ((Bool, Error?) -> Void)?
public private(set) var availableProducts: [SGProduct] = []
private var finishedSuccessfulTransactions = Set<String>()
private var onRestoreCompletion: (() -> Void)?
public final class SGProduct: Equatable {
private lazy var numberFormatter: NumberFormatter = {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .currency
numberFormatter.locale = self.skProduct.priceLocale
return numberFormatter
}()
public let skProduct: SKProduct
init(skProduct: SKProduct) {
self.skProduct = skProduct
}
public var id: String {
return self.skProduct.productIdentifier
}
public var isSubscription: Bool {
if #available(iOS 12.0, *) {
return self.skProduct.subscriptionGroupIdentifier != nil
} else {
return self.skProduct.subscriptionPeriod != nil
}
}
public var price: String {
return self.numberFormatter.string(from: self.skProduct.price) ?? ""
}
public func pricePerMonth(_ monthsCount: Int) -> String {
let price = self.skProduct.price.dividing(by: NSDecimalNumber(value: monthsCount)).round(2)
return self.numberFormatter.string(from: price) ?? ""
}
public func defaultPrice(_ value: NSDecimalNumber, monthsCount: Int) -> String {
let price = value.multiplying(by: NSDecimalNumber(value: monthsCount)).round(2)
let prettierPrice = price
.multiplying(by: NSDecimalNumber(value: 2))
.rounding(accordingToBehavior:
NSDecimalNumberHandler(
roundingMode: .up,
scale: Int16(0),
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
)
.dividing(by: NSDecimalNumber(value: 2))
.subtracting(NSDecimalNumber(value: 0.01))
return self.numberFormatter.string(from: prettierPrice) ?? ""
}
public func multipliedPrice(count: Int) -> String {
let price = self.skProduct.price.multiplying(by: NSDecimalNumber(value: count)).round(2)
let prettierPrice = price
.multiplying(by: NSDecimalNumber(value: 2))
.rounding(accordingToBehavior:
NSDecimalNumberHandler(
roundingMode: .up,
scale: Int16(0),
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
)
.dividing(by: NSDecimalNumber(value: 2))
.subtracting(NSDecimalNumber(value: 0.01))
return self.numberFormatter.string(from: prettierPrice) ?? ""
}
public var priceValue: NSDecimalNumber {
return self.skProduct.price
}
public var priceCurrencyAndAmount: (currency: String, amount: Int64) {
if let currencyCode = self.numberFormatter.currencyCode,
let amount = fractionalValueToCurrencyAmount(value: self.priceValue.doubleValue, currency: currencyCode) {
return (currencyCode, amount)
} else {
return ("", 0)
}
}
public static func ==(lhs: SGProduct, rhs: SGProduct) -> Bool {
if lhs.id != rhs.id {
return false
}
if lhs.isSubscription != rhs.isSubscription {
return false
}
if lhs.priceValue != rhs.priceValue {
return false
}
return true
}
}
public init(foo: Bool = false) { // I don't want to override init, idk why
super.init()
SKPaymentQueue.default().add(self)
#if DEBUG && false
DispatchQueue.main.asyncAfter(deadline: .now() + 20) {
self.requestProducts()
}
#else
self.requestProducts()
#endif
}
deinit {
SKPaymentQueue.default().remove(self)
}
public var canMakePayments: Bool {
return SKPaymentQueue.canMakePayments()
}
public func buyProduct(_ product: SKProduct) {
SGLogger.shared.log("SGIAP", "Buying \(product.productIdentifier)...")
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
private func requestProducts() {
SGLogger.shared.log("SGIAP", "Requesting products for \(SG_CONFIG.iaps.count) ids...")
let productRequest = SKProductsRequest(productIdentifiers: Set(SG_CONFIG.iaps))
productRequest.delegate = self
productRequest.start()
self.productRequest = productRequest
}
public func restorePurchases(completion: @escaping () -> Void) {
SGLogger.shared.log("SGIAP", "Restoring purchases...")
self.onRestoreCompletion = completion
let paymentQueue = SKPaymentQueue.default()
paymentQueue.restoreCompletedTransactions()
}
}
extension SGIAPManager: SKProductsRequestDelegate {
public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
self.productRequest = nil
DispatchQueue.main.async {
let products = response.products
SGLogger.shared.log("SGIAP", "Received products (\(products.count)): \(products.map({ $0.productIdentifier }).joined(separator: ", "))")
let currentlyAvailableProducts = self.availableProducts
self.availableProducts = products.map({ SGProduct(skProduct: $0) })
if currentlyAvailableProducts != self.availableProducts {
NotificationCenter.default.post(name: .SGIAPHelperProductsUpdatedNotification, object: nil)
}
}
}
public func request(_ request: SKRequest, didFailWithError error: Error) {
SGLogger.shared.log("SGIAP", "Failed to load list of products. Error \(error.localizedDescription)")
self.productRequest = nil
}
}
extension SGIAPManager: SKPaymentTransactionObserver {
public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
SGLogger.shared.log("SGIAP", "paymentQueue transactions \(transactions.count)")
var purchaceTransactions: [SKPaymentTransaction] = []
for transaction in transactions {
SGLogger.shared.log("SGIAP", "Transaction \(transaction.transactionIdentifier ?? "nil") state for product \(transaction.payment.productIdentifier): \(transaction.transactionState.description)")
switch transaction.transactionState {
case .purchased, .restored:
purchaceTransactions.append(transaction)
break
case .purchasing, .deferred:
// Ignoring
break
case .failed:
var localizedError: String = ""
if let transactionError = transaction.error as NSError?,
let localizedDescription = transaction.error?.localizedDescription,
transactionError.code != SKError.paymentCancelled.rawValue {
localizedError = localizedDescription
SGLogger.shared.log("SGIAP", "Transaction Error [\(transaction.transactionIdentifier ?? "nil")]: \(localizedDescription)")
}
SGLogger.shared.log("SGIAP", "Sending SGIAPHelperErrorNotification for \(transaction.transactionIdentifier ?? "nil")")
NotificationCenter.default.post(name: .SGIAPHelperErrorNotification, object: transaction, userInfo: ["localizedError": localizedError])
default:
SGLogger.shared.log("SGIAP", "Unknown transaction \(transaction.transactionIdentifier ?? "nil") state \(transaction.transactionState). Finishing transaction.")
SKPaymentQueue.default().finishTransaction(transaction)
}
}
if !purchaceTransactions.isEmpty {
SGLogger.shared.log("SGIAP", "Sending SGIAPHelperPurchaseNotification for \(purchaceTransactions.map({ $0.transactionIdentifier ?? "nil" }).joined(separator: ", "))")
NotificationCenter.default.post(name: .SGIAPHelperPurchaseNotification, object: purchaceTransactions)
}
}
public func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
SGLogger.shared.log("SGIAP", "Transactions restored")
if let onRestoreCompletion = self.onRestoreCompletion {
self.onRestoreCompletion = nil
onRestoreCompletion()
}
}
}
private extension NSDecimalNumber {
func round(_ decimals: Int) -> NSDecimalNumber {
return self.rounding(accordingToBehavior:
NSDecimalNumberHandler(roundingMode: .down,
scale: Int16(decimals),
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false))
}
func prettyPrice() -> NSDecimalNumber {
return self.multiplying(by: NSDecimalNumber(value: 2))
.rounding(accordingToBehavior:
NSDecimalNumberHandler(
roundingMode: .plain,
scale: Int16(0),
raiseOnExactness: false,
raiseOnOverflow: false,
raiseOnUnderflow: false,
raiseOnDivideByZero: false
)
)
.dividing(by: NSDecimalNumber(value: 2))
.subtracting(NSDecimalNumber(value: 0.01))
}
}
public func getPurchaceReceiptData() -> Data? {
var receiptData: Data?
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
do {
receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
} catch {
SGLogger.shared.log("SGIAP", "Couldn't read receipt data with error: \(error.localizedDescription)")
}
} else {
SGLogger.shared.log("SGIAP", "Couldn't find receipt path")
}
return receiptData
}
extension SKPaymentTransactionState {
var description: String {
switch self {
case .purchasing:
return "Purchasing"
case .purchased:
return "Purchased"
case .failed:
return "Failed"
case .restored:
return "Restored"
case .deferred:
return "Deferred"
@unknown default:
return "Unknown"
}
}
}

9
Swiftgram/SGIQTP/BUILD Normal file
View File

@ -0,0 +1,9 @@
filegroup(
name = "SGIQTP",
srcs = glob([
"Sources/**/*.swift",
]),
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,77 @@
import Foundation
import Postbox
import SwiftSignalKit
import TelegramApi
import MtProtoKit
import SGConfig
import SGLogging
public struct SGIQTPResponse {
public let status: Int
public let description: String?
public let text: String?
}
public func makeIqtpQuery(_ api: Int, _ method: String, _ args: [String] = []) -> String {
let buildNumber = Bundle.main.infoDictionary?[kCFBundleVersionKey as String] ?? ""
let baseQuery = "tp:\(api):\(buildNumber):\(method)"
if args.isEmpty {
return baseQuery
}
return baseQuery + ":" + args.joined(separator: ":")
}
public func sgIqtpQuery(engine: TelegramEngine, query: String, incompleteResults: Bool = false, staleCachedResults: Bool = false) -> Signal<SGIQTPResponse?, NoError> {
let queryId = arc4random()
#if DEBUG
SGLogger.shared.log("SGIQTP", "[\(queryId)] Query: \(query)")
#else
SGLogger.shared.log("SGIQTP", "[\(queryId)] Query")
#endif
return engine.peers.resolvePeerByName(name: SG_CONFIG.botUsername, referrer: nil)
|> mapToSignal { result -> Signal<EnginePeer?, NoError> in
guard case let .result(result) = result else {
SGLogger.shared.log("SGIQTP", "[\(queryId)] Failed to resolve peer \(SG_CONFIG.botUsername)")
return .complete()
}
return .single(result)
}
|> mapToSignal { peer -> Signal<ChatContextResultCollection?, NoError> in
guard let peer = peer else {
SGLogger.shared.log("SGIQTP", "[\(queryId)] Empty peer")
return .single(nil)
}
return engine.messages.requestChatContextResults(IQTP: true, botId: peer.id, peerId: engine.account.peerId, query: query, offset: "", incompleteResults: incompleteResults, staleCachedResults: staleCachedResults)
|> map { results -> ChatContextResultCollection? in
return results?.results
}
|> `catch` { error -> Signal<ChatContextResultCollection?, NoError> in
SGLogger.shared.log("SGIQTP", "[\(queryId)] Failed to request inline results")
return .single(nil)
}
}
|> map { contextResult -> SGIQTPResponse? in
guard let contextResult, let firstResult = contextResult.results.first else {
SGLogger.shared.log("SGIQTP", "[\(queryId)] Empty inline result")
return nil
}
var t: String?
if case let .text(text, _, _, _, _) = firstResult.message {
t = text
}
var status = 400
if let title = firstResult.title {
status = Int(title) ?? 400
}
let response = SGIQTPResponse(
status: status,
description: firstResult.description,
text: t
)
SGLogger.shared.log("SGIQTP", "[\(queryId)] Response: \(response)")
return response
}
}

View File

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGInputToolbar",
module_name = "SGInputToolbar",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,148 @@
import SwiftUI
import Foundation
// MARK: Swiftgram
@available(iOS 13.0, *)
public struct ChatToolbarView: View {
var onQuote: () -> Void
var onSpoiler: () -> Void
var onBold: () -> Void
var onItalic: () -> Void
var onMonospace: () -> Void
var onLink: () -> Void
var onStrikethrough: () -> Void
var onUnderline: () -> Void
var onCode: () -> Void
var onNewLine: () -> Void
@Binding private var showNewLine: Bool
var onClearFormatting: () -> Void
public init(
onQuote: @escaping () -> Void,
onSpoiler: @escaping () -> Void,
onBold: @escaping () -> Void,
onItalic: @escaping () -> Void,
onMonospace: @escaping () -> Void,
onLink: @escaping () -> Void,
onStrikethrough: @escaping () -> Void,
onUnderline: @escaping () -> Void,
onCode: @escaping () -> Void,
onNewLine: @escaping () -> Void,
showNewLine: Binding<Bool>,
onClearFormatting: @escaping () -> Void
) {
self.onQuote = onQuote
self.onSpoiler = onSpoiler
self.onBold = onBold
self.onItalic = onItalic
self.onMonospace = onMonospace
self.onLink = onLink
self.onStrikethrough = onStrikethrough
self.onUnderline = onUnderline
self.onCode = onCode
self.onNewLine = onNewLine
self._showNewLine = showNewLine
self.onClearFormatting = onClearFormatting
}
public func setShowNewLine(_ value: Bool) {
self.showNewLine = value
}
public var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
if showNewLine {
Button(action: onNewLine) {
Image(systemName: "return")
}
.buttonStyle(ToolbarButtonStyle())
}
Button(action: onClearFormatting) {
Image(systemName: "pencil.slash")
}
.buttonStyle(ToolbarButtonStyle())
Spacer()
// Quote Button
Button(action: onQuote) {
Image(systemName: "text.quote")
}
.buttonStyle(ToolbarButtonStyle())
// Spoiler Button
Button(action: onSpoiler) {
Image(systemName: "eye.slash")
}
.buttonStyle(ToolbarButtonStyle())
// Bold Button
Button(action: onBold) {
Image(systemName: "bold")
}
.buttonStyle(ToolbarButtonStyle())
// Italic Button
Button(action: onItalic) {
Image(systemName: "italic")
}
.buttonStyle(ToolbarButtonStyle())
// Monospace Button
Button(action: onMonospace) {
if #available(iOS 16.4, *) {
Text("M").monospaced()
} else {
Text("M")
}
}
.buttonStyle(ToolbarButtonStyle())
// Link Button
Button(action: onLink) {
Image(systemName: "link")
}
.buttonStyle(ToolbarButtonStyle())
// Underline Button
Button(action: onUnderline) {
Image(systemName: "underline")
}
.buttonStyle(ToolbarButtonStyle())
// Strikethrough Button
Button(action: onStrikethrough) {
Image(systemName: "strikethrough")
}
.buttonStyle(ToolbarButtonStyle())
// Code Button
Button(action: onCode) {
Image(systemName: "chevron.left.forwardslash.chevron.right")
}
.buttonStyle(ToolbarButtonStyle())
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
.background(Color(UIColor.clear))
}
}
@available(iOS 13.0, *)
struct ToolbarButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 17))
.frame(width: 36, height: 36, alignment: .center)
.background(Color(UIColor.tertiarySystemBackground))
.cornerRadius(8)
// TODO(swiftgram): Does not work for fast taps (like mine)
.opacity(configuration.isPressed ? 0.4 : 1.0)
}
}

View File

@ -0,0 +1,30 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGItemListUI",
module_name = "SGItemListUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/MtProtoKit:MtProtoKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/AppBundle:AppBundle",
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,333 @@
// MARK: Swiftgram
import SGLogging
import SGSimpleSettings
import SGStrings
import SGAPIToken
import Foundation
import UIKit
import Display
import SwiftSignalKit
import Postbox
import TelegramCore
import MtProtoKit
import MessageUI
import TelegramPresentationData
import TelegramUIPreferences
import ItemListUI
import PresentationDataUtils
import OverlayStatusController
import AccountContext
import AppBundle
import WebKit
import PeerNameColorScreen
public class SGItemListCounter {
private var _count = 0
public init() {}
public var count: Int {
_count += 1
return _count
}
public func increment(_ amount: Int) {
_count += amount
}
public func countWith(_ amount: Int) -> Int {
_count += amount
return count
}
}
public protocol SGItemListSection: Equatable {
var rawValue: Int32 { get }
}
public final class SGItemListArguments<BoolSetting: Hashable, SliderSetting: Hashable, OneFromManySetting: Hashable, DisclosureLink: Hashable, ActionType: Hashable> {
let context: AccountContext
//
let setBoolValue: (BoolSetting, Bool) -> Void
let updateSliderValue: (SliderSetting, Int32) -> Void
let setOneFromManyValue: (OneFromManySetting) -> Void
let openDisclosureLink: (DisclosureLink) -> Void
let action: (ActionType) -> Void
let searchInput: (String) -> Void
public init(
context: AccountContext,
//
setBoolValue: @escaping (BoolSetting, Bool) -> Void = { _,_ in },
updateSliderValue: @escaping (SliderSetting, Int32) -> Void = { _,_ in },
setOneFromManyValue: @escaping (OneFromManySetting) -> Void = { _ in },
openDisclosureLink: @escaping (DisclosureLink) -> Void = { _ in},
action: @escaping (ActionType) -> Void = { _ in },
searchInput: @escaping (String) -> Void = { _ in }
) {
self.context = context
//
self.setBoolValue = setBoolValue
self.updateSliderValue = updateSliderValue
self.setOneFromManyValue = setOneFromManyValue
self.openDisclosureLink = openDisclosureLink
self.action = action
self.searchInput = searchInput
}
}
public enum SGItemListUIEntry<Section: SGItemListSection, BoolSetting: Hashable, SliderSetting: Hashable, OneFromManySetting: Hashable, DisclosureLink: Hashable, ActionType: Hashable>: ItemListNodeEntry {
case header(id: Int, section: Section, text: String, badge: String?)
case toggle(id: Int, section: Section, settingName: BoolSetting, value: Bool, text: String, enabled: Bool)
case notice(id: Int, section: Section, text: String)
case percentageSlider(id: Int, section: Section, settingName: SliderSetting, value: Int32)
case oneFromManySelector(id: Int, section: Section, settingName: OneFromManySetting, text: String, value: String, enabled: Bool)
case disclosure(id: Int, section: Section, link: DisclosureLink, text: String)
case peerColorDisclosurePreview(id: Int, section: Section, name: String, color: UIColor)
case action(id: Int, section: Section, actionType: ActionType, text: String, kind: ItemListActionKind)
case searchInput(id: Int, section: Section, title: NSAttributedString, text: String, placeholder: String)
public var section: ItemListSectionId {
switch self {
case let .header(_, sectionId, _, _):
return sectionId.rawValue
case let .toggle(_, sectionId, _, _, _, _):
return sectionId.rawValue
case let .notice(_, sectionId, _):
return sectionId.rawValue
case let .disclosure(_, sectionId, _, _):
return sectionId.rawValue
case let .percentageSlider(_, sectionId, _, _):
return sectionId.rawValue
case let .peerColorDisclosurePreview(_, sectionId, _, _):
return sectionId.rawValue
case let .oneFromManySelector(_, sectionId, _, _, _, _):
return sectionId.rawValue
case let .action(_, sectionId, _, _, _):
return sectionId.rawValue
case let .searchInput(_, sectionId, _, _, _):
return sectionId.rawValue
}
}
public var stableId: Int {
switch self {
case let .header(stableIdValue, _, _, _):
return stableIdValue
case let .toggle(stableIdValue, _, _, _, _, _):
return stableIdValue
case let .notice(stableIdValue, _, _):
return stableIdValue
case let .disclosure(stableIdValue, _, _, _):
return stableIdValue
case let .percentageSlider(stableIdValue, _, _, _):
return stableIdValue
case let .peerColorDisclosurePreview(stableIdValue, _, _, _):
return stableIdValue
case let .oneFromManySelector(stableIdValue, _, _, _, _, _):
return stableIdValue
case let .action(stableIdValue, _, _, _, _):
return stableIdValue
case let .searchInput(stableIdValue, _, _, _, _):
return stableIdValue
}
}
public static func <(lhs: SGItemListUIEntry, rhs: SGItemListUIEntry) -> Bool {
return lhs.stableId < rhs.stableId
}
public static func ==(lhs: SGItemListUIEntry, rhs: SGItemListUIEntry) -> Bool {
switch (lhs, rhs) {
case let (.header(id1, section1, text1, badge1), .header(id2, section2, text2, badge2)):
return id1 == id2 && section1 == section2 && text1 == text2 && badge1 == badge2
case let (.toggle(id1, section1, settingName1, value1, text1, enabled1), .toggle(id2, section2, settingName2, value2, text2, enabled2)):
return id1 == id2 && section1 == section2 && settingName1 == settingName2 && value1 == value2 && text1 == text2 && enabled1 == enabled2
case let (.notice(id1, section1, text1), .notice(id2, section2, text2)):
return id1 == id2 && section1 == section2 && text1 == text2
case let (.percentageSlider(id1, section1, settingName1, value1), .percentageSlider(id2, section2, settingName2, value2)):
return id1 == id2 && section1 == section2 && value1 == value2 && settingName1 == settingName2
case let (.disclosure(id1, section1, link1, text1), .disclosure(id2, section2, link2, text2)):
return id1 == id2 && section1 == section2 && link1 == link2 && text1 == text2
case let (.peerColorDisclosurePreview(id1, section1, name1, currentColor1), .peerColorDisclosurePreview(id2, section2, name2, currentColor2)):
return id1 == id2 && section1 == section2 && name1 == name2 && currentColor1 == currentColor2
case let (.oneFromManySelector(id1, section1, settingName1, text1, value1, enabled1), .oneFromManySelector(id2, section2, settingName2, text2, value2, enabled2)):
return id1 == id2 && section1 == section2 && settingName1 == settingName2 && text1 == text2 && value1 == value2 && enabled1 == enabled2
case let (.action(id1, section1, actionType1, text1, kind1), .action(id2, section2, actionType2, text2, kind2)):
return id1 == id2 && section1 == section2 && actionType1 == actionType2 && text1 == text2 && kind1 == kind2
case let (.searchInput(id1, lhsValue1, lhsValue2, lhsValue3, lhsValue4), .searchInput(id2, rhsValue1, rhsValue2, rhsValue3, rhsValue4)):
return id1 == id2 && lhsValue1 == rhsValue1 && lhsValue2 == rhsValue2 && lhsValue3 == rhsValue3 && lhsValue4 == rhsValue4
default:
return false
}
}
public func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem {
let arguments = arguments as! SGItemListArguments<BoolSetting, SliderSetting, OneFromManySetting, DisclosureLink, ActionType>
switch self {
case let .header(_, _, string, badge):
return ItemListSectionHeaderItem(presentationData: presentationData, text: string, badge: badge, sectionId: self.section)
case let .toggle(_, _, setting, value, text, enabled):
return ItemListSwitchItem(presentationData: presentationData, title: text, value: value, enabled: enabled, sectionId: self.section, style: .blocks, updated: { value in
arguments.setBoolValue(setting, value)
})
case let .notice(_, _, string):
return ItemListTextItem(presentationData: presentationData, text: .markdown(string), sectionId: self.section)
case let .disclosure(_, _, link, text):
return ItemListDisclosureItem(presentationData: presentationData, title: text, label: "", sectionId: self.section, style: .blocks) {
arguments.openDisclosureLink(link)
}
case let .percentageSlider(_, _, setting, value):
return SliderPercentageItem(
theme: presentationData.theme,
strings: presentationData.strings,
value: value,
sectionId: self.section,
updated: { value in
arguments.updateSliderValue(setting, value)
}
)
case let .peerColorDisclosurePreview(_, _, name, color):
return ItemListDisclosureItem(presentationData: presentationData, title: " ", enabled: false, label: name, labelStyle: .semitransparentBadge(color), centerLabelAlignment: true, sectionId: self.section, style: .blocks, disclosureStyle: .none, action: {
})
case let .oneFromManySelector(_, _, settingName, text, value, enabled):
return ItemListDisclosureItem(presentationData: presentationData, title: text, enabled: enabled, label: value, sectionId: self.section, style: .blocks, action: {
arguments.setOneFromManyValue(settingName)
})
case let .action(_, _, actionType, text, kind):
return ItemListActionItem(presentationData: presentationData, title: text, kind: kind, alignment: .natural, sectionId: self.section, style: .blocks, action: {
arguments.action(actionType)
})
case let .searchInput(_, _, title, text, placeholder):
return ItemListSingleLineInputItem(presentationData: presentationData, title: title, text: text, placeholder: placeholder, returnKeyType: .done, spacing: 3.0, clearType: .always, selectAllOnFocus: true, secondaryStyle: true, sectionId: self.section, textUpdated: { input in arguments.searchInput(input) }, action: {}, dismissKeyboardOnEnter: true)
}
}
}
public func filterSGItemListUIEntrires<Section: SGItemListSection & Hashable, BoolSetting: Hashable, SliderSetting: Hashable, OneFromManySetting: Hashable, DisclosureLink: Hashable, ActionType: Hashable>(
entries: [SGItemListUIEntry<Section, BoolSetting, SliderSetting, OneFromManySetting, DisclosureLink, ActionType>],
by searchQuery: String?
) -> [SGItemListUIEntry<Section, BoolSetting, SliderSetting, OneFromManySetting, DisclosureLink, ActionType>] {
guard let query = searchQuery?.lowercased(), !query.isEmpty else {
return entries
}
var sectionIdsForEntireIncludion: Set<ItemListSectionId> = []
var sectionIdsWithMatches: Set<ItemListSectionId> = []
var filteredEntries: [SGItemListUIEntry<Section, BoolSetting, SliderSetting, OneFromManySetting, DisclosureLink, ActionType>] = []
func entryMatches(_ entry: SGItemListUIEntry<Section, BoolSetting, SliderSetting, OneFromManySetting, DisclosureLink, ActionType>, query: String) -> Bool {
switch entry {
case .header(_, _, let text, _):
return text.lowercased().contains(query)
case .toggle(_, _, _, _, let text, _):
return text.lowercased().contains(query)
case .notice(_, _, let text):
return text.lowercased().contains(query)
case .percentageSlider:
return false // Assuming percentage sliders don't have searchable text
case .oneFromManySelector(_, _, _, let text, let value, _):
return text.lowercased().contains(query) || value.lowercased().contains(query)
case .disclosure(_, _, _, let text):
return text.lowercased().contains(query)
case .peerColorDisclosurePreview:
return false // Never indexed during search
case .action(_, _, _, let text, _):
return text.lowercased().contains(query)
case .searchInput:
return true // Never hiding search input
}
}
// First pass: identify sections with matches
for entry in entries {
if entryMatches(entry, query: query) {
switch entry {
case .searchInput:
continue
default:
sectionIdsWithMatches.insert(entry.section)
}
}
}
// Second pass: keep matching entries and headers of sections with matches
for (index, entry) in entries.enumerated() {
switch entry {
case .header:
if entryMatches(entry, query: query) {
// Will show all entries for the same section
sectionIdsForEntireIncludion.insert(entry.section)
if !filteredEntries.contains(entry) {
filteredEntries.append(entry)
}
}
// Or show header if something from the section already matched
if sectionIdsWithMatches.contains(entry.section) {
if !filteredEntries.contains(entry) {
filteredEntries.append(entry)
}
}
default:
if entryMatches(entry, query: query) {
if case .notice = entry {
// add previous entry to if it's not another notice and if it's not already here
// possibly targeting related toggle / setting if we've matched it's description (notice) in search
if index > 0 {
let previousEntry = entries[index - 1]
if case .notice = previousEntry {} else {
if !filteredEntries.contains(previousEntry) {
filteredEntries.append(previousEntry)
}
}
}
if !filteredEntries.contains(entry) {
filteredEntries.append(entry)
}
} else {
if !filteredEntries.contains(entry) {
filteredEntries.append(entry)
}
// add next entry if it's notice
// possibly targeting description (notice) for the currently search-matched toggle/setting
if index < entries.count - 1 {
let nextEntry = entries[index + 1]
if case .notice = nextEntry {
if !filteredEntries.contains(nextEntry) {
filteredEntries.append(nextEntry)
}
}
}
}
} else if sectionIdsForEntireIncludion.contains(entry.section) {
if !filteredEntries.contains(entry) {
filteredEntries.append(entry)
}
}
}
}
return filteredEntries
}

View File

@ -0,0 +1,353 @@
import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import TelegramCore
import TelegramPresentationData
import LegacyComponents
import ItemListUI
import PresentationDataUtils
import AppBundle
public class SliderPercentageItem: ListViewItem, ItemListItem {
let theme: PresentationTheme
let strings: PresentationStrings
let value: Int32
public let sectionId: ItemListSectionId
let updated: (Int32) -> Void
public init(theme: PresentationTheme, strings: PresentationStrings, value: Int32, sectionId: ItemListSectionId, updated: @escaping (Int32) -> Void) {
self.theme = theme
self.strings = strings
self.value = value
self.sectionId = sectionId
self.updated = updated
}
public func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
async {
let node = SliderPercentageItemNode()
let (layout, apply) = node.asyncLayout()(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
node.contentSize = layout.contentSize
node.insets = layout.insets
Queue.mainQueue().async {
completion(node, {
return (nil, { _ in apply() })
})
}
}
}
public func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
Queue.mainQueue().async {
if let nodeValue = node() as? SliderPercentageItemNode {
let makeLayout = nodeValue.asyncLayout()
async {
let (layout, apply) = makeLayout(self, params, itemListNeighbors(item: self, topItem: previousItem as? ItemListItem, bottomItem: nextItem as? ItemListItem))
Queue.mainQueue().async {
completion(layout, { _ in
apply()
})
}
}
}
}
}
}
private func rescalePercentageValueToSlider(_ value: CGFloat) -> CGFloat {
return max(0.0, min(1.0, value))
}
private func rescaleSliderValueToPercentageValue(_ value: CGFloat) -> CGFloat {
return max(0.0, min(1.0, value))
}
class SliderPercentageItemNode: ListViewItemNode {
private let backgroundNode: ASDisplayNode
private let topStripeNode: ASDisplayNode
private let bottomStripeNode: ASDisplayNode
private let maskNode: ASImageNode
private var sliderView: TGPhotoEditorSliderView?
private let leftTextNode: ImmediateTextNode
private let rightTextNode: ImmediateTextNode
private let centerTextNode: ImmediateTextNode
private let centerMeasureTextNode: ImmediateTextNode
private let batteryImage: UIImage?
private let batteryBackgroundNode: ASImageNode
private let batteryForegroundNode: ASImageNode
private var item: SliderPercentageItem?
private var layoutParams: ListViewItemLayoutParams?
// MARK: Swiftgram
private let activateArea: AccessibilityAreaNode
init() {
self.backgroundNode = ASDisplayNode()
self.backgroundNode.isLayerBacked = true
self.topStripeNode = ASDisplayNode()
self.topStripeNode.isLayerBacked = true
self.bottomStripeNode = ASDisplayNode()
self.bottomStripeNode.isLayerBacked = true
self.maskNode = ASImageNode()
self.leftTextNode = ImmediateTextNode()
self.rightTextNode = ImmediateTextNode()
self.centerTextNode = ImmediateTextNode()
self.centerMeasureTextNode = ImmediateTextNode()
self.batteryImage = nil //UIImage(bundleImageName: "Settings/UsageBatteryFrame")
self.batteryBackgroundNode = ASImageNode()
self.batteryForegroundNode = ASImageNode()
// MARK: Swiftgram
self.activateArea = AccessibilityAreaNode()
super.init(layerBacked: false, dynamicBounce: false)
self.addSubnode(self.leftTextNode)
self.addSubnode(self.rightTextNode)
self.addSubnode(self.centerTextNode)
self.addSubnode(self.batteryBackgroundNode)
self.addSubnode(self.batteryForegroundNode)
self.addSubnode(self.activateArea)
// MARK: Swiftgram
self.activateArea.increment = { [weak self] in
if let self {
self.sliderView?.increase(by: 0.10)
}
}
self.activateArea.decrement = { [weak self] in
if let self {
self.sliderView?.decrease(by: 0.10)
}
}
}
override func didLoad() {
super.didLoad()
let sliderView = TGPhotoEditorSliderView()
sliderView.enableEdgeTap = true
sliderView.enablePanHandling = true
sliderView.trackCornerRadius = 1.0
sliderView.lineSize = 4.0
sliderView.minimumValue = 0.0
sliderView.startValue = 0.0
sliderView.maximumValue = 1.0
sliderView.disablesInteractiveTransitionGestureRecognizer = true
sliderView.displayEdges = true
if let item = self.item, let params = self.layoutParams {
sliderView.value = rescalePercentageValueToSlider(CGFloat(item.value) / 100.0)
sliderView.backgroundColor = item.theme.list.itemBlocksBackgroundColor
sliderView.backColor = item.theme.list.itemSwitchColors.frameColor
sliderView.trackColor = item.theme.list.itemAccentColor
sliderView.knobImage = PresentationResourcesItemList.knobImage(item.theme)
sliderView.frame = CGRect(origin: CGPoint(x: params.leftInset + 18.0, y: 36.0), size: CGSize(width: params.width - params.leftInset - params.rightInset - 18.0 * 2.0, height: 44.0))
}
self.view.addSubview(sliderView)
sliderView.addTarget(self, action: #selector(self.sliderValueChanged), for: .valueChanged)
self.sliderView = sliderView
}
func asyncLayout() -> (_ item: SliderPercentageItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
let currentItem = self.item
return { item, params, neighbors in
var themeUpdated = false
if currentItem?.theme !== item.theme {
themeUpdated = true
}
let contentSize: CGSize
let insets: UIEdgeInsets
let separatorHeight = UIScreenPixel
contentSize = CGSize(width: params.width, height: 88.0)
insets = itemListNeighborsGroupedInsets(neighbors, params)
let layout = ListViewItemNodeLayout(contentSize: contentSize, insets: insets)
let layoutSize = layout.size
return (layout, { [weak self] in
if let strongSelf = self {
strongSelf.item = item
strongSelf.layoutParams = params
strongSelf.backgroundNode.backgroundColor = item.theme.list.itemBlocksBackgroundColor
strongSelf.topStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
strongSelf.bottomStripeNode.backgroundColor = item.theme.list.itemBlocksSeparatorColor
if strongSelf.backgroundNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
}
if strongSelf.topStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.topStripeNode, at: 1)
}
if strongSelf.bottomStripeNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.bottomStripeNode, at: 2)
}
if strongSelf.maskNode.supernode == nil {
strongSelf.insertSubnode(strongSelf.maskNode, at: 3)
}
let hasCorners = itemListHasRoundedBlockLayout(params)
var hasTopCorners = false
var hasBottomCorners = false
switch neighbors.top {
case .sameSection(false):
strongSelf.topStripeNode.isHidden = true
default:
hasTopCorners = true
strongSelf.topStripeNode.isHidden = hasCorners
}
let bottomStripeInset: CGFloat
let bottomStripeOffset: CGFloat
switch neighbors.bottom {
case .sameSection(false):
bottomStripeInset = params.leftInset + 16.0
bottomStripeOffset = -separatorHeight
strongSelf.bottomStripeNode.isHidden = false
default:
bottomStripeInset = 0.0
bottomStripeOffset = 0.0
hasBottomCorners = true
strongSelf.bottomStripeNode.isHidden = hasCorners
}
strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil
strongSelf.backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: params.width, height: contentSize.height + min(insets.top, separatorHeight) + min(insets.bottom, separatorHeight)))
strongSelf.maskNode.frame = strongSelf.backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0)
strongSelf.topStripeNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight))
strongSelf.bottomStripeNode.frame = CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight))
strongSelf.leftTextNode.attributedText = NSAttributedString(string: "0%", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor)
strongSelf.rightTextNode.attributedText = NSAttributedString(string: "100%", font: Font.regular(13.0), textColor: item.theme.list.itemSecondaryTextColor)
let centralText: String = "\(item.value)%"
let centralMeasureText: String = centralText
strongSelf.batteryBackgroundNode.isHidden = true
strongSelf.batteryForegroundNode.isHidden = strongSelf.batteryBackgroundNode.isHidden
strongSelf.centerTextNode.attributedText = NSAttributedString(string: centralText, font: Font.regular(16.0), textColor: item.theme.list.itemPrimaryTextColor)
strongSelf.centerMeasureTextNode.attributedText = NSAttributedString(string: centralMeasureText, font: Font.regular(16.0), textColor: item.theme.list.itemPrimaryTextColor)
strongSelf.leftTextNode.isAccessibilityElement = true
strongSelf.leftTextNode.accessibilityLabel = "Minimum: \(Int32(rescaleSliderValueToPercentageValue(strongSelf.sliderView?.minimumValue ?? 0.0) * 100.0))%"
strongSelf.rightTextNode.isAccessibilityElement = true
strongSelf.rightTextNode.accessibilityLabel = "Maximum: \(Int32(rescaleSliderValueToPercentageValue(strongSelf.sliderView?.maximumValue ?? 1.0) * 100.0))%"
let leftTextSize = strongSelf.leftTextNode.updateLayout(CGSize(width: 100.0, height: 100.0))
let rightTextSize = strongSelf.rightTextNode.updateLayout(CGSize(width: 100.0, height: 100.0))
let centerTextSize = strongSelf.centerTextNode.updateLayout(CGSize(width: 200.0, height: 100.0))
let centerMeasureTextSize = strongSelf.centerMeasureTextNode.updateLayout(CGSize(width: 200.0, height: 100.0))
let sideInset: CGFloat = 18.0
strongSelf.leftTextNode.frame = CGRect(origin: CGPoint(x: params.leftInset + sideInset, y: 15.0), size: leftTextSize)
strongSelf.rightTextNode.frame = CGRect(origin: CGPoint(x: params.width - params.leftInset - sideInset - rightTextSize.width, y: 15.0), size: rightTextSize)
var centerFrame = CGRect(origin: CGPoint(x: floor((params.width - centerMeasureTextSize.width) / 2.0), y: 11.0), size: centerTextSize)
if !strongSelf.batteryBackgroundNode.isHidden {
centerFrame.origin.x -= 12.0
}
strongSelf.centerTextNode.frame = centerFrame
if let frameImage = strongSelf.batteryImage {
strongSelf.batteryBackgroundNode.image = generateImage(frameImage.size, rotatedContext: { size, context in
UIGraphicsPushContext(context)
context.clear(CGRect(origin: CGPoint(), size: size))
if let image = generateTintedImage(image: frameImage, color: item.theme.list.itemPrimaryTextColor.withMultipliedAlpha(0.9)) {
image.draw(in: CGRect(origin: CGPoint(), size: size))
let contentRect = CGRect(origin: CGPoint(x: 3.0, y: (size.height - 9.0) * 0.5), size: CGSize(width: 20.8, height: 9.0))
context.addPath(UIBezierPath(roundedRect: contentRect, cornerRadius: 2.0).cgPath)
context.clip()
}
UIGraphicsPopContext()
})
strongSelf.batteryForegroundNode.image = generateImage(frameImage.size, rotatedContext: { size, context in
UIGraphicsPushContext(context)
context.clear(CGRect(origin: CGPoint(), size: size))
let contentRect = CGRect(origin: CGPoint(x: 3.0, y: (size.height - 9.0) * 0.5), size: CGSize(width: 20.8, height: 9.0))
context.addPath(UIBezierPath(roundedRect: contentRect, cornerRadius: 2.0).cgPath)
context.clip()
context.setFillColor(UIColor.white.cgColor)
context.addPath(UIBezierPath(roundedRect: CGRect(origin: contentRect.origin, size: CGSize(width: contentRect.width * CGFloat(item.value) / 100.0, height: contentRect.height)), cornerRadius: 1.0).cgPath)
context.fillPath()
UIGraphicsPopContext()
})
let batteryColor: UIColor
if item.value <= 20 {
batteryColor = UIColor(rgb: 0xFF3B30)
} else {
batteryColor = item.theme.list.itemSwitchColors.positiveColor
}
if strongSelf.batteryForegroundNode.layer.layerTintColor == nil {
strongSelf.batteryForegroundNode.layer.layerTintColor = batteryColor.cgColor
} else {
ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut).updateTintColor(layer: strongSelf.batteryForegroundNode.layer, color: batteryColor)
}
strongSelf.batteryBackgroundNode.frame = CGRect(origin: CGPoint(x: centerFrame.minX + centerMeasureTextSize.width + 4.0, y: floor(centerFrame.midY - frameImage.size.height * 0.5)), size: frameImage.size)
strongSelf.batteryForegroundNode.frame = strongSelf.batteryBackgroundNode.frame
}
if let sliderView = strongSelf.sliderView {
if themeUpdated {
sliderView.backgroundColor = item.theme.list.itemBlocksBackgroundColor
sliderView.backColor = item.theme.list.itemSecondaryTextColor
sliderView.trackColor = item.theme.list.itemAccentColor.withAlphaComponent(0.45)
sliderView.knobImage = PresentationResourcesItemList.knobImage(item.theme)
}
sliderView.frame = CGRect(origin: CGPoint(x: params.leftInset + 18.0, y: 36.0), size: CGSize(width: params.width - params.leftInset - params.rightInset - 18.0 * 2.0, height: 44.0))
}
strongSelf.activateArea.accessibilityLabel = "Slider"
strongSelf.activateArea.accessibilityValue = centralMeasureText
strongSelf.activateArea.accessibilityTraits = .adjustable
strongSelf.activateArea.frame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: params.width - params.leftInset - params.rightInset, height: layout.contentSize.height))
}
})
}
}
override func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4)
}
override func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
}
@objc func sliderValueChanged() {
guard let sliderView = self.sliderView else {
return
}
self.item?.updated(Int32(rescaleSliderValueToPercentageValue(sliderView.value) * 100.0))
}
}

View File

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGKeychainBackupManager",
module_name = "SGKeychainBackupManager",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,131 @@
import Foundation
import Security
public enum KeychainError: Error {
case duplicateEntry
case unknown(OSStatus)
case itemNotFound
case invalidItemFormat
}
public class KeychainBackupManager {
public static let shared = KeychainBackupManager()
private let service = "\(Bundle.main.bundleIdentifier!).sessionsbackup"
private init() {}
// MARK: - Save Credentials
public func saveSession(id: String, _ session: Data) throws {
// Create query dictionary
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id,
kSecValueData as String: session,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
// Add to keychain
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
// Item already exists, update it
let updateQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id
]
let attributesToUpdate: [String: Any] = [
kSecValueData as String: session
]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary,
attributesToUpdate as CFDictionary)
if updateStatus != errSecSuccess {
throw KeychainError.unknown(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.unknown(status)
}
}
// MARK: - Retrieve Credentials
public func retrieveSession(for id: String) throws -> Data {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let sessionData = result as? Data else {
throw KeychainError.itemNotFound
}
return sessionData
}
// MARK: - Delete Credentials
public func deleteSession(for id: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: id
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unknown(status)
}
}
// MARK: - Retrieve All Accounts
public func getAllSessons() throws -> [Data] {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitAll
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound {
return []
}
guard status == errSecSuccess,
let credentialsDataArray = result as? [Data] else {
throw KeychainError.unknown(status)
}
return credentialsDataArray
}
// MARK: - Delete All Sessions
public func deleteAllSessions() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service
]
let status = SecItemDelete(query as CFDictionary)
// If no items were found, that's fine - just return
if status == errSecItemNotFound {
return
}
// For any other error, throw
if status != errSecSuccess {
throw KeychainError.unknown(status)
}
}
}

19
Swiftgram/SGLogging/BUILD Normal file
View File

@ -0,0 +1,19 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGLogging",
module_name = "SGLogging",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/ManagedFile:ManagedFile"
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,236 @@
import Foundation
import SwiftSignalKit
import ManagedFile
private let queue = DispatchQueue(label: "app.swiftgram.ios.trace", qos: .utility)
private var sharedLogger: SGLogger?
private let binaryEventMarker: UInt64 = 0xcadebabef00dcafe
private func rootPathForBasePath(_ appGroupPath: String) -> String {
return appGroupPath + "/telegram-data"
}
public final class SGLogger {
private let queue = Queue(name: "app.swiftgram.ios.log", qos: .utility)
private let maxLength: Int = 2 * 1024 * 1024
private let maxShortLength: Int = 1 * 1024 * 1024
private let maxFiles: Int = 20
private let rootPath: String
private let basePath: String
private var file: (ManagedFile, Int)?
private var shortFile: (ManagedFile, Int)?
public static let sgLogsPath = "/logs/app-logs-sg"
public var logToFile: Bool = true
public var logToConsole: Bool = true
public var redactSensitiveData: Bool = true
public static func setSharedLogger(_ logger: SGLogger) {
sharedLogger = logger
}
public static var shared: SGLogger {
if let sharedLogger = sharedLogger {
return sharedLogger
} else {
print("SGLogger setup...")
guard let baseAppBundleId = Bundle.main.bundleIdentifier else {
print("Can't setup logger (1)!")
return SGLogger(rootPath: "", basePath: "")
}
let appGroupName = "group.\(baseAppBundleId)"
let maybeAppGroupUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName)
guard let appGroupUrl = maybeAppGroupUrl else {
print("Can't setup logger (2)!")
return SGLogger(rootPath: "", basePath: "")
}
let newRootPath = rootPathForBasePath(appGroupUrl.path)
let newLogsPath = newRootPath + sgLogsPath
let _ = try? FileManager.default.createDirectory(atPath: newLogsPath, withIntermediateDirectories: true, attributes: nil)
self.setSharedLogger(SGLogger(rootPath: newRootPath, basePath: newLogsPath))
if let sharedLogger = sharedLogger {
return sharedLogger
} else {
print("Can't setup logger (3)!")
return SGLogger(rootPath: "", basePath: "")
}
}
}
public init(rootPath: String, basePath: String) {
self.rootPath = rootPath
self.basePath = basePath
}
public func collectLogs(prefix: String? = nil) -> Signal<[(String, String)], NoError> {
return Signal { subscriber in
self.queue.async {
let logsPath: String
if let prefix = prefix {
logsPath = self.rootPath + prefix
} else {
logsPath = self.basePath
}
var result: [(Date, String, String)] = []
if let files = try? FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: logsPath), includingPropertiesForKeys: [URLResourceKey.creationDateKey], options: []) {
for url in files {
if url.lastPathComponent.hasPrefix("log-") {
if let creationDate = (try? url.resourceValues(forKeys: Set([.creationDateKey])))?.creationDate {
result.append((creationDate, url.lastPathComponent, url.path))
}
}
}
}
result.sort(by: { $0.0 < $1.0 })
subscriber.putNext(result.map { ($0.1, $0.2) })
subscriber.putCompletion()
}
return EmptyDisposable
}
}
public func collectLogs(basePath: String) -> Signal<[(String, String)], NoError> {
return Signal { subscriber in
self.queue.async {
let logsPath: String = basePath
var result: [(Date, String, String)] = []
if let files = try? FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: logsPath), includingPropertiesForKeys: [URLResourceKey.creationDateKey], options: []) {
for url in files {
if url.lastPathComponent.hasPrefix("log-") {
if let creationDate = (try? url.resourceValues(forKeys: Set([.creationDateKey])))?.creationDate {
result.append((creationDate, url.lastPathComponent, url.path))
}
}
}
}
result.sort(by: { $0.0 < $1.0 })
subscriber.putNext(result.map { ($0.1, $0.2) })
subscriber.putCompletion()
}
return EmptyDisposable
}
}
public func log(_ tag: String, _ what: @autoclosure () -> String) {
if !self.logToFile && !self.logToConsole {
return
}
let string = what()
var rawTime = time_t()
time(&rawTime)
var timeinfo = tm()
localtime_r(&rawTime, &timeinfo)
var curTime = timeval()
gettimeofday(&curTime, nil)
let milliseconds = curTime.tv_usec / 1000
var consoleContent: String?
if self.logToConsole {
let content = String(format: "[SG.%@] %d-%d-%d %02d:%02d:%02d.%03d %@", arguments: [tag, Int(timeinfo.tm_year) + 1900, Int(timeinfo.tm_mon + 1), Int(timeinfo.tm_mday), Int(timeinfo.tm_hour), Int(timeinfo.tm_min), Int(timeinfo.tm_sec), Int(milliseconds), string])
consoleContent = content
print(content)
}
if self.logToFile {
self.queue.async {
let content: String
if let consoleContent = consoleContent {
content = consoleContent
} else {
content = String(format: "[SG.%@] %d-%d-%d %02d:%02d:%02d.%03d %@", arguments: [tag, Int(timeinfo.tm_year) + 1900, Int(timeinfo.tm_mon + 1), Int(timeinfo.tm_mday), Int(timeinfo.tm_hour), Int(timeinfo.tm_min), Int(timeinfo.tm_sec), Int(milliseconds), string])
}
var currentFile: ManagedFile?
var openNew = false
if let (file, length) = self.file {
if length >= self.maxLength {
self.file = nil
openNew = true
} else {
currentFile = file
}
} else {
openNew = true
}
if openNew {
let _ = try? FileManager.default.createDirectory(atPath: self.basePath, withIntermediateDirectories: true, attributes: nil)
var createNew = false
if let files = try? FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: self.basePath), includingPropertiesForKeys: [URLResourceKey.creationDateKey], options: []) {
var minCreationDate: (Date, URL)?
var maxCreationDate: (Date, URL)?
var count = 0
for url in files {
if url.lastPathComponent.hasPrefix("log-") {
if let values = try? url.resourceValues(forKeys: Set([URLResourceKey.creationDateKey])), let creationDate = values.creationDate {
count += 1
if minCreationDate == nil || minCreationDate!.0 > creationDate {
minCreationDate = (creationDate, url)
}
if maxCreationDate == nil || maxCreationDate!.0 < creationDate {
maxCreationDate = (creationDate, url)
}
}
}
}
if let (_, url) = minCreationDate, count >= self.maxFiles {
let _ = try? FileManager.default.removeItem(at: url)
}
if let (_, url) = maxCreationDate {
var value = stat()
if stat(url.path, &value) == 0 && Int(value.st_size) < self.maxLength {
if let file = ManagedFile(queue: self.queue, path: url.path, mode: .append) {
self.file = (file, Int(value.st_size))
currentFile = file
}
} else {
createNew = true
}
} else {
createNew = true
}
}
if createNew {
let fileName = String(format: "log-%d-%d-%d_%02d-%02d-%02d.%03d.txt", arguments: [Int(timeinfo.tm_year) + 1900, Int(timeinfo.tm_mon + 1), Int(timeinfo.tm_mday), Int(timeinfo.tm_hour), Int(timeinfo.tm_min), Int(timeinfo.tm_sec), Int(milliseconds)])
let path = self.basePath + "/" + fileName
if let file = ManagedFile(queue: self.queue, path: path, mode: .append) {
self.file = (file, 0)
currentFile = file
}
}
}
if let currentFile = currentFile {
if let data = content.data(using: .utf8) {
data.withUnsafeBytes { rawBytes -> Void in
let bytes = rawBytes.baseAddress!.assumingMemoryBound(to: UInt8.self)
let _ = currentFile.write(bytes, count: data.count)
}
var newline: UInt8 = 0x0a
let _ = currentFile.write(&newline, count: 1)
if let file = self.file {
self.file = (file.0, file.1 + data.count + 1)
} else {
assertionFailure()
}
}
}
}
}
}
}

View File

@ -0,0 +1,6 @@
//import Foundation
//
//public func extractNameFromPath(_ path: String) -> String {
// let fileName = URL(fileURLWithPath: path).lastPathComponent
// return String(fileName.prefix(upTo: fileName.lastIndex { $0 == "." } ?? fileName.endIndex))
//}

29
Swiftgram/SGPayWall/BUILD Normal file
View File

@ -0,0 +1,29 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
filegroup(
name = "SGPayWallAssets",
srcs = glob(["Images.xcassets/**"]),
visibility = ["//visibility:public"],
)
swift_library(
name = "SGPayWall",
module_name = "SGPayWall",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGIAP:SGIAP",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
"//Swiftgram/SGStrings:SGStrings",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Backup.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Filter.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Formatting.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Icons.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Mute.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "pro.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "pro@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "pro@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,993 @@
import Foundation
import SwiftUI
import StoreKit
import SGSwiftUI
import SGIAP
import TelegramPresentationData
import LegacyUI
import Display
import SGConfig
import SGStrings
import SwiftSignalKit
import TelegramUIPreferences
@available(iOS 13.0, *)
public func sgPayWallController(statusSignal: Signal<Int64, NoError>, replacementController: ViewController, presentationData: PresentationData? = nil, SGIAPManager: SGIAPManager, openUrl: @escaping (String, Bool) -> Void /* url, forceExternal */, paymentsEnabled: Bool, canBuyInBeta: Bool, openAppStorePage: @escaping () -> Void, proSupportUrl: String?) -> ViewController {
// let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let theme = defaultDarkColorPresentationTheme
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .modal(animateIn: true),
theme: theme,
strings: strings
)
// legacyController.displayNavigationBar = false
legacyController.statusBar.statusBarStyle = .White
legacyController.attemptNavigation = { _ in return false }
legacyController.view.disablesInteractiveTransitionGestureRecognizer = true
let swiftUIView = SGSwiftUIView<SGPayWallView>(
legacyController: legacyController,
content: {
SGPayWallView(wrapperController: legacyController, replacementController: replacementController, SGIAP: SGIAPManager, statusSignal: statusSignal, openUrl: openUrl, openAppStorePage: openAppStorePage, paymentsEnabled: paymentsEnabled, canBuyInBeta: canBuyInBeta, proSupportUrl: proSupportUrl)
}
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}
private let innerShadowWidth: CGFloat = 15.0
private let accentColorHex: String = "F1552E"
@available(iOS 13.0, *)
struct BackgroundView: View {
var body: some View {
ZStack {
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "A053F8").opacity(0.8), location: 0.0), // purple gradient
.init(color: Color.clear, location: 0.20),
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.edgesIgnoringSafeArea(.all)
LinearGradient(
gradient: Gradient(stops: [
.init(color: Color(hex: "CC4303").opacity(0.6), location: 0.0), // orange gradient
.init(color: Color.clear, location: 0.15),
]),
startPoint: .topTrailing,
endPoint: .bottomLeading
)
.blendMode(.lighten)
.edgesIgnoringSafeArea(.all)
.overlay(
RoundedRectangle(cornerRadius: 0)
.stroke(Color.clear, lineWidth: 0)
.background(
ZStack {
innerShadow(x: -2, y: -2, blur: 4, color: Color(hex: "FF8C56")) // orange shadow
innerShadow(x: 2, y: 2, blur: 4, color: Color(hex: "A053F8")) // purple shadow
// innerShadow(x: 0, y: 0, blur: 4, color: Color.white.opacity(0.3))
}
)
)
.edgesIgnoringSafeArea(.all)
}
.background(Color.black)
}
func innerShadow(x: CGFloat, y: CGFloat, blur: CGFloat, color: Color) -> some View {
return RoundedRectangle(cornerRadius: 0)
.stroke(color, lineWidth: innerShadowWidth)
.blur(radius: blur)
.offset(x: x, y: y)
.mask(RoundedRectangle(cornerRadius: 0).fill(LinearGradient(gradient: Gradient(colors: [Color.black, Color.clear]), startPoint: .top, endPoint: .bottom)))
}
}
@available(iOS 13.0, *)
struct SGPayWallFeatureDetails: View {
let dismissAction: () -> Void
var bottomOffset: CGFloat = 0.0
let contentHeight: CGFloat = 690.0
let features: [SGProFeature]
@State var shownFeature: SGProFeatureId?
// Add animation states
@State private var showBackground = false
@State private var showContent = false
@State private var dragOffset: CGFloat = 0
var body: some View {
ZStack(alignment: .bottom) {
// Background overlay
if showBackground {
Color.black.opacity(0.4)
.zIndex(0)
.edgesIgnoringSafeArea(.all)
.onTapGesture {
dismissWithAnimation()
}
.transition(.opacity)
}
// Bottom sheet content
if showContent {
VStack {
if #available(iOS 14.0, *) {
TabView(selection: $shownFeature) {
ForEach(features) { feature in
ScrollView(showsIndicators: false) {
SGProFeatureView(
feature: feature
)
Color.clear.frame(height: 8.0) // paginator padding
}
.tag(feature.id)
.scrollBounceBehaviorIfAvailable(.basedOnSize)
}
}
.tabViewStyle(.page)
.padding(.bottom, bottomOffset - 8.0)
}
// Spacer for purchase buttons
if !bottomOffset.isZero {
Color.clear.frame(height: bottomOffset)
}
}
.zIndex(1)
.frame(maxHeight: contentHeight)
.background(Color(.black))
.cornerRadius(8, corners: [.topLeft, .topRight])
.overlay(closeButtonView)
.offset(y: max(0, dragOffset))
.gesture(
DragGesture()
.onChanged { value in
// Only track downward movement
if value.translation.height > 0 {
dragOffset = value.translation.height
}
}
.onEnded { value in
// If dragged down more than 150 points or with significant velocity, dismiss
if value.translation.height > 150 || value.predictedEndTranslation.height > 200 {
dismissWithAnimation()
} else {
// Otherwise, reset position
withAnimation(.spring()) {
dragOffset = 0
}
}
}
)
.transition(.move(edge: .bottom))
}
}
.onAppear {
appearWithAnimation()
}
}
private func appearWithAnimation() {
withAnimation(.easeIn(duration: 0.2)) {
showBackground = true
}
withAnimation(.spring(duration: 0.3)/*.delay(0.1)*/) {
showContent = true
}
}
private func dismissWithAnimation() {
withAnimation(.spring()) {
showContent = false
dragOffset = 0
}
withAnimation(.easeOut(duration: 0.2).delay(0.1)) {
showBackground = false
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
dismissAction()
}
}
private var closeButtonView: some View {
Button(action: {
dismissWithAnimation()
}) {
Image(systemName: "xmark")
.font(.headline)
.foregroundColor(.secondary.opacity(0.6))
.frame(width: 44, height: 44)
.contentShape(Rectangle()) // Improve tappable area
}
.opacity(showContent ? 1.0 : 0.0)
.padding([.top, .trailing], 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
}
}
@available(iOS 13.0, *)
struct SGProFeatureView: View {
let feature: SGProFeature
var body: some View {
VStack(spacing: 16) {
feature.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: .infinity, maxHeight: 400.0, alignment: .top)
.clipped()
VStack(alignment: .center, spacing: 8) {
Text(feature.title)
.font(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)
Text(featureSubtitle)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
Spacer()
}
}
var featureSubtitle: String {
return feature.description ?? feature.subtitle
}
}
enum SGProFeatureId: Hashable {
case backup
case filter
case notifications
case toolbar
case icons
}
@available(iOS 13.0, *)
struct SGProFeature: Identifiable {
let id: SGProFeatureId
let title: String
let subtitle: String
let description: String?
@ViewBuilder
public var icon: some View {
switch (id) {
case .backup:
FeatureIcon(icon: "lock.fill", backgroundColor: .blue)
case .filter:
FeatureIcon(icon: "nosign", backgroundColor: .gray, fontWeight: .bold)
case .notifications:
FeatureIcon(icon: "bell.badge.slash.fill", backgroundColor: .red)
case .toolbar:
FeatureIcon(icon: "bold.underline", backgroundColor: .blue, iconSize: 16)
case .icons:
Image("SwiftgramSettings")
.resizable()
.frame(width: 32, height: 32)
@unknown default:
Image("SwiftgramPro")
.resizable()
.frame(width: 32, height: 32)
}
}
public var image: Image {
switch (id) {
case .backup:
return Image("ProDetailsBackup")
case .filter:
return Image("ProDetailsFilter")
case .notifications:
return Image("ProDetailsMute")
case .toolbar:
return Image("ProDetailsFormatting")
case .icons:
return Image("ProDetailsIcons")
@unknown default:
return Image("pro")
}
}
}
@available(iOS 13.0, *)
struct SGPayWallView: View {
@Environment(\.navigationBarHeight) var navigationBarHeight: CGFloat
@Environment(\.containerViewLayout) var containerViewLayout: ContainerViewLayout?
@Environment(\.lang) var lang: String
weak var wrapperController: LegacyController?
let replacementController: ViewController
let SGIAP: SGIAPManager
let statusSignal: Signal<Int64, NoError>
let openUrl: (String, Bool) -> Void // url, forceExternal
let openAppStorePage: () -> Void
let paymentsEnabled: Bool
let canBuyInBeta: Bool
let proSupportUrl: String?
private enum PayWallState: Equatable {
case ready // ready to buy
case restoring
case purchasing
case validating
}
// State management
@State private var product: SGIAPManager.SGProduct?
@State private var currentStatus: Int64 = 1
@State private var state: PayWallState = .ready
@State private var showErrorAlert: Bool = false
@State private var showConfetti: Bool = false
@State private var showDetails: Bool = false
@State private var shownFeature: SGProFeatureId? = nil
private let productsPub = NotificationCenter.default.publisher(for: .SGIAPHelperProductsUpdatedNotification, object: nil)
private let buyOrRestoreSuccessPub = NotificationCenter.default.publisher(for: .SGIAPHelperPurchaseNotification, object: nil)
private let buyErrorPub = NotificationCenter.default.publisher(for: .SGIAPHelperErrorNotification, object: nil)
private let validationErrorPub = NotificationCenter.default.publisher(for: .SGIAPHelperValidationErrorNotification, object: nil)
@State private var statusTask: Task<Void, Never>? = nil
@State private var hapticFeedback: HapticFeedback?
private let confettiDuration: Double = 5.0
@State private var purchaseSectionSize: CGSize = .zero
private var features: [SGProFeature] {
return [
SGProFeature(id: .backup, title: "PayWall.SessionBackup.Title".i18n(lang), subtitle: "PayWall.SessionBackup.Notice".i18n(lang), description: "PayWall.SessionBackup.Description".i18n(lang)),
SGProFeature(id: .filter, title: "PayWall.MessageFilter.Title".i18n(lang), subtitle: "PayWall.MessageFilter.Notice".i18n(lang), description: "PayWall.MessageFilter.Description".i18n(lang)),
SGProFeature(id: .notifications, title: "PayWall.Notifications.Title".i18n(lang), subtitle: "PayWall.Notifications.Notice".i18n(lang), description: "PayWall.Notifications.Description".i18n(lang)),
SGProFeature(id: .toolbar, title: "PayWall.InputToolbar.Title".i18n(lang), subtitle: "PayWall.InputToolbar.Notice".i18n(lang), description: "PayWall.InputToolbar.Description".i18n(lang)),
SGProFeature(id: .icons, title: "PayWall.AppIcons.Title".i18n(lang), subtitle: "PayWall.AppIcons.Notice".i18n(lang), description: nil)
]
}
var body: some View {
ZStack {
BackgroundView()
ZStack(alignment: .bottom) {
ScrollView(showsIndicators: false) {
VStack(spacing: 24) {
// Icon
Image("pro")
.frame(width: 100, height: 100)
// Title and Subtitle
VStack(spacing: 8) {
Text("Swiftgram Pro")
.font(.largeTitle)
.fontWeight(.bold)
Text("PayWall.Text".i18n(lang))
.font(.callout)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
// Features
VStack(spacing: 36) {
featuresSection
aboutSection
VStack(spacing: 8) {
HStack {
legalSection
Spacer()
}
HStack {
restorePurchasesButton
Spacer()
}
}
}
// Spacer for purchase buttons
Color.clear.frame(height: (purchaseSectionSize.height / 2.0))
}
.padding(.vertical, (purchaseSectionSize.height / 2.0))
}
.padding(.leading, max(innerShadowWidth + 8.0, sgLeftSafeAreaInset(containerViewLayout)))
.padding(.trailing, max(innerShadowWidth + 8.0, sgRightSafeAreaInset(containerViewLayout)))
if showDetails {
SGPayWallFeatureDetails(
dismissAction: dismissDetails,
bottomOffset: (purchaseSectionSize.height / 2.0) * 0.9, // reduced offset for paginator
features: features,
shownFeature: shownFeature)
}
// Fixed purchase button at bottom
purchaseSection
.trackSize($purchaseSectionSize)
}
}
.confetti(isActive: $showConfetti, duration: confettiDuration)
.overlay(closeButtonView)
.colorScheme(.dark)
.onReceive(productsPub) { _ in
updateSelectedProduct()
}
.onAppear {
hapticFeedback = HapticFeedback()
updateSelectedProduct()
statusTask = Task {
let statusStream = statusSignal.awaitableStream()
for await newStatus in statusStream {
#if DEBUG
print("SGPayWallView: newStatus = \(newStatus)")
#endif
if Task.isCancelled {
#if DEBUG
print("statusTask cancelled")
#endif
break
}
if currentStatus != newStatus {
currentStatus = newStatus
if newStatus > 1 {
handleUpgradedStatus()
}
}
}
}
}
.onDisappear {
#if DEBUG
print("Cancelling statusTask")
#endif
statusTask?.cancel()
}
.onReceive(buyOrRestoreSuccessPub) { _ in
state = .validating
}
.onReceive(buyErrorPub) { notification in
if let userInfo = notification.userInfo, let error = userInfo["localizedError"] as? String, !error.isEmpty {
showErrorAlert(error)
}
}
.onReceive(validationErrorPub) { notification in
if state == .validating {
if let userInfo = notification.userInfo, let error = userInfo["error"] as? String, !error.isEmpty {
showErrorAlert(error.i18n(lang))
} else {
showErrorAlert("PayWall.ValidationError".i18n(lang))
}
}
}
}
private var featuresSection: some View {
VStack(spacing: 8) {
ForEach(features) { feature in
FeatureRow(
icon: feature.icon,
title: feature.title,
subtitle: feature.subtitle,
action: {
showDetailsForFeature(feature.id)
}
)
}
}
}
private var restorePurchasesButton: some View {
Button(action: handleRestorePurchases) {
Text("PayWall.RestorePurchases".i18n(lang))
.font(.footnote)
.fontWeight(.semibold)
.foregroundColor(Color(hex: accentColorHex))
}
.disabled(state == .restoring || product == nil)
.opacity((state == .restoring || product == nil) ? 0.5 : 1.0)
}
private var purchaseSection: some View {
VStack(spacing: 0) {
Divider()
VStack(spacing: 8) {
Button(action: handlePurchase) {
Text(buttonTitle)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding()
.background(Color(hex: accentColorHex))
.foregroundColor(.white)
.cornerRadius(12)
}
.disabled((state != .ready || !canPurchase) && !(currentStatus > 1))
.opacity(((state != .ready || !canPurchase) && !(currentStatus > 1)) ? 0.5 : 1.0)
if let proSupportUrl = proSupportUrl {
HStack(alignment: .center, spacing: 4) {
Text("PayWall.ProSupport.Title".i18n(lang))
.font(.caption)
.foregroundColor(.secondary)
Button(action: {
openUrl(proSupportUrl, false)
}) {
Text("PayWall.ProSupport.Contact".i18n(lang))
.font(.caption)
.foregroundColor(Color(hex: accentColorHex))
}
}
}
}
.padding([.horizontal, .top])
.padding(.bottom, sgBottomSafeAreaInset(containerViewLayout) + 2.0)
}
.foregroundColor(Color.black)
.backgroundIfAvailable(material: .ultraThinMaterial)
.shadow(radius: 8, y: -4)
}
private var legalSection: some View {
Group {
if #available(iOS 15.0, *) {
Text(LocalizedStringKey("PayWall.Notice.Markdown".i18n(lang, args: "PayWall.TermsURL".i18n(lang), "PayWall.PrivacyURL".i18n(lang))))
.font(.caption)
.tint(Color(hex: accentColorHex))
.foregroundColor(.secondary)
.environment(\.openURL, OpenURLAction { url in
openUrl(url.absoluteString, false)
return .handled
})
} else {
Text("PayWall.Notice.Raw".i18n(lang))
.font(.caption)
.foregroundColor(.secondary)
HStack(alignment: .top, spacing: 8) {
Button(action: {
openUrl("PayWall.PrivacyURL".i18n(lang), true)
}) {
Text("PayWall.Privacy".i18n(lang))
.font(.caption)
.foregroundColor(Color(hex: accentColorHex))
}
Button(action: {
openUrl("PayWall.TermsURL".i18n(lang), true)
}) {
Text("PayWall.Terms".i18n(lang))
.font(.caption)
.foregroundColor(Color(hex: accentColorHex))
}
}
}
}
}
private var aboutSection: some View {
VStack(spacing: 8) {
HStack {
Text("PayWall.About.Title".i18n(lang))
.font(.headline)
.fontWeight(.medium)
Spacer()
}
HStack {
Text("PayWall.About.Notice".i18n(lang))
.font(.subheadline)
.foregroundColor(.secondary)
Spacer()
}
HStack {
Button(action: {
openUrl("PayWall.About.SignatureURL".i18n(lang), false)
}) {
Text("PayWall.About.Signature".i18n(lang))
.font(.caption)
.foregroundColor(Color(hex: accentColorHex))
}
Spacer()
}
}
}
private var closeButtonView: some View {
Button(action: {
wrapperController?.dismiss(animated: true)
}) {
Image(systemName: "xmark")
.font(.headline)
.foregroundColor(.secondary.opacity(0.6))
.frame(width: 44, height: 44)
.contentShape(Rectangle()) // Improve tappable area
}
.disabled(showDetails)
.opacity(showDetails ? 0.0 : 1.0)
.padding([.top, .trailing], 16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
}
private var buttonTitle: String {
if currentStatus > 1 {
return "PayWall.Button.OpenPro".i18n(lang)
} else {
if state == .purchasing {
return "PayWall.Button.Purchasing".i18n(lang)
} else if state == .restoring {
return "PayWall.Button.Restoring".i18n(lang)
} else if state == .validating {
return "PayWall.Button.Validating".i18n(lang)
} else if let product = product {
if !SGIAP.canMakePayments || paymentsEnabled == false {
return "PayWall.Button.PaymentsUnavailable".i18n(lang)
} else if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" && !canBuyInBeta {
return "PayWall.Button.BuyInAppStore".i18n(lang)
} else {
return "PayWall.Button.Subscribe".i18n(lang, args: product.price)
}
} else {
return "PayWall.Button.ContactingAppStore".i18n(lang)
}
}
}
private var canPurchase: Bool {
if !SGIAP.canMakePayments || paymentsEnabled == false {
return false
} else {
return product != nil
}
}
private func showDetailsForFeature(_ featureId: SGProFeatureId) {
if #available(iOS 14.0, *) {
shownFeature = featureId
showDetails = true
} // pagination is not available on iOS 13
}
private func dismissDetails() {
// shownFeature = nil
showDetails = false
}
private func updateSelectedProduct() {
product = SGIAP.availableProducts.first { $0.id == SG_CONFIG.iaps.first ?? "" }
}
private func handlePurchase() {
if currentStatus > 1 {
wrapperController?.replace(with: replacementController)
} else {
if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" && !canBuyInBeta {
openAppStorePage()
} else {
guard let product = product else { return }
state = .purchasing
SGIAP.buyProduct(product.skProduct)
}
}
}
private func handleRestorePurchases() {
state = .restoring
SGIAP.restorePurchases {
state = .validating
}
}
private func handleUpgradedStatus() {
DispatchQueue.main.async {
hapticFeedback?.success()
showConfetti = true
DispatchQueue.main.asyncAfter(deadline: .now() + confettiDuration + 1.0) {
showConfetti = false
}
}
}
private func showErrorAlert(_ message: String) {
let alertController = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: { action in
state = .ready
}))
DispatchQueue.main.async {
wrapperController?.present(alertController, animated: true)
}
}
}
@available(iOS 13.0, *)
struct FeatureIcon: View {
let icon: String
let iconColor: Color
let backgroundColor: Color
let iconSize: CGFloat
let frameSize: CGFloat
let fontWeight: SwiftUI.Font.Weight
init(
icon: String,
iconColor: Color = .white,
backgroundColor: Color = .blue,
iconSize: CGFloat = 18,
frameSize: CGFloat = 32,
fontWeight: SwiftUI.Font.Weight = .regular
) {
self.icon = icon
self.iconColor = iconColor
self.backgroundColor = backgroundColor
self.iconSize = iconSize
self.frameSize = frameSize
self.fontWeight = fontWeight
}
var body: some View {
Image(systemName: icon)
.font(.system(size: iconSize))
.fontWeightIfAvailable(fontWeight)
.foregroundColor(iconColor)
.frame(width: frameSize, height: frameSize)
.background(backgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@available(iOS 13.0, *)
struct FeatureRow<IconContent: View>: View {
let icon: IconContent
let title: String
let subtitle: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
HStack(alignment: .top, spacing: 12) {
icon
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
.fontWeight(.medium)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
Spacer()
if #available(iOS 14.0, *) {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.secondary)
} // Descriptions are not available on iOS 13
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.systemGray6))
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 4)
)
}
.buttonStyle(PlainButtonStyle())
}
}
// Confetti
@available(iOS 13.0, *)
struct ConfettiType {
let color: Color
let shape: ConfettiShape
static func random() -> ConfettiType {
let colors: [Color] = [.red, .blue, .green, .yellow, .pink, .purple, .orange]
return ConfettiType(
color: colors.randomElement() ?? .blue,
shape: ConfettiShape.allCases.randomElement() ?? .circle
)
}
}
@available(iOS 13.0, *)
enum ConfettiShape: CaseIterable {
case circle
case triangle
case square
case slimRectangle
case roundedCross
@ViewBuilder
func view(color: Color) -> some View {
switch self {
case .circle:
Circle().fill(color)
case .triangle:
Triangle().fill(color)
case .square:
Rectangle().fill(color)
case .slimRectangle:
SlimRectangle().fill(color)
case .roundedCross:
RoundedCross().fill(color)
}
}
}
@available(iOS 13.0, *)
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
return path
}
}
@available(iOS 13.0, *)
public struct SlimRectangle: Shape {
public func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: 4*rect.maxY/5))
path.addLine(to: CGPoint(x: rect.maxX, y: 4*rect.maxY/5))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
return path
}
}
@available(iOS 13.0, *)
public struct RoundedCross: Shape {
public func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY/3))
path.addQuadCurve(to: CGPoint(x: rect.maxX/3, y: rect.minY), control: CGPoint(x: rect.maxX/3, y: rect.maxY/3))
path.addLine(to: CGPoint(x: 2*rect.maxX/3, y: rect.minY))
path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY/3), control: CGPoint(x: 2*rect.maxX/3, y: rect.maxY/3))
path.addLine(to: CGPoint(x: rect.maxX, y: 2*rect.maxY/3))
path.addQuadCurve(to: CGPoint(x: 2*rect.maxX/3, y: rect.maxY), control: CGPoint(x: 2*rect.maxX/3, y: 2*rect.maxY/3))
path.addLine(to: CGPoint(x: rect.maxX/3, y: rect.maxY))
path.addQuadCurve(to: CGPoint(x: 2*rect.minX/3, y: 2*rect.maxY/3), control: CGPoint(x: rect.maxX/3, y: 2*rect.maxY/3))
return path
}
}
@available(iOS 13.0, *)
struct ConfettiModifier: ViewModifier {
@Binding var isActive: Bool
let duration: Double
func body(content: Content) -> some View {
content.overlay(
ZStack {
if isActive {
ForEach(0..<70) { _ in
ConfettiPiece(
confettiType: .random(),
duration: duration
)
}
}
}
)
}
}
@available(iOS 13.0, *)
struct ConfettiPiece: View {
let confettiType: ConfettiType
let duration: Double
@State private var isAnimating = false
@State private var rotation = Double.random(in: 0...1080)
var body: some View {
confettiType.shape.view(color: confettiType.color)
.frame(width: 10, height: 10)
.rotationEffect(.degrees(rotation))
.position(
x: .random(in: 0...UIScreen.main.bounds.width),
y: 0 //-20
)
.modifier(FallingModifier(distance: UIScreen.main.bounds.height + 20, duration: duration))
.opacity(isAnimating ? 0 : 1)
.onAppear {
withAnimation(.linear(duration: duration)) {
isAnimating = true
}
}
}
}
@available(iOS 13.0, *)
struct FallingModifier: ViewModifier {
let distance: CGFloat
let duration: Double
func body(content: Content) -> some View {
content.modifier(
MoveModifier(
offset: CGSize(
width: .random(in: -100...100),
height: distance
),
duration: duration
)
)
}
}
@available(iOS 13.0, *)
struct MoveModifier: ViewModifier {
let offset: CGSize
let duration: Double
@State private var isAnimating = false
func body(content: Content) -> some View {
content.offset(
x: isAnimating ? offset.width : 0,
y: isAnimating ? offset.height : 0
)
.onAppear {
withAnimation(
.linear(duration: duration)
.speed(.random(in: 0.5...2.5))
) {
isAnimating = true
}
}
}
}
// Extension to make it easier to use
@available(iOS 13.0, *)
extension View {
func confetti(isActive: Binding<Bool>, duration: Double = 2.0) -> some View {
modifier(ConfettiModifier(isActive: isActive, duration: duration))
}
}

41
Swiftgram/SGProUI/BUILD Normal file
View File

@ -0,0 +1,41 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGProUI",
module_name = "SGProUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGKeychainBackupManager:SGKeychainBackupManager",
"//Swiftgram/SGItemListUI:SGItemListUI",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
"//Swiftgram/SGAPI:SGAPI",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//Swiftgram/SGSwiftUI:SGSwiftUI",
#
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/MtProtoKit:MtProtoKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/AppBundle:AppBundle",
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
"//submodules/UndoUI:UndoUI",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,181 @@
import Foundation
import SwiftUI
import SGSwiftUI
import SGStrings
import SGSimpleSettings
import LegacyUI
import Display
import TelegramPresentationData
@available(iOS 13.0, *)
struct MessageFilterKeywordInputFieldModifier: ViewModifier {
@Binding var newKeyword: String
let onAdd: () -> Void
func body(content: Content) -> some View {
if #available(iOS 15.0, *) {
content
.submitLabel(.return)
.submitScope(false) // TODO(swiftgram): Keyboard still closing
.interactiveDismissDisabled()
.onSubmit {
onAdd()
}
} else {
content
}
}
}
@available(iOS 13.0, *)
struct MessageFilterKeywordInputView: View {
@Environment(\.lang) var lang: String
@Binding var newKeyword: String
let onAdd: () -> Void
var body: some View {
HStack {
TextField("MessageFilter.InputPlaceholder".i18n(lang), text: $newKeyword)
.autocorrectionDisabled(true)
.autocapitalization(.none)
.keyboardType(.default)
.modifier(MessageFilterKeywordInputFieldModifier(newKeyword: $newKeyword, onAdd: onAdd))
Button(action: onAdd) {
Image(systemName: "plus.circle.fill")
.foregroundColor(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? .secondary : .accentColor)
.imageScale(.large)
}
.disabled(newKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
.buttonStyle(PlainButtonStyle())
}
}
}
@available(iOS 13.0, *)
struct MessageFilterView: View {
weak var wrapperController: LegacyController?
@Environment(\.lang) var lang: String
@State private var newKeyword: String = ""
@State private var keywords: [String] {
didSet {
SGSimpleSettings.shared.messageFilterKeywords = keywords
}
}
init(wrapperController: LegacyController?) {
self.wrapperController = wrapperController
_keywords = State(initialValue: SGSimpleSettings.shared.messageFilterKeywords)
}
var bodyContent: some View {
List {
Section {
// Icon and title
VStack(spacing: 8) {
Image(systemName: "nosign.app.fill")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("MessageFilter.Title".i18n(lang))
.font(.title)
.bold()
Text("MessageFilter.SubTitle".i18n(lang))
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.listRowInsets(EdgeInsets())
}
Section {
MessageFilterKeywordInputView(newKeyword: $newKeyword, onAdd: addKeyword)
}
Section(header: Text("MessageFilter.Keywords.Title".i18n(lang))) {
ForEach(keywords.reversed(), id: \.self) { keyword in
Text(keyword)
}
.onDelete { indexSet in
let originalIndices = IndexSet(
indexSet.map { keywords.count - 1 - $0 }
)
deleteKeywords(at: originalIndices)
}
}
}
.tgNavigationBackButton(wrapperController: wrapperController)
}
var body: some View {
NavigationView {
if #available(iOS 14.0, *) {
bodyContent
.toolbar {
EditButton()
}
} else {
bodyContent
}
}
}
private func addKeyword() {
let trimmedKeyword = newKeyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKeyword.isEmpty else { return }
let keywordExists = keywords.contains {
$0 == trimmedKeyword
}
guard !keywordExists else {
return
}
withAnimation {
keywords.append(trimmedKeyword)
}
newKeyword = ""
}
private func deleteKeywords(at offsets: IndexSet) {
withAnimation {
keywords.remove(atOffsets: offsets)
}
}
}
@available(iOS 13.0, *)
public func sgMessageFilterController(presentationData: PresentationData? = nil) -> ViewController {
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
// Status bar color will break if theme changed
legacyController.statusBar.statusBarStyle = theme.rootController
.statusBarStyle.style
legacyController.displayNavigationBar = false
let swiftUIView = SGSwiftUIView<MessageFilterView>(
legacyController: legacyController,
content: {
MessageFilterView(wrapperController: legacyController)
}
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}

View File

@ -0,0 +1,186 @@
import Foundation
import UniformTypeIdentifiers
import SGItemListUI
import UndoUI
import AccountContext
import Display
import TelegramCore
import Postbox
import ItemListUI
import SwiftSignalKit
import TelegramPresentationData
import PresentationDataUtils
import TelegramUIPreferences
// Optional
import SGSimpleSettings
import SGLogging
private enum SGProControllerSection: Int32, SGItemListSection {
case base
case notifications
case footer
}
private enum SGProDisclosureLink: String {
case sessionBackupManager
case messageFilter
}
private enum SGProToggles: String {
case inputToolbar
}
private enum SGProOneFromManySetting: String {
case pinnedMessageNotifications
case mentionsAndRepliesNotifications
}
private enum SGProAction {
case resetIAP
}
private typealias SGProControllerEntry = SGItemListUIEntry<SGProControllerSection, SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, SGProAction>
private func SGProControllerEntries(presentationData: PresentationData) -> [SGProControllerEntry] {
var entries: [SGProControllerEntry] = []
let lang = presentationData.strings.baseLanguageCode
let id = SGItemListCounter()
entries.append(.disclosure(id: id.count, section: .base, link: .sessionBackupManager, text: "SessionBackup.Title".i18n(lang)))
entries.append(.disclosure(id: id.count, section: .base, link: .messageFilter, text: "MessageFilter.Title".i18n(lang)))
entries.append(.toggle(id: id.count, section: .base, settingName: .inputToolbar, value: SGSimpleSettings.shared.inputToolbar, text: "InputToolbar.Title".i18n(lang), enabled: true))
entries.append(.header(id: id.count, section: .notifications, text: presentationData.strings.Notifications_Title.uppercased(), badge: nil))
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .pinnedMessageNotifications, text: "Notifications.PinnedMessages.Title".i18n(lang), value: "Notifications.PinnedMessages.value.\(SGSimpleSettings.shared.pinnedMessageNotifications)".i18n(lang), enabled: true))
entries.append(.oneFromManySelector(id: id.count, section: .notifications, settingName: .mentionsAndRepliesNotifications, text: "Notifications.MentionsAndReplies.Title".i18n(lang), value: "Notifications.MentionsAndReplies.value.\(SGSimpleSettings.shared.mentionsAndRepliesNotifications)".i18n(lang), enabled: true))
#if DEBUG
entries.append(.action(id: id.count, section: .footer, actionType: .resetIAP, text: "Reset Pro", kind: .destructive))
#endif
return entries
}
public func okUndoController(_ text: String, _ presentationData: PresentationData) -> UndoOverlayController {
return UndoOverlayController(presentationData: presentationData, content: .succeed(text: text, timeout: nil, customUndoText: nil), elevatedLayout: false, action: { _ in return false })
}
public func sgProController(context: AccountContext) -> ViewController {
var presentControllerImpl: ((ViewController, ViewControllerPresentationArguments?) -> Void)?
var pushControllerImpl: ((ViewController) -> Void)?
let simplePromise = ValuePromise(true, ignoreRepeated: false)
let arguments = SGItemListArguments<SGProToggles, AnyHashable, SGProOneFromManySetting, SGProDisclosureLink, SGProAction>(context: context, setBoolValue: { toggleName, value in
switch toggleName {
case .inputToolbar:
SGSimpleSettings.shared.inputToolbar = value
}
}, setOneFromManyValue: { setting in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let lang = presentationData.strings.baseLanguageCode
let actionSheet = ActionSheetController(presentationData: presentationData)
var items: [ActionSheetItem] = []
switch (setting) {
case .pinnedMessageNotifications:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.pinnedMessageNotifications = value
SGSimpleSettings.shared.synchronizeShared()
simplePromise.set(true)
}
for value in SGSimpleSettings.PinnedMessageNotificationsSettings.allCases {
items.append(ActionSheetButtonItem(title: "Notifications.PinnedMessages.value.\(value.rawValue)".i18n(lang), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
case .mentionsAndRepliesNotifications:
let setAction: (String) -> Void = { value in
SGSimpleSettings.shared.mentionsAndRepliesNotifications = value
SGSimpleSettings.shared.synchronizeShared()
simplePromise.set(true)
}
for value in SGSimpleSettings.MentionsAndRepliesNotificationsSettings.allCases {
items.append(ActionSheetButtonItem(title: "Notifications.MentionsAndReplies.value.\(value.rawValue)".i18n(lang), color: .accent, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
setAction(value.rawValue)
}))
}
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [
ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in
actionSheet?.dismissAnimated()
})
])])
presentControllerImpl?(actionSheet, ViewControllerPresentationArguments(presentationAnimation: .modalSheet))
}, openDisclosureLink: { link in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch (link) {
case .sessionBackupManager:
if #available(iOS 13.0, *) {
pushControllerImpl?(sgSessionBackupManagerController(context: context, presentationData: presentationData))
} else {
presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil)
}
case .messageFilter:
if #available(iOS 13.0, *) {
pushControllerImpl?(sgMessageFilterController(presentationData: presentationData))
} else {
presentControllerImpl?(context.sharedContext.makeSGUpdateIOSController(), nil)
}
}
}, action: { action in
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
switch action {
case .resetIAP:
let updateSettingsSignal = updateSGStatusInteractively(accountManager: context.sharedContext.accountManager, { status in
var status = status
status.status = SGStatus.default.status
SGSimpleSettings.shared.primaryUserId = ""
return status
})
let _ = (updateSettingsSignal |> deliverOnMainQueue).start(next: {
presentControllerImpl?(UndoOverlayController(
presentationData: presentationData,
content: .info(title: nil, text: "Status reset completed. You can now restore purchases.", timeout: nil, customUndoText: nil),
elevatedLayout: false,
action: { _ in return false }
),
nil)
})
}
})
let signal = combineLatest(context.sharedContext.presentationData, simplePromise.get())
|> map { presentationData, _ -> (ItemListControllerState, (ItemListNodeState, Any)) in
let entries = SGProControllerEntries(presentationData: presentationData)
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: .text("Swiftgram Pro"), leftNavigationButton: nil, rightNavigationButton: nil, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back))
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: entries, style: .blocks, ensureVisibleItemTag: /*focusOnItemTag*/ nil, initialScrollToItem: nil /* scrollToItem*/ )
return (controllerState, (listState, arguments))
}
let controller = ItemListController(context: context, state: signal)
presentControllerImpl = { [weak controller] c, a in
controller?.present(c, in: .window(.root), with: a)
}
pushControllerImpl = { [weak controller] c in
(controller?.navigationController as? NavigationController)?.pushViewController(c)
}
// Workaround
let _ = pushControllerImpl
return controller
}

View File

@ -0,0 +1,520 @@
import Foundation
import UndoUI
import AccountContext
import TelegramCore
import Postbox
import Display
import SwiftSignalKit
import TelegramPresentationData
import PresentationDataUtils
import SGSimpleSettings
import SGLogging
import SGKeychainBackupManager
struct SessionBackup: Codable {
var name: String? = nil
var date: Date = Date()
let accountRecord: AccountRecord<TelegramAccountManagerTypes.Attribute>
var peerIdInternal: Int64 {
var userId: Int64 = 0
for attribute in accountRecord.attributes {
if case let .backupData(backupData) = attribute, let backupPeerID = backupData.data?.peerId {
userId = backupPeerID
break
}
}
return userId
}
var userId: Int64 {
return PeerId(peerIdInternal).id._internalGetInt64Value()
}
}
import SwiftUI
import SGSwiftUI
import LegacyUI
import SGStrings
@available(iOS 13.0, *)
struct SessionBackupRow: View {
@Environment(\.lang) var lang: String
let backup: SessionBackup
let isLoggedIn: Bool
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
var formattedDate: String {
if #available(iOS 15.0, *) {
return backup.date.formatted(date: .abbreviated, time: .shortened)
} else {
return dateFormatter.string(from: backup.date)
}
}
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(backup.name ?? String(backup.userId))
.font(.body)
Text("ID: \(backup.userId)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("SessionBackup.LastBackupAt".i18n(lang, args: formattedDate))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Text((isLoggedIn ? "SessionBackup.LoggedIn" : "SessionBackup.LoggedOut").i18n(lang))
.font(.caption)
.foregroundColor(isLoggedIn ? .white : .secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(isLoggedIn ? Color.accentColor : Color.secondary.opacity(0.1))
.cornerRadius(4)
}
.padding(.vertical, 4)
}
}
@available(iOS 13.0, *)
struct BorderedButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.accentColor, lineWidth: 1)
)
.opacity(configuration.isPressed ? 0.7 : 1.0)
}
}
@available(iOS 13.0, *)
struct SessionBackupManagerView: View {
@Environment(\.lang) var lang: String
weak var wrapperController: LegacyController?
let context: AccountContext
@State private var sessions: [SessionBackup] = []
@State private var loggedInPeerIDs: [Int64] = []
@State private var loggedInAccountsDisposable: Disposable? = nil
private func performBackup() {
let controller = OverlayStatusController(theme: context.sharedContext.currentPresentationData.with { $0 }.theme, type: .loading(cancelled: nil))
let signal = context.sharedContext.accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue
let signal2 = context.sharedContext.activeAccountsWithInfo
|> take(1)
|> deliverOnMainQueue
wrapperController?.present(controller, in: .window(.root), with: nil)
Task {
if let result = try? await combineLatest(signal, signal2).awaitable() {
let (view, accountsWithInfo) = result
backupSessionsFromView(view, accountsWithInfo: accountsWithInfo.1)
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
}
}
}
private func performRestore() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
let _ = (context.sharedContext.accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue).start(next: { [weak controller] view in
let backupSessions = getBackedSessions()
var restoredSessions: Int64 = 0
func importNextBackup(index: Int) {
// Check if we're done
if index >= backupSessions.count {
// All done, update UI
withAnimation {
sessions = getBackedSessions()
}
controller?.dismiss()
wrapperController?.present(
okUndoController("SessionBackup.RestoreOK".i18n(lang, args: "\(restoredSessions)"), presentationData),
in: .current
)
return
}
let backup = backupSessions[index]
// Check for existing record
let existingRecord = view.records.first { record in
var userId: Int64 = 0
for attribute in record.attributes {
if case let .backupData(backupData) = attribute {
userId = backupData.data?.peerId ?? 0
}
}
return userId == backup.peerIdInternal
}
if existingRecord != nil {
SGLogger.shared.log("SessionBackup", "Record \(backup.userId) already exists, skipping")
importNextBackup(index: index + 1)
return
}
var importAttributes = backup.accountRecord.attributes
importAttributes.removeAll { attribute in
if case .sortOrder = attribute {
return true
}
return false
}
let importBackupSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
let nextSortOrder = (transaction.getRecords().map({ record -> Int32 in
for attribute in record.attributes {
if case let .sortOrder(sortOrder) = attribute {
return sortOrder.order
}
}
return 0
}).max() ?? 0) + 1
importAttributes.append(.sortOrder(AccountSortOrderAttribute(order: nextSortOrder)))
let accountRecordId = transaction.createRecord(importAttributes)
SGLogger.shared.log("SessionBackup", "Imported record \(accountRecordId) for \(backup.userId)")
restoredSessions += 1
}
|> deliverOnMainQueue
let _ = importBackupSignal.start(completed: {
importNextBackup(index: index + 1)
})
}
// Start the import chain
importNextBackup(index: 0)
})
wrapperController?.present(controller, in: .window(.root), with: nil)
}
private func performDeleteAll() {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: context, title: "SessionBackup.DeleteAll.Title".i18n(lang), text: "SessionBackup.DeleteAll.Text".i18n(lang), actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
wrapperController?.present(controller, in: .window(.root), with: nil)
do {
try KeychainBackupManager.shared.deleteAllSessions()
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
} catch let e {
SGLogger.shared.log("SessionBackup", "Error deleting all sessions: \(e)")
}
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
])
wrapperController?.present(controller, in: .window(.root), with: nil)
}
private func performDelete(_ session: SessionBackup) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: context, title: "SessionBackup.DeleteSingle.Title".i18n(lang), text: "SessionBackup.DeleteSingle.Text".i18n(lang, args: "\(session.name ?? "\(session.userId)")"), actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
wrapperController?.present(controller, in: .window(.root), with: nil)
do {
try KeychainBackupManager.shared.deleteSession(for: "\(session.peerIdInternal)")
withAnimation {
sessions = getBackedSessions()
}
controller.dismiss()
} catch let e {
SGLogger.shared.log("SessionBackup", "Error deleting session: \(e)")
}
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
])
wrapperController?.present(controller, in: .window(.root), with: nil)
}
private func performRemoveSessionFromApp(session: SessionBackup) {
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
let controller = textAlertController(context: context, title: "SessionBackup.RemoveFromApp.Title".i18n(lang), text: "SessionBackup.RemoveFromApp.Text".i18n(lang, args: "\(session.name ?? "\(session.userId)")"), actions: [
TextAlertAction(type: .destructiveAction, title: presentationData.strings.Common_Delete, action: {
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
wrapperController?.present(controller, in: .window(.root), with: nil)
let signal = context.sharedContext.accountManager.accountRecords()
|> take(1)
|> deliverOnMainQueue
let _ = signal.start(next: { [weak controller] view in
// Find record to delete
let accountRecord = view.records.first { record in
var userId: Int64 = 0
for attribute in record.attributes {
if case let .backupData(backupData) = attribute {
userId = backupData.data?.peerId ?? 0
}
}
return userId == session.peerIdInternal
}
if let record = accountRecord {
let deleteSignal = context.sharedContext.accountManager.transaction { transaction -> Void in
transaction.updateRecord(record.id, { _ in return nil})
}
|> deliverOnMainQueue
let _ = deleteSignal.start(next: {
withAnimation {
sessions = getBackedSessions()
}
controller?.dismiss()
})
} else {
controller?.dismiss()
}
})
}),
TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_Cancel, action: {})
])
wrapperController?.present(controller, in: .window(.root), with: nil)
}
var body: some View {
List {
Section() {
Button(action: performBackup) {
HStack {
Image(systemName: "key.fill")
.frame(width: 30)
Text("SessionBackup.Actions.Backup".i18n(lang))
Spacer()
}
}
Button(action: performRestore) {
HStack {
Image(systemName: "arrow.2.circlepath")
.frame(width: 30)
Text("SessionBackup.Actions.Restore".i18n(lang))
Spacer()
}
}
Button(action: performDeleteAll) {
HStack {
Image(systemName: "trash")
.frame(width: 30)
Text("SessionBackup.Actions.DeleteAll".i18n(lang))
}
}
.foregroundColor(.red)
}
Text("SessionBackup.Notice".i18n(lang))
.font(.caption)
.foregroundColor(.secondary)
Section(header: Text("SessionBackup.Sessions.Title".i18n(lang))) {
ForEach(sessions, id: \.peerIdInternal) { session in
SessionBackupRow(
backup: session,
isLoggedIn: loggedInPeerIDs.contains(session.peerIdInternal)
)
.contextMenu {
Button(action: {
performDelete(session)
}, label: {
HStack(spacing: 4) {
Text("SessionBackup.Actions.DeleteOne".i18n(lang))
Image(systemName: "trash")
}
})
Button(action: {
performRemoveSessionFromApp(session: session)
}, label: {
HStack(spacing: 4) {
Text("SessionBackup.Actions.RemoveFromApp".i18n(lang))
Image(systemName: "trash")
}
})
}
}
// .onDelete { indexSet in
// performDelete(indexSet)
// }
}
}
.onAppear {
withAnimation {
sessions = getBackedSessions()
}
let accountsSignal = context.sharedContext.accountManager.accountRecords()
|> deliverOnMainQueue
loggedInAccountsDisposable = accountsSignal.start(next: { view in
var result: [Int64] = []
for record in view.records {
var isLoggedOut: Bool = false
var userId: Int64 = 0
for attribute in record.attributes {
if case .loggedOut = attribute {
isLoggedOut = true
} else if case let .backupData(backupData) = attribute {
userId = backupData.data?.peerId ?? 0
}
}
if !isLoggedOut && userId != 0 {
result.append(userId)
}
}
SGLogger.shared.log("SessionBackup", "Logged in accounts: \(result)")
if loggedInPeerIDs != result {
SGLogger.shared.log("SessionBackup", "Updating logged in accounts: \(result)")
loggedInPeerIDs = result
}
})
}
.onDisappear {
loggedInAccountsDisposable?.dispose()
}
}
}
func getBackedSessions() -> [SessionBackup] {
var sessions: [SessionBackup] = []
do {
let backupSessionsData = try KeychainBackupManager.shared.getAllSessons()
for sessionBackupData in backupSessionsData {
do {
let backup = try JSONDecoder().decode(SessionBackup.self, from: sessionBackupData)
sessions.append(backup)
} catch let e {
SGLogger.shared.log("SessionBackup", "IMPORT ERROR: \(e)")
}
}
} catch let e {
SGLogger.shared.log("SessionBackup", "Error getting all sessions: \(e)")
}
return sessions
}
func backupSessionsFromView(_ view: AccountRecordsView<TelegramAccountManagerTypes>, accountsWithInfo: [AccountWithInfo] = []) {
var recordsToBackup: [Int64: AccountRecord<TelegramAccountManagerTypes.Attribute>] = [:]
for record in view.records {
var sortOrder: Int32 = 0
var isLoggedOut: Bool = false
var isTestingEnvironment: Bool = false
var peerId: Int64 = 0
for attribute in record.attributes {
if case let .sortOrder(value) = attribute {
sortOrder = value.order
} else if case .loggedOut = attribute {
isLoggedOut = true
} else if case let .environment(environment) = attribute, case .test = environment.environment {
isTestingEnvironment = true
} else if case let .backupData(backupData) = attribute {
peerId = backupData.data?.peerId ?? 0
}
}
let _ = sortOrder
let _ = isTestingEnvironment
if !isLoggedOut && peerId != 0 {
recordsToBackup[peerId] = record
}
}
for (peerId, record) in recordsToBackup {
var backupName: String? = nil
if let accountWithInfo = accountsWithInfo.first(where: { $0.peer.id == PeerId(peerId) }) {
if let user = accountWithInfo.peer as? TelegramUser {
if let username = user.username {
backupName = "@\(username)"
} else {
backupName = user.nameOrPhone
}
}
}
let backup = SessionBackup(name: backupName, accountRecord: record)
do {
let data = try JSONEncoder().encode(backup)
try KeychainBackupManager.shared.saveSession(id: "\(backup.peerIdInternal)", data)
} catch let e {
SGLogger.shared.log("SessionBackup", "BACKUP ERROR: \(e)")
}
}
}
@available(iOS 13.0, *)
public func sgSessionBackupManagerController(context: AccountContext, presentationData: PresentationData? = nil) -> ViewController {
let theme = presentationData?.theme ?? (UITraitCollection.current.userInterfaceStyle == .dark ? defaultDarkColorPresentationTheme : defaultPresentationTheme)
let strings = presentationData?.strings ?? defaultPresentationStrings
let legacyController = LegacySwiftUIController(
presentation: .navigation,
theme: theme,
strings: strings
)
legacyController.statusBar.statusBarStyle = theme.rootController
.statusBarStyle.style
legacyController.title = "SessionBackup.Title".i18n(strings.baseLanguageCode)
let swiftUIView = SGSwiftUIView<SessionBackupManagerView>(
legacyController: legacyController,
manageSafeArea: true,
content: {
SessionBackupManagerView(wrapperController: legacyController, context: context)
}
)
let controller = UIHostingController(rootView: swiftUIView, ignoreSafeArea: true)
legacyController.bind(controller: controller)
return legacyController
}

27
Swiftgram/SGRegDate/BUILD Normal file
View File

@ -0,0 +1,27 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGRegDate",
module_name = "SGRegDate",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGRegDateScheme:SGRegDateScheme",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGAPI:SGAPI",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//Swiftgram/SGDeviceToken:SGDeviceToken",
"//Swiftgram/SGStrings:SGStrings",
"//submodules/AccountContext:AccountContext",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,45 @@
import Foundation
import SwiftSignalKit
import TelegramPresentationData
import SGLogging
import SGStrings
import SGRegDateScheme
import AccountContext
import SGSimpleSettings
import SGAPI
import SGAPIToken
import SGDeviceToken
public enum RegDateError {
case generic
}
public func getRegDate(context: AccountContext, peerId: Int64) -> Signal<RegDate?, NoError> {
return Signal { subscriber in
var tokensRequestSignal: Disposable? = nil
var apiRequestSignal: Disposable? = nil
if let regDateData = SGSimpleSettings.shared.regDateCache[String(peerId)], let regDate = try? JSONDecoder().decode(RegDate.self, from: regDateData), regDate.validUntil == 0 || regDate.validUntil > Int64(Date().timeIntervalSince1970) {
subscriber.putNext(regDate)
subscriber.putCompletion()
} else if SGSimpleSettings.shared.showRegDate {
tokensRequestSignal = combineLatest(getDeviceToken() |> mapError { error -> Void in SGLogger.shared.log("SGDeviceToken", "Error generating token: \(error)"); return Void() } , getSGApiToken(context: context) |> mapError { _ -> Void in return Void() }).start(next: { deviceToken, apiToken in
apiRequestSignal = getSGAPIRegDate(token: apiToken, deviceToken: deviceToken, userId: peerId).start(next: { regDate in
if let data = try? JSONEncoder().encode(regDate) {
SGSimpleSettings.shared.regDateCache[String(peerId)] = data
}
subscriber.putNext(regDate)
subscriber.putCompletion()
})
})
} else {
subscriber.putNext(nil)
subscriber.putCompletion()
}
return ActionDisposable {
tokensRequestSignal?.dispose()
apiRequestSignal?.dispose()
}
}
}

View File

@ -0,0 +1,17 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGRegDateScheme",
module_name = "SGRegDateScheme",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,7 @@
import Foundation
public struct RegDate: Codable {
public let from: Int64
public let to: Int64
public let validUntil: Int64
}

View File

@ -0,0 +1,18 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "SGRequests",
module_name = "SGRequests",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit"
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,72 @@
import Foundation
import SwiftSignalKit
public func requestsDownload(url: URL) -> Signal<(Data, URLResponse?), Error?> {
return Signal { subscriber in
let completed = Atomic<Bool>(value: false)
let downloadTask = URLSession.shared.downloadTask(with: url, completionHandler: { location, response, error in
let _ = completed.swap(true)
if let location = location, let data = try? Data(contentsOf: location) {
subscriber.putNext((data, response))
subscriber.putCompletion()
} else {
subscriber.putError(error)
}
})
downloadTask.resume()
return ActionDisposable {
if !completed.with({ $0 }) {
downloadTask.cancel()
}
}
}
}
public func requestsGet(url: URL) -> Signal<(Data, URLResponse?), Error?> {
return Signal { subscriber in
let completed = Atomic<Bool>(value: false)
let urlTask = URLSession.shared.dataTask(with: url, completionHandler: { data, response, error in
let _ = completed.swap(true)
if let strongData = data {
subscriber.putNext((strongData, response))
subscriber.putCompletion()
} else {
subscriber.putError(error)
}
})
urlTask.resume()
return ActionDisposable {
if !completed.with({ $0 }) {
urlTask.cancel()
}
}
}
}
public func requestsCustom(request: URLRequest) -> Signal<(Data, URLResponse?), Error?> {
return Signal { subscriber in
let completed = Atomic<Bool>(value: false)
let urlTask = URLSession.shared.dataTask(with: request, completionHandler: { data, response, error in
_ = completed.swap(true)
if let strongData = data {
subscriber.putNext((strongData, response))
subscriber.putCompletion()
} else {
subscriber.putError(error)
}
})
urlTask.resume()
return ActionDisposable {
if !completed.with({ $0 }) {
urlTask.cancel()
}
}
}
}

View File

@ -0,0 +1,10 @@
load("@build_bazel_rules_apple//apple:resources.bzl", "apple_bundle_import")
apple_bundle_import(
name = "SGSettingsBundle",
bundle_imports = glob([
"Settings.bundle/*",
"Settings.bundle/**/*",
]),
visibility = ["//visibility:public"]
)

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>StringsTable</key>
<string>Root</string>
<key>PreferenceSpecifiers</key>
<array>
<dict>
<key>Type</key>
<string>PSGroupSpecifier</string>
<key>FooterText</key>
<string>Reset.Notice</string>
<key>Title</key>
<string>Reset.Title</string>
</dict>
<dict>
<key>Type</key>
<string>PSToggleSwitchSpecifier</string>
<key>Title</key>
<string>Reset.Toggle</string>
<key>Key</key>
<string>sg_db_reset</string>
<key>DefaultValue</key>
<false/>
</dict>
<dict>
<key>Type</key>
<string>PSGroupSpecifier</string>
<key>FooterText</key>
<string>HardReset.Notice</string>
<key>Title</key>
<string>HardReset.Title</string>
</dict>
<dict>
<key>Type</key>
<string>PSToggleSwitchSpecifier</string>
<key>Title</key>
<string>HardReset.Toggle</string>
<key>Key</key>
<string>sg_db_hard_reset</string>
<key>DefaultValue</key>
<false/>
</dict>
</array>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
/* A single strings file, whose title is specified in your preferences schema. The strings files provide the localized content to display to the user for each of your preferences. */
"Reset.Title" = "TROUBLESHOOTING";
"Reset.Toggle" = "Reset Metadata";
"Reset.Notice" = "Use in case you're stuck and can't open the app. This WILL NOT log out your accounts, but all secret chats will be lost.";
"HardReset.Title" = "";
"HardReset.Toggle" = "Reset All";
"HardReset.Notice" = "Clears metadata, cached messages and media for all accounts. This should not log out your accounts, but proceed at YOUR OWN RISK. All secret chats will be lost.";

View File

@ -0,0 +1,6 @@
"Reset.Title" = "РЕШЕНИЕ ПРОБЛЕМ";
"Reset.Toggle" = "Сбросить Метаданные";
"Reset.Notice" = "Используйте, если приложение вылетает или не загружается. Эта опция НЕ СБРАСЫВАЕТ ваши аккаунты, но удалит все секретные чаты.";
"HardReset.Title" = "";
"HardReset.Toggle" = "Сбросить Всё";
"HardReset.Notice" = "Сбрасывает метаданные, кэшированные сообщения и медиа для всех аккаунтов. Эта опция не должна разлогинить ваши аккаунты, но используйте её на СВОЙ СТРАХ И РИСК. Все секретные чаты удалятся.";

View File

@ -0,0 +1,43 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
filegroup(
name = "SGUIAssets",
srcs = glob(["Images.xcassets/**"]),
visibility = ["//visibility:public"],
)
swift_library(
name = "SGSettingsUI",
module_name = "SGSettingsUI",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//Swiftgram/SGItemListUI:SGItemListUI",
"//Swiftgram/SGLogging:SGLogging",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGStrings:SGStrings",
# "//Swiftgram/SGAPI:SGAPI",
"//Swiftgram/SGAPIToken:SGAPIToken",
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Display:Display",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/MtProtoKit:MtProtoKit",
"//submodules/TelegramPresentationData:TelegramPresentationData",
"//submodules/TelegramUIPreferences:TelegramUIPreferences",
"//submodules/ItemListUI:ItemListUI",
"//submodules/PresentationDataUtils:PresentationDataUtils",
"//submodules/OverlayStatusController:OverlayStatusController",
"//submodules/AccountContext:AccountContext",
"//submodules/AppBundle:AppBundle",
"//submodules/TelegramUI/Components/Settings/PeerNameColorScreen",
"//submodules/UndoUI:UndoUI",
],
visibility = [
"//visibility:public",
],
)

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_lt_savetocloud.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Some files were not shown because too many files have changed in this diff Show More