Version 11.9
4
.github/workflows/build.yml
vendored
@ -1,8 +1,8 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# push:
|
||||||
branches: [ master ]
|
# branches: [ master ]
|
||||||
|
|
||||||
workflow_dispatch:
|
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/README.md
|
||||||
fastlane/report.xml
|
fastlane/report.xml
|
||||||
fastlane/test_output/*
|
fastlane/test_output/*
|
||||||
|
5
.gitmodules
vendored
@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
[submodule "submodules/rlottie/rlottie"]
|
[submodule "submodules/rlottie/rlottie"]
|
||||||
path = submodules/rlottie/rlottie
|
path = submodules/rlottie/rlottie
|
||||||
url=../rlottie.git
|
url=https://github.com/TelegramMessenger/rlottie.git
|
||||||
[submodule "build-system/bazel-rules/rules_apple"]
|
[submodule "build-system/bazel-rules/rules_apple"]
|
||||||
path = build-system/bazel-rules/rules_apple
|
path = build-system/bazel-rules/rules_apple
|
||||||
url=https://github.com/ali-fareed/rules_apple.git
|
url=https://github.com/ali-fareed/rules_apple.git
|
||||||
@ -13,7 +12,7 @@ url=https://github.com/bazelbuild/rules_swift.git
|
|||||||
url = https://github.com/bazelbuild/apple_support.git
|
url = https://github.com/bazelbuild/apple_support.git
|
||||||
[submodule "submodules/TgVoipWebrtc/tgcalls"]
|
[submodule "submodules/TgVoipWebrtc/tgcalls"]
|
||||||
path = submodules/TgVoipWebrtc/tgcalls
|
path = submodules/TgVoipWebrtc/tgcalls
|
||||||
url=../tgcalls.git
|
url=https://github.com/TelegramMessenger/tgcalls.git
|
||||||
[submodule "third-party/libvpx/libvpx"]
|
[submodule "third-party/libvpx/libvpx"]
|
||||||
path = third-party/libvpx/libvpx
|
path = third-party/libvpx/libvpx
|
||||||
url = https://github.com/webmproject/libvpx.git
|
url = https://github.com/webmproject/libvpx.git
|
||||||
|
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.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
|
||||||
"lldb.launch.expressions": "native",
|
"lldb.launch.expressions": "native",
|
||||||
"search.followSymlinks": false,
|
"search.followSymlinks": false,
|
||||||
|
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
|
# Telegram iOS Source Code Compilation Guide
|
||||||
|
|
||||||
We welcome all developers to use our API and source code to create applications on our platform.
|
We welcome all developers to use our API and source code to create applications on our platform.
|
||||||
@ -16,7 +29,7 @@ There are several things we require from **all developers** for the moment.
|
|||||||
## Get the Code
|
## Get the Code
|
||||||
|
|
||||||
```
|
```
|
||||||
git clone --recursive -j8 https://github.com/TelegramMessenger/Telegram-iOS.git
|
git clone --recursive -j8 https://github.com/Swiftgram/Telegram-iOS.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup Xcode
|
## Setup Xcode
|
||||||
@ -29,7 +42,7 @@ Install Xcode (directly from https://developer.apple.com/download/applications o
|
|||||||
```
|
```
|
||||||
openssl rand -hex 8
|
openssl rand -hex 8
|
||||||
```
|
```
|
||||||
2. Create a new Xcode project. Use `Telegram` as the Product Name. Use `org.{identifier from step 1}` as the Organization Identifier.
|
2. Create a new Xcode project. Use `Swiftgram` as the Product Name. Use `org.{identifier from step 1}` as the Organization Identifier.
|
||||||
3. Open `Keychain Access` and navigate to `Certificates`. Locate `Apple Development: your@email.address (XXXXXXXXXX)` and double tap the certificate. Under `Details`, locate `Organizational Unit`. This is the Team ID.
|
3. Open `Keychain Access` and navigate to `Certificates`. Locate `Apple Development: your@email.address (XXXXXXXXXX)` and double tap the certificate. Under `Details`, locate `Organizational Unit`. This is the Team ID.
|
||||||
4. Edit `build-system/template_minimal_development_configuration.json`. Use data from the previous steps.
|
4. Edit `build-system/template_minimal_development_configuration.json`. Use data from the previous steps.
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|