Version 11.15
4
.github/workflows/build.yml
vendored
@ -1,8 +1,8 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
# push:
|
||||
# branches: [ master ]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
6
.gitignore
vendored
@ -1,3 +1,9 @@
|
||||
submodules/**/.build/*
|
||||
swiftgram-scripts
|
||||
Swiftgram/Playground/custom_bazel_path.bzl
|
||||
Swiftgram/Playground/codesigning
|
||||
buildServer.json
|
||||
|
||||
fastlane/README.md
|
||||
fastlane/report.xml
|
||||
fastlane/test_output/*
|
||||
|
||||
5
.gitmodules
vendored
@ -1,7 +1,6 @@
|
||||
|
||||
[submodule "submodules/rlottie/rlottie"]
|
||||
path = submodules/rlottie/rlottie
|
||||
url=../rlottie.git
|
||||
url=https://github.com/TelegramMessenger/rlottie.git
|
||||
[submodule "build-system/bazel-rules/rules_apple"]
|
||||
path = build-system/bazel-rules/rules_apple
|
||||
url=https://github.com/ali-fareed/rules_apple.git
|
||||
@ -13,7 +12,7 @@ url=https://github.com/bazelbuild/rules_swift.git
|
||||
url = https://github.com/bazelbuild/apple_support.git
|
||||
[submodule "submodules/TgVoipWebrtc/tgcalls"]
|
||||
path = submodules/TgVoipWebrtc/tgcalls
|
||||
url=../tgcalls.git
|
||||
url=https://github.com/TelegramMessenger/tgcalls.git
|
||||
[submodule "third-party/libvpx/libvpx"]
|
||||
path = third-party/libvpx/libvpx
|
||||
url = https://github.com/webmproject/libvpx.git
|
||||
|
||||
16
.vscode/launch.json
vendored
@ -4,6 +4,22 @@
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Run Swiftgram",
|
||||
"preLaunchTask": "sweetpad: launch"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Debug Swiftgram",
|
||||
"preLaunchTask": "sweetpad: debugging-launch"
|
||||
},
|
||||
{
|
||||
"type": "swift",
|
||||
"request": "launch",
|
||||
|
||||
2
.vscode/settings.json
vendored
@ -1,5 +1,5 @@
|
||||
{
|
||||
"sweetpad.build.xcodeWorkspacePath": "Telegram/Telegram.xcodeproj/project.xcworkspace",
|
||||
"sweetpad.build.xcodeWorkspacePath": "Telegram/Swiftgram.xcodeproj/project.xcworkspace",
|
||||
"lldb.library": "/Applications/Xcode.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB",
|
||||
"lldb.launch.expressions": "native",
|
||||
"search.followSymlinks": false,
|
||||
|
||||
10
MODULE.bazel
@ -1,4 +1,6 @@
|
||||
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
|
||||
# MARK: Swiftgram
|
||||
new_git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "new_git_repository")
|
||||
|
||||
bazel_dep(name = "bazel_features", version = "1.30.0")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.7.1")
|
||||
@ -28,6 +30,14 @@ local_path_override(
|
||||
path = "./build-system/bazel-rules/apple_support",
|
||||
)
|
||||
|
||||
# MARK: Swiftgram
|
||||
new_git_repository(
|
||||
name = "flex_sdk",
|
||||
remote = "https://github.com/FLEXTool/FLEX.git",
|
||||
commit = "2bfba6715eff664ef84a02e8eb0ad9b5a609c684",
|
||||
build_file = "@//Swiftgram/FLEX:FLEX.BUILD"
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "cmake_tar_gz",
|
||||
urls = ["https://github.com/Kitware/CMake/releases/download/v3.23.1/cmake-3.23.1-macos-universal.tar.gz"],
|
||||
|
||||
17
README.md
@ -1,3 +1,16 @@
|
||||
# Swiftgram
|
||||
|
||||
Supercharged Telegram fork for iOS
|
||||
|
||||
[<img src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" height="50">](https://apps.apple.com/app/apple-store/id6471879502?pt=126511626&ct=gh&mt=8)
|
||||
|
||||
- Download: [App Store](https://apps.apple.com/app/apple-store/id6471879502?pt=126511626&ct=gh&mt=8)
|
||||
- Telegram channel: https://t.me/swiftgram
|
||||
- Telegram chat: https://t.me/swiftgramchat
|
||||
- TestFlight beta, local chats, translations and other [@SwiftgramLinks](https://t.me/s/SwiftgramLinks)
|
||||
|
||||
Swiftgram's compilation steps are the same as for the official app. Below you'll find a complete compilation guide based on the official app.
|
||||
|
||||
# Telegram iOS Source Code Compilation Guide
|
||||
|
||||
We welcome all developers to use our API and source code to create applications on our platform.
|
||||
@ -16,7 +29,7 @@ There are several things we require from **all developers** for the moment.
|
||||
## Get the Code
|
||||
|
||||
```
|
||||
git clone --recursive -j8 https://github.com/TelegramMessenger/Telegram-iOS.git
|
||||
git clone --recursive -j8 https://github.com/Swiftgram/Telegram-iOS.git
|
||||
```
|
||||
|
||||
## Setup Xcode
|
||||
@ -29,7 +42,7 @@ Install Xcode (directly from https://developer.apple.com/download/applications o
|
||||
```
|
||||
openssl rand -hex 8
|
||||
```
|
||||
2. Create a new Xcode project. Use `Telegram` as the Product Name. Use `org.{identifier from step 1}` as the Organization Identifier.
|
||||
2. Create a new Xcode project. Use `Swiftgram` as the Product Name. Use `org.{identifier from step 1}` as the Organization Identifier.
|
||||
3. Open `Keychain Access` and navigate to `Certificates`. Locate `Apple Development: your@email.address (XXXXXXXXXX)` and double tap the certificate. Under `Details`, locate `Organizational Unit`. This is the Team ID.
|
||||
4. Edit `build-system/template_minimal_development_configuration.json`. Use data from the previous steps.
|
||||
|
||||
|
||||
9
Swiftgram/AppleStyleFolders/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "AppleStyleFolders",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
1074
Swiftgram/AppleStyleFolders/Sources/File.swift
Normal file
9
Swiftgram/ChatControllerImplExtension/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "ChatControllerImplExtension",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
@ -0,0 +1,225 @@
|
||||
import SGSimpleSettings
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Postbox
|
||||
import SwiftSignalKit
|
||||
import Display
|
||||
import AsyncDisplayKit
|
||||
import TelegramCore
|
||||
import SafariServices
|
||||
import MobileCoreServices
|
||||
import Intents
|
||||
import LegacyComponents
|
||||
import TelegramPresentationData
|
||||
import TelegramUIPreferences
|
||||
import DeviceAccess
|
||||
import TextFormat
|
||||
import TelegramBaseController
|
||||
import AccountContext
|
||||
import TelegramStringFormatting
|
||||
import OverlayStatusController
|
||||
import DeviceLocationManager
|
||||
import ShareController
|
||||
import UrlEscaping
|
||||
import ContextUI
|
||||
import ComposePollUI
|
||||
import AlertUI
|
||||
import PresentationDataUtils
|
||||
import UndoUI
|
||||
import TelegramCallsUI
|
||||
import TelegramNotices
|
||||
import GameUI
|
||||
import ScreenCaptureDetection
|
||||
import GalleryUI
|
||||
import OpenInExternalAppUI
|
||||
import LegacyUI
|
||||
import InstantPageUI
|
||||
import LocationUI
|
||||
import BotPaymentsUI
|
||||
import DeleteChatPeerActionSheetItem
|
||||
import HashtagSearchUI
|
||||
import LegacyMediaPickerUI
|
||||
import Emoji
|
||||
import PeerAvatarGalleryUI
|
||||
import PeerInfoUI
|
||||
import RaiseToListen
|
||||
import UrlHandling
|
||||
import AvatarNode
|
||||
import AppBundle
|
||||
import LocalizedPeerData
|
||||
import PhoneNumberFormat
|
||||
import SettingsUI
|
||||
import UrlWhitelist
|
||||
import TelegramIntents
|
||||
import TooltipUI
|
||||
import StatisticsUI
|
||||
import MediaResources
|
||||
import GalleryData
|
||||
import ChatInterfaceState
|
||||
import InviteLinksUI
|
||||
import Markdown
|
||||
import TelegramPermissionsUI
|
||||
import Speak
|
||||
import TranslateUI
|
||||
import UniversalMediaPlayer
|
||||
import WallpaperBackgroundNode
|
||||
import ChatListUI
|
||||
import CalendarMessageScreen
|
||||
import ReactionSelectionNode
|
||||
import ReactionListContextMenuContent
|
||||
import AttachmentUI
|
||||
import AttachmentTextInputPanelNode
|
||||
import MediaPickerUI
|
||||
import ChatPresentationInterfaceState
|
||||
import Pasteboard
|
||||
import ChatSendMessageActionUI
|
||||
import ChatTextLinkEditUI
|
||||
import WebUI
|
||||
import PremiumUI
|
||||
import ImageTransparency
|
||||
import StickerPackPreviewUI
|
||||
import TextNodeWithEntities
|
||||
import EntityKeyboard
|
||||
import ChatTitleView
|
||||
import EmojiStatusComponent
|
||||
import ChatTimerScreen
|
||||
import MediaPasteboardUI
|
||||
import ChatListHeaderComponent
|
||||
import ChatControllerInteraction
|
||||
import FeaturedStickersScreen
|
||||
import ChatEntityKeyboardInputNode
|
||||
import StorageUsageScreen
|
||||
import AvatarEditorScreen
|
||||
import ChatScheduleTimeController
|
||||
import ICloudResources
|
||||
import StoryContainerScreen
|
||||
import MoreHeaderButton
|
||||
import VolumeButtons
|
||||
import ChatAvatarNavigationNode
|
||||
import ChatContextQuery
|
||||
import PeerReportScreen
|
||||
import PeerSelectionController
|
||||
import SaveToCameraRoll
|
||||
import ChatMessageDateAndStatusNode
|
||||
import ReplyAccessoryPanelNode
|
||||
import TextSelectionNode
|
||||
import ChatMessagePollBubbleContentNode
|
||||
import ChatMessageItem
|
||||
import ChatMessageItemImpl
|
||||
import ChatMessageItemView
|
||||
import ChatMessageItemCommon
|
||||
import ChatMessageAnimatedStickerItemNode
|
||||
import ChatMessageBubbleItemNode
|
||||
import ChatNavigationButton
|
||||
import WebsiteType
|
||||
import ChatQrCodeScreen
|
||||
import PeerInfoScreen
|
||||
import MediaEditorScreen
|
||||
import WallpaperGalleryScreen
|
||||
import WallpaperGridScreen
|
||||
import VideoMessageCameraScreen
|
||||
import TopMessageReactions
|
||||
import AudioWaveform
|
||||
import PeerNameColorScreen
|
||||
import ChatEmptyNode
|
||||
import ChatMediaInputStickerGridItem
|
||||
import AdsInfoScreen
|
||||
|
||||
extension ChatControllerImpl {
|
||||
|
||||
func forwardMessagesToCloud(messageIds: [MessageId], removeNames: Bool, openCloud: Bool, resetCurrent: Bool = false) {
|
||||
let _ = (self.context.engine.data.get(EngineDataMap(
|
||||
messageIds.map(TelegramEngine.EngineData.Item.Messages.Message.init)
|
||||
))
|
||||
|> deliverOnMainQueue).startStandalone(next: { [weak self] messages in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
if resetCurrent {
|
||||
strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState({ $0.withUpdatedForwardMessageIds(nil).withUpdatedForwardOptionsState(nil).withoutSelectionState() }) })
|
||||
}
|
||||
|
||||
let sortedMessages = messages.values.compactMap { $0?._asMessage() }.sorted { lhs, rhs in
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
|
||||
var attributes: [MessageAttribute] = []
|
||||
if removeNames {
|
||||
attributes.append(ForwardOptionsMessageAttribute(hideNames: true, hideCaptions: false))
|
||||
}
|
||||
|
||||
if !openCloud {
|
||||
Queue.mainQueue().after(0.88) {
|
||||
strongSelf.chatDisplayNode.hapticFeedback.success()
|
||||
}
|
||||
|
||||
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
||||
strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: true, text: messages.count == 1 ? presentationData.strings.Conversation_ForwardTooltip_SavedMessages_One : presentationData.strings.Conversation_ForwardTooltip_SavedMessages_Many), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
if case .info = value, let strongSelf = self {
|
||||
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.context.account.peerId))
|
||||
|> deliverOnMainQueue).startStandalone(next: { peer in
|
||||
guard let strongSelf = self, let peer = peer, let navigationController = strongSelf.effectiveNavigationController else {
|
||||
return
|
||||
}
|
||||
|
||||
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil))
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}), in: .current)
|
||||
}
|
||||
|
||||
let _ = (enqueueMessages(account: strongSelf.context.account, peerId: strongSelf.context.account.peerId, messages: sortedMessages.map { message -> EnqueueMessage in
|
||||
return .forward(source: message.id, threadId: nil, grouping: .auto, attributes: attributes, correlationId: nil)
|
||||
})
|
||||
|> deliverOnMainQueue).startStandalone(next: { messageIds in
|
||||
guard openCloud else {
|
||||
return
|
||||
}
|
||||
if let strongSelf = self {
|
||||
let signals: [Signal<Bool, NoError>] = messageIds.compactMap({ id -> Signal<Bool, NoError>? in
|
||||
guard let id = id else {
|
||||
return nil
|
||||
}
|
||||
return strongSelf.context.account.pendingMessageManager.pendingMessageStatus(id)
|
||||
|> mapToSignal { status, _ -> Signal<Bool, NoError> in
|
||||
if status != nil {
|
||||
return .never()
|
||||
} else {
|
||||
return .single(true)
|
||||
}
|
||||
}
|
||||
|> take(1)
|
||||
})
|
||||
if strongSelf.shareStatusDisposable == nil {
|
||||
strongSelf.shareStatusDisposable = MetaDisposable()
|
||||
}
|
||||
strongSelf.shareStatusDisposable?.set((combineLatest(signals)
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak strongSelf] _ in
|
||||
guard let strongSelf = strongSelf else {
|
||||
return
|
||||
}
|
||||
strongSelf.chatDisplayNode.hapticFeedback.success()
|
||||
let _ = (strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.context.account.peerId))
|
||||
|> deliverOnMainQueue).startStandalone(next: { [weak strongSelf] peer in
|
||||
guard let strongSelf = strongSelf, let peer = peer, let navigationController = strongSelf.effectiveNavigationController else {
|
||||
return
|
||||
}
|
||||
|
||||
var navigationSubject: ChatControllerSubject? = nil
|
||||
for messageId in messageIds {
|
||||
if let messageId = messageId {
|
||||
navigationSubject = .message(id: .id(messageId), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false)
|
||||
break
|
||||
}
|
||||
}
|
||||
strongSelf.context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: strongSelf.context, chatLocation: .peer(peer), subject: navigationSubject, keepStack: .always, purposefulAction: {}, peekData: nil))
|
||||
})
|
||||
} ))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
0
Swiftgram/FLEX/BUILD
Normal file
68
Swiftgram/FLEX/FLEX.BUILD
Normal file
@ -0,0 +1,68 @@
|
||||
objc_library(
|
||||
name = "FLEX",
|
||||
module_name = "FLEX",
|
||||
srcs = glob(
|
||||
["Classes/**/*"],
|
||||
exclude = [
|
||||
"Classes/Info.plist",
|
||||
"Classes/Utility/APPLE_LICENSE",
|
||||
"Classes/Network/OSCache/LICENSE.md",
|
||||
"Classes/Network/PonyDebugger/LICENSE",
|
||||
"Classes/GlobalStateExplorers/DatabaseBrowser/LICENSE",
|
||||
"Classes/GlobalStateExplorers/Keychain/SSKeychain_LICENSE",
|
||||
"Classes/GlobalStateExplorers/SystemLog/LLVM_LICENSE.TXT",
|
||||
]
|
||||
),
|
||||
hdrs = glob([
|
||||
"Classes/**/*.h"
|
||||
]),
|
||||
includes = [
|
||||
"Classes",
|
||||
"Classes/Core",
|
||||
"Classes/Core/Controllers",
|
||||
"Classes/Core/Views",
|
||||
"Classes/Core/Views/Cells",
|
||||
"Classes/Core/Views/Carousel",
|
||||
"Classes/ObjectExplorers",
|
||||
"Classes/ObjectExplorers/Sections",
|
||||
"Classes/ObjectExplorers/Sections/Shortcuts",
|
||||
"Classes/Network",
|
||||
"Classes/Network/PonyDebugger",
|
||||
"Classes/Network/OSCache",
|
||||
"Classes/Toolbar",
|
||||
"Classes/Manager",
|
||||
"Classes/Manager/Private",
|
||||
"Classes/Editing",
|
||||
"Classes/Editing/ArgumentInputViews",
|
||||
"Classes/Headers",
|
||||
"Classes/ExplorerInterface",
|
||||
"Classes/ExplorerInterface/Tabs",
|
||||
"Classes/ExplorerInterface/Bookmarks",
|
||||
"Classes/GlobalStateExplorers",
|
||||
"Classes/GlobalStateExplorers/Globals",
|
||||
"Classes/GlobalStateExplorers/Keychain",
|
||||
"Classes/GlobalStateExplorers/FileBrowser",
|
||||
"Classes/GlobalStateExplorers/SystemLog",
|
||||
"Classes/GlobalStateExplorers/DatabaseBrowser",
|
||||
"Classes/GlobalStateExplorers/RuntimeBrowser",
|
||||
"Classes/GlobalStateExplorers/RuntimeBrowser/DataSources",
|
||||
"Classes/ViewHierarchy",
|
||||
"Classes/ViewHierarchy/SnapshotExplorer",
|
||||
"Classes/ViewHierarchy/SnapshotExplorer/Scene",
|
||||
"Classes/ViewHierarchy/TreeExplorer",
|
||||
"Classes/Utility",
|
||||
"Classes/Utility/Runtime",
|
||||
"Classes/Utility/Runtime/Objc",
|
||||
"Classes/Utility/Runtime/Objc/Reflection",
|
||||
"Classes/Utility/Categories",
|
||||
"Classes/Utility/Categories/Private",
|
||||
"Classes/Utility/Keyboard"
|
||||
],
|
||||
copts = [
|
||||
"-Wno-deprecated-declarations",
|
||||
"-Wno-strict-prototypes",
|
||||
"-Wno-unsupported-availability-guard",
|
||||
],
|
||||
deps = [],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
43
Swiftgram/FixConcurrencyBackport/BUILD
Normal file
@ -0,0 +1,43 @@
|
||||
# Something changed in Telegram versions 11.8.1 -> 11.10
|
||||
# https://github.com/TelegramMessenger/Telegram-iOS/compare/release-11.8.1...TelegramMessenger:Telegram-iOS:release-11.10
|
||||
#
|
||||
# Since then, all binaries and libs are linked to the /usr/lib/swift/libswift_Concurrency.dylib instead of expected @rpath/libswift_Concurrency.dylib,
|
||||
# this makes swift-stdlib-tool to ignore libswift_Concurrency.dylib and not copy it to the app bundle.
|
||||
# This causes crash on every system that expects this backport (iOS 14 and below).
|
||||
# This script will remap the path to @rpath/libswift_Concurrency.dylib in all binaries of the App, it's only needed for iphoneos target in this project.
|
||||
# This is a temporary fix until minimum OS version will be bumped to iOS 15+ or Xcode version changed to 16.3 (with Swift 6.1 support)
|
||||
|
||||
# find "$1" -type f \( -perm +111 -o -name "*.dylib" \) | while read -r bin; do
|
||||
# if otool -L "$bin" | grep -q "/usr/lib/swift/libswift_Concurrency.dylib"; then
|
||||
# echo "Patching concurrency backport in: $bin"
|
||||
# install_name_tool -change /usr/lib/swift/libswift_Concurrency.dylib @rpath/libswift_Concurrency.dylib "$bin"
|
||||
# fi
|
||||
# done
|
||||
|
||||
# concurrency-dylib.patch must be applied in build-system/bazel-rules/rules_apple
|
||||
# cd Swiftgram/FixConcurrencyBackport
|
||||
# git apply ../../../Swiftgram/FixConcurrencyBackport/concurrency-dylib.patch
|
||||
# # Make a build
|
||||
# git apply -R ../../../Swiftgram/FixConcurrencyBackport/concurrency-dylib.patch
|
||||
|
||||
# Refs:
|
||||
# https://stackoverflow.com/questions/79522371/when-building-the-project-with-xcode-16-2-the-app-crashes-due-to-an-incorrect-l
|
||||
# https://github.com/swiftlang/swift/issues/74303
|
||||
# https://github.com/bazelbuild/rules_apple/pull/1393
|
||||
|
||||
genrule(
|
||||
name = "CopyConcurrencyDylib",
|
||||
cmd_bash =
|
||||
"""
|
||||
echo 'ditto "$$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.5/iphoneos/libswift_Concurrency.dylib" "$$1/Payload/Swiftgram.app/Frameworks/libswift_Concurrency.dylib"' > $(location CopyConcurrencyDylib.sh)
|
||||
echo 'ditto "$$1/Payload/Swiftgram.app/Frameworks/libswift_Concurrency.dylib" "$$1/SwiftSupport/iphoneos/libswift_Concurrency.dylib"' >> $(location CopyConcurrencyDylib.sh)
|
||||
echo '' >> $(location CopyConcurrencyDylib.sh)
|
||||
""",
|
||||
outs = [
|
||||
"CopyConcurrencyDylib.sh",
|
||||
],
|
||||
executable = True,
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
]
|
||||
)
|
||||
25
Swiftgram/FixConcurrencyBackport/concurrency-dylib.patch
Normal file
@ -0,0 +1,25 @@
|
||||
diff --git a/tools/swift_stdlib_tool/swift_stdlib_tool.py b/tools/swift_stdlib_tool/swift_stdlib_tool.py
|
||||
index fbb7f4fb..5a2277c5 100644
|
||||
--- a/tools/swift_stdlib_tool/swift_stdlib_tool.py
|
||||
+++ b/tools/swift_stdlib_tool/swift_stdlib_tool.py
|
||||
@@ -134,6 +134,20 @@ def _copy_swift_stdlibs(binaries_to_scan, sdk_platform, destination_path):
|
||||
if os.path.exists(libswiftcore_path):
|
||||
os.remove(libswiftcore_path)
|
||||
|
||||
+ # MARK: Swiftgram
|
||||
+ if sdk_platform == "iphoneos":
|
||||
+ # Copy the concurrency runtime to the destination path.
|
||||
+ _, stdout, stderr = execute.execute_and_filter_output(
|
||||
+ [
|
||||
+ "ditto",
|
||||
+ f"{developer_dir}/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.5/iphoneos/libswift_Concurrency.dylib",
|
||||
+ os.path.join(destination_path, "libswift_Concurrency.dylib")
|
||||
+ ], raise_on_failure=True)
|
||||
+ if stderr:
|
||||
+ print(stderr)
|
||||
+ if stdout:
|
||||
+ print(stdout)
|
||||
+
|
||||
|
||||
def _lipo_exec_files(exec_files, target_archs, strip_bitcode, source_path,
|
||||
destination_path):
|
||||
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
|
||||
}
|
||||
5
Swiftgram/SGAppBadgeAssets/BUILD
Normal file
@ -0,0 +1,5 @@
|
||||
filegroup(
|
||||
name = "SGAppBadgeAssets",
|
||||
srcs = glob(["Images.xcassets/**"]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
6
Swiftgram/SGAppBadgeAssets/Images.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/DayAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Day@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/DayAppBadge.imageset/Day@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/DuckyAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Ducky@3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/DuckyAppBadge.imageset/Ducky@3.png
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/NightAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Night@3-1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/NightAppBadge.imageset/Night@3-1.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/ProAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Pro@3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/ProAppBadge.imageset/Pro@3.png
vendored
Normal file
|
After Width: | Height: | Size: 18 KiB |
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/SkyAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Sky@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/SkyAppBadge.imageset/Sky@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 20 KiB |
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/SparklingAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Sparkling@3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/SparklingAppBadge.imageset/Sparkling@3.png
vendored
Normal file
|
After Width: | Height: | Size: 22 KiB |
21
Swiftgram/SGAppBadgeAssets/Images.xcassets/TitaniumAppBadge.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Titanium@3.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Swiftgram/SGAppBadgeAssets/Images.xcassets/TitaniumAppBadge.imageset/Titanium@3.png
vendored
Normal file
|
After Width: | Height: | Size: 14 KiB |
9
Swiftgram/SGAppBadgeOffset/BUILD
Normal file
@ -0,0 +1,9 @@
|
||||
filegroup(
|
||||
name = "SGAppBadgeOffset",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
84
Swiftgram/SGAppBadgeOffset/Sources/SGAppBadgeOffset.swift
Normal file
@ -0,0 +1,84 @@
|
||||
import UIKit
|
||||
import DeviceModel
|
||||
|
||||
|
||||
let DEVICE_MODELS_WITH_APP_BADGE_SUPPORT: [DeviceModel] = [
|
||||
.iPhoneX,
|
||||
.iPhoneXS,
|
||||
.iPhoneXSMax,
|
||||
.iPhoneXR,
|
||||
.iPhone11,
|
||||
.iPhone11Pro,
|
||||
.iPhone11ProMax,
|
||||
.iPhone12,
|
||||
.iPhone12Mini,
|
||||
.iPhone12Pro,
|
||||
.iPhone12ProMax,
|
||||
.iPhone13,
|
||||
.iPhone13Mini,
|
||||
.iPhone13Pro,
|
||||
.iPhone13ProMax,
|
||||
.iPhone14,
|
||||
.iPhone14Plus,
|
||||
.iPhone14Pro,
|
||||
.iPhone14ProMax,
|
||||
.iPhone15,
|
||||
.iPhone15Plus,
|
||||
.iPhone15Pro,
|
||||
.iPhone15ProMax,
|
||||
.iPhone16,
|
||||
.iPhone16Plus,
|
||||
.iPhone16Pro,
|
||||
.iPhone16ProMax,
|
||||
.iPhone16e
|
||||
]
|
||||
|
||||
extension DeviceMetrics {
|
||||
|
||||
func sgAppBadgeOffset() -> CGFloat {
|
||||
let currentDevice = DeviceModel.current
|
||||
var defaultOffset: CGFloat = 0.0
|
||||
// https://www.ios-resolution.com/
|
||||
// Similar height + Scale
|
||||
switch currentDevice {
|
||||
case .iPhoneX, .iPhoneXS, .iPhone11Pro, .iPhone12Mini, .iPhone13Mini:
|
||||
defaultOffset = 2.0
|
||||
case .iPhone11, .iPhoneXR:
|
||||
defaultOffset = 6.0
|
||||
case .iPhone11ProMax, .iPhoneXSMax:
|
||||
defaultOffset = 4.0
|
||||
case .iPhone12, .iPhone12Pro, .iPhone13, .iPhone13Pro, .iPhone14, .iPhone16e:
|
||||
defaultOffset = 4.0
|
||||
case .iPhone12ProMax, .iPhone13ProMax, .iPhone14Plus:
|
||||
defaultOffset = 6.0
|
||||
case .iPhone14Pro, .iPhone15, .iPhone15Pro, .iPhone16:
|
||||
defaultOffset = 18.0
|
||||
case .iPhone14ProMax, .iPhone15Plus, .iPhone15ProMax, .iPhone16Plus:
|
||||
defaultOffset = 19.0
|
||||
case .iPhone16Pro:
|
||||
defaultOffset = 21.0
|
||||
case .iPhone16ProMax:
|
||||
defaultOffset = 22.0
|
||||
default:
|
||||
defaultOffset = 0.0 // Any device in 2025+ should be like iPhone 14 Pro or better
|
||||
}
|
||||
let offset: CGFloat = floorToScreenPixels(defaultOffset * self.sgScaleFactor)
|
||||
#if DEBUG
|
||||
print("deviceMetrics \(self). deviceModel: \(currentDevice). sgIsDisplayZoomed: \(self.sgIsDisplayZoomed). sgScaleFactor: \(self.sgScaleFactor) defaultOffset: \(defaultOffset), offset: \(offset)")
|
||||
#endif
|
||||
return offset
|
||||
}
|
||||
|
||||
var sgIsDisplayZoomed: Bool {
|
||||
UIScreen.main.scale < UIScreen.main.nativeScale
|
||||
}
|
||||
|
||||
var sgScaleFactor: CGFloat {
|
||||
UIScreen.main.scale / UIScreen.main.nativeScale
|
||||
}
|
||||
|
||||
var sgShowAppBadge: Bool {
|
||||
return DEVICE_MODELS_WITH_APP_BADGE_SUPPORT.contains(DeviceModel.current) // MARK: Swiftgram
|
||||
}
|
||||
|
||||
}
|
||||
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
|
||||
|
||||
public let FALLBACK_BASE_BUNDLE_ID: 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 = FALLBACK_BASE_BUNDLE_ID
|
||||
}
|
||||
} else {
|
||||
baseBundleId = bundleId
|
||||
}
|
||||
} else {
|
||||
baseBundleId = FALLBACK_BASE_BUNDLE_ID
|
||||
}
|
||||
|
||||
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",
|
||||
],
|
||||
)
|
||||
224
Swiftgram/SGDebugUI/Sources/SGDebugUI.swift
Normal file
@ -0,0 +1,224 @@
|
||||
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 clearOutgoingTranslationLanguageCache
|
||||
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(.action(id: id.count, section: .base, actionType: .clearOutgoingTranslationLanguageCache, text: "Clear Outgoing Translation 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 .clearOutgoingTranslationLanguageCache:
|
||||
SGLogger.shared.log("SGDebug", "Outgoing translation language cache cleanup init")
|
||||
SGSimpleSettings.shared.outgoingLanguageTranslation.drop()
|
||||
SGLogger.shared.log("SGDebug", "Outgoing translation language cache cleanup succesfull")
|
||||
presentControllerImpl?(okUndoController("OK: Outgoing translation language cache cleaned", presentationData), nil)
|
||||
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/SGGHSettings/BUILD
Normal file
@ -0,0 +1,21 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "SGGHSettings",
|
||||
module_name = "SGGHSettings",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//Swiftgram/SGGHSettingsScheme:SGGHSettingsScheme",
|
||||
"//Swiftgram/SGLogging:SGLogging",
|
||||
"//submodules/AccountContext:AccountContext",
|
||||
"//submodules/TelegramCore:TelegramCore",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
99
Swiftgram/SGGHSettings/Sources/SGGHSettings.swift
Normal file
@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
import SGLogging
|
||||
import SGGHSettingsScheme
|
||||
import AccountContext
|
||||
import TelegramCore
|
||||
|
||||
|
||||
public func updateSGGHSettingsInteractivelly(context: AccountContext) {
|
||||
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
|
||||
let locale = presentationData.strings.baseLanguageCode
|
||||
let _ = Task {
|
||||
do {
|
||||
let settings = try await fetchSGGHSettings(locale: locale)
|
||||
let _ = await (context.account.postbox.transaction { transaction in
|
||||
updateAppConfiguration(transaction: transaction, { configuration -> AppConfiguration in
|
||||
var configuration = configuration
|
||||
configuration.sgGHSettings = settings
|
||||
return configuration
|
||||
})
|
||||
}).task()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let maxRetries: Int = 3
|
||||
|
||||
enum SGGHFetchError: Error {
|
||||
case invalidURL
|
||||
case notFound
|
||||
case fetchFailed(statusCode: Int)
|
||||
case decodingFailed
|
||||
}
|
||||
|
||||
func fetchSGGHSettings(locale: String) async throws -> SGGHSettings {
|
||||
let baseURL = "https://raw.githubusercontent.com/Swiftgram/settings/refs/heads/main"
|
||||
var candidates: [String] = []
|
||||
if let buildNumber = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
|
||||
if locale != "en" {
|
||||
candidates.append("\(buildNumber)_\(locale).json")
|
||||
}
|
||||
candidates.append("\(buildNumber).json")
|
||||
}
|
||||
if locale != "en" {
|
||||
candidates.append("latest_\(locale).json")
|
||||
}
|
||||
candidates.append("latest.json")
|
||||
|
||||
var lastError: Error?
|
||||
for candidate in candidates {
|
||||
let urlString = "\(baseURL)/\(candidate)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
SGLogger.shared.log("SGGHSettings", "[0] Fetch failed for \(candidate). Invalid URL: \(urlString)")
|
||||
continue
|
||||
}
|
||||
|
||||
attemptsOuter: for attempt in 1...maxRetries {
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
SGLogger.shared.log("SGGHSettings", "[\(attempt)] Fetch failed for \(candidate). Invalid response type: \(response)")
|
||||
throw SGGHFetchError.fetchFailed(statusCode: -1)
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
do {
|
||||
let jsonDecoder = JSONDecoder()
|
||||
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
let settings = try jsonDecoder.decode(SGGHSettings.self, from: data)
|
||||
SGLogger.shared.log("SGGHSettings", "[\(attempt)] Fetched \(candidate): \(settings)")
|
||||
return settings
|
||||
} catch {
|
||||
SGLogger.shared.log("SGGHSettings", "[\(attempt)] Failed to decode \(candidate): \(error)")
|
||||
throw SGGHFetchError.decodingFailed
|
||||
}
|
||||
case 404:
|
||||
SGLogger.shared.log("SGGHSettings", "[\(attempt)] Not found \(candidate) on the remote.")
|
||||
break attemptsOuter
|
||||
default:
|
||||
SGLogger.shared.log("SGGHSettings", "[\(attempt)] Fetch failed for \(candidate), status code: \(httpResponse.statusCode)")
|
||||
throw SGGHFetchError.fetchFailed(statusCode: httpResponse.statusCode)
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
if attempt == maxRetries {
|
||||
break
|
||||
}
|
||||
try await Task.sleep(nanoseconds: UInt64(attempt * 2 * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SGLogger.shared.log("SGGHSettings", "All attempts failed. Last error: \(String(describing: lastError))")
|
||||
throw SGGHFetchError.fetchFailed(statusCode: -1)
|
||||
}
|
||||
17
Swiftgram/SGGHSettingsScheme/BUILD
Normal file
@ -0,0 +1,17 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "SGGHSettingsScheme",
|
||||
module_name = "SGGHSettingsScheme",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
public struct SGGHSettings: Codable, Equatable {
|
||||
public let announcementsData: String?
|
||||
|
||||
public static var defaultValue: SGGHSettings {
|
||||
return SGGHSettings(announcementsData: nil)
|
||||
}
|
||||
}
|
||||
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",
|
||||
],
|
||||
)
|
||||
335
Swiftgram/SGItemListUI/Sources/SGItemListUI.swift
Normal file
@ -0,0 +1,335 @@
|
||||
// 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: {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) // Closing search keyboard if active
|
||||
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: {
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) // Closing search keyboard if active
|
||||
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",
|
||||
],
|
||||
)
|
||||
183
Swiftgram/SGLogging/Sources/SGLogger.swift
Normal file
@ -0,0 +1,183 @@
|
||||
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 class SGLogger {
|
||||
public 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
|
||||
|
||||
public let rootPath: String
|
||||
public 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 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))
|
||||
//}
|
||||
19
Swiftgram/SGLoggingComposer/BUILD
Normal file
@ -0,0 +1,19 @@
|
||||
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
|
||||
|
||||
swift_library(
|
||||
name = "SGLoggingComposer",
|
||||
module_name = "SGLoggingComposer",
|
||||
srcs = glob([
|
||||
"Sources/**/*.swift",
|
||||
]),
|
||||
copts = [
|
||||
"-warnings-as-errors",
|
||||
],
|
||||
deps = [
|
||||
"//Swiftgram/SGLogging:SGLogging",
|
||||
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
],
|
||||
)
|
||||
59
Swiftgram/SGLoggingComposer/Sources/SGLoggingComposer.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import SGLogging
|
||||
import SwiftSignalKit
|
||||
|
||||
|
||||
extension SGLogger {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||