From 70508c1325e9679f77b5abd2d3d48311ff02451f Mon Sep 17 00:00:00 2001 From: relikd Date: Fri, 17 Apr 2020 23:37:03 +0200 Subject: [PATCH] Tutorial Sheet (incl. Welcome message + Recordings introduction) --- AppCheck.xcodeproj/project.pbxproj | 30 ++- main/Base.lproj/Main.storyboard | 158 +++++++------- .../EditableRows.swift | 0 main/Common Classes/QuickUI.swift | 67 ++++++ main/Common Classes/TutorialSheet.swift | 199 ++++++++++++++++++ main/Extensions/AutoLayout.swift | 82 ++++++++ main/Extensions/Generic.swift | 14 +- .../{FileManager.swift => URL.swift} | 4 - main/Recordings/VCEditRecording.swift | 3 + main/Recordings/VCRecordings.swift | 44 ++++ main/Settings/TVCSettings.swift | 15 +- main/TBCMain.swift | 31 ++- 12 files changed, 545 insertions(+), 102 deletions(-) rename main/{TVC Extensions => Common Classes}/EditableRows.swift (100%) create mode 100644 main/Common Classes/QuickUI.swift create mode 100644 main/Common Classes/TutorialSheet.swift create mode 100644 main/Extensions/AutoLayout.swift rename main/Extensions/{FileManager.swift => URL.swift} (79%) diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index bd0b035..b9c81c8 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -22,9 +22,12 @@ 543CDB2523EEE61900B7F323 /* GlassVPN.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; }; 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; }; + 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; }; + 545DDDD124436983003B6544 /* QuickUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD024436983003B6544 /* QuickUI.swift */; }; + 545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDD324466D37003B6544 /* AutoLayout.swift */; }; 546063E523FEFAFE008F505A /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; }; - 54751E512423955100168273 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* FileManager.swift */; }; - 54751E522423955100168273 /* FileManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* FileManager.swift */; }; + 54751E512423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; }; + 54751E522423955100168273 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54751E502423955000168273 /* URL.swift */; }; 54953E3323DC752E0054345C /* SQDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B7562223D7B2DC008F0C41 /* SQDB.swift */; }; 54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E5E23DEBE840054345C /* TVCDomains.swift */; }; 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; }; @@ -166,7 +169,10 @@ 543CDB2223EEE61900B7F323 /* GlassVPN.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GlassVPN.entitlements; sourceTree = ""; }; 544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = ""; }; 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = ""; }; - 54751E502423955000168273 /* FileManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManager.swift; sourceTree = ""; }; + 545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = ""; }; + 545DDDD024436983003B6544 /* QuickUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickUI.swift; sourceTree = ""; }; + 545DDDD324466D37003B6544 /* AutoLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoLayout.swift; sourceTree = ""; }; + 54751E502423955000168273 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; 548B1F9423D338EC005B047C /* main.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = main.entitlements; sourceTree = ""; }; 54953E5E23DEBE840054345C /* TVCDomains.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCDomains.swift; sourceTree = ""; }; 54953E6023E0D69A0054345C /* TVCHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHosts.swift; sourceTree = ""; }; @@ -340,10 +346,10 @@ children = ( 54B3459A2415651C004C53CC /* DB */, 54B345A4241BB975004C53CC /* Extensions */, + 545DDDD224436A03003B6544 /* Common Classes */, 548B1F9423D338EC005B047C /* main.entitlements */, 541AC5D72399498A00A769D7 /* AppDelegate.swift */, 542E2A972404973F001462DC /* TBCMain.swift */, - 54B34597240F18DD004C53CC /* TVC Extensions */, 540C6454240D5BAE00E948F9 /* Requests */, 540E677E242D2CD200871BBE /* Recordings */, 540C6455240D5BD200E948F9 /* Settings */, @@ -380,12 +386,14 @@ path = GlassVPN; sourceTree = ""; }; - 54B34597240F18DD004C53CC /* TVC Extensions */ = { + 545DDDD224436A03003B6544 /* Common Classes */ = { isa = PBXGroup; children = ( + 545DDDD024436983003B6544 /* QuickUI.swift */, + 545DDDCE243E6267003B6544 /* TutorialSheet.swift */, 540C6456240D929300E948F9 /* EditableRows.swift */, ); - path = "TVC Extensions"; + path = "Common Classes"; sourceTree = ""; }; 54B3459A2415651C004C53CC /* DB */ = { @@ -406,7 +414,8 @@ 54B345AC241BBB00004C53CC /* DBExtensions.swift */, 54B345AA241BBA5B004C53CC /* AlertSheet.swift */, 54B34595240F0513004C53CC /* TableView.swift */, - 54751E502423955000168273 /* FileManager.swift */, + 54751E502423955000168273 /* URL.swift */, + 545DDDD324466D37003B6544 /* AutoLayout.swift */, ); path = Extensions; sourceTree = ""; @@ -760,18 +769,20 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 545DDDD424466D37003B6544 /* AutoLayout.swift in Sources */, 54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */, 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */, 54B345A6241BB982004C53CC /* Notifications.swift in Sources */, 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */, 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */, + 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */, 54B345A9241BBA0B004C53CC /* Generic.swift in Sources */, 54B34596240F0513004C53CC /* TableView.swift in Sources */, 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */, 54953E3323DC752E0054345C /* SQDB.swift in Sources */, 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */, 540C6457240D929300E948F9 /* EditableRows.swift in Sources */, - 54751E512423955100168273 /* FileManager.swift in Sources */, + 54751E512423955100168273 /* URL.swift in Sources */, 542E2A9A24051556001462DC /* TVCSettings.swift in Sources */, 54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */, 54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */, @@ -779,6 +790,7 @@ 54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */, 542E2A982404973F001462DC /* TBCMain.swift in Sources */, 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */, + 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, 54B345992414F491004C53CC /* DBWrapper.swift in Sources */, 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */, @@ -839,7 +851,7 @@ 54CA025F2426B2FD003A5E04 /* ProxyServer.swift in Sources */, 54CA02842426B2FD003A5E04 /* Rule.swift in Sources */, 54CA02B92426B2FD003A5E04 /* DirectProxySocket.swift in Sources */, - 54751E522423955100168273 /* FileManager.swift in Sources */, + 54751E522423955100168273 /* URL.swift in Sources */, 54CA02A92426B2FD003A5E04 /* RejectAdapter.swift in Sources */, 54CA02732426B2FD003A5E04 /* IPPool.swift in Sources */, 54CA027E2426B2FD003A5E04 /* DomainListRule.swift in Sources */, diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index 32143f4..093dff1 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -1,9 +1,9 @@ - + - + @@ -18,7 +18,6 @@ - @@ -28,36 +27,6 @@ - - - - - - - - - - - - - - - - - Your data belongs to you. Therefore, monitoring and analysis take place on your device only. The app does not share any data with us or any other third-party. - - - - - - - - - - - - - @@ -577,7 +546,7 @@ Duration: 60:00 - + @@ -608,54 +577,12 @@ Duration: 60:00 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -682,7 +609,7 @@ Duration: 60:00 - + @@ -710,6 +637,75 @@ Duration: 60:00 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -772,12 +768,12 @@ Duration: 60:00 + + + - - - diff --git a/main/TVC Extensions/EditableRows.swift b/main/Common Classes/EditableRows.swift similarity index 100% rename from main/TVC Extensions/EditableRows.swift rename to main/Common Classes/EditableRows.swift diff --git a/main/Common Classes/QuickUI.swift b/main/Common Classes/QuickUI.swift new file mode 100644 index 0000000..f5ab6ad --- /dev/null +++ b/main/Common Classes/QuickUI.swift @@ -0,0 +1,67 @@ +import UIKit + +struct QuickUI { + + static func button(_ title: String, target: Any? = nil, action: Selector? = nil) -> UIButton { + let x = UIButton(type: .roundedRect) + x.setTitle(title, for: .normal) + x.titleLabel?.font = .preferredFont(forTextStyle: .body) + x.sizeToFit() + if let a = action { x.addTarget(target, action: a, for: .touchUpInside) } + if #available(iOS 10.0, *) { + x.titleLabel?.adjustsFontForContentSizeCategory = true + } + return x + } + + static func image(_ img: UIImage?, frame: CGRect = CGRect.zero) -> UIImageView { + let x = UIImageView(frame: frame) + x.contentMode = .scaleAspectFit + x.image = img + return x + } + + static func text(_ str: String, frame: CGRect = CGRect.zero) -> UITextView { + let x = UITextView(frame: frame) + x.font = .preferredFont(forTextStyle: .body) // .systemFont(ofSize: UIFont.systemFontSize) + x.isSelectable = false + x.isEditable = false + x.text = str + if #available(iOS 10.0, *) { + x.adjustsFontForContentSizeCategory = true + } + return x + } + + static func text(attributed: NSAttributedString, frame: CGRect = CGRect.zero) -> UITextView { + let txt = self.text("", frame: frame) + txt.attributedText = attributed + return txt + } +} + +extension NSMutableAttributedString { + static private var def: UIFont = .preferredFont(forTextStyle: .body) + + func normal(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: .preferredFont(forTextStyle: style)) } + func bold(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).bold()) } + func italic(_ str: String, _ style: UIFont.TextStyle = .body) -> Self { append(str, withFont: UIFont.preferredFont(forTextStyle: style).italic()) } + + func h1(_ str: String) -> Self { normal(str, .title1) } + func h2(_ str: String) -> Self { normal(str, .title2) } + func h3(_ str: String) -> Self { normal(str, .title3) } + + private func append(_ str: String, withFont: UIFont) -> Self { + append(NSAttributedString(string: str, attributes: [.font : withFont])) + return self + } +} + + +extension UIFont { + func withTraits(traits: UIFontDescriptor.SymbolicTraits) -> UIFont { + UIFont(descriptor: fontDescriptor.withSymbolicTraits(traits)!, size: 0) // keep size as is + } + func bold() -> UIFont { withTraits(traits: .traitBold) } + func italic() -> UIFont { withTraits(traits: .traitItalic) } +} diff --git a/main/Common Classes/TutorialSheet.swift b/main/Common Classes/TutorialSheet.swift new file mode 100644 index 0000000..1177725 --- /dev/null +++ b/main/Common Classes/TutorialSheet.swift @@ -0,0 +1,199 @@ +import UIKit + +fileprivate let margin: CGFloat = 20 +fileprivate let cornerRadius: CGFloat = 15 +fileprivate let uniRect = CGRect(x: 0, y: 0, width: 500, height: 500) + +class TutorialSheet: UIViewController, UIScrollViewDelegate { + + public var buttonTitleNext: String = "Next" + public var buttonTitleDone: String = "Close" + + private var priorIndex: Int? + private var lastAnchor: NSLayoutConstraint? + private var shouldAnimate: Bool = true + private var shouldCloseBlock: (() -> Bool)? = nil + private var didCloseBlock: (() -> Void)? = nil + + private let sheetBg: UIView = { + let x = UIView(frame: uniRect) + x.autoresizingMask = [.flexibleWidth, .flexibleHeight] + x.backgroundColor = .sysBg + x.layer.cornerRadius = cornerRadius + x.layer.shadowColor = UIColor.black.cgColor + x.layer.shadowRadius = 10 + x.layer.shadowOpacity = 0.75 + x.layer.shadowOffset = CGSize(width: 0, height: 4) + return x + }() + + private let pager: UIPageControl = { + let x = UIPageControl(frame: uniRect) + x.frame.size.height = x.size(forNumberOfPages: 1).height + x.currentPageIndicatorTintColor = UIColor.sysFg.withAlphaComponent(0.5) + x.pageIndicatorTintColor = UIColor.sysFg.withAlphaComponent(0.25) + x.numberOfPages = 0 + x.hidesForSinglePage = true + x.addTarget(self, action: #selector(pagerDidChange), for: .valueChanged) + return x + }() + + private let pageScroll: UIScrollView = { + let x = UIScrollView(frame: uniRect) + x.bounces = false + x.isPagingEnabled = true + x.showsVerticalScrollIndicator = false + x.showsHorizontalScrollIndicator = false + + let content = UIView() + x.addSubview(content) + content.translatesAutoresizingMaskIntoConstraints = false + content.anchor([.left, .right, .top, .bottom], to: x) + content.anchor([.width, .height], to: x) | .defaultLow + return x + }() + + private let button: UIButton = { + let x = QuickUI.button("", target: self, action: #selector(buttonTapped)) + x.contentEdgeInsets = UIEdgeInsets(all: 8) + return x + }() + + + // MARK: Init + + required init?(coder: NSCoder) { super.init(coder: coder) } + + required init() { + super.init(nibName: nil, bundle: nil) + view = makeControlUI() + modalPresentationStyle = .custom + if #available(iOS 13.0, *) { + isModalInPresentation = true + } + UIDevice.orientationDidChangeNotification.observe(call: #selector(didChangeOrientation), on: self) + } + + /// Present Tutorial Sheet Controller + /// - Parameter viewController: If set to `nil`, use main application as canvas. (Default: `nil`) + /// - Parameter animate: Use `present` and `dismiss` animations. (Default: `true`) + /// - Parameter shouldClose: Called before the view controller is dismissed. Return `false` to prevent the dismissal. + /// Use this block to extract user data from input fields. (Default: `nil`) + /// - Parameter didClose: Called after the view controller is completely dismissed (with animations). + /// Use this block to update UI and visible changes. (Default: `nil`) + func present(in viewController: UIViewController? = nil, animate: Bool = true, shouldClose: (() -> Bool)? = nil, didClose: (() -> Void)? = nil) { + guard let vc = viewController ?? UIApplication.shared.keyWindow?.rootViewController else { + return + } + shouldCloseBlock = shouldClose + didCloseBlock = didClose + shouldAnimate = animate + vc.present(self, animated: animate) + } + + + // MARK: Dynamic UI + + @discardableResult func addSheet(_ closure: ((UIStackView) -> Void)? = nil) -> UIStackView { + pager.numberOfPages += 1 + updateButtonTitle() + let x = UIStackView(frame: pageScroll.bounds) + x.translatesAutoresizingMaskIntoConstraints = false + x.axis = .vertical + x.backgroundColor = UIColor.black + x.isOpaque = true + guard let content = pageScroll.subviews.first else { + return x + } + let prev = content.subviews.last + content.addSubview(x) + x.anchor([.top, .width, .height], to: pageScroll) + x.leadingAnchor =&= (prev==nil ? content.leadingAnchor : prev!.trailingAnchor) + lastAnchor?.isActive = false + lastAnchor = (x.trailingAnchor =&= pageScroll.trailingAnchor) + closure?(x) + return x + } + + + // MARK: Static UI + + private func makeControlUI() -> UIView { + pageScroll.delegate = self + + sheetBg.addSubview(pager) + sheetBg.addSubview(pageScroll) + sheetBg.addSubview(button) + + for x in sheetBg.subviews { x.translatesAutoresizingMaskIntoConstraints = false } + + pager.anchor([.top, .left, .right], to: sheetBg) + pageScroll.topAnchor =&= pager.bottomAnchor + pageScroll.anchor([.left, .right, .top, .bottom], to: sheetBg, margin: cornerRadius/2) | .defaultHigh + button.topAnchor =&= pageScroll.bottomAnchor + button.anchor([.bottom, .centerX], to: sheetBg) +// button.bottomAnchor =&= sheetBg.bottomAnchor - 30 +// button.centerXAnchor =&= sheetBg.centerXAnchor + + let bg = UIView(frame: uniRect) + bg.autoresizingMask = [.flexibleWidth, .flexibleHeight] + bg.addSubview(sheetBg) + + let h: CGFloat = UIApplication.shared.isStatusBarHidden ? 0 : UIApplication.shared.statusBarFrame.height + sheetBg.frame = bg.frame.inset(by: UIEdgeInsets(all: margin, top: margin + h)) + return bg + } + + + // MARK: Delegates + + override func viewWillLayoutSubviews() { + priorIndex = pager.currentPage + } + + @objc private func didChangeOrientation() { + if let i = priorIndex { + priorIndex = nil + switchToSheet(i, animated: false) + } + for case let x as UIStackView in pageScroll.subviews.first!.subviews { + x.axis = (x.frame.width > x.frame.height) ? .horizontal : .vertical + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let w = scrollView.frame.width + let new = Int((scrollView.contentOffset.x + w/2) / w) + if pager.currentPage != new { + pager.currentPage = new + updateButtonTitle() + } + } + + @objc private func pagerDidChange(sender: UIPageControl) { + switchToSheet(sender.currentPage, animated: true) + } + + private func switchToSheet(_ i: Int, animated: Bool) { + pageScroll.setContentOffset(CGPoint(x: CGFloat(i) * pageScroll.bounds.width, y: 0), animated: animated) + } + + private func updateButtonTitle() { + let last = (pager.currentPage == pager.numberOfPages - 1) + let title = last ? buttonTitleDone : buttonTitleNext + if button.title(for: .normal) != title { + button.setTitle(title, for: .normal) + } + } + + @objc private func buttonTapped() { + let next = pager.currentPage + 1 + if next < pager.numberOfPages { + switchToSheet(next, animated: true) + } else { + if shouldCloseBlock?() ?? true { + dismiss(animated: shouldAnimate, completion: didCloseBlock) + } + } + } +} diff --git a/main/Extensions/AutoLayout.swift b/main/Extensions/AutoLayout.swift new file mode 100644 index 0000000..073b352 --- /dev/null +++ b/main/Extensions/AutoLayout.swift @@ -0,0 +1,82 @@ +import UIKit + +/* + Readable Auto Layout Constraints + + Usage: + A.anchor =&= multiplier * B.anchor + constant | priority +*/ + +infix operator =&= : AdditionPrecedence +infix operator =<= : AdditionPrecedence +infix operator =>= : AdditionPrecedence +//infix operator | : AdditionPrecedence + +/// Create and activate an `equal` constraint between left and right anchor. Format: `A.anchor =&= multiplier * B.anchor + constant | priority` +@discardableResult func =&= (l: NSLayoutAnchor, r: NSLayoutAnchor) -> NSLayoutConstraint { l.constraint(equalTo: r).on() } +/// Create and activate a `lessThan` constraint between left and right anchor. Format: `A.anchor =<= multiplier * B.anchor + constant | priority` +@discardableResult func =<= (l: NSLayoutAnchor, r: NSLayoutAnchor) -> NSLayoutConstraint { l.constraint(lessThanOrEqualTo: r).on() } +/// Create and activate a `greaterThan` constraint between left and right anchor. Format: `A.anchor =>= multiplier * B.anchor + constant | priority` +@discardableResult func =>= (l: NSLayoutAnchor, r: NSLayoutAnchor) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualTo: r).on() } + +extension NSLayoutDimension { // higher precedence, so multiply first + /// Create intermediate anchor multiplier result. + static func *(l: CGFloat, r: NSLayoutDimension) -> AnchorMultiplier { .init(anchor: r, m: l) } +} + +/// Intermediate `NSLayoutConstraint` anchor with multiplier supplement +struct AnchorMultiplier { + let anchor: NSLayoutDimension, m: CGFloat + + /// Create and activate an `equal` constraint between left and right anchor. Format: `A.anchor =&= multiplier * B.anchor + constant | priority` + @discardableResult static func =&=(l: NSLayoutDimension, r: Self) -> NSLayoutConstraint { l.constraint(equalTo: r.anchor, multiplier: r.m).on() } + /// Create and activate a `lessThan` constraint between left and right anchor. Format: `A.anchor =<= multiplier * B.anchor + constant | priority` + @discardableResult static func =<=(l: NSLayoutDimension, r: Self) -> NSLayoutConstraint { l.constraint(lessThanOrEqualTo: r.anchor, multiplier: r.m).on() } + /// Create and activate a `greaterThan` constraint between left and right anchor. Format: `A.anchor =>= multiplier * B.anchor + constant | priority` + @discardableResult static func =>=(l: NSLayoutDimension, r: Self) -> NSLayoutConstraint { l.constraint(greaterThanOrEqualTo: r.anchor, multiplier: r.m).on() } +} + +extension NSLayoutConstraint { + /// Change `isActive`to `true` and return `self` + func on() -> Self { isActive = true; return self } + /// Change `constant`attribute and return `self` + @discardableResult static func +(l: NSLayoutConstraint, r: CGFloat) -> NSLayoutConstraint { l.constant = r; return l } + /// Change `constant` attribute and return `self` + @discardableResult static func -(l: NSLayoutConstraint, r: CGFloat) -> NSLayoutConstraint { l.constant = -r; return l } + /// Change `priority` attribute and return `self` + @discardableResult static func |(l: NSLayoutConstraint, r: UILayoutPriority) -> NSLayoutConstraint { l.priority = r; return l } +} + +/* + UIView extension to generate multiple constraints at once + + Usage: + child.anchor([.width, .height], to: parent) | .defaultLow +*/ + +extension UIView { + /// Edges that need the relation to flip arguments. For these we need to inverse the constant value and relation. + private static let inverseItem: [NSLayoutConstraint.Attribute] = [.right, .bottom, .trailing, .lastBaseline, .rightMargin, .bottomMargin, .trailingMargin] + + /// Create and active constraints for provided edges. Constraints will anchor the same edge on both `self` and `other`. + /// - Parameters: + /// - edges: List of constraint attributes, e.g. `[.top, .bottom, .left, .right]` + /// - other: Instance to bind to, e.g. `UIView` or `UILayoutGuide` + /// - margin: Used as constant value. Multiplier will always be `1.0`. If you need to change the multiplier, use single constraints instead. (Default: `0`) + /// - rel: Constraint relation. (Default: `.equal`) + /// - Returns: List of created and active constraints + @discardableResult func anchor(_ edges: [NSLayoutConstraint.Attribute], to other: Any, margin: CGFloat = 0, if rel: NSLayoutConstraint.Relation = .equal) -> [NSLayoutConstraint] { + edges.map { + let (A, B) = UIView.inverseItem.contains($0) ? (other, self) : (self, other) + return NSLayoutConstraint(item: A, attribute: $0, relatedBy: rel, toItem: B, attribute: $0, multiplier: 1, constant: margin).on() + } + } +} + +extension Array where Element: NSLayoutConstraint { + /// set `priority` on all elements and return same list + @discardableResult static func |(l: Self, r: UILayoutPriority) -> Self { + for x in l { x.priority = r } + return l + } +} diff --git a/main/Extensions/Generic.swift b/main/Extensions/Generic.swift index 71886d1..116e8f5 100644 --- a/main/Extensions/Generic.swift +++ b/main/Extensions/Generic.swift @@ -1,4 +1,4 @@ -import Foundation +import UIKit struct QLog { private init() {} @@ -99,5 +99,15 @@ struct TimeFormat { static func since(_ date: Date, millis: Bool = false) -> String { from(Date().timeIntervalSince(date), millis: millis) } - +} + +extension UIColor { + static var sysBg: UIColor { get { if #available(iOS 13.0, *) { return .systemBackground } else { return .white } }} + static var sysFg: UIColor { get { if #available(iOS 13.0, *) { return .label } else { return .black } }} +} + +extension UIEdgeInsets { + init(all: CGFloat = 0, top: CGFloat? = nil, left: CGFloat? = nil, bottom: CGFloat? = nil, right: CGFloat? = nil) { + self.init(top: top ?? all, left: left ?? all, bottom: bottom ?? all, right: right ?? all) + } } diff --git a/main/Extensions/FileManager.swift b/main/Extensions/URL.swift similarity index 79% rename from main/Extensions/FileManager.swift rename to main/Extensions/URL.swift index b7b4fd5..9f8f63e 100644 --- a/main/Extensions/FileManager.swift +++ b/main/Extensions/URL.swift @@ -10,14 +10,10 @@ fileprivate extension FileManager { func internalDB() -> URL { appGroupDir().appendingPathComponent("dns-logs.sqlite") } - func appGroupIPC() -> URL { - appGroupDir().appendingPathComponent("data-exchange.dat") - } } extension URL { static func exportDir() -> URL { FileManager.default.exportDir() } static func appGroupDir() -> URL { FileManager.default.appGroupDir() } static func internalDB() -> URL { FileManager.default.internalDB() } - static func appGroupIPC() -> URL { FileManager.default.appGroupIPC() } } diff --git a/main/Recordings/VCEditRecording.swift b/main/Recordings/VCEditRecording.swift index c5ca010..59f1382 100644 --- a/main/Recordings/VCEditRecording.swift +++ b/main/Recordings/VCEditRecording.swift @@ -23,6 +23,9 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate validateSaveButton() if deleteOnCancel { // mark as destructive buttonCancel.tintColor = .systemRed + if #available(iOS 13.0, *) { + isModalInPresentation = true + } } UIResponder.keyboardWillShowNotification.observe(call: #selector(keyboardWillShow), on: self) UIResponder.keyboardWillHideNotification.observe(call: #selector(keyboardWillHide), on: self) diff --git a/main/Recordings/VCRecordings.swift b/main/Recordings/VCRecordings.swift index 8fb927d..da2e30d 100644 --- a/main/Recordings/VCRecordings.swift +++ b/main/Recordings/VCRecordings.swift @@ -19,6 +19,10 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate { // hide timer if not running updateUI(setRecording: false, animated: false) currentRecording = DBWrp.recordingGetCurrent() + + if !UserDefaults.standard.bool(forKey: "didShowTutorialRecordings") { + self.perform(#selector(showTutorial), with: nil, afterDelay: 0.5) + } } override func viewDidAppear(_ animated: Bool) { @@ -86,4 +90,44 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate { self.startButton.setTitleColor(color, for: .normal) } } + + + // MARK: Tutorial View Controller + + @objc private func showTutorial() { + let x = TutorialSheet() + x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString() + .h1("What are Recordings?\n") + .normal("\nSimilar to the default logging, recordings will intercept every request and log it for later review. " + + "Recordings are usually 3 – 5 minutes long and cover a single application. " + + "You can utilize recordings for App analysis or to get a ground truth for background traffic." + + "\n\n" + + "Optionally, you can help us by providing app specific recordings. " + + "Together with your findings we can create a community driven privacy monitor. " + + "The research results will help you and others avoid Apps that unnecessarily share data with third-party providers.") + )) + x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString() + .h1("How to record?\n") + .normal("\nBefore you begin a new recording make sure that you quit all running applications. " + + "Tap on the 'Start Recording' button and switch to the application you'd like to inspect. " + + "Use the App as you would normally. Try to get to all corners and functionality the App provides. " + + "When you feel that you have captured enough content, come back to ").italic("AppCheck").normal(" and stop the recording." + + "\n\n" + + "Upon completion you will find your recording in the 'Previous Recordings' section. " + + "You can review your results and remove user specific information if necessary.") + )) + x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString() + .h1("Share results\n") + .normal("\nThis step is completely ").bold("optional").normal(". " + + "You can choose to share your results with us. " + + "We can compare similar applications and suggest privacy friendly alternatives. " + + "Together with other likeminded individuals we can increase the awareness for privacy friendly design." + + "\n\n" + + "Thank you very much.") + )) + x.buttonTitleDone = "Got it" + x.present { + UserDefaults.standard.set(true, forKey: "didShowTutorialRecordings") + } + } } diff --git a/main/Settings/TVCSettings.swift b/main/Settings/TVCSettings.swift index d2026b9..94b6601 100644 --- a/main/Settings/TVCSettings.swift +++ b/main/Settings/TVCSettings.swift @@ -52,11 +52,18 @@ class TVCSettings: UITableViewController { // }.presentIn(self) } + @IBAction func resetTutorialAlerts(_ sender: UIButton) { + UserDefaults.standard.removeObject(forKey: "didShowTutorialAppWelcome") + UserDefaults.standard.removeObject(forKey: "didShowTutorialRecordings") + Alert(title: sender.titleLabel?.text, + text: "\nDone.\n\nYou may need to restart the application.").presentIn(self) + } + @IBAction func clearDatabaseResults(_ sender: Any) { - AskAlert(title: "Clear results?", text: """ - You are about to delete all results that have been logged in the past. Your preference for blocked and ignored domains is preserved. - Continue? - """, buttonText: "Delete", buttonStyle: .destructive) { _ in + AskAlert(title: "Clear results?", text: + "You are about to delete all results that have been logged in the past. " + + "Your preferences for blocked and ignored domains are preserved.\n" + + "Continue?", buttonText: "Delete", buttonStyle: .destructive) { _ in DBWrp.deleteHistory() }.presentIn(self) } diff --git a/main/TBCMain.swift b/main/TBCMain.swift index 6e1de53..3c173ae 100644 --- a/main/TBCMain.swift +++ b/main/TBCMain.swift @@ -5,13 +5,40 @@ class TBCMain: UITabBarController { override func viewDidLoad() { super.viewDidLoad() -// perform(#selector(showWelcomeMessage), with: nil, afterDelay: 3) NotifyVPNStateChanged.observe(call: #selector(vpnStateChanged(_:)), on: self) changedState(currentVPNState) + + if !UserDefaults.standard.bool(forKey: "didShowTutorialAppWelcome") { + self.perform(#selector(showWelcomeMessage), with: nil, afterDelay: 0.5) + } } @objc func showWelcomeMessage() { - performSegue(withIdentifier: "welcome", sender: nil) + let x = TutorialSheet() + x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString() + .h1("Welcome\n") + .normal("\nAppCheck helps you identify which applications communicate with third parties. " + + "It does so by logging network requests. " + + "AppCheck learns only the destination addresses, not the actual data that is exchanged." + + "\n\n" + + "Your data belongs to you. " + + "Therefore, monitoring and analysis take place on your device only. " + + "The app does not share any data with us or any other third-party. " + + "Unless you choose to.") + )) + x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString() + .h1("How it works\n") + .normal("\nAppCheck creates a local VPN tunnel to intercept all network connections. " + + "For each connection AppCheck looks into the DNS headers only, namely the domain names. " + + "\n" + + "These domain names are logged in the background while the VPN is active. " + + "That means, AppCheck does not have to be active in the foreground. " + + "You can close the app and come back later to see the results." + ) + )) + x.present { + UserDefaults.standard.set(true, forKey: "didShowTutorialAppWelcome") + } } @objc func vpnStateChanged(_ notification: Notification) {