├── .gitignore ├── CONTRIBUTING.md ├── COPYING ├── COPYING.icons ├── Cartfile ├── Cartfile.resolved ├── FreeOTP.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── FreeOTP.xcscheme ├── FreeOTP ├── AboutViewController.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── 120.png │ │ ├── 152.png │ │ ├── 167.png │ │ ├── 20.png │ │ ├── 29.png │ │ ├── 40-1.png │ │ ├── 40-2.png │ │ ├── 40.png │ │ ├── 58.png │ │ ├── 60.png │ │ ├── 80.png │ │ ├── 87.png │ │ ├── Contents.json │ │ ├── icon120.png │ │ ├── icon180.png │ │ ├── icon58.png │ │ ├── icon76.png │ │ ├── icon80.png │ │ └── store.png │ ├── Contents.json │ ├── LockIcon.imageset │ │ ├── Contents.json │ │ └── LockIcon.pdf │ ├── ShareIcon.imageset │ │ ├── Contents.json │ │ └── Share.pdf │ └── SplashIcon.imageset │ │ ├── Contents.json │ │ ├── splash-icon.png │ │ ├── splash-icon@2x.png │ │ └── splash-icon@3x.png ├── Base.lproj │ └── Main.storyboard ├── CircleProgressView.swift ├── Device.swift ├── EmptyStateView.swift ├── FontAwesomeIconCell.swift ├── FreeOTP-Bridging-Header.h ├── ImageDownloader.swift ├── Info.plist ├── KeychainStore.swift ├── Launch.storyboard ├── MainNavigationController.swift ├── ManualAddViewController.swift ├── ManualInputTokenData.swift ├── ManualToUrlcModule.swift ├── OTP.swift ├── RTLSupport.swift ├── RecommendedIconCell.swift ├── ScanViewController.swift ├── SectionHeader.swift ├── ShareViewController.swift ├── Style.swift ├── Token.swift ├── TokenCell.swift ├── TokenIcon.swift ├── TokenStore.swift ├── TokensViewController.swift ├── UICollectionViewFlowLayout.swift ├── URIIconViewController.swift ├── URILabelViewController.swift ├── URILockViewController.swift ├── URIMainIconViewController.swift ├── URIParameters.swift ├── default.png ├── en.lproj │ └── InfoPlist.strings ├── iTunesArtwork@2x ├── lock.png ├── qrcode.png └── token.png ├── FreeOTPTests ├── FreeOTPTests-Bridging-Header.h ├── HOTP.swift ├── Icon.swift ├── Info.plist ├── Storage.swift ├── TOTP.swift └── URI.swift ├── FreeOTPUITests ├── FreeOTPUITests.swift └── Info.plist └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.xcuserstate 2 | xcuserdata 3 | .DS_Store 4 | Carthage/* 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | ## Pull Requests 4 | 5 | Pull requests (PRs) on GitHub are welcome under the Apache 2.0 license, see [COPYING](COPYING). 6 | 7 | To submit a PR, do the following: 8 | 9 | 1. Create your own [fork](https://help.github.com/github/getting-started-with-github/fork-a-repo) 10 | of the [freeotp-ios](https://github.com/freeotp/freeotp-ios) project. 11 | 2. Make a feature branch with your changes following the [feature branch workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/feature-branch-workflow) 12 | 3. Commit and push your changes to the branch in your fork, adhering to the 13 | [commit message format](#commit-message-format). 14 | 4. Unless you have a large or complex PR, 15 | [squash your changes](https://medium.com/@slamflipstrom/a-beginners-guide-to-squashing-commits-with-git-rebase-8185cf6e62ec) 16 | into a single commit. 17 | 5. If your changes include updates to the user interface, attach [screenshots](#ui-changes) of 18 | your new or updated screens as comments in the PR. 19 | 20 | ## Commit Message Format 21 | 22 | Commits messages should adhere to the following structure: 23 | 24 | ```text 25 | 26 | <BLANK LINE> 27 | <body - optional> 28 | <BLANK LINE> 29 | <footer - optional> 30 | ``` 31 | 32 | 1. `title` - a title for your commit. This should be less than 50 characters in length. 33 | 2. `body` - the body with details of your change. Each line should be less than 100 characters in 34 | length. 35 | 3. `footer` - one or more of the following may be placed in the footer: 36 | 1. If your change fixes an existing GitHub issue, reference it with `Fixes #<issue-number>` 37 | Mark each issue fixed on a separate line. 38 | 2. If your organization requires sign-offs, add your sign off line at the bottom of the footer. 39 | Ex: `Signed off by Jane Smith<jsmith@mycompany.com>` 40 | 41 | A full example: 42 | 43 | ```text 44 | Add Settings Screen 45 | 46 | - Create new settings screen to manage user preferences 47 | - Migrate existing configurations to user preferences 48 | 49 | Fixes #23 50 | Signed off by Jane Smith<jsmith@mycompany.com> 51 | ``` 52 | 53 | ## UI Changes 54 | 55 | If your PR creates or updates a screen, attach screen shots of your updates as a comment to the 56 | pull request. Changes to existing screens should annotate the screen shot to highlight changed 57 | elements. 58 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /COPYING.icons: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015 Nathaniel McCallum, Red Hat 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | 16 | 17 | FreeOTP/lock.png from the Google Material Icons project is licensed CC-BY. 18 | 19 | https://www.google.com/design/icons/ 20 | https://creativecommons.org/licenses/by/4.0/ 21 | -------------------------------------------------------------------------------- /Cartfile: -------------------------------------------------------------------------------- 1 | github "norio-nomura/Base32" "0.9.0" 2 | github "roberthein/TinyConstraints" ~> 4.0 3 | github "SDWebImage/SDWebImage" 4 | -------------------------------------------------------------------------------- /Cartfile.resolved: -------------------------------------------------------------------------------- 1 | github "SDWebImage/SDWebImage" "5.19.2" 2 | github "norio-nomura/Base32" "0.9.0" 3 | github "roberthein/TinyConstraints" "4.0.2" 4 | -------------------------------------------------------------------------------- /FreeOTP.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <Workspace 3 | version = "1.0"> 4 | <FileRef 5 | location = "self:FreeOTP.xcodeproj"> 6 | </FileRef> 7 | </Workspace> 8 | -------------------------------------------------------------------------------- /FreeOTP.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>IDEDidComputeMac32BitWarning</key> 6 | <true/> 7 | </dict> 8 | </plist> 9 | -------------------------------------------------------------------------------- /FreeOTP.xcodeproj/xcshareddata/xcschemes/FreeOTP.xcscheme: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <Scheme 3 | LastUpgradeVersion = "1130" 4 | version = "1.3"> 5 | <BuildAction 6 | parallelizeBuildables = "YES" 7 | buildImplicitDependencies = "YES"> 8 | <BuildActionEntries> 9 | <BuildActionEntry 10 | buildForTesting = "YES" 11 | buildForRunning = "YES" 12 | buildForProfiling = "YES" 13 | buildForArchiving = "YES" 14 | buildForAnalyzing = "YES"> 15 | <BuildableReference 16 | BuildableIdentifier = "primary" 17 | BlueprintIdentifier = "F10D88BF1B56A3C400482D15" 18 | BuildableName = "FreeOTP.app" 19 | BlueprintName = "FreeOTP" 20 | ReferencedContainer = "container:FreeOTP.xcodeproj"> 21 | </BuildableReference> 22 | </BuildActionEntry> 23 | </BuildActionEntries> 24 | </BuildAction> 25 | <TestAction 26 | buildConfiguration = "Debug" 27 | selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 28 | selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 29 | shouldUseLaunchSchemeArgsEnv = "YES"> 30 | <Testables> 31 | <TestableReference 32 | skipped = "NO"> 33 | <BuildableReference 34 | BuildableIdentifier = "primary" 35 | BlueprintIdentifier = "F10D88D31B56A3C400482D15" 36 | BuildableName = "FreeOTPTests.xctest" 37 | BlueprintName = "FreeOTPTests" 38 | ReferencedContainer = "container:FreeOTP.xcodeproj"> 39 | </BuildableReference> 40 | </TestableReference> 41 | <TestableReference 42 | skipped = "NO"> 43 | <BuildableReference 44 | BuildableIdentifier = "primary" 45 | BlueprintIdentifier = "89F8EFD5256BA81F00460AA9" 46 | BuildableName = "FreeOTPUITests.xctest" 47 | BlueprintName = "FreeOTPUITests" 48 | ReferencedContainer = "container:FreeOTP.xcodeproj"> 49 | </BuildableReference> 50 | </TestableReference> 51 | </Testables> 52 | </TestAction> 53 | <LaunchAction 54 | buildConfiguration = "Debug" 55 | selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" 56 | selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" 57 | launchStyle = "0" 58 | useCustomWorkingDirectory = "NO" 59 | ignoresPersistentStateOnLaunch = "NO" 60 | debugDocumentVersioning = "YES" 61 | debugServiceExtension = "internal" 62 | allowLocationSimulation = "YES"> 63 | <BuildableProductRunnable 64 | runnableDebuggingMode = "0"> 65 | <BuildableReference 66 | BuildableIdentifier = "primary" 67 | BlueprintIdentifier = "F10D88BF1B56A3C400482D15" 68 | BuildableName = "FreeOTP.app" 69 | BlueprintName = "FreeOTP" 70 | ReferencedContainer = "container:FreeOTP.xcodeproj"> 71 | </BuildableReference> 72 | </BuildableProductRunnable> 73 | </LaunchAction> 74 | <ProfileAction 75 | buildConfiguration = "Release" 76 | shouldUseLaunchSchemeArgsEnv = "YES" 77 | savedToolIdentifier = "" 78 | useCustomWorkingDirectory = "NO" 79 | debugDocumentVersioning = "YES"> 80 | <BuildableProductRunnable 81 | runnableDebuggingMode = "0"> 82 | <BuildableReference 83 | BuildableIdentifier = "primary" 84 | BlueprintIdentifier = "F10D88BF1B56A3C400482D15" 85 | BuildableName = "FreeOTP.app" 86 | BlueprintName = "FreeOTP" 87 | ReferencedContainer = "container:FreeOTP.xcodeproj"> 88 | </BuildableReference> 89 | </BuildableProductRunnable> 90 | </ProfileAction> 91 | <AnalyzeAction 92 | buildConfiguration = "Debug"> 93 | </AnalyzeAction> 94 | <ArchiveAction 95 | buildConfiguration = "Release" 96 | revealArchiveInOrganizer = "YES"> 97 | </ArchiveAction> 98 | </Scheme> 99 | -------------------------------------------------------------------------------- /FreeOTP/AboutViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutViewController.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 6/23/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | extension UITextView { 13 | // Perform multiple link text replacements in a given string 14 | func addHyperLinksToText(originalText: String, hyperLinks: [String: String]) { 15 | let style = NSMutableParagraphStyle() 16 | style.alignment = .left 17 | let attributedOriginalText = NSMutableAttributedString(string: originalText) 18 | for (hyperLink, urlString) in hyperLinks { 19 | let linkRange = attributedOriginalText.mutableString.range(of: hyperLink) 20 | let fullRange = NSRange(location: 0, length: attributedOriginalText.length) 21 | attributedOriginalText.addAttribute(NSAttributedString.Key.link, value: urlString, range: linkRange) 22 | attributedOriginalText.addAttribute(NSAttributedString.Key.paragraphStyle, value: style, range: fullRange) 23 | attributedOriginalText.addAttribute(NSAttributedString.Key.font, value: UIFont.systemFont(ofSize: 18), range: fullRange) 24 | 25 | var textColor = UIColor() 26 | if #available(iOS 13.0, *) { 27 | textColor = UIColor.secondaryLabel 28 | } else { 29 | textColor = UIColor.gray 30 | } 31 | 32 | attributedOriginalText.addAttribute(NSAttributedString.Key.foregroundColor, value: textColor, range: fullRange) 33 | } 34 | 35 | self.linkTextAttributes = [ 36 | NSAttributedString.Key.foregroundColor: UIColor.systemBlue, 37 | NSAttributedString.Key.underlineStyle: NSUnderlineStyle.single.rawValue, 38 | ] 39 | self.attributedText = attributedOriginalText 40 | } 41 | } 42 | 43 | class AboutViewController : UIViewController, UITextViewDelegate { 44 | @IBOutlet weak var versionLabel: UILabel! 45 | @IBOutlet weak var aboutTextView: UITextView! 46 | 47 | let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String 48 | 49 | override func viewWillAppear(_ animated: Bool) { 50 | super.viewDidAppear(animated) 51 | 52 | versionLabel.text = "FreeOTP \(appVersion)" 53 | versionLabel.font = UIFont.boldSystemFont(ofSize: 28.0) 54 | 55 | aboutTextView.delegate = self 56 | aboutTextView.text = """ 57 | 2013-2020 - Red Hat, Inc., et al. 58 | 59 | FreeOTP is licensed under Apache 2.0 60 | 61 | For more information, see our website 62 | 63 | We welcome your feedback 64 | - Report a Problem 65 | - Ask for Help 66 | """ 67 | 68 | aboutTextView.addHyperLinksToText(originalText: aboutTextView.text, 69 | hyperLinks: 70 | ["Apache 2.0": "https://www.apache.org/licenses/LICENSE-2.0.html", 71 | "website": "https://freeotp.github.io", 72 | "Report a Problem": "https://github.com/freeotp/freeotp-ios/issues", 73 | "Ask for Help": "https://lists.fedorahosted.org/mailman/listinfo/freeotp-devel"]) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /FreeOTP/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import UIKit 23 | 24 | @UIApplicationMain 25 | class AppDelegate : UIResponder, UIApplicationDelegate { 26 | var window: UIWindow? 27 | 28 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 29 | if let window = UIApplication.shared.windows.first as UIWindow? { 30 | window.backgroundColor = UIColor.app.background 31 | } 32 | return true 33 | } 34 | 35 | func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 36 | return true 37 | } 38 | 39 | func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any]) -> Bool { 40 | if let urlc = URLComponents(url: url, resolvingAgainstBaseURL: true) { 41 | let navigationController = app.windows[0].rootViewController as! UINavigationController 42 | if let scanvc = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "scan") as? ScanViewController { 43 | scanvc.urlc = urlc 44 | scanvc.urlSent = true 45 | navigationController.pushViewController(scanvc, animated: true) 46 | return true 47 | } 48 | } 49 | 50 | return false 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/152.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/167.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/20.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/29.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/40-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/40-1.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/40-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/40-2.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "40-2.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "60.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "icon58.png", 19 | "scale" : "2x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "87.png", 25 | "scale" : "3x" 26 | }, 27 | { 28 | "size" : "40x40", 29 | "idiom" : "iphone", 30 | "filename" : "icon80.png", 31 | "scale" : "2x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "120.png", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "size" : "60x60", 41 | "idiom" : "iphone", 42 | "filename" : "icon120.png", 43 | "scale" : "2x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "icon180.png", 49 | "scale" : "3x" 50 | }, 51 | { 52 | "size" : "20x20", 53 | "idiom" : "ipad", 54 | "filename" : "20.png", 55 | "scale" : "1x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "40.png", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "size" : "29x29", 65 | "idiom" : "ipad", 66 | "filename" : "29.png", 67 | "scale" : "1x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "58.png", 73 | "scale" : "2x" 74 | }, 75 | { 76 | "size" : "40x40", 77 | "idiom" : "ipad", 78 | "filename" : "40-1.png", 79 | "scale" : "1x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "80.png", 85 | "scale" : "2x" 86 | }, 87 | { 88 | "size" : "76x76", 89 | "idiom" : "ipad", 90 | "filename" : "icon76.png", 91 | "scale" : "1x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "152.png", 97 | "scale" : "2x" 98 | }, 99 | { 100 | "size" : "83.5x83.5", 101 | "idiom" : "ipad", 102 | "filename" : "167.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "1024x1024", 107 | "idiom" : "ios-marketing", 108 | "filename" : "store.png", 109 | "scale" : "1x" 110 | } 111 | ], 112 | "info" : { 113 | "version" : 1, 114 | "author" : "xcode" 115 | } 116 | } -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/icon120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/icon120.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/icon180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/icon180.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/icon58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/icon58.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/icon76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/icon76.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/icon80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/icon80.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/AppIcon.appiconset/store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/AppIcon.appiconset/store.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/LockIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LockIcon.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/LockIcon.imageset/LockIcon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/LockIcon.imageset/LockIcon.pdf -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/ShareIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "Share.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template", 14 | "preserves-vector-representation" : true 15 | } 16 | } -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/ShareIcon.imageset/Share.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/ShareIcon.imageset/Share.pdf -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/SplashIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "splash-icon.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "splash-icon@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "splash-icon@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/SplashIcon.imageset/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/SplashIcon.imageset/splash-icon.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/SplashIcon.imageset/splash-icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/SplashIcon.imageset/splash-icon@2x.png -------------------------------------------------------------------------------- /FreeOTP/Assets.xcassets/SplashIcon.imageset/splash-icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/Assets.xcassets/SplashIcon.imageset/splash-icon@3x.png -------------------------------------------------------------------------------- /FreeOTP/CircleProgressView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import UIKit 23 | 24 | @IBDesignable class CircleProgressView : UIView { 25 | @IBInspectable var hollow: Bool = true { 26 | didSet { 27 | setNeedsDisplay() 28 | } 29 | } 30 | 31 | var clockwise: Bool = true { 32 | didSet { 33 | setNeedsDisplay() 34 | } 35 | } 36 | 37 | @IBInspectable var threshold: CGFloat = 0.0 { 38 | didSet { 39 | setNeedsDisplay() 40 | } 41 | } 42 | 43 | var progress: CGFloat = 0.0 { 44 | didSet { 45 | setNeedsDisplay() 46 | } 47 | } 48 | 49 | override func willMove(toSuperview newSuperview: UIView?) { 50 | backgroundColor = UIColor.clear 51 | } 52 | 53 | override func draw(_ rect: CGRect) { 54 | let prog = self.clockwise ? self.progress : (1.0 - self.progress) 55 | let center = CGPoint(x: self.bounds.midX, y: self.bounds.midY) 56 | let radius = max(min(self.bounds.size.height / 2.0, self.bounds.size.width / 2.0) - 4, 1) 57 | let radians = max(min(Double(prog) * 2 * Double.pi, 2 * Double.pi), 0) 58 | 59 | var color = UIColor(red: 0.0, green: 122.0/255.0, blue: 1.0, alpha: 1.0) 60 | if (threshold < 0 && progress < abs(threshold)) { 61 | color = UIColor(red: 1.0, green: progress * (1 / abs(threshold)), blue: 0.0, alpha: 1.0) 62 | } else if (threshold > 0 && progress > threshold) { 63 | color = UIColor(red: 1.0, green: (1 - progress) * (1 / (1 - threshold)), blue: 0.0, alpha: 1.0) 64 | } 65 | 66 | let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: CGFloat(-Double.pi / 2), 67 | endAngle: CGFloat(radians - Double.pi / 2), clockwise: self.clockwise) 68 | if (self.hollow) { 69 | color.setStroke() 70 | path.lineWidth = 3.0 71 | path.stroke() 72 | } else { 73 | color.setFill() 74 | path.addLine(to: center) 75 | path.addClip() 76 | UIRectFill(self.bounds); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /FreeOTP/Device.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Device.swift 3 | // FreeOTP 4 | // 5 | // Created by Vinícius Soares on 10/06/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | final class Device { 12 | enum Size { case small, medium, large } 13 | 14 | static var size: Size { 15 | switch UIScreen.main.bounds.width { 16 | case 1...320: return .small 17 | case 321...375: return .medium 18 | default: return .large 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /FreeOTP/EmptyStateView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import TinyConstraints 22 | import UIKit 23 | 24 | class EmptyStateView: UIView { 25 | private(set) lazy var stackView: UIStackView = { 26 | let view = UIStackView() 27 | view.axis = .vertical 28 | view.distribution = .fill 29 | view.spacing = 8 30 | return view 31 | }() 32 | 33 | private(set) lazy var titleLabel: UILabel = { 34 | let view = UILabel() 35 | view.font = .dynamicSystemFont(ofSize: 14, weight: .regular) 36 | view.text = "No tokens have been added yet." 37 | view.textAlignment = .center 38 | view.textColor = UIColor.app.secondaryText 39 | return view 40 | }() 41 | 42 | private(set) lazy var addTokenButton: UIButton = { 43 | let view = UIButton(type: .system) 44 | view.setTitle("Add a token", for: .normal) 45 | view.setTitleColor(UIColor.app.accent, for: .normal) 46 | return view 47 | }() 48 | 49 | var addToken: (() -> Void)? 50 | 51 | override init(frame: CGRect) { 52 | super.init(frame: frame) 53 | setup() 54 | } 55 | 56 | required init?(coder: NSCoder) { 57 | super.init(coder: coder) 58 | setup() 59 | } 60 | 61 | private func setup() { 62 | backgroundColor = UIColor.app.background 63 | 64 | addSubview(stackView) 65 | 66 | stackView.addArrangedSubview(titleLabel) 67 | stackView.addArrangedSubview(addTokenButton) 68 | 69 | stackView.centerYToSuperview() 70 | stackView.leftToSuperview(offset: 24) 71 | stackView.rightToSuperview(offset: -24) 72 | 73 | addTokenButton.addTarget(self, action: #selector(addTokenAction), for: .touchUpInside) 74 | } 75 | 76 | @objc private func addTokenAction() { 77 | addToken?() 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /FreeOTP/FontAwesomeIconCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontAwesomeIconCell.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 2/12/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class FontAwesomeIconCell: UICollectionViewCell { 12 | 13 | @IBOutlet weak var iconImage: UIImageView! 14 | 15 | override var isSelected: Bool { 16 | didSet { 17 | self.layer.borderWidth = 3.0 18 | self.layer.borderColor = isSelected ? UIColor.blue.cgColor : UIColor.clear.cgColor 19 | } 20 | } 21 | 22 | override func prepareForReuse() { 23 | super.prepareForReuse() 24 | iconImage.backgroundColor = UIColor.clear 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /FreeOTP/FreeOTP-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import <CommonCrypto/CommonHMAC.h> 6 | -------------------------------------------------------------------------------- /FreeOTP/ImageDownloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import Photos 23 | import UIKit 24 | import SDWebImage 25 | 26 | class ImageDownloader : NSObject { 27 | fileprivate let DEFAULT = UIImage(contentsOfFile: Bundle.main.path(forResource: "default", ofType: "png")!)! 28 | fileprivate let size: CGSize 29 | 30 | init(_ size: CGSize) { 31 | self.size = size 32 | super.init() 33 | } 34 | 35 | func isPHAssetAuthorized(_ status: PHAuthorizationStatus) -> Bool! { 36 | switch status { 37 | case .denied, .restricted: 38 | return false 39 | case .notDetermined: 40 | return nil 41 | case .authorized, .limited: 42 | return true 43 | @unknown default: 44 | return false 45 | } 46 | } 47 | 48 | func fromPHAsset(_ asset: PHAsset, completion: @escaping (UIImage) -> Void) { 49 | let opts: PHImageRequestOptions = PHImageRequestOptions() 50 | opts.isSynchronous = true 51 | 52 | PHImageManager.default().requestImage( 53 | for: asset, 54 | targetSize: size, 55 | contentMode: PHImageContentMode.aspectFill, 56 | options: opts, 57 | resultHandler: { 58 | (image: UIImage?, objects: [AnyHashable: Any]?) -> Void in 59 | completion(image == nil ? self.DEFAULT : image!) 60 | } 61 | ) 62 | } 63 | 64 | func fromALAsset(_ asset: URL, completion: @escaping (UIImage) -> Void) { 65 | let status = PHPhotoLibrary.authorizationStatus() 66 | let authorized = isPHAssetAuthorized(status) 67 | var access_granted = false 68 | 69 | if (authorized == false) { 70 | // shortcut completion 71 | } else if (authorized == nil) { 72 | PHPhotoLibrary.requestAuthorization { (status) -> Void in 73 | if (self.isPHAssetAuthorized(status) == true) { 74 | access_granted = true 75 | } 76 | } 77 | } 78 | 79 | if (authorized == true || access_granted == true) { 80 | if asset.scheme == "assets-library" { 81 | let rslt = PHAsset.fetchAssets(withALAssetURLs: [asset], options: nil) 82 | if rslt.count > 0 { 83 | return fromPHAsset(rslt[0] , completion: completion) 84 | } 85 | } 86 | } 87 | return completion(DEFAULT) 88 | } 89 | 90 | func fromURL(_ url: URL, _ iv: UIImageView, completion: @escaping (UIImage) -> Void) { 91 | if let scheme = url.scheme { 92 | switch scheme { 93 | case "file": 94 | if let img = UIImage(contentsOfFile: url.path) { 95 | return completion(img) 96 | } 97 | 98 | case "assets-library": 99 | return fromALAsset(url, completion: completion) 100 | 101 | case "http": 102 | fallthrough 103 | case "https": 104 | iv.sd_setImage(with: url, placeholderImage: self.DEFAULT, 105 | completed: { (image, error, cacheType, url) in 106 | if let image { 107 | completion(image) 108 | } 109 | }) 110 | return 111 | default: 112 | break 113 | } 114 | 115 | return completion(DEFAULT) 116 | } 117 | } 118 | 119 | func fromURI(_ uri: String?, _ iv: UIImageView, completion: @escaping (UIImage) -> Void) { 120 | if var u = uri { 121 | if u.hasPrefix("phasset:") { 122 | let id = String(u[u.index(u.startIndex, offsetBy: "phasset:".count)...]) 123 | let rslt = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil) 124 | if rslt.count > 0 { 125 | return fromPHAsset(rslt[0], completion: completion) 126 | } 127 | } else { 128 | // App Transport Security doesn't allow arbitrary loading of 129 | // HTTP resources any longer. Most desired images can be 130 | // retrieved via HTTPS, so just promote URIs to HTTPS. 131 | if u.hasPrefix("http:") { 132 | u.insert("s", at: u.index(u.startIndex, offsetBy: 4)) 133 | } 134 | 135 | if let remote = URL(string: u) { 136 | return fromURL(remote, iv, completion: completion) 137 | } 138 | } 139 | } 140 | 141 | return completion(DEFAULT) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /FreeOTP/Info.plist: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>UIAppFonts</key> 6 | <array> 7 | <string>fa-brands-400.ttf</string> 8 | <string>fa-regular-400.ttf</string> 9 | <string>fa-solid-900.ttf</string> 10 | </array> 11 | <key>LSApplicationCategoryType</key> 12 | <string></string> 13 | <key>CFBundleDevelopmentRegion</key> 14 | <string>en</string> 15 | <key>CFBundleDisplayName</key> 16 | <string>FreeOTP</string> 17 | <key>CFBundleExecutable</key> 18 | <string>$(EXECUTABLE_NAME)</string> 19 | <key>CFBundleIdentifier</key> 20 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 21 | <key>CFBundleInfoDictionaryVersion</key> 22 | <string>6.0</string> 23 | <key>CFBundleName</key> 24 | <string>$(PRODUCT_NAME)</string> 25 | <key>CFBundlePackageType</key> 26 | <string>APPL</string> 27 | <key>CFBundleShortVersionString</key> 28 | <string>$(MARKETING_VERSION)</string> 29 | <key>CFBundleSignature</key> 30 | <string>????</string> 31 | <key>CFBundleURLTypes</key> 32 | <array> 33 | <dict> 34 | <key>CFBundleTypeRole</key> 35 | <string>Editor</string> 36 | <key>CFBundleURLName</key> 37 | <string>org.fedorahosted.freeotp.otpauth</string> 38 | <key>CFBundleURLSchemes</key> 39 | <array> 40 | <string>otpauth</string> 41 | </array> 42 | </dict> 43 | </array> 44 | <key>CFBundleVersion</key> 45 | <string>$(CURRENT_PROJECT_VERSION)</string> 46 | <key>LSRequiresIPhoneOS</key> 47 | <true/> 48 | <key>NSBluetoothAlwaysUsageDescription</key> 49 | <string>$(PRODUCT_NAME) requests access to Bluetooth to share token codes</string> 50 | <key>NSBluetoothPeripheralUsageDescription</key> 51 | <string>$(PRODUCT_NAME) requests access to Bluetooth to share token codes</string> 52 | <key>NSCameraUsageDescription</key> 53 | <string>$(PRODUCT_NAME) requires access to the camera to scan a QR code for token import</string> 54 | <key>NSFaceIDUsageDescription</key> 55 | <string>Biometric support for locked tokens</string> 56 | <key>NSPhotoLibraryUsageDescription</key> 57 | <string>$(PRODUCT_NAME) requests access to the Photo Library to select token key image</string> 58 | <key>UILaunchStoryboardName</key> 59 | <string>Launch</string> 60 | <key>UIMainStoryboardFile</key> 61 | <string>Main</string> 62 | <key>UISupportedInterfaceOrientations</key> 63 | <array> 64 | <string>UIInterfaceOrientationPortrait</string> 65 | <string>UIInterfaceOrientationPortraitUpsideDown</string> 66 | <string>UIInterfaceOrientationLandscapeLeft</string> 67 | <string>UIInterfaceOrientationLandscapeRight</string> 68 | </array> 69 | </dict> 70 | </plist> 71 | -------------------------------------------------------------------------------- /FreeOTP/KeychainStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import Security 23 | 24 | public protocol KeychainStorable : NSCoding { 25 | static var store: KeychainStore<Self> { get } 26 | var account: String { get } 27 | } 28 | 29 | open class KeychainStore<T: KeychainStorable> { 30 | fileprivate let service: String 31 | 32 | 33 | fileprivate func query(_ account: String) -> [String: AnyObject] { 34 | return [ 35 | kSecClass as String: kSecClassGenericPassword, 36 | kSecAttrAccount as String: account as AnyObject, 37 | kSecAttrService as String: service as AnyObject 38 | ] 39 | } 40 | 41 | fileprivate func add(_ account: String, _ data: Data, _ locked: Bool = false) -> Bool { 42 | let date = Date() 43 | var add: [String: AnyObject] = [ 44 | kSecClass as String: kSecClassGenericPassword, 45 | kSecAttrCreationDate as String: date as AnyObject, 46 | kSecAttrModificationDate as String: date as AnyObject, 47 | kSecAttrAccount as String: account as AnyObject, 48 | kSecAttrService as String: service as AnyObject, 49 | kSecValueData as String: data as AnyObject, 50 | ] 51 | 52 | if locked { 53 | let sac = SecAccessControlCreateWithFlags( 54 | kCFAllocatorDefault, 55 | kSecAttrAccessibleWhenUnlocked, 56 | .userPresence, 57 | nil 58 | ) 59 | 60 | add[kSecAttrAccessControl as String] = sac 61 | } else { 62 | add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked 63 | } 64 | 65 | return SecItemAdd(add as CFDictionary, nil) == errSecSuccess 66 | } 67 | 68 | open var lockingSupported: Bool { 69 | let id = UUID().uuidString 70 | if add(id, Data(), true) { 71 | return erase(id) 72 | } 73 | 74 | return false 75 | } 76 | 77 | public init() { 78 | service = NSStringFromClass(T.self) 79 | } 80 | 81 | @discardableResult open func add(_ storable: T, locked: Bool = false) -> Bool { 82 | return add( 83 | storable.account, 84 | NSKeyedArchiver.archivedData(withRootObject: storable), 85 | locked && lockingSupported 86 | ) 87 | } 88 | 89 | @discardableResult open func save(_ storable: T) -> Bool { 90 | let update: [String: AnyObject] = [ 91 | kSecValueData as String: NSKeyedArchiver.archivedData(withRootObject: storable) as AnyObject, 92 | kSecAttrModificationDate as String: Date() as AnyObject, 93 | ] 94 | 95 | return SecItemUpdate(query(storable.account) as CFDictionary, update as CFDictionary) == errSecSuccess 96 | } 97 | 98 | open func load(_ account: String) -> T? { 99 | var dict = query(account) 100 | dict[kSecReturnData as String] = true as AnyObject 101 | 102 | var output: AnyObject? 103 | let status = SecItemCopyMatching(dict as CFDictionary, &output) 104 | if status == errSecSuccess { 105 | if let o = output { 106 | let data = o as! Data 107 | return NSKeyedUnarchiver.unarchiveObject(with: data) as? T 108 | } 109 | } 110 | 111 | return nil 112 | } 113 | 114 | @discardableResult open func erase(_ storable: T) -> Bool { 115 | return erase(storable.account) 116 | } 117 | 118 | @discardableResult open func erase(_ account: String) -> Bool { 119 | return SecItemDelete(query(account) as CFDictionary) == errSecSuccess 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /FreeOTP/Launch.storyboard: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15705" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="NFX-rR-3z9"> 3 | <device id="retina4_7" orientation="portrait" appearance="light"/> 4 | <dependencies> 5 | <deployment identifier="iOS"/> 6 | <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15706"/> 7 | <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> 8 | </dependencies> 9 | <scenes> 10 | <!--View Controller--> 11 | <scene sceneID="Mb7-7d-l2M"> 12 | <objects> 13 | <viewController id="NFX-rR-3z9" sceneMemberID="viewController"> 14 | <layoutGuides> 15 | <viewControllerLayoutGuide type="top" id="dui-bO-tR1"/> 16 | <viewControllerLayoutGuide type="bottom" id="hPL-zI-H6P"/> 17 | </layoutGuides> 18 | <view key="view" contentMode="scaleToFill" id="kcT-Q6-Z2r"> 19 | <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> 20 | <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> 21 | <subviews> 22 | <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="SplashIcon" translatesAutoresizingMaskIntoConstraints="NO" id="5qP-Jw-c5C"> 23 | <rect key="frame" x="123.5" y="269.5" width="128" height="128"/> 24 | <constraints> 25 | <constraint firstAttribute="width" constant="128" id="FTl-6X-58f"/> 26 | <constraint firstAttribute="height" constant="128" id="Ogl-vN-ntV"/> 27 | </constraints> 28 | </imageView> 29 | </subviews> 30 | <color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> 31 | <constraints> 32 | <constraint firstItem="5qP-Jw-c5C" firstAttribute="centerX" secondItem="kcT-Q6-Z2r" secondAttribute="centerX" id="5oA-QS-Biw"/> 33 | <constraint firstItem="5qP-Jw-c5C" firstAttribute="centerY" secondItem="kcT-Q6-Z2r" secondAttribute="centerY" id="amO-mz-8dD"/> 34 | </constraints> 35 | </view> 36 | </viewController> 37 | <placeholder placeholderIdentifier="IBFirstResponder" id="jEw-k5-4M7" userLabel="First Responder" sceneMemberID="firstResponder"/> 38 | </objects> 39 | <point key="canvasLocation" x="581.60000000000002" y="380.95952023988008"/> 40 | </scene> 41 | </scenes> 42 | <resources> 43 | <image name="SplashIcon" width="514" height="514"/> 44 | </resources> 45 | </document> 46 | -------------------------------------------------------------------------------- /FreeOTP/MainNavigationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MainNavigationController.swift 3 | // FreeOTP 4 | // 5 | // Created by Vinícius Soares on 12/06/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class MainNavigationController: UINavigationController { 12 | override func viewDidLoad() { 13 | super.viewDidLoad() 14 | navigationBar.barTintColor = UIColor.app.navigationBackground 15 | navigationBar.isTranslucent = false 16 | navigationBar.tintColor = UIColor.app.accent 17 | navigationBar.titleTextAttributes = [.foregroundColor: UIColor.app.primaryText] 18 | 19 | navigationBar.setBackgroundImage(UIImage(), for: .default) 20 | navigationBar.shadowImage = UIColor.app.navigationHairline.asHalfPointImage 21 | } 22 | } 23 | 24 | private extension UIColor { 25 | var asHalfPointImage: UIImage { 26 | UIGraphicsBeginImageContext(CGSize(width: 0.5, height: 0.5)) 27 | setFill() 28 | UIGraphicsGetCurrentContext()?.fill(CGRect(x: 0, y: 0, width: 0.5, height: 0.5)) 29 | let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage() 30 | UIGraphicsEndImageContext() 31 | return image 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /FreeOTP/ManualAddViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManualAddViewController.swift 3 | // FreeOTP 4 | // 5 | // Created by Игорь Андрианов on 09.04.2022. 6 | // Copyright © 2022 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class ManualAddViewController: UIViewController { 12 | 13 | struct Intervals { 14 | var title: String 15 | var interval: Int 16 | } 17 | 18 | @IBOutlet weak var nextButton: UIBarButtonItem! 19 | @IBOutlet weak var issuerField: UITextField! 20 | @IBOutlet weak var descriptionField: UITextField! 21 | @IBOutlet weak var secretField: UITextField! 22 | @IBOutlet weak var typeSegmentedControl: UISegmentedControl! 23 | @IBOutlet weak var digitsSegmentedControl: UISegmentedControl! 24 | @IBOutlet weak var algorithmButton: UIButton! 25 | @IBOutlet weak var intervalButton: UIButton! 26 | 27 | let type: [String] = ["TOTP","HOTP"] 28 | let digits: [Int] = [6, 7, 8, 9] 29 | let algorithms: [String] = ["SHA1", "SHA224", "SHA256", "SHA384", "SHA512", "MD5"] 30 | 31 | let intervals: [Intervals] = [ 32 | Intervals(title: "15s", interval: 15), 33 | Intervals(title: "30s", interval: 30), 34 | Intervals(title: "1m", interval: 60), 35 | Intervals(title: "2m", interval: 120), 36 | Intervals(title: "5m", interval: 300), 37 | Intervals(title: "10m", interval: 600), 38 | ] 39 | 40 | var URI = URIParameters() 41 | var icon = TokenIcon() 42 | 43 | override func viewDidLoad() { 44 | super.viewDidLoad() 45 | view.accessibilityIdentifier = "manualAddView" 46 | configureSubviews() 47 | } 48 | 49 | private func configureSubviews() { 50 | nextButton.accessibilityIdentifier = "nextButton" 51 | issuerField.accessibilityIdentifier = "issuerField" 52 | descriptionField.accessibilityIdentifier = "descriptionField" 53 | secretField.accessibilityIdentifier = "secretField" 54 | 55 | typeSegmentedControl.removeAllSegments() 56 | type.enumerated().forEach { (index, element) in 57 | typeSegmentedControl.insertSegment(withTitle: String(element), at: index, animated: false) 58 | } 59 | typeSegmentedControl.selectedSegmentIndex = 0 60 | typeSegmentedControl.accessibilityIdentifier = "typeControl" 61 | 62 | digitsSegmentedControl.removeAllSegments() 63 | digits.enumerated().forEach { (index, element) in 64 | digitsSegmentedControl.insertSegment(withTitle: String(element), at: index, animated: false) 65 | } 66 | digitsSegmentedControl.selectedSegmentIndex = 0 67 | digitsSegmentedControl.accessibilityIdentifier = "digitsControl" 68 | 69 | algorithmButton.setTitle(algorithms[2], for: []) 70 | algorithmButton.accessibilityIdentifier = "algorithmControl" 71 | 72 | intervalButton.setTitle(intervals[1].title, for: []) 73 | intervalButton.accessibilityIdentifier = "intervalControl" 74 | } 75 | 76 | @IBAction func nextTapped(_ sender: Any) { 77 | guard let issuer = issuerField.text, 78 | issuer.count > 0, 79 | let description = descriptionField.text, 80 | description.count > 0, 81 | let secret = secretField.text, 82 | secret.count > 0 83 | else { 84 | showOkAlert(title: "Some fields are empty!", message: "Please fill in all fields") 85 | return 86 | } 87 | guard let _ = secret.base32DecodedData else { 88 | showOkAlert( 89 | title: "Token is invalid!", 90 | message: "The token you are attempting to add is invalid. Please check that each field is valid following the OTP Key Uri Format") 91 | secretField.text = "" 92 | return 93 | } 94 | 95 | guard let kind: Token.Kind = "TOTP" == type[typeSegmentedControl.selectedSegmentIndex] ? .totp : .hotp, 96 | let algorithm = algorithmButton.title(for: [])?.lowercased(), 97 | let interval = intervals.first(where: { $0.title == intervalButton.title(for: []) })?.interval 98 | else { return } 99 | let digits = digits[digitsSegmentedControl.selectedSegmentIndex] 100 | 101 | let manualData = ManualInputTokenData(algorithm: algorithm, secret: secret, digits: digits, period: interval, kind: kind, issuer: issuer, label: description, locked: nil) 102 | let urlc = ManualToUrlcModule().makeUrlc(from: manualData) 103 | 104 | if !pushNextViewController(urlc) { 105 | TokenStore().add(urlc) 106 | 107 | switch UIDevice.current.userInterfaceIdiom { 108 | case .pad: 109 | dismiss(animated: true, completion: nil) 110 | popoverPresentationController?.delegate?.popoverPresentationControllerDidDismissPopover?(popoverPresentationController!) 111 | default: 112 | navigationController?.popToRootViewController(animated: true) 113 | } 114 | } 115 | } 116 | 117 | private func pushNextViewController(_ urlc: URLComponents) -> Bool { 118 | 119 | if URI.paramUnset(urlc, "image", ""), 120 | let issuer = issuerField.text, 121 | icon.issuerBrandMapping[issuer] == nil { 122 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URIMainIconViewController") as? URIMainIconViewController { 123 | viewController.inputUrlc = urlc 124 | if let navigator = navigationController { 125 | navigator.pushViewController(viewController, animated: true) 126 | } 127 | } 128 | } else if URI.paramUnset(urlc, "lock", false) { 129 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URILockViewController") as? URILockViewController { 130 | viewController.inputUrlc = urlc 131 | if let navigator = navigationController { 132 | navigator.pushViewController(viewController, animated: true) 133 | } 134 | } 135 | } else { return false } 136 | 137 | return true 138 | } 139 | 140 | @IBAction func algorithmTapped(_ sender: Any) { 141 | showPopupMenu(button: algorithmButton, with: algorithms) 142 | } 143 | 144 | @IBAction func intervalTapped(_ sender: Any) { 145 | showPopupMenu(button: intervalButton, with: intervals.map { $0.title }) 146 | } 147 | 148 | private func showPopupMenu(button: UIButton, with actions: [String]) { 149 | let alert = UIAlertController(title: nil, message: nil, preferredStyle: .alert) 150 | 151 | actions.forEach { title in 152 | alert.addAction(UIAlertAction(title: title, style: .default) { _ in 153 | button.setTitle(title, for: []) 154 | }) 155 | } 156 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in }) 157 | present(alert, animated: true, completion: nil) 158 | } 159 | 160 | private func showOkAlert(title: String, message: String) { 161 | let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) 162 | let action = UIAlertAction(title: "OK", style: .default) 163 | alert.addAction(action) 164 | self.present(alert, animated: true) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /FreeOTP/ManualInputTokenData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManualInputTokenData.swift 3 | // FreeOTP 4 | // 5 | // Created by Игорь Андрианов on 17.04.2022. 6 | // Copyright © 2022 Fedora Project. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct ManualInputTokenData { 12 | let algorithm: String 13 | let secret: String 14 | let digits: Int 15 | let period: Int 16 | let kind: Token.Kind 17 | let issuer: String 18 | let label: String 19 | let locked: Bool? 20 | } 21 | -------------------------------------------------------------------------------- /FreeOTP/ManualToUrlcModule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ManualToUrlcModule.swift 3 | // FreeOTP 4 | // 5 | // Created by Игорь Андрианов on 01.05.2022. 6 | // Copyright © 2022 Fedora Project. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class ManualToUrlcModule { 12 | 13 | func makeUrlc(from data: ManualInputTokenData) -> URLComponents { 14 | var urlc = URLComponents() 15 | urlc.scheme = "otpauth" 16 | urlc.host = data.kind == .totp ? "totp" : "hotp" 17 | urlc.path = data.issuer + ":" + data.label 18 | urlc.queryItems = [ 19 | URLQueryItem(name: "algorithm", value: data.algorithm), 20 | URLQueryItem(name: "secret", value: data.secret), 21 | URLQueryItem(name: "digits", value: String(data.digits)), 22 | URLQueryItem(name: "period", value: String(data.period)), 23 | ] 24 | if let locked = data.locked { 25 | urlc.queryItems?.append(URLQueryItem(name: "lock", value: locked.description)) 26 | } 27 | return urlc 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /FreeOTP/OTP.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Base32 22 | import Foundation 23 | 24 | public final class OTP : NSObject, KeychainStorable { 25 | public static let store = KeychainStore<OTP>() 26 | public let account: String 27 | 28 | fileprivate var algo: Int = Int(kCCHmacAlgSHA1) 29 | fileprivate var size: Int = Int(CC_SHA1_DIGEST_LENGTH) 30 | fileprivate var secret: Data = Data() 31 | fileprivate var digits: Int = 6 32 | 33 | public init?(urlc: URLComponents) { 34 | account = UUID().uuidString 35 | super.init() 36 | 37 | if let query = urlc.queryItems { 38 | for item: URLQueryItem in query { 39 | if item.value == nil { continue } 40 | 41 | switch item.name.lowercased() { 42 | case "secret": 43 | if let s = item.value!.base32DecodedData { 44 | secret = s 45 | } else { 46 | return nil 47 | } 48 | 49 | case "algorithm": 50 | switch item.value!.lowercased() { 51 | case "md5": 52 | algo = Int(kCCHmacAlgMD5) 53 | size = Int(CC_MD5_DIGEST_LENGTH) 54 | case "sha1": 55 | algo = Int(kCCHmacAlgSHA1) 56 | size = Int(CC_SHA1_DIGEST_LENGTH) 57 | case "sha224": 58 | algo = Int(kCCHmacAlgSHA224) 59 | size = Int(CC_SHA224_DIGEST_LENGTH) 60 | case "sha256": 61 | algo = Int(kCCHmacAlgSHA256) 62 | size = Int(CC_SHA256_DIGEST_LENGTH) 63 | case "sha384": 64 | algo = Int(kCCHmacAlgSHA384) 65 | size = Int(CC_SHA384_DIGEST_LENGTH) 66 | case "sha512": 67 | algo = Int(kCCHmacAlgSHA512) 68 | size = Int(CC_SHA512_DIGEST_LENGTH) 69 | default: 70 | return nil 71 | } 72 | 73 | case "digits": 74 | switch item.value! { 75 | case "6": 76 | digits = 6 77 | case "7": 78 | digits = 7 79 | case "8": 80 | digits = 8 81 | case "9": 82 | digits = 9 83 | default: 84 | return nil 85 | } 86 | 87 | default: 88 | continue 89 | } 90 | } 91 | } 92 | 93 | if secret.count == 0 { 94 | return nil 95 | } 96 | } 97 | 98 | @objc required public init?(coder aDecoder: NSCoder) { 99 | account = aDecoder.decodeObject(forKey: "account") as! String 100 | secret = aDecoder.decodeObject(forKey: "secret") as! Data 101 | algo = aDecoder.decodeInteger(forKey: "algo") 102 | size = aDecoder.decodeInteger(forKey: "size") 103 | digits = aDecoder.decodeInteger(forKey: "digits") 104 | super.init() 105 | } 106 | 107 | @objc public func encode(with aCoder: NSCoder) { 108 | aCoder.encode(account, forKey: "account") 109 | aCoder.encode(secret, forKey: "secret") 110 | aCoder.encode(algo, forKey: "algo") 111 | aCoder.encode(size, forKey: "size") 112 | aCoder.encode(digits, forKey: "digits") 113 | } 114 | 115 | public func code(_ counter: Int64) -> String { 116 | // Network byte order 117 | var cnt = counter.bigEndian 118 | 119 | // Do the HMAC 120 | var buf = [UInt8](repeating: 0, count: size) 121 | CCHmac(UInt32(algo), (secret as NSData).bytes, secret.count, &cnt, MemoryLayout.size(ofValue: cnt), &buf) 122 | 123 | // Unparse UInt32 124 | let off = Int(buf[buf.count - 1]) & 0x0f; 125 | let bin_code = Array(buf[off...off + 3]) 126 | var msk = bin_code.withUnsafeBytes { 127 | $0.load(as: UInt32.self).bigEndian 128 | } 129 | msk &= 0x7fffffff 130 | 131 | // Create digits divisor 132 | var div: UInt32 = 1 133 | for _ in 0..<digits { div *= 10 } 134 | return String(format: String(format: "%%0%hhulu", digits), msk % div) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /FreeOTP/RTLSupport.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import UIKit 22 | import TinyConstraints 23 | 24 | extension UIView { 25 | private var usingDefaultLayoutDirection: Bool { 26 | return Self.userInterfaceLayoutDirection(for: self.semanticContentAttribute) != .rightToLeft 27 | } 28 | 29 | @discardableResult 30 | func rtlLeftToSuperview(offset: CGFloat = 0) -> Constraint { 31 | usingDefaultLayoutDirection ? leftToSuperview(offset: offset) : rightToSuperview(offset: -offset) 32 | } 33 | 34 | @discardableResult 35 | func rtlRightToSuperview(offset: CGFloat = 0) -> Constraint { 36 | usingDefaultLayoutDirection ? rightToSuperview(offset: offset) : leftToSuperview(offset: -offset) 37 | } 38 | 39 | @discardableResult 40 | func rtlLeftToRight(of constrainable: Constrainable, offset: CGFloat = 0) -> Constraint { 41 | usingDefaultLayoutDirection ? leftToRight(of: constrainable, offset: offset) : rightToLeft(of: constrainable, offset: -offset) 42 | } 43 | 44 | @discardableResult 45 | func rtlRightToLeft(of constrainable: Constrainable, offset: CGFloat = 0) -> Constraint { 46 | usingDefaultLayoutDirection ? rightToLeft(of: constrainable, offset: offset) : leftToRight(of: constrainable, offset: -offset) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /FreeOTP/RecommendedIconCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecommendedIconCell.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 4/29/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class RecommendedIconCell: UICollectionViewCell { 13 | 14 | @IBOutlet weak var iconImage: UIImageView! 15 | } 16 | -------------------------------------------------------------------------------- /FreeOTP/ScanViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import UIKit 23 | import AVFoundation 24 | 25 | class ScanViewController : UIViewController, AVCaptureMetadataOutputObjectsDelegate { 26 | var preview: AVCaptureVideoPreviewLayer = AVCaptureVideoPreviewLayer(session: AVCaptureSession()) 27 | var enabled: Bool = false 28 | var urlc = URLComponents() 29 | var URI = URIParameters() 30 | var icon = TokenIcon() 31 | var urlSent = false 32 | 33 | @IBOutlet weak var image: UIImageView! 34 | @IBOutlet weak var activity: UIActivityIndicatorView! 35 | @IBOutlet weak var error: UILabel! 36 | 37 | fileprivate func orient(_ toInterfaceOrientation: UIInterfaceOrientation) { 38 | preview.frame = view.bounds 39 | 40 | switch toInterfaceOrientation { 41 | case .portrait: 42 | preview.connection?.videoOrientation = .portrait 43 | case .portraitUpsideDown: 44 | preview.connection?.videoOrientation = .portraitUpsideDown 45 | case .landscapeLeft: 46 | preview.connection?.videoOrientation = .landscapeLeft 47 | case .landscapeRight: 48 | preview.connection?.videoOrientation = .landscapeRight 49 | case .unknown: 50 | break 51 | @unknown default: 52 | break 53 | } 54 | } 55 | 56 | override func willAnimateRotation(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) { 57 | UIView.animate(withDuration: duration, animations: { self.orient(toInterfaceOrientation) }) 58 | } 59 | 60 | override func viewDidLoad() { 61 | super.viewDidLoad() 62 | 63 | preview.videoGravity = AVLayerVideoGravity.resizeAspectFill 64 | preview.position = CGPoint(x: view.layer.bounds.midX, y: view.layer.bounds.midY) 65 | view.layer.addSublayer(preview) 66 | 67 | image.layer.borderColor = UIColor.white.cgColor 68 | image.layer.borderWidth = 6 69 | view.addSubview(image) 70 | view.addSubview(error) 71 | 72 | activity.startAnimating() 73 | view.addSubview(activity) 74 | 75 | do { 76 | if let device = AVCaptureDevice.default(for: AVMediaType.video) { 77 | let input = try AVCaptureDeviceInput(device: device) 78 | preview.session!.addInput(input) 79 | } 80 | } catch { 81 | dismiss(animated: true, completion: nil) 82 | return 83 | } 84 | 85 | let output = AVCaptureMetadataOutput() 86 | preview.session!.addOutput(output) 87 | output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main) 88 | if output.availableMetadataObjectTypes.contains(.qr) { 89 | output.metadataObjectTypes = [AVMetadataObject.ObjectType.qr] 90 | } else { 91 | showError("Device does not support scanning") 92 | dismiss(animated: true, completion: nil) 93 | return 94 | } 95 | 96 | orient(UIApplication.shared.statusBarOrientation) 97 | } 98 | 99 | override func viewDidAppear(_ animated: Bool) { 100 | enabled = true 101 | if urlSent { 102 | urlSent = false 103 | if !pushNextViewController(urlc) { 104 | TokenStore().add(urlc) 105 | switch UIDevice.current.userInterfaceIdiom { 106 | case .pad: 107 | dismiss(animated: true, completion: nil) 108 | popoverPresentationController?.delegate?.popoverPresentationControllerDidDismissPopover?(popoverPresentationController!) 109 | default: 110 | navigationController?.popToRootViewController(animated: true) 111 | } 112 | } 113 | } else { 114 | preview.session!.startRunning() 115 | } 116 | } 117 | 118 | override func viewDidDisappear(_ animated: Bool) { 119 | enabled = false 120 | } 121 | 122 | // Due to conditional navigation logic, we manage the navigation stack ourselves to avoid 123 | // a storyboard with too many segues 124 | func pushNextViewController(_ urlc: URLComponents) -> Bool { 125 | var issuer = "" 126 | 127 | if let label = URI.getLabel(from: urlc) { 128 | issuer = label.issuer 129 | } 130 | 131 | if URI.accountUnset(urlc) { 132 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URILabelViewController") as? URILabelViewController { 133 | viewController.inputUrlc = urlc 134 | if let navigator = navigationController { 135 | navigator.pushViewController(viewController, animated: true) 136 | } 137 | } 138 | } else if URI.paramUnset(urlc, "image", "") && 139 | icon.issuerBrandMapping[issuer] == nil { 140 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URIMainIconViewController") as? URIMainIconViewController { 141 | viewController.inputUrlc = urlc 142 | if let navigator = navigationController { 143 | navigator.pushViewController(viewController, animated: true) 144 | } 145 | } 146 | } else if URI.paramUnset(urlc, "lock", false) { 147 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URILockViewController") as? URILockViewController { 148 | viewController.inputUrlc = urlc 149 | if let navigator = navigationController { 150 | navigator.pushViewController(viewController, animated: true) 151 | } 152 | } 153 | } else { 154 | return false 155 | } 156 | 157 | return true 158 | } 159 | 160 | fileprivate func showError(_ err: String) { 161 | enabled = false 162 | error.text = err 163 | UIView.animate(withDuration: 2, animations: { 164 | self.error.alpha = 1.0 165 | self.activity.alpha = 0.0 166 | }, completion: { 167 | (_: Bool) -> Void in 168 | UIView.animate(withDuration: 2, animations: { 169 | self.error.alpha = 0.0 170 | self.activity.alpha = 1.0 171 | }, completion: { 172 | (_: Bool) -> Void in 173 | self.enabled = true 174 | } 175 | ) 176 | } 177 | ) 178 | } 179 | 180 | func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { 181 | if (!enabled) { 182 | return 183 | } 184 | for metadata in metadataObjects { 185 | if metadata.type != AVMetadataObject.ObjectType.qr { 186 | continue 187 | } 188 | 189 | let obj = metadata as! AVMetadataMachineReadableCodeObject 190 | let code = preview.transformedMetadataObject(for: obj) 191 | if (!image.frame.contains((code?.bounds)!)) { 192 | continue 193 | } 194 | 195 | if let urlc = URLComponents(string: obj.stringValue!) { 196 | if URI.validateURI(uri: urlc) { 197 | self.urlc = urlc 198 | 199 | preview.session?.stopRunning() 200 | 201 | if !pushNextViewController(urlc) { 202 | TokenStore().add(urlc) 203 | switch UIDevice.current.userInterfaceIdiom { 204 | case .pad: 205 | dismiss(animated: true, completion: nil) 206 | popoverPresentationController?.delegate?.popoverPresentationControllerDidDismissPopover?(popoverPresentationController!) 207 | default: 208 | navigationController?.popToRootViewController(animated: true) 209 | } 210 | } 211 | } else { 212 | showError("Invalid URI!") 213 | } 214 | } else { 215 | showError("Invalid URI!") 216 | } 217 | 218 | break 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /FreeOTP/SectionHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SectionHeader.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 2/18/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class SectionHeader: UICollectionReusableView { 12 | @IBOutlet weak var sectionHeaderLabel: UILabel! 13 | } 14 | -------------------------------------------------------------------------------- /FreeOTP/ShareViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import CoreBluetooth 22 | import Foundation 23 | import UIKit 24 | 25 | extension CBPeripheral { 26 | func findService(_ svc: CBUUID) -> CBService? { 27 | if let svcs = services { 28 | for s in svcs { 29 | if s.uuid == svc { 30 | return s 31 | } 32 | } 33 | } 34 | 35 | return nil 36 | } 37 | } 38 | 39 | extension CBService { 40 | func findCharacteristic(_ chr: CBUUID) -> CBCharacteristic? { 41 | if let chrs = characteristics { 42 | for c in chrs { 43 | if c.uuid == chr { 44 | return c 45 | } 46 | } 47 | } 48 | 49 | return nil 50 | } 51 | } 52 | 53 | class ShareViewController : UITableViewController, CBCentralManagerDelegate, CBPeripheralDelegate { 54 | fileprivate let SERVICE = CBUUID(string: "B670003C-0079-465C-9BA7-6C0539CCD67F") 55 | fileprivate let CHARACT = CBUUID(string: "F4186B06-D796-4327-AF39-AC22C50BDCA8") 56 | fileprivate var peripherals = [CBPeripheral]() 57 | fileprivate var manager: CBCentralManager! 58 | 59 | var token: Token! 60 | 61 | fileprivate func finish() { 62 | if let nc = navigationController { 63 | nc.popViewController(animated: true) 64 | } else { 65 | dismiss(animated: true, completion: nil) 66 | } 67 | } 68 | 69 | fileprivate func register(_ peripheral: CBPeripheral) -> Bool { 70 | if peripherals.contains(peripheral) { return false } 71 | if peripheral.name == nil { return false } 72 | peripherals.append(peripheral) 73 | 74 | // Add the device to the UI 75 | tableView.beginUpdates() 76 | if tableView.numberOfSections == 1 { tableView.insertSections(IndexSet(integer: 1), with: .fade) } 77 | tableView.insertRows(at: [IndexPath(row: peripherals.count - 1, section: 1)], with: UITableView.RowAnimation.fade) 78 | tableView.endUpdates() 79 | return true 80 | } 81 | 82 | fileprivate func unregister(_ peripheral: CBPeripheral) { 83 | if let i = peripherals.firstIndex(of: peripheral) { 84 | manager.cancelPeripheralConnection(peripherals.remove(at: i)) 85 | 86 | tableView.beginUpdates() 87 | tableView.deleteRows(at: [IndexPath(row: i, section: 1)], with: .fade) 88 | if i == 0 { tableView.deleteSections(IndexSet(integer: 1), with: .fade) } 89 | tableView.endUpdates() 90 | } 91 | } 92 | 93 | fileprivate func connect(_ peripheral: CBPeripheral) { 94 | if peripherals.contains(peripheral) { 95 | switch peripheral.state { 96 | case .disconnecting: fallthrough 97 | case .disconnected: 98 | manager.connect(peripheral, options: nil) 99 | 100 | case .connected: 101 | self.centralManager(manager, didConnect: peripheral) 102 | 103 | case .connecting: 104 | return; 105 | 106 | @unknown default: 107 | break 108 | } 109 | 110 | Timer.scheduledTimer( 111 | timeInterval: 3, 112 | target: self, 113 | selector: #selector(ShareViewController.timeout(_:)), 114 | userInfo: peripheral, 115 | repeats: false 116 | ) 117 | } 118 | } 119 | 120 | @objc func timeout(_ timer: Timer) { 121 | if let p = timer.userInfo as! CBPeripheral? { 122 | if p.findService(SERVICE)?.findCharacteristic(CHARACT) == nil { 123 | switch p.state { 124 | case .connecting: fallthrough 125 | case .connected: 126 | manager.cancelPeripheralConnection(p) 127 | 128 | default: break 129 | } 130 | } 131 | } 132 | } 133 | 134 | override func numberOfSections(in tableView: UITableView) -> Int { 135 | return peripherals.count > 0 ? 2 : 1 136 | } 137 | 138 | override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 139 | switch section { 140 | case 0: 141 | return "Local" 142 | 143 | case 1: 144 | return "Bluetooth" 145 | 146 | default: 147 | return nil 148 | } 149 | } 150 | 151 | override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 152 | switch section { 153 | case 0: 154 | return 1 155 | 156 | case 1: 157 | return peripherals.count 158 | 159 | default: 160 | return 0 161 | } 162 | } 163 | 164 | override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 165 | let cell = tableView.dequeueReusableCell(withIdentifier: "shareRow")! 166 | let lbl = cell.viewWithTag(1) as! UILabel 167 | let act = cell.viewWithTag(2) as! UIActivityIndicatorView 168 | 169 | switch indexPath.section { 170 | case 0: 171 | lbl.text = "Copy to Clipboard" 172 | cell.isUserInteractionEnabled = true 173 | lbl.isEnabled = true 174 | 175 | case 1: 176 | let chr = peripherals[indexPath.row].findService(SERVICE)?.findCharacteristic(CHARACT) 177 | cell.isUserInteractionEnabled = chr != nil 178 | act.alpha = chr != nil ? 0 : 1 179 | lbl.isEnabled = chr != nil 180 | lbl.text = peripherals[indexPath.row].name 181 | 182 | default: 183 | break 184 | } 185 | 186 | return cell 187 | } 188 | 189 | override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 190 | tableView.deselectRow(at: indexPath, animated: true) 191 | 192 | let codes = token.codes 193 | 194 | if codes.count > 0 { 195 | switch indexPath.section { 196 | case 0: 197 | UIPasteboard.general.string = codes[0].value 198 | finish() 199 | 200 | case 1: 201 | if let c = peripherals[indexPath.row].findService(SERVICE)?.findCharacteristic(CHARACT) { 202 | if let d = codes[0].value.data(using: String.Encoding.utf8) { 203 | peripherals[indexPath.row].writeValue(d, for: c, type: .withResponse) 204 | } 205 | } 206 | 207 | default: 208 | break 209 | } 210 | } 211 | } 212 | 213 | func centralManagerDidUpdateState(_ central: CBCentralManager) { 214 | switch central.state { 215 | case .poweredOn: 216 | for p in central.retrieveConnectedPeripherals(withServices: [SERVICE]) { 217 | if register(p) { 218 | connect(p) 219 | } 220 | } 221 | 222 | central.scanForPeripherals(withServices: [SERVICE], options: nil) 223 | 224 | default: 225 | central.stopScan() 226 | } 227 | } 228 | 229 | func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { 230 | if register(peripheral) { 231 | connect(peripheral) 232 | } 233 | } 234 | 235 | func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { 236 | peripheral.delegate = self 237 | 238 | if let svc = peripheral.findService(SERVICE) { 239 | if let _ = svc.findCharacteristic(CHARACT) { 240 | self.peripheral(peripheral, didDiscoverCharacteristicsFor: svc, error: nil) 241 | } else { 242 | peripheral.discoverCharacteristics([CHARACT], for: svc) 243 | } 244 | } else { 245 | peripheral.discoverServices(nil) 246 | } 247 | } 248 | 249 | func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { 250 | if let svc = peripheral.findService(SERVICE) { 251 | if let _ = svc.findCharacteristic(CHARACT) { 252 | self.peripheral(peripheral, didDiscoverCharacteristicsFor: svc, error: nil) 253 | } else { 254 | peripheral.discoverCharacteristics([CHARACT], for: svc) 255 | } 256 | } else { 257 | switch peripheral.state { 258 | case .connecting: fallthrough 259 | case .connected: 260 | unregister(peripheral) 261 | 262 | default: break 263 | } 264 | } 265 | } 266 | 267 | func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { 268 | if let _ = service.findCharacteristic(CHARACT) { 269 | let i = peripherals.firstIndex(of: peripheral)! 270 | let c = tableView.cellForRow(at: IndexPath(row: i, section: 1)) 271 | 272 | UIView.animate(withDuration: 0.3, animations: { 273 | let l = c?.viewWithTag(1) as! UILabel? 274 | l?.isEnabled = true 275 | c?.viewWithTag(2)?.alpha = 0.0 276 | c?.isUserInteractionEnabled = true 277 | }) 278 | 279 | return 280 | } 281 | 282 | switch peripheral.state { 283 | case .connecting: fallthrough 284 | case .connected: 285 | unregister(peripheral) 286 | 287 | default: break 288 | } 289 | } 290 | 291 | func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { 292 | finish() 293 | } 294 | 295 | func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { 296 | connect(peripheral) 297 | } 298 | 299 | func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { 300 | connect(peripheral) 301 | } 302 | 303 | override func viewDidLoad() { 304 | manager = CBCentralManager(delegate: self, queue: nil) 305 | } 306 | 307 | override func viewWillDisappear(_ animated: Bool) { 308 | manager.stopScan() 309 | for p in peripherals.reversed() { 310 | unregister(p) 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /FreeOTP/Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import UIKit 22 | 23 | enum AppColors { 24 | static let background = UIColor.theme(darkHex: "#000000", lightHex: "#F2F2F6") 25 | static let cardBackground = UIColor.theme(darkHex: "#1C1C1E", lightHex: "#FFFFFF") 26 | static let navigationBackground = UIColor.theme(darkHex: "#171717", lightHex: "#FEFEFE") 27 | static let navigationHairline = UIColor.theme(darkHex: "#262626", lightHex: "#BEBEC1") 28 | static let accent = UIColor.theme(darkHex: "#2D8FFF", lightHex: "#007AFF") 29 | static let primaryText = UIColor.theme(darkHex: "#FFFFFF", lightHex: "#1A1A1A") 30 | static let secondaryText = UIColor.theme(darkHex: "#8E8E92", lightHex: "#8E8E92") 31 | } 32 | 33 | extension UIColor { 34 | static var app = AppColors.self 35 | 36 | fileprivate static func theme(darkHex: String, lightHex: String) -> UIColor { 37 | let darkColor = UIColor(hexString: darkHex) 38 | let lightColor = UIColor(hexString: lightHex) 39 | 40 | if #available(iOS 13.0, *) { 41 | return UIColor { $0.userInterfaceStyle == .dark ? darkColor : lightColor } 42 | } else { 43 | return lightColor 44 | } 45 | } 46 | } 47 | 48 | extension UIFont { 49 | static func dynamicSystemFont(ofSize fontSize: CGFloat, weight: UIFont.Weight) -> UIFont { 50 | if Device.size == .large { 51 | return .systemFont(ofSize: fontSize + 4, weight: weight) 52 | } else if Device.size == .medium { 53 | return .systemFont(ofSize: fontSize + 2, weight: weight) 54 | } 55 | return .systemFont(ofSize: fontSize, weight: weight) 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /FreeOTP/Token.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import MobileCoreServices 23 | 24 | public final class Token : NSObject, KeychainStorable, Codable, NSItemProviderReading, NSItemProviderWriting { 25 | public static let store = KeychainStore<Token>() 26 | public let account: String 27 | 28 | public enum Kind: Int, Codable { 29 | case hotp = 0 30 | case totp = 1 31 | } 32 | 33 | enum CodingKeys: String, CodingKey { 34 | case locked 35 | case account 36 | case counter 37 | case image 38 | case imageOrig 39 | case issuer 40 | case issuerOrig 41 | case color 42 | case icon 43 | case kind 44 | case label 45 | case labelOrig 46 | case period 47 | } 48 | 49 | open class Code { 50 | fileprivate(set) open var value: String 51 | fileprivate(set) open var from: Date 52 | fileprivate(set) open var to: Date 53 | 54 | fileprivate init(_ value: String, _ from: Date, _ period: Int64) { 55 | self.value = value 56 | self.from = from 57 | self.to = from.addingTimeInterval(TimeInterval(period)) 58 | } 59 | } 60 | 61 | fileprivate var issuerOrig: String = "" 62 | fileprivate var labelOrig: String = "" 63 | fileprivate var imageOrig: String? 64 | fileprivate var counter: Int64 = 0 65 | fileprivate var period: Int64 = 30 66 | 67 | fileprivate (set) public var kind: Kind = .hotp 68 | 69 | public var locked: Bool = false { 70 | didSet { 71 | if let otp = OTP.store.load(account) { 72 | if OTP.store.erase(otp) { 73 | if OTP.store.add(otp, locked: locked) { 74 | return 75 | } 76 | } 77 | } 78 | 79 | locked = !locked 80 | } 81 | } 82 | 83 | public var codes: [Code] { 84 | if let otp = OTP.store.load(account) { 85 | let now = Date() 86 | 87 | switch kind { 88 | case .hotp: 89 | let code = Code(otp.code(counter), now, period) 90 | counter += 1 91 | if Token.store.save(self) { 92 | return [code] 93 | } 94 | 95 | case .totp: 96 | func totp(_ otp: OTP, now: Date) -> Code { 97 | let c = Int64(now.timeIntervalSince1970) / period 98 | let i = Date(timeIntervalSince1970: TimeInterval(c * period)) 99 | return Code(otp.code(c), i, period) 100 | } 101 | 102 | let next = now.addingTimeInterval(TimeInterval(period)) 103 | return [totp(otp, now: now), totp(otp, now: next)] 104 | } 105 | } 106 | 107 | return [] 108 | } 109 | 110 | @objc public var issuer: String! = nil { 111 | didSet { 112 | if issuer == nil { issuer = issuerOrig } 113 | } 114 | } 115 | 116 | @objc public var label: String! = nil { 117 | didSet { 118 | if label == nil { label = labelOrig } 119 | } 120 | } 121 | 122 | @objc public var image: String? = nil { 123 | didSet { 124 | if image == nil { image = imageOrig } 125 | } 126 | } 127 | 128 | var color: String? 129 | 130 | var icon: String? 131 | 132 | public init?(otp: OTP, urlc: URLComponents, load: Bool = false) { 133 | self.account = otp.account 134 | super.init() 135 | 136 | if urlc.scheme != "otpauth" || urlc.host == nil { 137 | return nil 138 | } 139 | 140 | // Get kind 141 | switch urlc.host!.lowercased() { 142 | case "totp": 143 | kind = .totp 144 | 145 | case "hotp": 146 | kind = .hotp 147 | 148 | default: 149 | return nil 150 | } 151 | 152 | // Normalize path 153 | var path = urlc.path 154 | while path.hasPrefix("/") { 155 | path = String(path[path.index(path.startIndex, offsetBy: 1)...]) 156 | } 157 | 158 | if path == "" { 159 | return nil 160 | } 161 | 162 | // Get issuer and label 163 | let comps = path.components(separatedBy: ":") 164 | issuer = comps[0] 165 | label = comps.count > 1 ? comps[1] : "" 166 | 167 | let query = urlc.queryItems 168 | if (query == nil) { return nil } 169 | for item: URLQueryItem in query! { 170 | if item.value == nil { continue } 171 | 172 | switch item.name.lowercased() { 173 | case "period": 174 | if let tmp = Int64(item.value!) { 175 | if tmp < 5 { 176 | return nil 177 | } 178 | 179 | period = tmp 180 | } 181 | 182 | case "counter": 183 | if let tmp = Int64(item.value!) { 184 | if tmp < 0 { 185 | return nil 186 | } 187 | 188 | counter = tmp 189 | } 190 | 191 | case "lock": 192 | switch item.value!.lowercased() { 193 | case "": fallthrough 194 | case "0": fallthrough 195 | case "off": fallthrough 196 | case "false": 197 | locked = false 198 | 199 | default: 200 | locked = Token.store.lockingSupported 201 | } 202 | 203 | case "image": 204 | image = item.value! 205 | if !load { image = item.value! } 206 | 207 | case "issuerorig": 208 | if !load { issuerOrig = item.value! } 209 | 210 | case "color": 211 | color = item.value! 212 | 213 | case "icon": 214 | icon = item.value! 215 | 216 | case "nameorig": 217 | if !load { labelOrig = item.value! } 218 | 219 | case "imageorig": 220 | if !load { imageOrig = item.value! } 221 | 222 | default: 223 | continue 224 | } 225 | } 226 | 227 | if load { 228 | // This works around a bug where we stored a URL to the default image, 229 | // but this changed with the app id. 230 | if image != nil && image!.hasPrefix("file:") && image!.hasSuffix("/FreeOTP.app/default.png") { 231 | image = nil 232 | } 233 | if imageOrig != nil && imageOrig!.hasPrefix("file:") && imageOrig!.hasSuffix("/FreeOTP.app/default.png") { 234 | imageOrig = nil 235 | } 236 | } else { 237 | imageOrig = image 238 | issuerOrig = issuer 239 | labelOrig = label 240 | } 241 | } 242 | 243 | // Conform to NSItemProvider Protocols 244 | public static var writableTypeIdentifiersForItemProvider: [String] { 245 | return [(kUTTypeData) as String] 246 | } 247 | 248 | public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { 249 | 250 | let progress = Progress(totalUnitCount: 100) 251 | 252 | do { 253 | let encoder = JSONEncoder() 254 | encoder.outputFormatting = .prettyPrinted 255 | let data = try encoder.encode(self) 256 | _ = String(data: data, encoding: String.Encoding.utf8) 257 | progress.completedUnitCount = 100 258 | completionHandler(data, nil) 259 | } catch { 260 | completionHandler(nil, error) 261 | } 262 | 263 | return progress 264 | } 265 | 266 | public static var readableTypeIdentifiersForItemProvider: [String] { 267 | return [(kUTTypeData) as String] 268 | } 269 | 270 | public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> Token { 271 | let decoder = JSONDecoder() 272 | do { 273 | let tokenjson = try decoder.decode(Token.self, from: data) 274 | return tokenjson 275 | } catch { 276 | fatalError("Error decoding token object") 277 | } 278 | } 279 | 280 | @objc required public init?(coder aDecoder: NSCoder) { 281 | locked = aDecoder.decodeBool(forKey: "locked") 282 | account = aDecoder.decodeObject(forKey: "account") as! String 283 | counter = aDecoder.decodeInt64(forKey: "counter") 284 | image = aDecoder.decodeObject(forKey: "image") as? String 285 | imageOrig = aDecoder.decodeObject(forKey: "imageOrig") as? String 286 | issuer = aDecoder.decodeObject(forKey: "issuer") as? String 287 | issuerOrig = aDecoder.decodeObject(forKey: "issuerOrig") as! String 288 | color = aDecoder.decodeObject(forKey: "color") as? String 289 | icon = aDecoder.decodeObject(forKey: "icon") as? String 290 | kind = Kind(rawValue: aDecoder.decodeInteger(forKey: "kind"))! 291 | label = aDecoder.decodeObject(forKey: "label") as? String 292 | labelOrig = aDecoder.decodeObject(forKey: "labelOrig") as! String 293 | period = aDecoder.decodeInt64(forKey: "period") 294 | 295 | super.init() 296 | } 297 | 298 | @objc public func encode(with aCoder: NSCoder) { 299 | aCoder.encode(locked, forKey: "locked") 300 | aCoder.encode(account, forKey: "account") 301 | aCoder.encode(counter, forKey: "counter") 302 | aCoder.encode(image, forKey: "image") 303 | aCoder.encode(imageOrig, forKey: "imageOrig") 304 | aCoder.encode(issuer, forKey: "issuer") 305 | aCoder.encode(issuerOrig, forKey: "issuerOrig") 306 | aCoder.encode(color, forKey: "color") 307 | aCoder.encode(icon, forKey: "icon") 308 | aCoder.encode(kind.rawValue, forKey: "kind") 309 | aCoder.encode(label, forKey: "label") 310 | aCoder.encode(labelOrig, forKey: "labelOrig") 311 | aCoder.encode(period, forKey: "period") 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /FreeOTP/TokenCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import TinyConstraints 22 | import UIKit 23 | 24 | protocol TokenCellDelegate: AnyObject { 25 | func share(token: Token, sender: UIView?) 26 | } 27 | 28 | class TokenCell: UICollectionViewCell { 29 | static let identifier = "TokenCell" 30 | 31 | private let defaultIcon = UIImage(contentsOfFile: Bundle.main.path(forResource: "default", ofType: "png")!) 32 | 33 | private(set) lazy var imageView: UIImageView = { 34 | let view = UIImageView() 35 | view.contentMode = .scaleAspectFill 36 | view.image = defaultIcon 37 | return view 38 | }() 39 | 40 | private(set) lazy var outerProgressView = CircleProgressView() 41 | 42 | private(set) lazy var innerProgressView: CircleProgressView = { 43 | let view = CircleProgressView() 44 | view.hollow = false 45 | view.threshold = -0.25 46 | return view 47 | }() 48 | 49 | private(set) lazy var lockImagView: UIImageView = { 50 | let view = UIImageView() 51 | view.image = UIImage(named: "LockIcon") 52 | view.tintColor = UIColor.app.cardBackground 53 | view.contentMode = .scaleAspectFit 54 | return view 55 | }() 56 | 57 | private(set) lazy var stackView: UIStackView = { 58 | let view = UIStackView() 59 | view.axis = .vertical 60 | view.distribution = .fill 61 | view.spacing = 2 62 | return view 63 | }() 64 | 65 | private(set) lazy var issuerLabel: UILabel = { 66 | let view = UILabel() 67 | view.font = .dynamicSystemFont(ofSize: 16, weight: .regular) 68 | view.textColor = UIColor.app.primaryText 69 | view.setCompressionResistance(.init(751), for: .vertical) 70 | return view 71 | }() 72 | 73 | private(set) lazy var subtitleLabel: UILabel = { 74 | let view = UILabel() 75 | view.font = .dynamicSystemFont(ofSize: 14, weight: .regular) 76 | view.textColor = UIColor.app.secondaryText 77 | view.numberOfLines = 0 78 | return view 79 | }() 80 | 81 | private(set) lazy var shareButton: UIButton = { 82 | let view = UIButton(type: .system) 83 | view.tintColor = UIColor.app.accent 84 | view.setImage(UIImage(named: "ShareIcon"), for: .normal) 85 | return view 86 | }() 87 | 88 | private(set) lazy var codeLabel: UILabel = { 89 | let view = UILabel() 90 | view.adjustsFontSizeToFitWidth = true 91 | view.baselineAdjustment = .alignCenters 92 | view.font = .monospacedDigitSystemFont(ofSize: 100, weight: .regular) 93 | view.minimumScaleFactor = 0.2 94 | view.textAlignment = .center 95 | view.textColor = UIColor.app.primaryText 96 | return view 97 | }() 98 | 99 | var timer: Timer? 100 | var state: [Token.Code]? { 101 | didSet { animate() } 102 | } 103 | 104 | var token: Token? { 105 | didSet { 106 | guard let token = token else { return } 107 | lockImagView.isHidden = !token.locked 108 | outerProgressView.isHidden = token.kind != .totp 109 | issuerLabel.text = token.issuer 110 | subtitleLabel.text = token.label 111 | } 112 | } 113 | 114 | weak var delegate: TokenCellDelegate? 115 | 116 | override init(frame: CGRect) { 117 | super.init(frame: frame) 118 | setup() 119 | } 120 | 121 | required init?(coder: NSCoder) { 122 | super.init(coder: coder) 123 | setup() 124 | } 125 | 126 | private func setup() { 127 | contentView.backgroundColor = UIColor.app.cardBackground 128 | contentView.layer.cornerRadius = 10 129 | contentView.layer.masksToBounds = true 130 | 131 | contentView.addSubview(imageView) 132 | contentView.addSubview(outerProgressView) 133 | contentView.addSubview(innerProgressView) 134 | contentView.addSubview(lockImagView) 135 | contentView.addSubview(stackView) 136 | contentView.addSubview(shareButton) 137 | contentView.addSubview(codeLabel) 138 | 139 | stackView.addArrangedSubview(issuerLabel) 140 | stackView.addArrangedSubview(subtitleLabel) 141 | 142 | imageView.topToSuperview() 143 | imageView.rtlLeftToSuperview() 144 | imageView.bottomToSuperview() 145 | imageView.widthToHeight(of: imageView) 146 | 147 | outerProgressView.center(in: imageView) 148 | outerProgressView.size(to: imageView, multiplier: 1 / 2) 149 | 150 | innerProgressView.center(in: imageView) 151 | innerProgressView.size(to: imageView, multiplier: 5 / 12) 152 | 153 | lockImagView.rtlLeftToSuperview(offset: 4) 154 | lockImagView.bottomToSuperview(offset: -4) 155 | lockImagView.size(CGSize(width: 14, height: 14)) 156 | 157 | stackView.rtlLeftToRight(of: imageView, offset: 12) 158 | stackView.rtlRightToLeft(of: shareButton, offset: -12) 159 | stackView.centerYToSuperview() 160 | 161 | shareButton.rtlRightToSuperview(offset: -12) 162 | shareButton.centerYToSuperview() 163 | shareButton.size(CGSize(width: 24, height: 24)) 164 | 165 | codeLabel.topToSuperview() 166 | codeLabel.rtlLeftToRight(of: imageView, offset: 12) 167 | codeLabel.rtlRightToSuperview(offset: -12) 168 | codeLabel.bottomToSuperview() 169 | 170 | shareButton.addTarget(self, action: #selector(share), for: .touchUpInside) 171 | } 172 | 173 | @objc private func share() { 174 | if let token = token { 175 | delegate?.share(token: token, sender: shareButton) 176 | } 177 | } 178 | 179 | override func prepareForReuse() { 180 | super.prepareForReuse() 181 | imageView.image = defaultIcon 182 | } 183 | 184 | private func animate() { 185 | let showToken: Bool 186 | 187 | if state == nil || state?.count == 0 { 188 | timer?.invalidate() 189 | showToken = false 190 | } else if timer == nil || !timer!.isValid { 191 | timer = Timer.scheduledTimer( 192 | timeInterval: 0.1, 193 | target: self, 194 | selector: #selector(timerCallback), 195 | userInfo: nil, 196 | repeats: true 197 | ) 198 | 199 | showToken = true 200 | } else { 201 | return 202 | } 203 | 204 | UIView.animate( 205 | withDuration: 0.5, 206 | animations: { 207 | self.issuerLabel.alpha = showToken ? 0 : 1 208 | self.subtitleLabel.alpha = showToken ? 0 : 1 209 | self.innerProgressView.alpha = showToken ? 1 : 0 210 | self.outerProgressView.alpha = showToken ? 1 : 0 211 | self.imageView.alpha = showToken ? 0.4 : 1 212 | self.codeLabel.alpha = showToken ? 1 : 0 213 | self.shareButton.alpha = showToken ? 0 : 1 214 | self.lockImagView.alpha = showToken ? 0 : 1 215 | }, 216 | completion: { _ in 217 | if showToken == false { 218 | self.outerProgressView.progress = 0.0 219 | self.innerProgressView.progress = 0.0 220 | self.codeLabel.text = "" 221 | } 222 | } 223 | ) 224 | } 225 | 226 | fileprivate func progress(_ start: Date, _ point: Date, _ end: Date) -> CGFloat { 227 | let s = start.timeIntervalSince1970 228 | let p = point.timeIntervalSince1970 229 | let e = end.timeIntervalSince1970 230 | return 1.0 - CGFloat((p - s) / (e - s)) 231 | } 232 | 233 | @objc func timerCallback() { 234 | let state = self.state ?? [] 235 | let first = state.first 236 | let last = state.last 237 | 238 | var curr: Token.Code? 239 | 240 | let now = Date() 241 | 242 | for c in state { 243 | if c.from.timeIntervalSince1970 <= now.timeIntervalSince1970 && now.timeIntervalSince1970 < c.to.timeIntervalSince1970 { 244 | curr = c 245 | break 246 | } 247 | } 248 | 249 | if let curr = curr, let first = first, let last = last { 250 | innerProgressView.progress = progress(curr.from as Date, now, curr.to as Date) 251 | outerProgressView.progress = progress(first.from as Date, now, last.to as Date) 252 | 253 | UIView.animate( 254 | withDuration: 0.2, 255 | delay: 0, 256 | options: .transitionCrossDissolve, 257 | animations: { self.codeLabel.text = curr.value }, 258 | completion: nil 259 | ) 260 | } else { 261 | self.state = nil 262 | } 263 | } 264 | 265 | override func updateConstraints() { 266 | let base: CGFloat = frame.size.height / 8 * 1.5 267 | issuerLabel.font = issuerLabel.font.withSize(base * 0.85) 268 | subtitleLabel.font = subtitleLabel.font.withSize(base * 0.80) 269 | super.updateConstraints() 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /FreeOTP/TokenStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import Security 23 | 24 | open class TokenStore : NSObject { 25 | @objc(_TokenOrder) fileprivate final class TokenOrder : NSObject, KeychainStorable { 26 | static let ACCOUNT = "09E969FC-53C3-4BE2-B653-4802949A26A7" 27 | static let store = KeychainStore<TokenOrder>() 28 | let account = ACCOUNT 29 | let array: NSMutableArray 30 | 31 | override init() { 32 | array = NSMutableArray() 33 | super.init() 34 | } 35 | 36 | @objc init?(coder aDecoder: NSCoder) { 37 | array = aDecoder.decodeObject(forKey: "array") as! NSMutableArray 38 | } 39 | 40 | @objc fileprivate func encode(with aCoder: NSCoder) { 41 | aCoder.encode(array, forKey: "array") 42 | } 43 | } 44 | 45 | open var count: Int { 46 | if let ord = TokenOrder.store.load(TokenOrder.ACCOUNT) { 47 | return ord.array.count 48 | } 49 | 50 | return 0 51 | } 52 | 53 | public override init() { 54 | super.init() 55 | 56 | // Migrate UserDefaults tokens to Keyring tokens 57 | let def = UserDefaults.standard 58 | if var keys = def.stringArray(forKey: "tokenOrder") { 59 | var remove = [String]() 60 | 61 | for key in keys.reversed() { 62 | if let url = def.string(forKey: key) { 63 | if let urlc = URLComponents(string: url) { 64 | if add(urlc) != nil { 65 | def.removeObject(forKey: key) 66 | remove.append(key) 67 | } 68 | } 69 | } 70 | } 71 | 72 | for key in remove { 73 | keys.remove(at: keys.firstIndex(of: key)!) 74 | } 75 | 76 | if keys.count == 0 { 77 | def.removeObject(forKey: "tokenOrder") 78 | } 79 | } 80 | } 81 | 82 | func getAllTokens() -> [Token] { 83 | let orderedTokens = TokenOrder.store.load(TokenOrder.ACCOUNT) 84 | return orderedTokens != nil ? orderedTokens!.array.map { Token.store.load($0 as! String)! } : [] 85 | } 86 | 87 | @discardableResult open func add(_ urlc: URLComponents) -> Token? { 88 | var ord: TokenOrder 89 | if let a = TokenOrder.store.load(TokenOrder.ACCOUNT) { 90 | ord = a 91 | } else { 92 | ord = TokenOrder() 93 | if !TokenOrder.store.add(ord) { 94 | return nil 95 | } 96 | } 97 | 98 | if let otp = OTP(urlc: urlc) { 99 | if let token = Token(otp: otp, urlc: urlc) { 100 | ord.array.insert(otp.account, at: 0) 101 | if OTP.store.add(otp, locked: token.locked) { 102 | if Token.store.add(token) { 103 | if TokenOrder.store.save(ord) { 104 | return token 105 | } else { 106 | Token.store.erase(token) 107 | OTP.store.erase(otp) 108 | } 109 | } else { 110 | OTP.store.erase(otp) 111 | } 112 | } 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | 119 | @discardableResult open func erase(index: Int) -> Bool { 120 | if let ord = TokenOrder.store.load(TokenOrder.ACCOUNT) { 121 | if let account = ord.array.object(at: index) as? String { 122 | ord.array.removeObject(at: index) 123 | if TokenOrder.store.save(ord) { 124 | Token.store.erase(account) 125 | OTP.store.erase(account) 126 | return true 127 | } 128 | } 129 | } 130 | 131 | return false 132 | } 133 | 134 | @discardableResult open func erase(token: Token) -> Bool { 135 | if let ord = TokenOrder.store.load(TokenOrder.ACCOUNT) { 136 | return erase(index: ord.array.index(of: token.account)) 137 | } 138 | 139 | return false 140 | } 141 | 142 | open func load(_ index: Int) -> Token? { 143 | if let ord = TokenOrder.store.load(TokenOrder.ACCOUNT) { 144 | if let account = ord.array.object(at: index) as? String { 145 | return Token.store.load(account) 146 | } 147 | } 148 | 149 | return nil 150 | } 151 | 152 | @discardableResult open func move(_ from: Int, to: Int) -> Bool { 153 | if let ord = TokenOrder.store.load(TokenOrder.ACCOUNT) { 154 | if let id = ord.array.object(at: from) as? String { 155 | ord.array.removeObject(at: from) 156 | ord.array.insert(id, at: to) 157 | 158 | return TokenOrder.store.save(ord) 159 | } 160 | } 161 | 162 | return false 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /FreeOTP/TokensViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import UIKit 23 | 24 | class TokensViewController : UICollectionViewController, UICollectionViewDelegateFlowLayout, UIPopoverPresentationControllerDelegate, 25 | UICollectionViewDragDelegate, UICollectionViewDropDelegate { 26 | 27 | let defaultIcon = UIImage(contentsOfFile: Bundle.main.path(forResource: "default", ofType: "png")!) 28 | fileprivate var lastPath: IndexPath? = nil 29 | fileprivate var store = TokenStore() 30 | var icon = TokenIcon() 31 | 32 | @IBOutlet weak var aboutButton: UIBarButtonItem! 33 | @IBOutlet weak var scanButton: UIBarButtonItem! 34 | @IBOutlet weak var addButton: UIBarButtonItem! 35 | 36 | private lazy var emptyStateView = EmptyStateView() 37 | var searchController: UISearchController! 38 | 39 | // the tokens array 40 | private var tokensArray: [Token]! = [] // contains all the tokens as loaded from the store 41 | private var searchedTokensArray: [Token]! = [] // contains the filtered tokens 42 | 43 | // search params 44 | private var searchingTokens = false 45 | 46 | override func numberOfSections(in collectionView: UICollectionView) -> Int { 47 | return 1 48 | } 49 | 50 | override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 51 | return searchingTokens ? searchedTokensArray.count : store.count 52 | } 53 | 54 | override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 55 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TokenCell.identifier, for: indexPath) as! TokenCell 56 | 57 | let size = self.collectionView(collectionView, layout: collectionView.collectionViewLayout, sizeForItemAt: indexPath) 58 | let imageSize = CGSize(width: size.height, height: size.height) 59 | 60 | if let token = getTokenAtIndex(tokenIndex: indexPath.row) { 61 | 62 | cell.state = nil 63 | var iconName = "" 64 | 65 | if let image = token.image { 66 | if image.hasSuffix("/FreeOTP.app/default.png") { 67 | cell.imageView.image = defaultIcon 68 | } else { 69 | ImageDownloader(imageSize).fromURI(token.image, cell.imageView, completion: { 70 | (image: UIImage) -> Void in 71 | UIView.animate(withDuration: 0.3, animations: { 72 | cell.imageView.image = image.addImagePadding(x: 30, y: 30) 73 | }) 74 | }) 75 | } 76 | } else { 77 | // Retrieve and use saved issuer -> icon mapping in User Defaults 78 | if let custIcon = icon.getCustomIcon(issuer: token.issuer, size: imageSize) { 79 | cell.imageView.image = custIcon.iconImg.addImagePadding(x: 30, y: 30) 80 | iconName = custIcon.name 81 | // Issuer matches an icon name brand 82 | } else if let faIcon = icon.faIconExists(for: token.issuer) { 83 | let image = UIImage.fontAwesomeIcon(faName: faIcon.name, faType: faIcon.type, textColor: .white, size: imageSize) 84 | cell.imageView.image = image.addImagePadding(x: 30, y: 30) 85 | iconName = faIcon.name 86 | } 87 | } 88 | 89 | cell.imageView.backgroundColor = icon.getBackgroundColor(name: iconName) 90 | 91 | cell.token = token 92 | cell.delegate = self 93 | } 94 | 95 | return cell 96 | } 97 | 98 | func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { 99 | reloadData() 100 | } 101 | 102 | fileprivate func next<T: UIViewController>(_ name: String, sender: AnyObject?, dir: UIPopoverArrowDirection) -> T { 103 | switch UI_USER_INTERFACE_IDIOM() { 104 | case .pad: 105 | let vc = storyboard!.instantiateViewController(withIdentifier: name + "Nav") as! UINavigationController 106 | 107 | vc.modalPresentationStyle = .popover 108 | vc.popoverPresentationController?.delegate = self 109 | vc.popoverPresentationController?.permittedArrowDirections = dir 110 | 111 | switch sender { 112 | case let b as UIBarButtonItem: 113 | vc.popoverPresentationController?.barButtonItem = b 114 | case let v as UIView: 115 | vc.popoverPresentationController?.sourceView = v 116 | vc.popoverPresentationController?.sourceRect = v.bounds 117 | default: 118 | break 119 | } 120 | 121 | presentedViewController?.dismiss(animated: true, completion: nil) 122 | present(vc, animated: true, completion: nil) 123 | return vc.topViewController! as! T 124 | 125 | default: 126 | let ret = storyboard?.instantiateViewController(withIdentifier: name) as! T 127 | navigationController?.pushViewController(ret, animated: true) 128 | return ret 129 | } 130 | } 131 | 132 | @IBAction func scanClicked(_ sender: UIBarButtonItem) { 133 | showScanScreen(sender) 134 | } 135 | 136 | @IBAction func addClicked(_ sender: UIBarButtonItem) { 137 | showAddScreen(sender) 138 | } 139 | 140 | private func showScanScreen(_ sender: AnyObject) { 141 | let vc: UIViewController = self.next("scan", sender: sender, dir: [.up, .down]) 142 | vc.preferredContentSize = CGSize( 143 | width: UIScreen.main.bounds.width / 2, 144 | height: vc.preferredContentSize.height 145 | ) 146 | } 147 | 148 | private func showAddScreen(_ sender: AnyObject) { 149 | let vc: UIViewController = self.next("new", sender: sender, dir: [.up, .down]) 150 | vc.preferredContentSize = CGSize( 151 | width: UIScreen.main.bounds.width / 2, 152 | height: vc.preferredContentSize.height 153 | ) 154 | } 155 | 156 | override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 157 | collectionView.deselectItem(at: indexPath, animated: true) 158 | 159 | if let cell = collectionView.cellForItem(at: indexPath) as! TokenCell? { 160 | if let token = getTokenAtIndex(tokenIndex: indexPath.row) { 161 | cell.state = token.codes 162 | } 163 | } 164 | } 165 | 166 | override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { 167 | super.viewWillTransition(to: size, with: coordinator) 168 | collectionView.collectionViewLayout.invalidateLayout() 169 | } 170 | 171 | func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { 172 | var numCols: CGFloat = 1 173 | 174 | let o = UIApplication.shared.statusBarOrientation 175 | if o == .landscapeLeft || o == .landscapeRight { 176 | numCols += 1 177 | } 178 | 179 | if UI_USER_INTERFACE_IDIOM() == .pad { 180 | numCols += 1 181 | } 182 | 183 | let width = (collectionViewLayout as! UICollectionViewFlowLayout).columnWidth(collectionView, numCols: numCols) 184 | return CGSize(width: width, height: width / 3.25); 185 | } 186 | 187 | // Drag and drop delegate methods 188 | func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { 189 | // no need for drag and drop when searching 190 | if searchingTokens == false { 191 | if let token = store.load(indexPath.row) { 192 | let itemProvider = NSItemProvider(object: token) 193 | let dragItem = UIDragItem(itemProvider: itemProvider) 194 | return [dragItem] 195 | } else { 196 | return [] 197 | } 198 | } else { 199 | return [] 200 | } 201 | } 202 | 203 | func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal { 204 | return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) 205 | } 206 | 207 | func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) { 208 | guard let dstIndex = coordinator.destinationIndexPath else { return } 209 | 210 | for item in coordinator.items { 211 | // Drag item originated in the collection view 212 | if let srcIndex = item.sourceIndexPath { 213 | store.move(srcIndex.row, to: dstIndex.row) 214 | collectionView.moveItem(at: srcIndex, to: dstIndex) 215 | coordinator.drop(item.dragItem, toItemAt: dstIndex) 216 | } else { 217 | // Drag and drop from other apps not implemented 218 | } 219 | } 220 | } 221 | 222 | @objc func handleSwipe(_ gestureRecognizer: UISwipeGestureRecognizer) { 223 | if gestureRecognizer.state == .ended { 224 | let p = gestureRecognizer.location(in: collectionView) 225 | if let currPath = collectionView?.indexPathForItem(at: p) { 226 | if let cell = collectionView?.cellForItem(at: currPath) { 227 | if let token = getTokenAtIndex(tokenIndex: currPath.row) { 228 | UIView.animate(withDuration: 0.5, animations: { 229 | cell.transform = CGAffineTransform(translationX: 1200, y: 0) 230 | }, completion: { (Bool) -> Void in 231 | let actionSheetController: UIAlertController = UIAlertController(title: token.issuer, message: token.label, preferredStyle: .actionSheet) 232 | 233 | let removeAction: UIAlertAction = UIAlertAction(title: "Remove token", style: .destructive) { action -> Void in 234 | TokenStore().erase(token: token) 235 | var array = [IndexPath]() 236 | array.append(currPath) 237 | 238 | if self.searchingTokens { 239 | self.searchedTokensArray.remove(at: currPath.row) 240 | self.collectionView.deleteItems(at: array) 241 | 242 | } else { 243 | self.collectionView.deleteItems(at: array) 244 | } 245 | 246 | // also reload the tokens array 247 | self.tokensArray = self.store.getAllTokens() 248 | } 249 | 250 | let cancelAction: UIAlertAction = UIAlertAction(title: "Cancel", style: .cancel) { action -> Void in 251 | UIView.animate(withDuration: 0.3, animations: { 252 | cell.transform = .identity 253 | }) 254 | self.reloadData() 255 | } 256 | 257 | actionSheetController.addAction(removeAction) 258 | actionSheetController.addAction(cancelAction) 259 | 260 | /* Handle iPad popover */ 261 | if let popoverController = actionSheetController.popoverPresentationController { 262 | popoverController.sourceView = self.view 263 | popoverController.sourceRect = CGRect(x: self.view.bounds.midX, y: self.view.bounds.maxY, width: 0, height: 0) 264 | } 265 | self.present(actionSheetController, animated: true) 266 | }) 267 | } 268 | } 269 | } 270 | } 271 | } 272 | 273 | @IBAction func unwindToTokens(_ sender: UIStoryboardSegue) { 274 | reloadData() 275 | } 276 | 277 | override func viewDidLoad() { 278 | super.viewDidLoad() 279 | 280 | if #available(iOS 13.0, *) { 281 | aboutButton.image = UIImage(systemName: "info.circle") 282 | } else { 283 | aboutButton.image = UIImage.fontAwesomeIcon(faName: "fa-info-circle", faType: .solid, textColor: .white) 284 | } 285 | 286 | scanButton.accessibilityIdentifier = "scanButton" 287 | 288 | if #available(iOS 13.0, *) { 289 | addButton.image = UIImage(systemName: "plus") 290 | } else { 291 | addButton.image = UIImage.fontAwesomeIcon(faName: "fa-plus", faType: .solid, textColor: .white) 292 | } 293 | 294 | addButton.accessibilityIdentifier = "manualAddButton" 295 | 296 | // Setup collection view. 297 | collectionView?.backgroundColor = UIColor.app.background 298 | collectionView?.alwaysBounceVertical = true 299 | collectionView?.allowsSelection = true 300 | collectionView?.allowsMultipleSelection = false 301 | collectionView?.register(TokenCell.self, forCellWithReuseIdentifier: TokenCell.identifier) 302 | collectionView?.dragDelegate = self 303 | collectionView?.dropDelegate = self 304 | collectionView.dragInteractionEnabled = true 305 | 306 | if #available(iOS 11.0, *) { 307 | collectionView?.contentInsetAdjustmentBehavior = .always 308 | } 309 | 310 | // EmptyState 311 | emptyStateView.alpha = 0 312 | emptyStateView.addToken = { self.showScanScreen(self.emptyStateView.addTokenButton) } 313 | view.addSubview(emptyStateView) 314 | emptyStateView.topToSuperview() 315 | emptyStateView.rtlLeftToSuperview() 316 | emptyStateView.rtlRightToSuperview() 317 | emptyStateView.bottomToSuperview() 318 | 319 | let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(self.handleSwipe)) 320 | collectionView?.addGestureRecognizer(swipeGesture) 321 | } 322 | 323 | override func viewWillAppear(_ animated: Bool) { 324 | super.viewWillAppear(animated) 325 | configureSearchBar() 326 | reloadData() 327 | } 328 | 329 | func reloadData() { 330 | collectionView?.reloadData() 331 | 332 | UIView.animate(withDuration: 0.25) { 333 | self.emptyStateView.alpha = self.store.count == 0 ? 1 : 0 334 | } 335 | } 336 | 337 | private func configureSearchBar() { 338 | searchController = UISearchController(searchResultsController: nil) 339 | searchController.searchResultsUpdater = self 340 | searchController.obscuresBackgroundDuringPresentation = false 341 | definesPresentationContext = true 342 | searchController.hidesNavigationBarDuringPresentation = true 343 | 344 | searchController.searchBar.delegate = self 345 | searchController.searchBar.sizeToFit() 346 | searchController.searchBar.tintColor = UIColor.app.accent 347 | searchController.searchBar.isAccessibilityElement = false 348 | searchController.searchBar.placeholder = "Search Tokens" 349 | 350 | navigationItem.searchController = searchController 351 | } 352 | 353 | // helper func to return token at a certain position depending on the state of the UICollectionView 354 | private func getTokenAtIndex(tokenIndex: Int) -> Token? { 355 | return searchingTokens ? searchedTokensArray[tokenIndex] : store.load(tokenIndex) 356 | } 357 | } 358 | 359 | extension TokensViewController: TokenCellDelegate { 360 | func share(token: Token, sender: UIView?) { 361 | let svc: ShareViewController = self.next("share", sender: sender, dir: [.left, .right]) 362 | svc.token = token 363 | } 364 | } 365 | 366 | extension TokensViewController: UISearchResultsUpdating { 367 | func updateSearchResults(for searchController: UISearchController) { 368 | let searchText = searchController.searchBar.text! 369 | if searchText.isEmpty { 370 | tokensArray = store.getAllTokens() 371 | searchedTokensArray = tokensArray 372 | } else { 373 | searchingTokens = true 374 | searchedTokensArray = tokensArray.filter { 375 | $0.issuer.lowercased().contains(searchText.lowercased()) 376 | || $0.label.lowercased().contains(searchText.lowercased()) 377 | } 378 | } 379 | reloadData() 380 | } 381 | } 382 | 383 | extension TokensViewController: UISearchBarDelegate { 384 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { 385 | searchingTokens = false 386 | tokensArray.removeAll() 387 | searchedTokensArray.removeAll() 388 | } 389 | } 390 | -------------------------------------------------------------------------------- /FreeOTP/UICollectionViewFlowLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import UIKit 23 | 24 | extension UICollectionViewFlowLayout { 25 | private var isLandscape: Bool { 26 | let orientation = UIApplication.shared.statusBarOrientation 27 | return orientation == .landscapeLeft || orientation == .landscapeRight 28 | } 29 | 30 | func columnWidth(_ collectionView: UICollectionView, numCols: CGFloat) -> CGFloat { 31 | var width = collectionView.frame.size.width 32 | 33 | if #available(iOS 11.0, *), isLandscape { 34 | let window = UIApplication.shared.keyWindow 35 | width -= window?.safeAreaInsets.left ?? 0 36 | width -= window?.safeAreaInsets.right ?? 0 37 | } 38 | 39 | let ispace = minimumInteritemSpacing * (numCols - 1) 40 | let ospace = sectionInset.left + sectionInset.right 41 | return (width - ispace - ospace) / numCols 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /FreeOTP/URIIconViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URIIconViewController.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 2/10/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class URIIconViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UISearchBarDelegate { 12 | // MARK: - Properties 13 | struct selectedIcon { 14 | var name: String = "" 15 | var faName: String = "" 16 | var type: faIconType = .brands 17 | } 18 | 19 | var inputUrlc = URLComponents() 20 | var outputUrlc = URLComponents() 21 | var URI = URIParameters() 22 | var icon = TokenIcon() 23 | var selectedIndexPath = IndexPath() 24 | var selectedIconfaName = "" 25 | var suggestedIcons = [IconMatch]() 26 | var selection = selectedIcon() 27 | var uriColor = "" 28 | var brandIcons = [String]() 29 | var solidIcons = [String]() 30 | 31 | // MARK: - Outlets 32 | @IBOutlet weak var iconCollectionView: UICollectionView! { 33 | didSet { 34 | iconCollectionView.delegate = self 35 | iconCollectionView.dataSource = self 36 | } 37 | } 38 | 39 | @IBOutlet weak var recommendedIconCollectionView: UICollectionView! { 40 | didSet { 41 | recommendedIconCollectionView.delegate = self 42 | recommendedIconCollectionView.dataSource = self 43 | } 44 | } 45 | @IBOutlet weak var iconSearchBar: UISearchBar! 46 | @IBOutlet weak var recommendedLabel: UILabel! 47 | 48 | // MARK: - Search Bar 49 | func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 50 | if searchText == "" { 51 | brandIcons = icon.iconsBrand 52 | solidIcons = icon.iconsSolid 53 | } else { 54 | brandIcons = icon.iconsBrand.filter { $0.contains(searchText.lowercased()) } 55 | solidIcons = icon.iconsSolid.filter { $0.contains(searchText.lowercased()) } 56 | } 57 | iconCollectionView.reloadData() 58 | } 59 | 60 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) 61 | { 62 | searchBar.endEditing(true) 63 | } 64 | 65 | // MARK: - Collection View 66 | func numberOfSections(in collectionView: UICollectionView) -> Int { 67 | if collectionView == self.recommendedIconCollectionView { 68 | return 1 69 | } else { 70 | return 2 71 | } 72 | } 73 | 74 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 75 | if collectionView == self.recommendedIconCollectionView { 76 | return suggestedIcons.count 77 | } else { 78 | switch section { 79 | case 0: 80 | return brandIcons.count 81 | case 1: 82 | return solidIcons.count 83 | default: 84 | break 85 | } 86 | } 87 | 88 | return 0 89 | } 90 | 91 | func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 92 | var cell = UICollectionViewCell() 93 | let size = CGSize(width: 50, height: 50) 94 | 95 | if collectionView == self.recommendedIconCollectionView { 96 | cell = collectionView.dequeueReusableCell(withReuseIdentifier: "RecommendedIconCell", for: indexPath) 97 | 98 | if let iconCell = cell as? RecommendedIconCell { 99 | let image = UIImage.fontAwesomeIcon(faName: suggestedIcons[indexPath.item].name, faType: .brands, size: size) 100 | iconCell.iconImage.image = image.addImagePadding(x: 30, y: 30) 101 | 102 | iconCell.iconImage.backgroundColor = icon.getBackgroundColor(name: suggestedIcons[indexPath.item].name, uriColor: uriColor) 103 | } 104 | } else { 105 | cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FontAwesomeIconCell", for: indexPath) 106 | 107 | switch indexPath.section { 108 | case 0: 109 | if let iconCell = cell as? FontAwesomeIconCell { 110 | let image = UIImage.fontAwesomeIcon(faName: brandIcons[indexPath.item], faType: .brands, size: size) 111 | iconCell.iconImage.image = image.addImagePadding(x: 30, y: 30) 112 | iconCell.iconImage.backgroundColor = icon.getBackgroundColor(name: brandIcons[indexPath.item], uriColor: uriColor) 113 | } 114 | case 1: 115 | if let iconCell = cell as? FontAwesomeIconCell { 116 | let image = UIImage.fontAwesomeIcon(faName: solidIcons[indexPath.item], faType: .solid, size: size) 117 | iconCell.iconImage.image = image.addImagePadding(x: 30, y: 30) 118 | iconCell.iconImage.backgroundColor = icon.getBackgroundColor(name: solidIcons[indexPath.item], uriColor: uriColor) 119 | } 120 | default: 121 | break 122 | } 123 | } 124 | return cell 125 | } 126 | 127 | func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { 128 | 129 | if let sectionHeader = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "SectionHeaderID", for: indexPath) as? SectionHeader { 130 | 131 | if sectionHeader.sectionHeaderLabel.text != nil { 132 | var headerText = "" 133 | switch indexPath.section { 134 | case 0: 135 | headerText = "Choose an icon" 136 | case 1: 137 | headerText = "Other" 138 | default: 139 | break 140 | } 141 | sectionHeader.sectionHeaderLabel.text = headerText 142 | } 143 | return sectionHeader 144 | } 145 | return UICollectionReusableView() 146 | } 147 | 148 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 149 | // Clear previous selection 150 | let prevCell = iconCollectionView.cellForItem(at: selectedIndexPath) 151 | prevCell?.layer.borderWidth = 0 152 | prevCell?.layer.borderColor = UIColor.gray.cgColor 153 | 154 | selectedIndexPath = indexPath 155 | 156 | if collectionView == self.recommendedIconCollectionView { 157 | selection = .init(name: suggestedIcons[indexPath.item].name, faName: suggestedIcons[indexPath.item].name, type: .recommended) 158 | } else { 159 | switch indexPath.section { 160 | case 0: 161 | selection = .init(name: brandIcons[indexPath.item], faName: brandIcons[indexPath.item], type: .brands) 162 | case 1: 163 | selection = .init(name: solidIcons[indexPath.item], faName: solidIcons[indexPath.item], type: .solid) 164 | default: 165 | break 166 | } 167 | } 168 | performSegue(withIdentifier: "unwindToMainIconWithSegue", sender: self) 169 | } 170 | 171 | // MARK: - Methods 172 | func presentAlert(_ title: String, _ message: String, _ actionTitle: String) { 173 | let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) 174 | alert.addAction(UIAlertAction(title: actionTitle, style: UIAlertAction.Style.default, handler: nil)) 175 | self.present(alert, animated: true, completion: nil) 176 | } 177 | 178 | override func viewDidLoad() { 179 | super.viewDidLoad() 180 | view.accessibilityIdentifier = "uriIconView" 181 | outputUrlc = inputUrlc 182 | 183 | iconCollectionView.layer.borderColor = UIColor.darkGray.cgColor 184 | iconCollectionView.layer.borderWidth = 2.0 185 | iconCollectionView.layer.cornerRadius = 5.0 186 | 187 | iconCollectionView.allowsMultipleSelection = false 188 | 189 | suggestedIcons = icon.getSuggestions(urlc: outputUrlc) 190 | if suggestedIcons.count == 0 { 191 | recommendedIconCollectionView.isHidden = true 192 | recommendedLabel.isHidden = true 193 | } 194 | 195 | iconSearchBar.delegate = self 196 | 197 | brandIcons = icon.iconsBrand 198 | solidIcons = icon.iconsSolid 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /FreeOTP/URILabelViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URILabelViewController.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 2/7/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class URILabelViewController: UIViewController, UITextFieldDelegate { 12 | // MARK: - Properties 13 | var inputUrlc = URLComponents() 14 | var outputUrlc = URLComponents() 15 | var URI = URIParameters() 16 | var icon = TokenIcon() 17 | var account = "" 18 | 19 | // MARK: - Outlets 20 | @IBOutlet weak var issuerTextField: UITextField! 21 | 22 | // MARK: - Actions 23 | @IBAction func nextClicked(_ sender: UIBarButtonItem) { 24 | if issuerTextField.text == "" { 25 | presentAlert(title: "Issuer missing", message: "It is recommended to provide a value for the Issuer field to take advantage of FreeOTP Icon features. Do you really want to use an empty issuer value?", actionTitleAccept: "Use empty issuer", actionTitleCancel: "Cancel") 26 | } else { 27 | submitForm() 28 | } 29 | } 30 | 31 | // MARK: - UITextFieldDelegate 32 | func textFieldShouldReturn(_ textField: UITextField) -> Bool { 33 | textField.resignFirstResponder() 34 | return true 35 | } 36 | 37 | // MARK: - Navigation 38 | func pushNextViewController(_ urlc: URLComponents) -> Bool { 39 | var issuer = "" 40 | 41 | if let label = URI.getLabel(from: urlc) { 42 | issuer = label.issuer 43 | } 44 | 45 | if URI.paramUnset(urlc, "image", "") && 46 | icon.issuerBrandMapping[issuer] == nil { 47 | // Icon feature will not work, just save token 48 | if issuer == "" { 49 | return false 50 | } 51 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URIMainIconViewController") as? URIMainIconViewController { 52 | viewController.inputUrlc = urlc 53 | if let navigator = navigationController { 54 | navigator.pushViewController(viewController, animated: true) 55 | } 56 | } 57 | } else if URI.paramUnset(urlc, "lock", false) { 58 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URILockViewController") as? URILockViewController { 59 | viewController.inputUrlc = urlc 60 | if let navigator = navigationController { 61 | navigator.pushViewController(viewController, animated: true) 62 | } 63 | } 64 | } else { 65 | return false 66 | } 67 | 68 | return true 69 | } 70 | 71 | // MARK: - Methods 72 | func submitForm() { 73 | // Update URI with new label 74 | let issuer = issuerTextField.text! 75 | outputUrlc.path = "/" + issuer + ":" + account 76 | 77 | if !pushNextViewController(outputUrlc) { 78 | TokenStore().add(outputUrlc) 79 | switch UIDevice.current.userInterfaceIdiom { 80 | case .pad: 81 | dismiss(animated: true, completion: nil) 82 | popoverPresentationController?.delegate?.popoverPresentationControllerDidDismissPopover?(popoverPresentationController!) 83 | default: 84 | navigationController?.popToRootViewController(animated: true) 85 | } 86 | } 87 | } 88 | 89 | func presentAlert(title: String, message: String, actionTitle: String) { 90 | let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) 91 | alert.addAction(UIAlertAction(title: actionTitle, style: UIAlertAction.Style.default, handler: nil)) 92 | self.present(alert, animated: true, completion: nil) 93 | } 94 | 95 | func presentAlert(title: String, message: String, actionTitleAccept: String, actionTitleCancel: String) { 96 | let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) 97 | alert.addAction(UIAlertAction(title: actionTitleAccept, style: UIAlertAction.Style.default, handler: { 98 | _ in self.submitForm() 99 | })) 100 | alert.addAction(UIAlertAction(title: actionTitleCancel, style: UIAlertAction.Style.cancel, handler: { 101 | _ in return 102 | })) 103 | self.present(alert, animated: true, completion: nil) 104 | } 105 | 106 | override func viewDidLoad() { 107 | super.viewDidLoad() 108 | 109 | issuerTextField.delegate = self 110 | 111 | outputUrlc = inputUrlc 112 | if let inputParams = URI.getLabel(from: inputUrlc) { 113 | account = inputParams.account 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /FreeOTP/URILockViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URILockViewController.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 2/7/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class URILockViewController: UIViewController { 12 | // MARK: - Properties 13 | var inputUrlc = URLComponents() 14 | var outputUrlc = URLComponents() 15 | var URI = URIParameters() 16 | var icon = TokenIcon() 17 | 18 | // MARK: - Outlets 19 | @IBOutlet weak var lockSwitch: UISwitch! 20 | 21 | // MARK: - Actions 22 | @IBAction func backClicked(_ sender: UIBarButtonItem) { 23 | navigationController?.popViewController(animated: true) 24 | } 25 | @IBAction func helpClicked(_ sender: UIButton) { 26 | presentAlert(title: "Lock", message: "The lock parameter is a boolean which will ensure that the token secret is stored in such a way that it can only be accessed by a recent authentication on the device.", 27 | actionTitle: "Ok") 28 | } 29 | 30 | @IBAction func doneClicked(_ sender: UIBarButtonItem) { 31 | let newVal = lockSwitch.isOn ? "true" : "false" 32 | 33 | var queryItems: [URLQueryItem] = outputUrlc.queryItems! 34 | 35 | let newItem = URLQueryItem(name: "lock", value: newVal) 36 | 37 | if let lockVal = URI.getQueryItem(outputUrlc, "lock") { 38 | let prev = URLQueryItem(name: "lock", value: lockVal) 39 | if let index = outputUrlc.queryItems?.firstIndex(of: prev) { 40 | queryItems.remove(at: index) 41 | queryItems.append(newItem) 42 | } 43 | } else { 44 | queryItems.append(newItem) 45 | } 46 | 47 | outputUrlc.queryItems = queryItems 48 | 49 | TokenStore().add(outputUrlc) 50 | switch UIDevice.current.userInterfaceIdiom { 51 | case .pad: 52 | dismiss(animated: true, completion: nil) 53 | popoverPresentationController?.delegate?.popoverPresentationControllerDidDismissPopover?(popoverPresentationController!) 54 | default: 55 | navigationController?.popToRootViewController(animated: true) 56 | } 57 | } 58 | 59 | // MARK: - Methods 60 | func presentAlert(title: String, message: String, actionTitle: String) { 61 | let alert = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) 62 | alert.addAction(UIAlertAction(title: actionTitle, style: UIAlertAction.Style.default, handler: nil)) 63 | self.present(alert, animated: true, completion: nil) 64 | } 65 | 66 | override func viewDidLoad() { 67 | super.viewDidLoad() 68 | outputUrlc = inputUrlc 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /FreeOTP/URIMainIconViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URIMainIconViewController.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 4/28/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | class URIMainIconViewController: UIViewController { 12 | var inputUrlc = URLComponents() 13 | var outputUrlc = URLComponents() 14 | var URI = URIParameters() 15 | var suggestedIcons = [IconMatch]() 16 | var icon = TokenIcon() 17 | var uriColor = "" 18 | let levDistMax = 5 19 | let highMatchlevDist = 1 20 | var foundGoodMatch = false 21 | var iconChoice = String() 22 | 23 | // MARK: - Actions 24 | @IBAction func nextClicked(_ sender: UIBarButtonItem) { 25 | if let label = URI.getLabel(from: outputUrlc) { 26 | 27 | var color: String! 28 | 29 | if iconChoice != "" { 30 | color = icon.getBrandColorHex(iconChoice) 31 | } 32 | icon.saveMapping(issuer: label.issuer, iconName: iconChoice, iconColor: color ?? "") 33 | } 34 | 35 | if !pushNextViewController(outputUrlc) { 36 | TokenStore().add(outputUrlc) 37 | switch UIDevice.current.userInterfaceIdiom { 38 | case .pad: 39 | dismiss(animated: true, completion: nil) 40 | popoverPresentationController?.delegate?.popoverPresentationControllerDidDismissPopover?(popoverPresentationController!) 41 | default: 42 | navigationController?.popToRootViewController(animated: true) 43 | } 44 | } 45 | } 46 | 47 | @IBAction func unwindToMainIcon(segue: UIStoryboardSegue) { 48 | let source = segue.source as? URIIconViewController 49 | if let selection = source?.selection { 50 | let size = CGSize(width: 96, height: 96) 51 | 52 | switch selection.type { 53 | case .recommended: fallthrough 54 | case .brands: 55 | let image = UIImage.fontAwesomeIcon(faName: selection.faName, faType: .brands, size: size) 56 | bestIcon.image = image.addImagePadding(x: 30, y: 30) 57 | bestIcon.backgroundColor = icon.getBackgroundColor(name: selection.faName, uriColor: uriColor) 58 | 59 | case .solid: 60 | let image = UIImage.fontAwesomeIcon(faName: selection.faName, faType: .solid, size: size) 61 | bestIcon.image = image.addImagePadding(x: 30, y: 30) 62 | bestIcon.backgroundColor = icon.getBackgroundColor(name: selection.faName, uriColor: uriColor) 63 | } 64 | 65 | iconChoice = selection.faName 66 | } 67 | } 68 | 69 | // MARK: - Outlets 70 | @IBOutlet weak var bestIcon: UIImageView! 71 | @IBOutlet weak var foundIconLabel: UILabel! 72 | @IBOutlet weak var moreIconsButton: UIButton! 73 | 74 | // MARK: - Navigation 75 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 76 | // Create a new variable to store the instance of PlayerTableViewController 77 | if let destinationVC = segue.destination as? URIIconViewController { 78 | destinationVC.inputUrlc = outputUrlc 79 | destinationVC.uriColor = uriColor 80 | destinationVC.suggestedIcons = suggestedIcons 81 | } 82 | } 83 | 84 | func pushNextViewController(_ urlc: URLComponents) -> Bool { 85 | if URI.paramUnset(urlc, "lock", false) { 86 | if let viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "URILockViewController") as? URILockViewController { 87 | viewController.inputUrlc = urlc 88 | if let navigator = navigationController { 89 | navigator.pushViewController(viewController, animated: true) 90 | } 91 | } 92 | } else { 93 | return false 94 | } 95 | 96 | return true 97 | } 98 | 99 | // MARK: - Methods 100 | func saveBestMatch(iconName: String) { 101 | if foundGoodMatch { 102 | return 103 | } 104 | 105 | iconChoice = iconName 106 | foundGoodMatch = true 107 | foundIconLabel.isHidden = false 108 | moreIconsButton.isHidden = false 109 | let size = CGSize(width: 96, height: 96) 110 | let image = UIImage.fontAwesomeIcon(faName: iconChoice, faType: .brands, size: size) 111 | bestIcon.image = image.addImagePadding(x: 30, y: 30) 112 | 113 | bestIcon.backgroundColor = icon.getBackgroundColor(name: iconChoice, uriColor: uriColor) 114 | } 115 | 116 | override func viewDidLoad() { 117 | super.viewDidLoad() 118 | 119 | moreIconsButton.layer.cornerRadius = 4 120 | outputUrlc = inputUrlc 121 | 122 | foundIconLabel.isHidden = true 123 | 124 | if let color = URI.getQueryItem(outputUrlc, "color") { 125 | uriColor = color 126 | } 127 | 128 | suggestedIcons = icon.getSuggestions(urlc: outputUrlc) 129 | 130 | for icons in suggestedIcons { 131 | if icons.levDist == highMatchlevDist { 132 | saveBestMatch(iconName: icons.name) 133 | } 134 | } 135 | 136 | if !foundGoodMatch { 137 | performSegue(withIdentifier: "moreIconsSegue", sender: self) 138 | moreIconsButton.isHidden = false 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /FreeOTP/URIParameters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URIParameters.swift 3 | // FreeOTP 4 | // 5 | // Created by Justin Stephenson on 2/7/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Label { 12 | public var issuer = "" 13 | public var account = "" 14 | } 15 | 16 | public class URIParameters { 17 | // MARK: - Methods 18 | public init() {} 19 | 20 | public func accountUnset(_ uri: URLComponents) -> Bool! { 21 | if let label = getLabel(from: uri) { 22 | return label.issuer == "" ? true : false 23 | } else { 24 | return nil 25 | } 26 | } 27 | 28 | public func paramUnset<T>(_ uri: URLComponents, _ name: String, _ type: T) -> Bool { 29 | let value = getQueryItem(uri, name) 30 | 31 | if value == nil { 32 | return true 33 | } 34 | 35 | switch(T.self) { 36 | case is String.Type: 37 | return value != "" ? false : true 38 | case is Bool.Type: 39 | return value == "true" || value == "false" ? false : true 40 | default: 41 | return true 42 | } 43 | } 44 | 45 | public func getLabel(from uri: URLComponents) -> Label! { 46 | var label = Label() 47 | 48 | var path = uri.path 49 | while path.hasPrefix("/") { 50 | path = String(path[path.index(path.startIndex, offsetBy: 1)...]) 51 | } 52 | if path == "" { 53 | return nil 54 | } 55 | 56 | let components = path.components(separatedBy: ":") 57 | if components.count == 1 { 58 | label.account = components[0] 59 | } else if components.count > 1 { 60 | label.issuer = components[0] 61 | label.account = components[1] 62 | } else { 63 | return nil 64 | } 65 | 66 | return label 67 | } 68 | 69 | public func getQueryItem(_ uri: URLComponents, _ keyItem: String) -> String! { 70 | return uri.queryItems?.first(where: { $0.name == keyItem })?.value 71 | } 72 | 73 | public func validateURI(uri: URLComponents) -> Bool { 74 | if uri.scheme != "otpauth" || uri.host == nil { 75 | return false 76 | } 77 | 78 | if uri.host!.lowercased() != "totp" && uri.host!.lowercased() != "hotp" { 79 | return false 80 | } 81 | 82 | var path = uri.path 83 | while path.hasPrefix("/") { 84 | path = String(path[path.index(path.startIndex, offsetBy: 1)...]) 85 | } 86 | 87 | if path == "" { 88 | return false 89 | } 90 | 91 | let query = uri.queryItems 92 | if (query == nil) { return false } 93 | 94 | if let secret = query?.first(where: { $0.name == "secret" })?.value { 95 | if (secret.isEmpty) { return false} 96 | } else { 97 | return false 98 | } 99 | 100 | return true 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /FreeOTP/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/default.png -------------------------------------------------------------------------------- /FreeOTP/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | /* Localized versions of Info.plist keys */ 2 | 3 | -------------------------------------------------------------------------------- /FreeOTP/iTunesArtwork@2x: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/iTunesArtwork@2x -------------------------------------------------------------------------------- /FreeOTP/lock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/lock.png -------------------------------------------------------------------------------- /FreeOTP/qrcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/qrcode.png -------------------------------------------------------------------------------- /FreeOTP/token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeotp/freeotp-ios/d110f8b20ddb8f5369c9b12d8ec4e2121a6fbbc8/FreeOTP/token.png -------------------------------------------------------------------------------- /FreeOTPTests/FreeOTPTests-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Use this file to import your target's public headers that you would like to expose to Swift. 3 | // 4 | 5 | #import "FreeOTP-Bridging-Header.h" 6 | -------------------------------------------------------------------------------- /FreeOTPTests/HOTP.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import FreeOTP 22 | import Foundation 23 | import XCTest 24 | 25 | class HOTP: XCTestCase { 26 | func validateOTPs(uri: String, expectedotps: [String]) { 27 | let urlc = URLComponents(string: uri) 28 | XCTAssertNotNil(urlc) 29 | 30 | var otp = OTP(urlc: urlc!) 31 | XCTAssertNotNil(otp) 32 | 33 | let data = NSKeyedArchiver.archivedData(withRootObject: otp!) 34 | 35 | for i in 0..<expectedotps.count { 36 | XCTAssertEqual(otp!.code(Int64(i)), expectedotps[i]) 37 | } 38 | 39 | otp = NSKeyedUnarchiver.unarchiveObject(with: data) as? OTP 40 | XCTAssertNotNil(otp) 41 | 42 | for i in 0..<expectedotps.count { 43 | let code = otp!.code(Int64(i)) 44 | XCTAssertEqual(code, expectedotps[i]) 45 | } 46 | } 47 | 48 | func test() { 49 | let tests: [String] = [ 50 | "755224", 51 | "287082", 52 | "359152", 53 | "969429", 54 | "338314", 55 | "254676", 56 | "287922", 57 | "162583", 58 | "399871", 59 | "520489" 60 | ] 61 | let uri = "otpauth://hotp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=6" 62 | 63 | validateOTPs(uri: uri, expectedotps: tests) 64 | } 65 | 66 | func testDigits() { 67 | let tests: [String] = [ 68 | "356072009", 69 | "318978277", 70 | "663605382", 71 | "976886461", 72 | "607466828", 73 | "091964552", 74 | "150788607", 75 | "729059761", 76 | "690070028", 77 | "906336243", 78 | ] 79 | 80 | let uri7 = "otpauth://hotp/foo?secret=akn3jgzz6d3p4c5r4fokaz2uvxjeltjbdzgyuv4ufscxumc7fjxl5vjh&algorithm=SHA1&digits=7" 81 | let uri9 = "otpauth://hotp/foo?secret=akn3jgzz6d3p4c5r4fokaz2uvxjeltjbdzgyuv4ufscxumc7fjxl5vjh&algorithm=SHA1&digits=9" 82 | 83 | validateOTPs(uri: uri7, expectedotps: tests.map { 84 | String($0.dropFirst(2)) 85 | }) 86 | validateOTPs(uri: uri9, expectedotps: tests) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /FreeOTPTests/Icon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Icon.swift 3 | // FreeOTPTests 4 | // 5 | // Created by Justin Stephenson on 5/13/20. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import FreeOTP 10 | import XCTest 11 | 12 | class Icon: XCTestCase { 13 | 14 | func testMapping() { 15 | let URI = URIParameters() 16 | let icon = TokenIcon() 17 | let urlc = URLComponents(string: "otpauth://hotp/Example:alice@google.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2&image=http%3A%2F%2Ffoo%2Fbar") 18 | let iconChoice = "fa-amazon" 19 | let color = "#74DFDF" 20 | let expected = ["Name": iconChoice, "Color": color] 21 | if let label = URI.getLabel(from: urlc!) { 22 | icon.saveMapping(issuer: label.issuer, iconName: iconChoice, iconColor: color) 23 | if let iconMapping = icon.loadMapping(issuer: label.issuer) { 24 | XCTAssert(iconMapping == expected) 25 | return 26 | } 27 | } 28 | 29 | XCTFail() 30 | } 31 | 32 | func testBrand() { 33 | let icon = TokenIcon() 34 | 35 | XCTAssert(icon.getBrandColorHex("redhat") == "#FF0000") 36 | XCTAssert(icon.getBrandColorHex("gitlab") == "#292961") 37 | XCTAssert(icon.getBrandColorHex("steam") == "#242424") 38 | 39 | let redhatColor = UIColor(hexString: "#FF0000") 40 | let gitlabColor = UIColor(hexString: "#292961") 41 | let steamColor = UIColor(hexString: "242424") 42 | 43 | XCTAssert(icon.getBrandColor("redhat") == redhatColor) 44 | XCTAssert(icon.getBrandColor("gitlab") == gitlabColor) 45 | XCTAssert(icon.getBrandColor("fa-steam") == steamColor) 46 | 47 | XCTAssertNil(icon.getBrandColor(("test"))) 48 | 49 | XCTAssert(icon.getBackgroundColor(name: "redhat") == redhatColor) 50 | XCTAssert(icon.getBackgroundColor(name: "gitlab") == gitlabColor) 51 | } 52 | 53 | func testFontAwesome() { 54 | // Test an issuer -> icon mapping saved from the URIIcon wizard 55 | // Issuer: Example -> Icon: Slack 56 | let URI = URIParameters() 57 | let icon = TokenIcon() 58 | let urlc = URLComponents(string: "otpauth://hotp/Example:alice@google.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2&image=http%3A%2F%2Ffoo%2Fbar") 59 | let iconChoice = "slack" 60 | let color = "#74DFDF" 61 | let label = URI.getLabel(from: urlc!) 62 | icon.saveMapping(issuer: label!.issuer, iconName: iconChoice, iconColor: color) 63 | 64 | let custIcon = icon.getCustomIcon(issuer: label!.issuer) 65 | XCTAssertNotNil(custIcon) 66 | XCTAssert(iconChoice == custIcon!.name) 67 | 68 | // Test an exact match issuer -> icon lookup 69 | XCTAssertNotNil(UIImage.fontAwesomeIcon(faName: "github", faType: .brands)) 70 | XCTAssertNotNil(UIImage.fontAwesomeIcon(faName: "slack", faType: .brands)) 71 | XCTAssertNotNil(UIImage.fontAwesomeIcon(faName: "microsoft", faType: .brands)) 72 | 73 | // Test solids 74 | XCTAssertNotNil(UIImage.fontAwesomeIcon(faName: "fa-snowman", faType: .solid)) 75 | XCTAssertNotNil(UIImage.fontAwesomeIcon(faName: "fa-archive", faType: .solid)) 76 | XCTAssertNotNil(UIImage.fontAwesomeIcon(faName: "redhat", faType: .solid)) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /FreeOTPTests/Info.plist: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>CFBundleDevelopmentRegion</key> 6 | <string>en</string> 7 | <key>CFBundleExecutable</key> 8 | <string>$(EXECUTABLE_NAME)</string> 9 | <key>CFBundleIdentifier</key> 10 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 11 | <key>CFBundleInfoDictionaryVersion</key> 12 | <string>6.0</string> 13 | <key>CFBundleName</key> 14 | <string>$(PRODUCT_NAME)</string> 15 | <key>CFBundlePackageType</key> 16 | <string>BNDL</string> 17 | <key>CFBundleShortVersionString</key> 18 | <string>$(MARKETING_VERSION)</string> 19 | <key>CFBundleSignature</key> 20 | <string>????</string> 21 | <key>CFBundleVersion</key> 22 | <string>1</string> 23 | </dict> 24 | </plist> 25 | -------------------------------------------------------------------------------- /FreeOTPTests/Storage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import Foundation 22 | import FreeOTP 23 | import XCTest 24 | 25 | class Storage: XCTestCase { 26 | func test() { 27 | let def = UserDefaults.standard 28 | def.set(["baz:bar"] as [String], forKey: "tokenOrder") 29 | def.set("otpauth://hotp/foo:bar?secret=JBSWY3DPEHPK3PXP&issuer=baz", forKey: "baz:bar") 30 | def.synchronize() 31 | 32 | let ts = TokenStore() 33 | XCTAssertGreaterThan(ts.count, 0) 34 | 35 | let token = ts.load(0) 36 | XCTAssertNotNil(token) 37 | XCTAssertEqual(token!.issuer, "foo") 38 | XCTAssertEqual(token!.label, "bar") 39 | ts.erase(token: token!) 40 | 41 | XCTAssertNil(def.stringArray(forKey: "tokenOrder")) 42 | XCTAssertNil(def.string(forKey: "baz:bar")) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /FreeOTPTests/TOTP.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import FreeOTP 22 | import Foundation 23 | import XCTest 24 | 25 | class TOTP: XCTestCase { 26 | struct TestData { 27 | let time: Int64 28 | let code: String 29 | } 30 | 31 | func validateOTPs(uri: String, otpdata: [TestData]) { 32 | let urlc = URLComponents(string: uri) 33 | XCTAssertNotNil(urlc) 34 | 35 | var otp = OTP(urlc: urlc!) 36 | XCTAssertNotNil(otp) 37 | 38 | let data = NSKeyedArchiver.archivedData(withRootObject: otp!) 39 | 40 | for d in otpdata { 41 | XCTAssertEqual(otp!.code(d.time / Int64(30)), d.code) 42 | } 43 | 44 | otp = NSKeyedUnarchiver.unarchiveObject(with: data) as? OTP 45 | XCTAssertNotNil(otp) 46 | 47 | for d in otpdata { 48 | XCTAssertEqual(otp!.code(d.time / Int64(30)), d.code) 49 | } 50 | } 51 | 52 | func testSHA1() { 53 | let tests: [TestData] = [ 54 | TestData(time: 59, code: "94287082"), 55 | TestData(time: 1111111109, code: "07081804"), 56 | TestData(time: 1111111111, code: "14050471"), 57 | TestData(time: 1234567890, code: "89005924"), 58 | TestData(time: 2000000000, code: "69279037"), 59 | TestData(time: 20000000000, code: "65353130"), 60 | ] 61 | 62 | let uri = "otpauth://totp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&algorithm=SHA1&digits=8" 63 | validateOTPs(uri: uri, otpdata: tests) 64 | } 65 | 66 | func testSHA256() { 67 | let tests: [TestData] = [ 68 | TestData(time: 59, code: "46119246"), 69 | TestData(time: 1111111109, code: "68084774"), 70 | TestData(time: 1111111111, code: "67062674"), 71 | TestData(time: 1234567890, code: "91819424"), 72 | TestData(time: 2000000000, code: "90698825"), 73 | TestData(time: 20000000000, code: "77737706"), 74 | ] 75 | 76 | let uri = "otpauth://totp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA====&algorithm=SHA256&digits=8" 77 | validateOTPs(uri: uri, otpdata: tests) 78 | } 79 | 80 | func testSHA512() { 81 | let tests: [TestData] = [ 82 | TestData(time: 59, code: "90693936"), 83 | TestData(time: 1111111109, code: "25091201"), 84 | TestData(time: 1111111111, code: "99943326"), 85 | TestData(time: 1234567890, code: "93441116"), 86 | TestData(time: 2000000000, code: "38618901"), 87 | TestData(time: 20000000000, code: "47863826"), 88 | ] 89 | 90 | let uri = "otpauth://totp/foo?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA=&algorithm=SHA512&digits=8" 91 | validateOTPs(uri: uri, otpdata: tests) 92 | } 93 | 94 | func testDigits() { 95 | let tests: [TestData] = [ 96 | TestData(time: 59, code: "318978277"), 97 | TestData(time: 1111111109, code: "985507210"), 98 | TestData(time: 1111111111, code: "846109060"), 99 | TestData(time: 1234567890, code: "462647324"), 100 | TestData(time: 2000000000, code: "910524948"), 101 | TestData(time: 20000000000, code: "137696517"), 102 | ] 103 | 104 | let uri9 = "otpauth://totp/foo?secret=akn3jgzz6d3p4c5r4fokaz2uvxjeltjbdzgyuv4ufscxumc7fjxl5vjh&algorithm=SHA1&digits=9" 105 | let uri7 = "otpauth://totp/foo?secret=akn3jgzz6d3p4c5r4fokaz2uvxjeltjbdzgyuv4ufscxumc7fjxl5vjh&algorithm=SHA1&digits=7" 106 | 107 | validateOTPs(uri: uri9, otpdata: tests) 108 | let tests7 = tests.map { 109 | (value: TestData) -> TestData in 110 | return TestData(time: value.time, code: String(value.code.dropFirst(2))) 111 | } 112 | 113 | validateOTPs(uri: uri7, otpdata: tests7) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /FreeOTPTests/URI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTP 3 | // 4 | // Authors: Nathaniel McCallum <npmccallum@redhat.com> 5 | // 6 | // Copyright (C) 2015 Nathaniel McCallum, Red Hat 7 | // 8 | // Licensed under the Apache License, Version 2.0 (the "License"); 9 | // you may not use this file except in compliance with the License. 10 | // You may obtain a copy of the License at 11 | // 12 | // http://www.apache.org/licenses/LICENSE-2.0 13 | // 14 | // Unless required by applicable law or agreed to in writing, software 15 | // distributed under the License is distributed on an "AS IS" BASIS, 16 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | // See the License for the specific language governing permissions and 18 | // limitations under the License. 19 | // 20 | 21 | import FreeOTP 22 | import XCTest 23 | 24 | class URI: XCTestCase { 25 | func valid(_ string: String, load: Bool = false) -> Token? { 26 | if let urlc = URLComponents(string: string) { 27 | if let otp = OTP(urlc: urlc) { 28 | if let token = Token(otp: otp, urlc: urlc, load: load) { 29 | return token 30 | } 31 | } 32 | } 33 | 34 | return nil 35 | } 36 | 37 | func test() { 38 | // Test cases that are suppossed to fail 39 | XCTAssertNil(valid("xxxxxxx://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP")) 40 | XCTAssertNil(valid("otpauth://xxxx/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP")) 41 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com")) 42 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&counter=-1")) 43 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&period=-1")) 44 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&period=1")) 45 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=-1")) 46 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=1")) 47 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=5")) 48 | XCTAssertNil(valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&digits=10")) 49 | 50 | // Test the basic test case 51 | let urlc = URLComponents(string: "otpauth://hotp/Example:alice@google.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2&image=http%3A%2F%2Ffoo%2Fbar") 52 | XCTAssertNotNil(urlc) 53 | var token = TokenStore().add(urlc!) 54 | XCTAssertNotNil(token) 55 | XCTAssertEqual(token!.issuer, "Example") 56 | XCTAssertEqual(token!.label, "alice@google.com") 57 | XCTAssertEqual(token!.image!, "http://foo/bar") 58 | XCTAssertEqual(token!.codes[0].value, "755224") 59 | 60 | // Make sure save and restore work 61 | token = TokenStore().load(0) 62 | XCTAssertNotNil(token) 63 | XCTAssertEqual(token!.codes[0].value, "287082") 64 | XCTAssert(TokenStore().erase(token: token!)) 65 | 66 | // Make sure that the file://*/FreeOTP.app/default.png URLs aren't loaded 67 | token = valid("otpauth://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&image=file%3A%2F%2Ffoo%2Fbar%2FFreeOTP.app%2Fdefault.png&imageOrig=file%3A%2F%2Ffoo%2Fbar%2FFreeOTP.app%2Fdefault.png&issuerOrig=foo&nameOrig=bar", load: true) 68 | XCTAssertNotNil(token) 69 | XCTAssertEqual(token!.issuer, "Example") 70 | XCTAssertEqual(token!.label, "alice@google.com") 71 | XCTAssertNil(token!.image) 72 | } 73 | 74 | func testUnicodeChars() { 75 | let example_one = "otpauth://hotp/Example:firstname.lastname%40example.com%20(foobar)%20-%20TESTING?secret=qfwpwaf2d5korpye6x5ldftjcitb2dvk5ozbvvizslayv2ezdt3mgf5o&algorithm=SHA256&digits=6&period=30&counter=0" 76 | 77 | let urlc = URLComponents(string: example_one) 78 | XCTAssertNotNil(urlc) 79 | 80 | let token = TokenStore().add(urlc!) 81 | XCTAssertNotNil(token) 82 | XCTAssertEqual(token!.issuer, "Example") 83 | XCTAssertEqual(token!.label, "firstname.lastname@example.com (foobar) - TESTING") 84 | 85 | let exampleChinese = "otpauth://hotp/%E7%81%AB%E5%B0%B8%E6%9C%A8%E7%81%AB1%E5%8D%81%E5%AF%A5%E6%97%A5%E7%83%A4:%E7%81%AB%E6%97%A5%E8%82%96%E3%80%80%E4%BD%A0%EF%BC%9B%E7%81%AB?secret=qfwpwaf2d5korpye6x5ldftjcitb2dvk5ozbvvizslayv2ezdt3mgf5o&algorithm=SHA256&digits=6&period=30&counter=0" 86 | 87 | let urlcChinese = URLComponents(string: exampleChinese) 88 | XCTAssertNotNil(urlcChinese) 89 | 90 | let tokenChinese = TokenStore().add(urlcChinese!) 91 | XCTAssertEqual(tokenChinese!.issuer, "火尸木火1十寥日烤") 92 | XCTAssertEqual(tokenChinese!.label, "火日肖 你;火") 93 | 94 | let exampleUnicode = "otpauth://hotp/Robert%E2%80%99s%E2%80%9D!!%40%23%24%25%5E:slkdjfkj%22%22%C3%A9!%22'%C3%A8(%C3%A0%C3%A9%C3%A9!%C3%A9?secret=qfwpwaf2d5korpye6x5ldftjcitb2dvk5ozbvvizslayv2ezdt3mgf5o&algorithm=SHA256&digits=6&period=30&counter=0" 95 | 96 | let urlcUnicode = URLComponents(string: exampleUnicode) 97 | XCTAssertNotNil(urlcUnicode) 98 | 99 | let tokenUnicode = TokenStore().add(urlcUnicode!) 100 | XCTAssertEqual(tokenUnicode!.issuer, "Robert’s”!!@#$%^") 101 | XCTAssertEqual(tokenUnicode!.label, "slkdjfkj\"\"é!\"'è(àéé!é") 102 | } 103 | 104 | func testUnsetParams() { 105 | let params = URIParameters() 106 | let urlc = URLComponents(string: "otpauth://hotp/Example:alice@google.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2&image=http%3A%2F%2Ffoo%2Fbar&lock=true") 107 | let urlcUnset = URLComponents(string: "otpauth://hotp/Example?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2") 108 | XCTAssertNotNil(urlcUnset) 109 | XCTAssertTrue(params.accountUnset(urlcUnset!)) 110 | 111 | XCTAssertFalse(params.paramUnset(urlc!, "image", "")) 112 | XCTAssertTrue(params.paramUnset(urlcUnset!, "image", "")) 113 | 114 | XCTAssertFalse(params.paramUnset(urlc!, "lock", "")) 115 | XCTAssertTrue(params.paramUnset(urlcUnset!, "lock", "")) 116 | } 117 | 118 | func testGetParams() { 119 | let params = URIParameters() 120 | let urlc = URLComponents(string: "otpauth://hotp/Example:alice@google.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2&image=http%3A%2F%2Ffoo%2Fbar") 121 | let urlcAcctOnly = URLComponents(string: "otpauth://hotp/Example?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&image=http%3A%2F%2Ffoo%2Fbar") 122 | 123 | let label = params.getLabel(from: urlc!) 124 | XCTAssert(label != nil) 125 | XCTAssert(label!.issuer == "Example") 126 | XCTAssert(label!.account == "alice@google.com") 127 | 128 | let label2 = params.getLabel(from: urlcAcctOnly!) 129 | XCTAssert(label2 != nil) 130 | XCTAssert(label2!.account == "Example") 131 | XCTAssert(label2!.issuer == "") 132 | } 133 | 134 | func testValidateURI() { 135 | let params = URIParameters() 136 | 137 | XCTAssertFalse(params.validateURI(uri: URLComponents(string: "xxxxxxx://hotp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP")!)) 138 | XCTAssertFalse(params.validateURI(uri: URLComponents(string: "otpauth://xxxx/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP")!)) 139 | XCTAssertFalse(params.validateURI(uri: URLComponents(string: "otpauth://hotp/Example:alice@google.com")!)) 140 | XCTAssertFalse(params.validateURI(uri: URLComponents(string: "otpauth://hotp/?secret=by6p223gcdxtmxakeaqapld6um3k6x2gos5lcgvlaznjxcgw5cudwr5y&algorithm=SHA256&digits=6&period=30&counter=0")!)) 141 | 142 | XCTAssert(params.validateURI(uri: URLComponents(string: "otpauth://hotp/Example:alice@google.com?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example2&image=http%3A%2F%2Ffoo%2Fbar")!)) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /FreeOTPUITests/FreeOTPUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FreeOTPUITests.swift 3 | // FreeOTPUITests 4 | // 5 | // Created by Mulili Nzuki on 23/11/2020. 6 | // Copyright © 2020 Fedora Project. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class FreeOTPUITests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | continueAfterFailure = false 15 | } 16 | 17 | func testSearch() throws { 18 | // UI tests must launch the application that they test. 19 | let app = XCUIApplication() 20 | 21 | app.launch() 22 | 23 | let collectionView = app.otherElements.collectionViews.element(boundBy: 0) 24 | XCTAssert(collectionView.exists) 25 | collectionView.swipeDown() 26 | 27 | // check if the search bar exists 28 | let searchBarElement = app.searchFields.firstMatch 29 | XCTAssert(searchBarElement.exists) 30 | searchBarElement.tap() 31 | 32 | // check if the filtering works as expected 33 | app.typeText("test") 34 | 35 | XCTAssert(collectionView.cells.count > 0) 36 | 37 | // filtering should fail for a random strting 38 | app.typeText("blah " + String(arc4random()) + " blah") 39 | XCTAssertFalse(collectionView.cells.count > 0) 40 | 41 | let cancelButton = app.buttons["Cancel"].firstMatch 42 | XCTAssert(cancelButton.exists) 43 | cancelButton.tap() 44 | } 45 | 46 | func testManualAdd() throws { 47 | let app = XCUIApplication() 48 | 49 | let typesCount = 2 50 | let digitsCount = 4 51 | let algorithmsCount = 6 52 | let intervalsCount = 6 53 | let testedIssuerName = "blah123" 54 | var testedIssuerTokensCount = 0 55 | 56 | app.launch() 57 | 58 | //search for the number of old tokens with the issuer name under test 59 | let collectionView = app.otherElements.collectionViews.element(boundBy: 0) 60 | XCTAssert(collectionView.exists) 61 | collectionView.swipeDown() 62 | 63 | let searchBarElement = app.searchFields.firstMatch 64 | XCTAssert(searchBarElement.exists) 65 | searchBarElement.tap() 66 | 67 | app.typeText(testedIssuerName) 68 | testedIssuerTokensCount = collectionView.cells.count 69 | 70 | let cancelButton = app.buttons["Cancel"].firstMatch 71 | XCTAssert(cancelButton.exists) 72 | cancelButton.tap() 73 | 74 | //test manual add 75 | let manualAddButton = app.buttons["manualAddButton"].firstMatch 76 | XCTAssert(manualAddButton.exists) 77 | manualAddButton.tap() 78 | 79 | let manualAddView = app.otherElements["manualAddView"].firstMatch 80 | XCTAssert(manualAddView.exists) 81 | 82 | let nextButton = app.buttons["nextButton"].firstMatch 83 | XCTAssert(nextButton.exists) 84 | 85 | let issuerField = manualAddView.textFields["issuerField"].firstMatch 86 | XCTAssert(issuerField.exists) 87 | 88 | let descriptionField = manualAddView.textFields["descriptionField"].firstMatch 89 | XCTAssert(descriptionField.exists) 90 | 91 | let secretField = manualAddView.textFields["secretField"].firstMatch 92 | XCTAssert(secretField.exists) 93 | 94 | let typeSegmentedControl = manualAddView.segmentedControls["typeControl"].firstMatch 95 | XCTAssert(typeSegmentedControl.exists) 96 | XCTAssert(typeSegmentedControl.buttons.count == typesCount) 97 | 98 | let digitsSegmentedControl = manualAddView.segmentedControls["digitsControl"].firstMatch 99 | XCTAssert(digitsSegmentedControl.exists) 100 | XCTAssert(digitsSegmentedControl.buttons.count == digitsCount) 101 | 102 | let algorithmButton = manualAddView.buttons["algorithmControl"].firstMatch 103 | XCTAssert(algorithmButton.exists) 104 | 105 | let intervalButton = manualAddView.buttons["intervalControl"].firstMatch 106 | XCTAssert(intervalButton.exists) 107 | 108 | //check empty fields alert 109 | nextButton.tap() 110 | let emptyAlert = app.alerts.firstMatch 111 | XCTAssert(emptyAlert.staticTexts["Some fields are empty!"].exists) 112 | 113 | emptyAlert.buttons.firstMatch.tap() 114 | 115 | //check wrong secret alert 116 | descriptionField.tap() 117 | descriptionField.typeText(UUID().uuidString) 118 | issuerField.tap() 119 | issuerField.typeText(testedIssuerName) 120 | secretField.tap() 121 | secretField.typeText("d") 122 | 123 | nextButton.tap() 124 | let secretAlert = app.alerts.firstMatch 125 | XCTAssert(secretAlert.staticTexts["Token is invalid!"].exists) 126 | 127 | secretAlert.buttons.firstMatch.tap() 128 | guard let text = secretField.value as? String else { 129 | XCTFail("secret field text failing") 130 | return 131 | } 132 | XCTAssert(text == "") 133 | 134 | //check algorithm menu 135 | algorithmButton.tap() 136 | let algorithmMenu = app.alerts.firstMatch 137 | XCTAssert(algorithmMenu.exists) 138 | XCTAssert(algorithmMenu.buttons.count == algorithmsCount + 1) 139 | 140 | let sha1Button = algorithmMenu.buttons["SHA1"].firstMatch 141 | XCTAssert(sha1Button.exists) 142 | sha1Button.tap() 143 | sleep(1) 144 | XCTAssert(algorithmButton.label == "SHA1") 145 | 146 | //check interval menu 147 | intervalButton.tap() 148 | let intervalMenu = app.alerts.firstMatch 149 | XCTAssert(intervalMenu.exists) 150 | XCTAssert(intervalMenu.buttons.count == intervalsCount + 1) 151 | 152 | let minuteButton = algorithmMenu.buttons["1m"].firstMatch 153 | XCTAssert(minuteButton.exists) 154 | minuteButton.tap() 155 | sleep(1) 156 | XCTAssert(intervalButton.label == "1m") 157 | 158 | //check with correct input 159 | secretField.tap() 160 | secretField.typeText("mdf3v2s3nzcmwzy5ettbsjq572bpvo5o3wmkfqe7egyktzxufj3hsg7b") 161 | 162 | nextButton.tap() 163 | 164 | let uriIconView = app.otherElements["uriIconView"].firstMatch 165 | XCTAssert(uriIconView.waitForExistence(timeout: 1.0)) 166 | let cell = uriIconView.cells.firstMatch 167 | XCTAssert(cell.exists) 168 | cell.tap() 169 | sleep(1) 170 | 171 | let iconNextButton = app.buttons["Next"].firstMatch 172 | XCTAssert(iconNextButton.exists) 173 | iconNextButton.tap() 174 | sleep(1) 175 | 176 | let lockNextButton = app.buttons["Next"].firstMatch 177 | XCTAssert(lockNextButton.exists) 178 | lockNextButton.tap() 179 | sleep(1) 180 | 181 | //detecting an increase in the number of cells with the issuer name under test 182 | XCTAssert(collectionView.exists) 183 | collectionView.swipeDown() 184 | XCTAssert(searchBarElement.exists) 185 | searchBarElement.tap() 186 | 187 | app.typeText(testedIssuerName) 188 | let secCollectionView = app.otherElements.collectionViews.element(boundBy: 0) 189 | XCTAssert(secCollectionView.exists) 190 | 191 | //if error - try to remove all "blah123" tokens first 192 | XCTAssert(secCollectionView.cells.count == testedIssuerTokensCount + 1) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /FreeOTPUITests/Info.plist: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 | <plist version="1.0"> 4 | <dict> 5 | <key>CFBundleDevelopmentRegion</key> 6 | <string>$(DEVELOPMENT_LANGUAGE)</string> 7 | <key>CFBundleExecutable</key> 8 | <string>$(EXECUTABLE_NAME)</string> 9 | <key>CFBundleIdentifier</key> 10 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 11 | <key>CFBundleInfoDictionaryVersion</key> 12 | <string>6.0</string> 13 | <key>CFBundleName</key> 14 | <string>$(PRODUCT_NAME)</string> 15 | <key>CFBundlePackageType</key> 16 | <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> 17 | <key>CFBundleShortVersionString</key> 18 | <string>1.0</string> 19 | <key>CFBundleVersion</key> 20 | <string>1</string> 21 | </dict> 22 | </plist> 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FreeOTP 2 | 3 | [FreeOTP](https://freeotp.github.io/) is a two-factor authentication application for systems 4 | utilizing one-time password protocols. Tokens can be added easily by scanning a QR code. 5 | 6 | FreeOTP implements open standards: 7 | 8 | * HOTP (HMAC-Based One-Time Password Algorithm) [RFC 4226](https://www.ietf.org/rfc/rfc4226.txt) 9 | * TOTP (Time-Based One-Time Password Algorithm) [RFC 6238](https://www.ietf.org/rfc/rfc6238.txt) 10 | 11 | This means that no proprietary server-side component is necessary: use any server-side component 12 | that implements these standards. 13 | 14 | ## Download FreeOTP for iOS 15 | 16 | * [App Store](https://apps.apple.com/app/freeotp-authenticator/id872559395) 17 | 18 | ## Contributing 19 | 20 | Pull requests on GitHub are welcome under the Apache 2.0 license, see 21 | [CONTRIBUTING](CONTRIBUTING.md) for more details. 22 | 23 | ### Install Build dependencies 24 | 25 | You need to have [Carthage](https://github.com/Carthage/Carthage) installed for managing dependencies. In simple steps: 26 | 27 | brew install carthage 28 | carthage update --use-xcframeworks --platform iOS 29 | --------------------------------------------------------------------------------