Version 11.9
4
.github/workflows/build.yml
vendored
@ -1,8 +1,8 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
# push:
|
||||
# branches: [ master ]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
|
6
.gitignore
vendored
@ -1,3 +1,9 @@
|
||||
submodules/**/.build/*
|
||||
swiftgram-scripts
|
||||
Swiftgram/Playground/custom_bazel_path.bzl
|
||||
Swiftgram/Playground/codesigning
|
||||
buildServer.json
|
||||
|
||||
fastlane/README.md
|
||||
fastlane/report.xml
|
||||
fastlane/test_output/*
|
||||
|
5
.gitmodules
vendored
@ -1,7 +1,6 @@
|
||||
|
||||
[submodule "submodules/rlottie/rlottie"]
|
||||
path = submodules/rlottie/rlottie
|
||||
url=../rlottie.git
|
||||
url=https://github.com/TelegramMessenger/rlottie.git
|
||||
[submodule "build-system/bazel-rules/rules_apple"]
|
||||
path = build-system/bazel-rules/rules_apple
|
||||
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
|
||||
[submodule "submodules/TgVoipWebrtc/tgcalls"]
|
||||
path = submodules/TgVoipWebrtc/tgcalls
|
||||
url=../tgcalls.git
|
||||
url=https://github.com/TelegramMessenger/tgcalls.git
|
||||
[submodule "third-party/libvpx/libvpx"]
|
||||
path = third-party/libvpx/libvpx
|
||||
url = https://github.com/webmproject/libvpx.git
|
||||
|
2
.vscode/settings.json
vendored
@ -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.launch.expressions": "native",
|
||||
"search.followSymlinks": false,
|
||||
|
17
README.md
@ -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
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
git clone --recursive -j8 https://github.com/TelegramMessenger/Telegram-iOS.git
|
||||
git clone --recursive -j8 https://github.com/Swiftgram/Telegram-iOS.git
|
||||
```
|
||||
|
||||
## Setup Xcode
|
||||
@ -29,7 +42,7 @@ Install Xcode (directly from https://developer.apple.com/download/applications o
|
||||
```
|
||||
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.
|
||||
4. Edit `build-system/template_minimal_development_configuration.json`. Use data from the previous steps.
|
||||
|
||||
|
9
Swiftgram/AppleStyleFolders/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "AppleStyleFolders",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
1074
Swiftgram/AppleStyleFolders/Sources/File.swift
Normal file
9
Swiftgram/ChatControllerImplExtension/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "ChatControllerImplExtension",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -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
68
Swiftgram/FLEX/FLEX.BUILD
Normal 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"],
|
||||
)
|
3
Swiftgram/Playground/.swiftformat
Normal file
@ -0,0 +1,3 @@
|
||||
--maxwidth 100
|
||||
--indent 4
|
||||
--disable redundantSelf
|
87
Swiftgram/Playground/BUILD
Normal 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"],
|
||||
),
|
||||
)
|
25
Swiftgram/Playground/README.md
Normal 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
|
||||
```
|
39
Swiftgram/Playground/Resources/Info.plist
Normal 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>
|
25
Swiftgram/Playground/Resources/LaunchScreen.storyboard
Normal 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>
|
82
Swiftgram/Playground/Sources/AppDelegate.swift
Normal 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
|
||||
}
|
||||
}
|
100
Swiftgram/Playground/Sources/AppNavigationSetup.swift
Normal 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
|
||||
}
|
||||
}
|
5
Swiftgram/Playground/Sources/Application.swift
Normal file
@ -0,0 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
@objc(Application) class Application: UIApplication {
|
||||
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
362
Swiftgram/Playground/Sources/PlaygroundTheme.swift
Normal 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
|
||||
)
|
||||
)
|
85
Swiftgram/Playground/Sources/SwiftUIViewController.swift
Normal 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
|
||||
}
|
7
Swiftgram/Playground/Sources/main.m
Normal file
@ -0,0 +1,7 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
@autoreleasepool {
|
||||
return UIApplicationMain(argc, argv, @"Application", @"AppDelegate");
|
||||
}
|
||||
}
|
78
Swiftgram/Playground/generate_project.py
Executable 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()
|
170
Swiftgram/Playground/launch_on_simulator.py
Executable 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)
|
17
Swiftgram/SFSafariViewControllerPlus/BUILD
Normal 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",
|
||||
],
|
||||
)
|
@ -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
@ -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",
|
||||
],
|
||||
)
|
188
Swiftgram/SGAPI/Sources/SGAPI.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
24
Swiftgram/SGAPIToken/BUILD
Normal 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",
|
||||
],
|
||||
)
|
133
Swiftgram/SGAPIToken/Sources/SGAPIToken.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
23
Swiftgram/SGAPIWebSettings/BUILD
Normal 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",
|
||||
],
|
||||
)
|
50
Swiftgram/SGAPIWebSettings/Sources/File.swift
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
17
Swiftgram/SGActionRequestHandlerSanitizer/BUILD
Normal 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",
|
||||
],
|
||||
)
|
15
Swiftgram/SGActionRequestHandlerSanitizer/Sources/File.swift
Normal 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
|
||||
}
|
17
Swiftgram/SGAppGroupIdentifier/BUILD
Normal 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",
|
||||
],
|
||||
)
|
@ -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
@ -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",
|
||||
],
|
||||
)
|
21
Swiftgram/SGConfig/Sources/File.swift
Normal 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)!
|
18
Swiftgram/SGContentAnalysis/BUILD
Normal 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",
|
||||
],
|
||||
)
|
64
Swiftgram/SGContentAnalysis/Sources/ContentAnalysis.swift
Normal 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)
|
||||
}
|
||||
}
|
9
Swiftgram/SGDBReset/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "SGDBReset",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
162
Swiftgram/SGDBReset/Sources/File.swift
Normal 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
@ -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",
|
||||
],
|
||||
)
|
217
Swiftgram/SGDebugUI/Sources/SGDebugUI.swift
Normal 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
|
||||
}
|
||||
|
||||
|
18
Swiftgram/SGDeviceToken/BUILD
Normal 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",
|
||||
],
|
||||
)
|
31
Swiftgram/SGDeviceToken/Sources/File.swift
Normal 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 {
|
||||
}
|
||||
}
|
||||
}
|
9
Swiftgram/SGDoubleTapMessageAction/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "SGDoubleTapMessageAction",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -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
|
||||
}
|
||||
}
|
9
Swiftgram/SGEmojiKeyboardDefaultFirst/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "SGEmojiKeyboardDefaultFirst",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
@ -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
@ -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",
|
||||
],
|
||||
)
|
384
Swiftgram/SGIAP/Sources/SGIAP.swift
Normal 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
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "SGIQTP",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
77
Swiftgram/SGIQTP/Sources/SGIQTP.swift
Normal 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
|
||||
}
|
||||
}
|
17
Swiftgram/SGInputToolbar/BUILD
Normal 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",
|
||||
],
|
||||
)
|
148
Swiftgram/SGInputToolbar/Sources/SGInputToolbar.swift
Normal 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)
|
||||
}
|
||||
}
|
30
Swiftgram/SGItemListUI/BUILD
Normal 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",
|
||||
],
|
||||
)
|
333
Swiftgram/SGItemListUI/Sources/SGItemListUI.swift
Normal 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
|
||||
}
|
353
Swiftgram/SGItemListUI/Sources/SliderPercentageItem.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
|
17
Swiftgram/SGKeychainBackupManager/BUILD
Normal 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",
|
||||
],
|
||||
)
|
@ -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
@ -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",
|
||||
],
|
||||
)
|
236
Swiftgram/SGLogging/Sources/SGLogger.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
6
Swiftgram/SGLogging/Sources/Utils.swift
Normal 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
@ -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",
|
||||
],
|
||||
)
|
6
Swiftgram/SGPayWall/Images.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
BIN
Swiftgram/SGPayWall/Images.xcassets/ProDetailsBackup.imageset/Backup.png
vendored
Normal file
After Width: | Height: | Size: 376 KiB |
21
Swiftgram/SGPayWall/Images.xcassets/ProDetailsBackup.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
21
Swiftgram/SGPayWall/Images.xcassets/ProDetailsFilter.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
Swiftgram/SGPayWall/Images.xcassets/ProDetailsFilter.imageset/Filter.png
vendored
Normal file
After Width: | Height: | Size: 522 KiB |
21
Swiftgram/SGPayWall/Images.xcassets/ProDetailsFormatting.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
Swiftgram/SGPayWall/Images.xcassets/ProDetailsFormatting.imageset/Formatting.png
vendored
Normal file
After Width: | Height: | Size: 246 KiB |
21
Swiftgram/SGPayWall/Images.xcassets/ProDetailsIcons.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
Swiftgram/SGPayWall/Images.xcassets/ProDetailsIcons.imageset/Icons.png
vendored
Normal file
After Width: | Height: | Size: 366 KiB |
21
Swiftgram/SGPayWall/Images.xcassets/ProDetailsMute.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
Swiftgram/SGPayWall/Images.xcassets/ProDetailsMute.imageset/Mute.png
vendored
Normal file
After Width: | Height: | Size: 703 KiB |
23
Swiftgram/SGPayWall/Images.xcassets/pro.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
BIN
Swiftgram/SGPayWall/Images.xcassets/pro.imageset/pro.png
vendored
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
Swiftgram/SGPayWall/Images.xcassets/pro.imageset/pro@2x.png
vendored
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
Swiftgram/SGPayWall/Images.xcassets/pro.imageset/pro@3x.png
vendored
Normal file
After Width: | Height: | Size: 61 KiB |
993
Swiftgram/SGPayWall/Sources/SGPayWall.swift
Normal 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
@ -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",
|
||||
],
|
||||
)
|
181
Swiftgram/SGProUI/Sources/MessageFilterController.swift
Normal 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
|
||||
}
|
186
Swiftgram/SGProUI/Sources/SGProUI.swift
Normal 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
|
||||
}
|
||||
|
||||
|
520
Swiftgram/SGProUI/Sources/SessionBackupController.swift
Normal 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
@ -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",
|
||||
],
|
||||
)
|
45
Swiftgram/SGRegDate/Sources/SGRegDate.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
17
Swiftgram/SGRegDateScheme/BUILD
Normal 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",
|
||||
],
|
||||
)
|
7
Swiftgram/SGRegDateScheme/Sources/File.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public struct RegDate: Codable {
|
||||
public let from: Int64
|
||||
public let to: Int64
|
||||
public let validUntil: Int64
|
||||
}
|
18
Swiftgram/SGRequests/BUILD
Normal 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",
|
||||
],
|
||||
)
|
72
Swiftgram/SGRequests/Sources/File.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
Swiftgram/SGSettingsBundle/BUILD
Normal 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"]
|
||||
)
|
47
Swiftgram/SGSettingsBundle/Settings.bundle/Root.plist
Normal 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>
|
@ -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.";
|
@ -0,0 +1,6 @@
|
||||
"Reset.Title" = "РЕШЕНИЕ ПРОБЛЕМ";
|
||||
"Reset.Toggle" = "Сбросить Метаданные";
|
||||
"Reset.Notice" = "Используйте, если приложение вылетает или не загружается. Эта опция НЕ СБРАСЫВАЕТ ваши аккаунты, но удалит все секретные чаты.";
|
||||
"HardReset.Title" = "";
|
||||
"HardReset.Toggle" = "Сбросить Всё";
|
||||
"HardReset.Notice" = "Сбрасывает метаданные, кэшированные сообщения и медиа для всех аккаунтов. Эта опция не должна разлогинить ваши аккаунты, но используйте её на СВОЙ СТРАХ И РИСК. Все секретные чаты удалятся.";
|
43
Swiftgram/SGSettingsUI/BUILD
Normal 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",
|
||||
],
|
||||
)
|
6
Swiftgram/SGSettingsUI/Images.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
12
Swiftgram/SGSettingsUI/Images.xcassets/SaveToCloud.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "ic_lt_savetocloud.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|