Remove Multiplatform targets

This commit is contained in:
Maurice Parker
2021-10-15 17:23:40 -05:00
parent 3d5bdb44fb
commit 23fe288fe9
239 changed files with 0 additions and 18881 deletions

View File

@@ -1,418 +0,0 @@
//
// AppDelegate.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 6/28/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import RSWeb
import Account
import BackgroundTasks
import os.log
import Secrets
var appDelegate: AppDelegate!
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate, UnreadCountProvider {
private var bgTaskDispatchQueue = DispatchQueue.init(label: "BGTaskScheduler")
private var waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
private var syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
var syncTimer: ArticleStatusSyncTimer?
var shuttingDown = false {
didSet {
if shuttingDown {
syncTimer?.shuttingDown = shuttingDown
syncTimer?.invalidate()
}
}
}
var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Application")
var userNotificationManager: UserNotificationManager!
var faviconDownloader: FaviconDownloader!
var imageDownloader: ImageDownloader!
var authorAvatarDownloader: AuthorAvatarDownloader!
var webFeedIconDownloader: WebFeedIconDownloader!
// TODO: Add Extension back in
// var extensionContainersFile: ExtensionContainersFile!
// var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile!
var unreadCount = 0 {
didSet {
if unreadCount != oldValue {
postUnreadCountDidChangeNotification()
UIApplication.shared.applicationIconBadgeNumber = unreadCount
}
}
}
var isSyncArticleStatusRunning = false
var isWaitingForSyncTasks = false
override init() {
super.init()
appDelegate = self
SecretsManager.provider = Secrets()
let documentFolder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let documentAccountsFolder = documentFolder.appendingPathComponent("Accounts").absoluteString
let documentAccountsFolderPath = String(documentAccountsFolder.suffix(from: documentAccountsFolder.index(documentAccountsFolder.startIndex, offsetBy: 7)))
AccountManager.shared = AccountManager(accountsFolder: documentAccountsFolderPath)
let documentThemesFolder = documentFolder.appendingPathComponent("Themes").absoluteString
let documentThemesFolderPath = String(documentThemesFolder.suffix(from: documentAccountsFolder.index(documentThemesFolder.startIndex, offsetBy: 7)))
ArticleThemesManager.shared = ArticleThemesManager(folderPath: documentThemesFolderPath)
FeedProviderManager.shared.delegate = ExtensionPointManager.shared
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil)
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
AppDefaults.registerDefaults()
let isFirstRun = AppDefaults.shared.isFirstRun()
if isFirstRun {
os_log("Is first run.", log: log, type: .info)
}
if isFirstRun && !AccountManager.shared.anyAccountHasAtLeastOneFeed() {
let localAccount = AccountManager.shared.defaultAccount
DefaultFeedsImporter.importDefaultFeeds(account: localAccount)
}
registerBackgroundTasks()
CacheCleaner.purgeIfNecessary()
initializeDownloaders()
initializeHomeScreenQuickActions()
DispatchQueue.main.async {
self.unreadCount = AccountManager.shared.unreadCount
}
UNUserNotificationCenter.current().getNotificationSettings { (settings) in
if settings.authorizationStatus == .authorized {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
UNUserNotificationCenter.current().delegate = self
userNotificationManager = UserNotificationManager()
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
// extensionContainersFile = ExtensionContainersFile()
// extensionFeedAddRequestFile = ExtensionFeedAddRequestFile()
syncTimer = ArticleStatusSyncTimer()
#if DEBUG
syncTimer!.update()
#endif
return true
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
DispatchQueue.main.async {
self.resumeDatabaseProcessingIfNecessary()
AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) {
self.suspendApplication()
completionHandler(.newData)
}
}
}
func applicationWillTerminate(_ application: UIApplication) {
shuttingDown = true
}
// MARK: Notifications
@objc func unreadCountDidChange(_ note: Notification) {
if note.object is AccountManager {
unreadCount = AccountManager.shared.unreadCount
}
}
@objc func accountRefreshDidFinish(_ note: Notification) {
AppDefaults.shared.lastRefresh = Date()
}
// MARK: - API
func resumeDatabaseProcessingIfNecessary() {
if AccountManager.shared.isSuspended {
AccountManager.shared.resumeAll()
os_log("Application processing resumed.", log: self.log, type: .info)
}
}
func prepareAccountsForBackground() {
// extensionFeedAddRequestFile.suspend()
syncTimer?.invalidate()
scheduleBackgroundFeedRefresh()
syncArticleStatus()
waitForSyncTasksToFinish()
}
func prepareAccountsForForeground() {
// extensionFeedAddRequestFile.resume()
syncTimer?.update()
if let lastRefresh = AppDefaults.shared.lastRefresh {
if Date() > lastRefresh.addingTimeInterval(15 * 60) {
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
} else {
AccountManager.shared.syncArticleStatusAll()
}
} else {
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log)
}
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .badge, .sound])
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
defer { completionHandler() }
// TODO: Add back in User Notification handling
// if let sceneDelegate = response.targetScene?.delegate as? SceneDelegate {
// sceneDelegate.handle(response)
// }
}
}
// MARK: App Initialization
private extension AppDelegate {
private func initializeDownloaders() {
let tempDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
let faviconsFolderURL = tempDir.appendingPathComponent("Favicons")
let imagesFolderURL = tempDir.appendingPathComponent("Images")
try! FileManager.default.createDirectory(at: faviconsFolderURL, withIntermediateDirectories: true, attributes: nil)
let faviconsFolder = faviconsFolderURL.absoluteString
let faviconsFolderPath = faviconsFolder.suffix(from: faviconsFolder.index(faviconsFolder.startIndex, offsetBy: 7))
faviconDownloader = FaviconDownloader(folder: String(faviconsFolderPath))
let imagesFolder = imagesFolderURL.absoluteString
let imagesFolderPath = imagesFolder.suffix(from: imagesFolder.index(imagesFolder.startIndex, offsetBy: 7))
try! FileManager.default.createDirectory(at: imagesFolderURL, withIntermediateDirectories: true, attributes: nil)
imageDownloader = ImageDownloader(folder: String(imagesFolderPath))
authorAvatarDownloader = AuthorAvatarDownloader(imageDownloader: imageDownloader)
let tempFolder = tempDir.absoluteString
let tempFolderPath = tempFolder.suffix(from: tempFolder.index(tempFolder.startIndex, offsetBy: 7))
webFeedIconDownloader = WebFeedIconDownloader(imageDownloader: imageDownloader, folder: String(tempFolderPath))
}
private func initializeHomeScreenQuickActions() {
let unreadTitle = NSLocalizedString("First Unread", comment: "First Unread")
let unreadIcon = UIApplicationShortcutIcon(systemImageName: "chevron.down.circle")
let unreadItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.FirstUnread", localizedTitle: unreadTitle, localizedSubtitle: nil, icon: unreadIcon, userInfo: nil)
let searchTitle = NSLocalizedString("Search", comment: "Search")
let searchIcon = UIApplicationShortcutIcon(systemImageName: "magnifyingglass")
let searchItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.ShowSearch", localizedTitle: searchTitle, localizedSubtitle: nil, icon: searchIcon, userInfo: nil)
let addTitle = NSLocalizedString("Add Feed", comment: "Add Feed")
let addIcon = UIApplicationShortcutIcon(systemImageName: "plus")
let addItem = UIApplicationShortcutItem(type: "com.ranchero.NetNewsWire.ShowAdd", localizedTitle: addTitle, localizedSubtitle: nil, icon: addIcon, userInfo: nil)
UIApplication.shared.shortcutItems = [addItem, searchItem, unreadItem]
}
}
// MARK: Go To Background
private extension AppDelegate {
func waitForSyncTasksToFinish() {
guard !isWaitingForSyncTasks && UIApplication.shared.applicationState == .background else { return }
isWaitingForSyncTasks = true
self.waitBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask { [weak self] in
guard let self = self else { return }
self.completeProcessing(true)
os_log("Accounts wait for progress terminated for running too long.", log: self.log, type: .info)
}
DispatchQueue.main.async { [weak self] in
self?.waitToComplete() { [weak self] suspend in
self?.completeProcessing(suspend)
}
}
}
func waitToComplete(completion: @escaping (Bool) -> Void) {
guard UIApplication.shared.applicationState == .background else {
os_log("App came back to forground, no longer waiting.", log: self.log, type: .info)
completion(false)
return
}
if AccountManager.shared.refreshInProgress || isSyncArticleStatusRunning {
os_log("Waiting for sync to finish...", log: self.log, type: .info)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.waitToComplete(completion: completion)
}
} else {
os_log("Refresh progress complete.", log: self.log, type: .info)
completion(true)
}
}
func completeProcessing(_ suspend: Bool) {
if suspend {
suspendApplication()
}
UIApplication.shared.endBackgroundTask(self.waitBackgroundUpdateTask)
self.waitBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
isWaitingForSyncTasks = false
}
func syncArticleStatus() {
guard !isSyncArticleStatusRunning else { return }
isSyncArticleStatusRunning = true
let completeProcessing = { [unowned self] in
self.isSyncArticleStatusRunning = false
UIApplication.shared.endBackgroundTask(self.syncBackgroundUpdateTask)
self.syncBackgroundUpdateTask = UIBackgroundTaskIdentifier.invalid
}
self.syncBackgroundUpdateTask = UIApplication.shared.beginBackgroundTask {
completeProcessing()
os_log("Accounts sync processing terminated for running too long.", log: self.log, type: .info)
}
DispatchQueue.main.async {
AccountManager.shared.syncArticleStatusAll() {
completeProcessing()
}
}
}
func suspendApplication() {
guard UIApplication.shared.applicationState == .background else { return }
AccountManager.shared.suspendNetworkAll()
AccountManager.shared.suspendDatabaseAll()
CoalescingQueue.standard.performCallsImmediately()
os_log("Application processing suspended.", log: self.log, type: .info)
}
}
// MARK: Background Tasks
private extension AppDelegate {
/// Register all background tasks.
func registerBackgroundTasks() {
// Register background feed refresh.
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.ranchero.NetNewsWire.FeedRefresh", using: nil) { (task) in
self.performBackgroundFeedRefresh(with: task as! BGAppRefreshTask)
}
}
/// Schedules a background app refresh based on `AppDefaults.refreshInterval`.
func scheduleBackgroundFeedRefresh() {
let request = BGAppRefreshTaskRequest(identifier: "com.ranchero.NetNewsWire.FeedRefresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
// We send this to a dedicated serial queue because as of 11/05/19 on iOS 13.2 the call to the
// task scheduler can hang indefinitely.
bgTaskDispatchQueue.async {
do {
try BGTaskScheduler.shared.submit(request)
} catch {
os_log(.error, log: self.log, "Could not schedule app refresh: %@", error.localizedDescription)
}
}
}
/// Performs background feed refresh.
/// - Parameter task: `BGAppRefreshTask`
/// - Warning: As of Xcode 11 beta 2, when triggered from the debugger this doesn't work.
func performBackgroundFeedRefresh(with task: BGAppRefreshTask) {
scheduleBackgroundFeedRefresh() // schedule next refresh
os_log("Woken to perform account refresh.", log: self.log, type: .info)
DispatchQueue.main.async {
if AccountManager.shared.isSuspended {
AccountManager.shared.resumeAll()
}
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) { [unowned self] in
if !AccountManager.shared.isSuspended {
self.suspendApplication()
os_log("Account refresh operation completed.", log: self.log, type: .info)
task.setTaskCompleted(success: true)
}
}
}
// set expiration handler
task.expirationHandler = { [weak task] in
DispatchQueue.main.sync {
self.suspendApplication()
}
os_log("Accounts refresh processing terminated for running too long.", log: self.log, type: .info)
task?.setTaskCompleted(success: false)
}
}
}
private extension AppDelegate {
@objc func userDefaultsDidChange() {
updateUserInterfaceStyle()
}
var window: UIWindow? {
guard let scene = UIApplication.shared.connectedScenes.first,
let windowSceneDelegate = scene.delegate as? UIWindowSceneDelegate,
let window = windowSceneDelegate.window else {
return nil
}
return window
}
func updateUserInterfaceStyle() {
// switch AppDefaults.shared.userInterfaceColorPalette {
// case .automatic:
// window?.overrideUserInterfaceStyle = .unspecified
// case .light:
// window?.overrideUserInterfaceStyle = .light
// case .dark:
// window?.overrideUserInterfaceStyle = .dark
// }
}
}

View File

@@ -1,33 +0,0 @@
//
// ArticleShareView.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/13/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import SwiftUI
import Articles
extension UIActivityViewController {
convenience init(url: URL, title: String?, applicationActivities: [UIActivity]?) {
let itemSource = ArticleActivityItemSource(url: url, subject: title)
let titleSource = TitleActivityItemSource(title: title)
self.init(activityItems: [titleSource, itemSource], applicationActivities: applicationActivities)
}
}
struct ActivityViewController: UIViewControllerRepresentable {
var title: String?
var url: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
return UIActivityViewController(url: url, title: title, applicationActivities: [FindInArticleActivity(), OpenInSafariActivity()])
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {
}
}

View File

@@ -1,33 +0,0 @@
//
// ArticleActivityItemSource.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/13/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class ArticleActivityItemSource: NSObject, UIActivityItemSource {
private let url: URL
private let subject: String?
init(url: URL, subject: String?) {
self.url = url
self.subject = subject
}
func activityViewControllerPlaceholderItem(_ : UIActivityViewController) -> Any {
return url
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
return url
}
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String {
return subject ?? ""
}
}

View File

@@ -1,26 +0,0 @@
//
// ArticleView.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Articles
struct ArticleView: UIViewControllerRepresentable {
@EnvironmentObject private var sceneModel: SceneModel
func makeUIViewController(context: Context) -> ArticleViewController {
let controller = ArticleViewController()
controller.sceneModel = sceneModel
return controller
}
func updateUIViewController(_ uiViewController: ArticleViewController, context: Context) {
}
}

View File

@@ -1,163 +0,0 @@
//
// ArticleViewController.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import Combine
import WebKit
import Account
import Articles
import SafariServices
class ArticleViewController: UIViewController {
weak var sceneModel: SceneModel?
private var pageViewController: UIPageViewController!
private var currentWebViewController: WebViewController? {
return pageViewController?.viewControllers?.first as? WebViewController
}
var articles: [Article]? {
didSet {
currentArticle = articles?.first
}
}
var currentArticle: Article? {
didSet {
if let controller = currentWebViewController, controller.article != currentArticle {
controller.setArticle(currentArticle)
DispatchQueue.main.async {
// You have to set the view controller to clear out the UIPageViewController child controller cache.
// You also have to do it in an async call or you will get a strange assertion error.
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
}
}
}
}
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
pageViewController.delegate = self
pageViewController.dataSource = self
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pageViewController.view)
addChild(pageViewController!)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
])
sceneModel?.timelineModel.selectedArticlesPublisher?.sink { [weak self] articles in
self?.articles = articles
}
.store(in: &cancellables)
let controller = createWebViewController(currentArticle, updateView: true)
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
}
// MARK: API
func focus() {
currentWebViewController?.focus()
}
func canScrollDown() -> Bool {
return currentWebViewController?.canScrollDown() ?? false
}
func scrollPageDown() {
currentWebViewController?.scrollPageDown()
}
func stopArticleExtractorIfProcessing() {
currentWebViewController?.stopArticleExtractorIfProcessing()
}
}
// MARK: WebViewControllerDelegate
extension ArticleViewController: WebViewControllerDelegate {
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
if webViewController === currentWebViewController {
// articleExtractorButton.buttonState = buttonState
}
}
}
// MARK: UIPageViewControllerDataSource
extension ArticleViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = sceneModel?.findPrevArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = sceneModel?.findNextArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
}
// MARK: UIPageViewControllerDelegate
extension ArticleViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard finished, completed else { return }
// guard let article = currentWebViewController?.article else { return }
// articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() })
}
}
// MARK: Private
private extension ArticleViewController {
func createWebViewController(_ article: Article?, updateView: Bool = true) -> WebViewController {
let controller = WebViewController()
controller.sceneModel = sceneModel
controller.delegate = self
controller.setArticle(article, updateView: updateView)
return controller
}
}
public extension Notification.Name {
static let FindInArticle = Notification.Name("FindInArticle")
static let EndFindInArticle = Notification.Name("EndFindInArticle")
}

View File

@@ -1,40 +0,0 @@
//
// FindInArticleActivity.swift
// NetNewsWire-iOS
//
// Created by Brian Sanders on 5/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class FindInArticleActivity: UIActivity {
override var activityTitle: String? {
NSLocalizedString("Find in Article", comment: "Find in Article")
}
override var activityType: UIActivity.ActivityType? {
UIActivity.ActivityType(rawValue: "com.ranchero.NetNewsWire.find")
}
override var activityImage: UIImage? {
UIImage(systemName: "magnifyingglass", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
}
override class var activityCategory: UIActivity.Category {
.action
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
true
}
override func prepare(withActivityItems activityItems: [Any]) {
}
override func perform() {
NotificationCenter.default.post(Notification(name: .FindInArticle))
activityDidFinish(true)
}
}

View File

@@ -1,127 +0,0 @@
//
// IconView.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
final class IconView: UIView {
var iconImage: IconImage? = nil {
didSet {
if iconImage !== oldValue {
imageView.image = iconImage?.image
if self.traitCollection.userInterfaceStyle == .dark {
if self.iconImage?.isDark ?? false {
self.isDiscernable = false
self.setNeedsLayout()
} else {
self.isDiscernable = true
self.setNeedsLayout()
}
} else {
if self.iconImage?.isBright ?? false {
self.isDiscernable = false
self.setNeedsLayout()
} else {
self.isDiscernable = true
self.setNeedsLayout()
}
}
self.setNeedsLayout()
}
}
}
private var isDiscernable = true
private let imageView: UIImageView = {
let imageView = UIImageView(image: AppAssets.faviconTemplateImage)
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 4.0
return imageView
}()
private var isVerticalBackgroundExposed: Bool {
return imageView.frame.size.height < bounds.size.height
}
private var isSymbolImage: Bool {
return iconImage?.isSymbol ?? false
}
private var isBackgroundSuppressed: Bool {
return iconImage?.isBackgroundSupressed ?? false
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
convenience init() {
self.init(frame: .zero)
}
override func didMoveToSuperview() {
setNeedsLayout()
}
override func layoutSubviews() {
imageView.setFrameIfNotEqual(rectForImageView())
if (iconImage != nil && isVerticalBackgroundExposed) || !isDiscernable {
backgroundColor = AppAssets.uiIconBackgroundColor
} else {
backgroundColor = nil
}
}
}
private extension IconView {
func commonInit() {
layer.cornerRadius = 4
clipsToBounds = true
addSubview(imageView)
}
func rectForImageView() -> CGRect {
guard let image = iconImage?.image else {
return CGRect.zero
}
let imageSize = image.size
let viewSize = bounds.size
if imageSize.height == imageSize.width {
if imageSize.height >= viewSize.height {
return CGRect(x: 0.0, y: 0.0, width: viewSize.width, height: viewSize.height)
}
let offset = floor((viewSize.height - imageSize.height) / 2.0)
return CGRect(x: offset, y: offset, width: imageSize.width, height: imageSize.height)
}
else if imageSize.height > imageSize.width {
let factor = viewSize.height / imageSize.height
let width = imageSize.width * factor
let originX = floor((viewSize.width - width) / 2.0)
return CGRect(x: originX, y: 0.0, width: width, height: viewSize.height)
}
// Wider than tall: imageSize.width > imageSize.height
let factor = viewSize.width / imageSize.width
let height = imageSize.height * factor
let originY = floor((viewSize.height - height) / 2.0)
return CGRect(x: 0.0, y: originY, width: viewSize.width, height: height)
}
}

View File

@@ -1,361 +0,0 @@
//
// ImageScrollView.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate {
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView)
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView)
}
open class ImageScrollView: UIScrollView {
@objc public enum ScaleMode: Int {
case aspectFill
case aspectFit
case widthFill
case heightFill
}
@objc public enum Offset: Int {
case begining
case center
}
static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2
@objc open var imageContentMode: ScaleMode = .widthFill
@objc open var initialOffset: Offset = .begining
@objc public private(set) var zoomView: UIImageView? = nil
@objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate?
var imageSize: CGSize = CGSize.zero
private var pointToCenterAfterResize: CGPoint = CGPoint.zero
private var scaleToRestoreAfterResize: CGFloat = 1.0
var maxScaleFromMinScale: CGFloat = 3.0
var zoomedFrame: CGRect {
return zoomView?.frame ?? CGRect.zero
}
override open var frame: CGRect {
willSet {
if frame.equalTo(newValue) == false && newValue.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
prepareToResize()
}
}
didSet {
if frame.equalTo(oldValue) == false && frame.equalTo(CGRect.zero) == false && imageSize.equalTo(CGSize.zero) == false {
recoverFromResizing()
}
}
}
override public init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
private func initialize() {
showsVerticalScrollIndicator = false
showsHorizontalScrollIndicator = false
bouncesZoom = true
decelerationRate = UIScrollView.DecelerationRate.fast
delegate = self
}
@objc public func adjustFrameToCenter() {
guard let unwrappedZoomView = zoomView else {
return
}
var frameToCenter = unwrappedZoomView.frame
// center horizontally
if frameToCenter.size.width < bounds.width {
frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2
} else {
frameToCenter.origin.x = 0
}
// center vertically
if frameToCenter.size.height < bounds.height {
frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2
} else {
frameToCenter.origin.y = 0
}
unwrappedZoomView.frame = frameToCenter
}
private func prepareToResize() {
let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY)
pointToCenterAfterResize = convert(boundsCenter, to: zoomView)
scaleToRestoreAfterResize = zoomScale
// If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum
// allowable scale when the scale is restored.
if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) {
scaleToRestoreAfterResize = 0
}
}
private func recoverFromResizing() {
setMaxMinZoomScalesForCurrentBounds()
// restore zoom scale, first making sure it is within the allowable range.
let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize)
zoomScale = min(maximumZoomScale, maxZoomScale)
// restore center point, first making sure it is within the allowable range.
// convert our desired center point back to our own coordinate space
let boundsCenter = convert(pointToCenterAfterResize, to: zoomView)
// calculate the content offset that would yield that center point
var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0)
// restore offset, adjusted to be within the allowable range
let maxOffset = maximumContentOffset()
let minOffset = minimumContentOffset()
var realMaxOffset = min(maxOffset.x, offset.x)
offset.x = max(minOffset.x, realMaxOffset)
realMaxOffset = min(maxOffset.y, offset.y)
offset.y = max(minOffset.y, realMaxOffset)
contentOffset = offset
}
private func maximumContentOffset() -> CGPoint {
return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height)
}
private func minimumContentOffset() -> CGPoint {
return CGPoint.zero
}
// MARK: - Set up
open func setup() {
var topSupperView = superview
while topSupperView?.superview != nil {
topSupperView = topSupperView?.superview
}
// Make sure views have already layout with precise frame
topSupperView?.layoutIfNeeded()
}
// MARK: - Display image
@objc open func display(image: UIImage) {
if let zoomView = zoomView {
zoomView.removeFromSuperview()
}
zoomView = UIImageView(image: image)
zoomView!.isUserInteractionEnabled = true
addSubview(zoomView!)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(doubleTapGestureRecognizer(_:)))
tapGesture.numberOfTapsRequired = 2
zoomView!.addGestureRecognizer(tapGesture)
let downSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeUpGestureRecognizer(_:)))
downSwipeGesture.direction = .down
zoomView!.addGestureRecognizer(downSwipeGesture)
let upSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(swipeDownGestureRecognizer(_:)))
upSwipeGesture.direction = .up
zoomView!.addGestureRecognizer(upSwipeGesture)
configureImageForSize(image.size)
adjustFrameToCenter()
}
private func configureImageForSize(_ size: CGSize) {
imageSize = size
contentSize = imageSize
setMaxMinZoomScalesForCurrentBounds()
zoomScale = minimumZoomScale
switch initialOffset {
case .begining:
contentOffset = CGPoint.zero
case .center:
let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2
let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2
switch imageContentMode {
case .aspectFit:
contentOffset = CGPoint.zero
case .aspectFill:
contentOffset = CGPoint(x: xOffset, y: yOffset)
case .heightFill:
contentOffset = CGPoint(x: xOffset, y: 0)
case .widthFill:
contentOffset = CGPoint(x: 0, y: yOffset)
}
}
}
private func setMaxMinZoomScalesForCurrentBounds() {
// calculate min/max zoomscale
let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise
let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise
var minScale: CGFloat = 1
switch imageContentMode {
case .aspectFill:
minScale = max(xScale, yScale)
case .aspectFit:
minScale = min(xScale, yScale)
case .widthFill:
minScale = xScale
case .heightFill:
minScale = yScale
}
let maxScale = maxScaleFromMinScale*minScale
// don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.)
if minScale > maxScale {
minScale = maxScale
}
maximumZoomScale = maxScale
minimumZoomScale = minScale // * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController
}
// MARK: - Gesture
@objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
// zoom out if it bigger than middle scale point. Else, zoom in
if zoomScale >= maximumZoomScale / 2.0 {
setZoomScale(minimumZoomScale, animated: true)
} else {
let center = gestureRecognizer.location(in: gestureRecognizer.view)
let zoomRect = zoomRectForScale(ImageScrollView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center)
zoom(to: zoomRect, animated: true)
}
}
@objc func swipeUpGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .ended {
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeUp(imageScrollView: self)
}
}
@objc func swipeDownGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .ended {
imageScrollViewDelegate?.imageScrollViewDidGestureSwipeDown(imageScrollView: self)
}
}
private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
// the zoom rect is in the content view's coordinates.
// at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds.
// as the zoom scale decreases, so more content is visible, the size of the rect grows.
zoomRect.size.height = frame.size.height / scale
zoomRect.size.width = frame.size.width / scale
// choose an origin so as to get the right center.
zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0)
zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0)
return zoomRect
}
open func refresh() {
if let image = zoomView?.image {
display(image: image)
}
}
open func resize() {
self.configureImageForSize(self.imageSize)
}
}
extension ImageScrollView: UIScrollViewDelegate {
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidScroll?(scrollView)
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView)
}
public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset)
}
public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate)
}
public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView)
}
public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView)
}
public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView)
}
public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view)
}
public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale)
}
public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
return false
}
@available(iOS 11.0, *)
public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) {
imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView)
}
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return zoomView
}
public func scrollViewDidZoom(_ scrollView: UIScrollView) {
adjustFrameToCenter()
imageScrollViewDelegate?.scrollViewDidZoom?(scrollView)
}
}

View File

@@ -1,110 +0,0 @@
//
// ImageTransition.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
private weak var webViewController: WebViewController?
private let duration = 0.4
var presenting = true
var originFrame: CGRect!
var maskFrame: CGRect!
var originImage: UIImage!
init(controller: WebViewController) {
self.webViewController = controller
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return duration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
if presenting {
animateTransitionPresenting(using: transitionContext)
} else {
animateTransitionReturning(using: transitionContext)
}
}
private func animateTransitionPresenting(using transitionContext: UIViewControllerContextTransitioning) {
let imageView = UIImageView(image: originImage)
imageView.frame = originFrame
let fromView = transitionContext.view(forKey: .from)!
fromView.removeFromSuperview()
transitionContext.containerView.backgroundColor = .systemBackground
transitionContext.containerView.addSubview(imageView)
webViewController?.hideClickedImage()
UIView.animate(
withDuration: duration,
delay:0.0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2,
animations: {
let imageController = transitionContext.viewController(forKey: .to) as! ImageViewController
imageView.frame = imageController.zoomedFrame
}, completion: { _ in
imageView.removeFromSuperview()
let toView = transitionContext.view(forKey: .to)!
transitionContext.containerView.addSubview(toView)
transitionContext.completeTransition(true)
})
}
private func animateTransitionReturning(using transitionContext: UIViewControllerContextTransitioning) {
let imageController = transitionContext.viewController(forKey: .from) as! ImageViewController
let imageView = UIImageView(image: originImage)
imageView.frame = imageController.zoomedFrame
let fromView = transitionContext.view(forKey: .from)!
let windowFrame = fromView.window!.frame
fromView.removeFromSuperview()
let toView = transitionContext.view(forKey: .to)!
transitionContext.containerView.addSubview(toView)
let maskingView = UIView()
let fullMaskFrame = CGRect(x: windowFrame.minX, y: maskFrame.minY, width: windowFrame.width, height: maskFrame.height)
let path = UIBezierPath(rect: fullMaskFrame)
let maskLayer = CAShapeLayer()
maskLayer.path = path.cgPath
maskingView.layer.mask = maskLayer
maskingView.addSubview(imageView)
transitionContext.containerView.addSubview(maskingView)
UIView.animate(
withDuration: duration,
delay:0.0,
usingSpringWithDamping: 0.8,
initialSpringVelocity: 0.2,
animations: {
imageView.frame = self.originFrame
}, completion: { _ in
if let controller = self.webViewController {
controller.showClickedImage() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
}
} else {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
})
}
}

View File

@@ -1,92 +0,0 @@
//
// ImageViewController.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class ImageViewController: UIViewController {
@IBOutlet weak var closeButton: UIButton!
@IBOutlet weak var shareButton: UIButton!
@IBOutlet weak var imageScrollView: ImageScrollView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var titleBackground: UIVisualEffectView!
@IBOutlet weak var titleLeading: NSLayoutConstraint!
@IBOutlet weak var titleTrailing: NSLayoutConstraint!
var image: UIImage!
var imageTitle: String?
var zoomedFrame: CGRect {
return imageScrollView.zoomedFrame
}
override func viewDidLoad() {
super.viewDidLoad()
closeButton.imageView?.contentMode = .scaleAspectFit
closeButton.accessibilityLabel = NSLocalizedString("Close", comment: "Close")
shareButton.accessibilityLabel = NSLocalizedString("Share", comment: "Share")
imageScrollView.setup()
imageScrollView.imageScrollViewDelegate = self
imageScrollView.imageContentMode = .aspectFit
imageScrollView.initialOffset = .center
imageScrollView.display(image: image)
titleLabel.text = imageTitle ?? ""
layoutTitleLabel()
guard imageTitle != "" else {
titleBackground.removeFromSuperview()
return
}
titleBackground.layer.cornerRadius = 6
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { [weak self] context in
self?.imageScrollView.resize()
})
}
@IBAction func share(_ sender: Any) {
guard let image = image else { return }
let activityViewController = UIActivityViewController(activityItems: [image], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = shareButton
activityViewController.popoverPresentationController?.sourceRect = shareButton.bounds
present(activityViewController, animated: true)
}
@IBAction func done(_ sender: Any) {
dismiss(animated: true)
}
private func layoutTitleLabel(){
let width = view.frame.width
let multiplier = UIDevice.current.userInterfaceIdiom == .pad ? CGFloat(0.1) : CGFloat(0.04)
titleLeading.constant += width * multiplier
titleTrailing.constant -= width * multiplier
titleLabel.layoutIfNeeded()
}
}
// MARK: ImageScrollViewDelegate
extension ImageViewController: ImageScrollViewDelegate {
func imageScrollViewDidGestureSwipeUp(imageScrollView: ImageScrollView) {
dismiss(animated: true)
}
func imageScrollViewDidGestureSwipeDown(imageScrollView: ImageScrollView) {
dismiss(animated: true)
}
}

View File

@@ -1,49 +0,0 @@
//
// OpenInSafariActivity.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/13/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class OpenInSafariActivity: UIActivity {
private var activityItems: [Any]?
override var activityTitle: String? {
return NSLocalizedString("Open in Safari", comment: "Open in Safari")
}
override var activityImage: UIImage? {
return UIImage(systemName: "safari", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
}
override var activityType: UIActivity.ActivityType? {
return UIActivity.ActivityType(rawValue: "com.rancharo.NetNewsWire-Evergreen.safari")
}
override class var activityCategory: UIActivity.Category {
return .action
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
return true
}
override func prepare(withActivityItems activityItems: [Any]) {
self.activityItems = activityItems
}
override func perform() {
guard let url = activityItems?.first(where: { $0 is URL }) as? URL else {
activityDidFinish(false)
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
activityDidFinish(true)
}
}

View File

@@ -1,41 +0,0 @@
//
// TitleActivityItemSource.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/13/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class TitleActivityItemSource: NSObject, UIActivityItemSource {
private let title: String?
init(title: String?) {
self.title = title
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return title as Any
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
guard let activityType = activityType,
let title = title else {
return NSNull()
}
switch activityType.rawValue {
case "com.omnigroup.OmniFocus3.iOS.QuickEntry",
"com.culturedcode.ThingsiPhone.ShareExtension",
"com.tapbots.Tweetbot4.shareextension",
"com.tapbots.Tweetbot6.shareextension",
"com.buffer.buffer.Buffer":
return title
default:
return NSNull()
}
}
}

View File

@@ -1,765 +0,0 @@
//
// WebViewController.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import WebKit
import RSCore
import Account
import Articles
import SafariServices
import MessageUI
protocol WebViewControllerDelegate: AnyObject {
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
}
class WebViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
static let showFeedInspector = "showFeedInspector"
}
private var topShowBarsView: UIView!
private var bottomShowBarsView: UIView!
private var topShowBarsViewConstraint: NSLayoutConstraint!
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
private var webView: PreloadedWebView? {
guard view.subviews.count > 0 else { return nil }
return view.subviews[0] as? PreloadedWebView
}
// private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
private var isFullScreenAvailable: Bool {
return AppDefaults.shared.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone // && coordinator.isRootSplitCollapsed
}
private lazy var transition = ImageTransition(controller: self)
private var clickedImageCompletion: (() -> Void)?
private var articleExtractor: ArticleExtractor? = nil
var extractedArticle: ExtractedArticle? {
didSet {
windowScrollY = 0
}
}
var isShowingExtractedArticle = false
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
didSet {
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
}
}
var sceneModel: SceneModel?
weak var delegate: WebViewControllerDelegate?
private(set) var article: Article?
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3)
var windowScrollY = 0
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
// Configure the tap zones
// configureTopShowBarsView()
// configureBottomShowBarsView()
loadWebView()
}
// MARK: Notifications
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
// MARK: Actions
// @objc func showBars(_ sender: Any) {
// showBars()
// }
// MARK: API
func setArticle(_ article: Article?, updateView: Bool = true) {
stopArticleExtractor()
if article != self.article {
self.article = article
if updateView {
if article?.webFeed?.isArticleExtractorAlwaysOn ?? false {
startArticleExtractor()
}
windowScrollY = 0
loadWebView()
}
}
}
func focus() {
webView?.becomeFirstResponder()
}
func canScrollDown() -> Bool {
guard let webView = webView else { return false }
return webView.scrollView.contentOffset.y < finalScrollPosition()
}
func scrollPageDown() {
guard let webView = webView else { return }
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
let scrollToY: CGFloat = {
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
let final = finalScrollPosition()
return fullScroll < final ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
}
func fullReload() {
loadWebView(replaceExistingWebView: true)
}
// func showBars() {
// AppDefaults.shared.articleFullscreenEnabled = false
// coordinator.showStatusBar()
// topShowBarsViewConstraint?.constant = 0
// bottomShowBarsViewConstraint?.constant = 0
// navigationController?.setNavigationBarHidden(false, animated: true)
// navigationController?.setToolbarHidden(false, animated: true)
// configureContextMenuInteraction()
// }
//
// func hideBars() {
// if isFullScreenAvailable {
// AppDefaults.shared.articleFullscreenEnabled = true
// coordinator.hideStatusBar()
// topShowBarsViewConstraint?.constant = -44.0
// bottomShowBarsViewConstraint?.constant = 44.0
// navigationController?.setNavigationBarHidden(true, animated: true)
// navigationController?.setToolbarHidden(true, animated: true)
// configureContextMenuInteraction()
// }
// }
func toggleArticleExtractor() {
guard let article = article else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
loadWebView()
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
loadWebView()
articleExtractorButtonState = .off
return
}
if let articleExtractor = articleExtractor {
if article.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
} else {
startArticleExtractor()
}
}
func stopArticleExtractorIfProcessing() {
if articleExtractor?.state == .processing {
stopArticleExtractor()
}
}
func stopWebViewActivity() {
if let webView = webView {
stopMediaPlayback(webView)
cancelImageLoad(webView)
}
}
}
// MARK: ArticleExtractorDelegate
extension WebViewController: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleExtractorButtonState = .error
loadWebView()
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if articleExtractor?.state != .cancelled {
self.extractedArticle = extractedArticle
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
}
}
// MARK: UIContextMenuInteractionDelegate
//extension WebViewController: UIContextMenuInteractionDelegate {
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
//
// return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in
// guard let self = self else { return nil }
// var actions = [UIAction]()
//
// if let action = self.prevArticleAction() {
// actions.append(action)
// }
// if let action = self.nextArticleAction() {
// actions.append(action)
// }
// if let action = self.toggleReadAction() {
// actions.append(action)
// }
// actions.append(self.toggleStarredAction())
// if let action = self.nextUnreadArticleAction() {
// actions.append(action)
// }
// actions.append(self.toggleArticleExtractorAction())
// actions.append(self.shareAction())
//
// return UIMenu(title: "", children: actions)
// }
// }
//
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
// coordinator.showBrowserForCurrentArticle()
// }
//
//}
// MARK: WKNavigationDelegate
extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
for (index, view) in view.subviews.enumerated() {
if index != 0, let oldWebView = view as? PreloadedWebView {
oldWebView.removeFromSuperview()
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
decisionHandler(.cancel)
// If the resource cannot be opened with an installed app, present the web view.
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
assert(Thread.isMainThread)
guard didOpen == false else {
return
}
let vc = SFSafariViewController(url: url)
self.present(vc, animated: true)
}
} else if components?.scheme == "mailto" {
decisionHandler(.cancel)
guard let emailAddress = url.percentEncodedEmailAddress else {
return
}
if UIApplication.shared.canOpenURL(emailAddress) {
UIApplication.shared.open(emailAddress, options: [.universalLinksOnly : false], completionHandler: nil)
} else {
let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert)
alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil))
self.present(alert, animated: true, completion: nil)
}
} else if components?.scheme == "tel" {
decisionHandler(.cancel)
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [.universalLinksOnly : false], completionHandler: nil)
}
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
fullReload()
}
}
// MARK: WKUIDelegate
extension WebViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
}
// MARK: WKScriptMessageHandler
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
case MessageName.showFeedInspector:
return
// if let webFeed = article?.webFeed {
// coordinator.showFeedInspector(for: webFeed)
// }
default:
return
}
}
}
// MARK: UIViewControllerTransitioningDelegate
extension WebViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK:
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
}
@objc func scrollPositionDidChange() {
webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in
guard error == nil else { return }
let javascriptScrollY = scrollY as? Int ?? 0
// I don't know why this value gets returned sometimes, but it is in error
guard javascriptScrollY != 33554432 else { return }
self.windowScrollY = javascriptScrollY
}
}
}
// MARK: JSON
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageTitle: String?
let imageURL: String
}
// MARK: Private
private extension WebViewController {
func loadWebView(replaceExistingWebView: Bool = false) {
guard isViewLoaded else { return }
if !replaceExistingWebView, let webView = webView {
self.renderPage(webView)
return
}
sceneModel?.webViewProvider?.dequeueWebView() { webView in
webView.ready {
// Add the webview
webView.translatesAutoresizingMaskIntoConstraints = false
self.view.insertSubview(webView, at: 0)
NSLayoutConstraint.activate([
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
])
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
// us from easily swiping between WKWebViews. This hack fixes that.
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
webView.scrollView.setZoomScale(1.0, animated: false)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
// Configure the webview
webView.navigationDelegate = self
webView.uiDelegate = self
webView.scrollView.delegate = self
// self.configureContextMenuInteraction()
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
self.renderPage(webView)
}
}
}
func renderPage(_ webView: PreloadedWebView?) {
guard let webView = webView else { return }
let theme = ArticleThemesManager.shared.currentTheme
let rendering: ArticleRenderer.Rendering
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
rendering = ArticleRenderer.loadingHTML(theme: theme)
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
} else if let article = article, let extractedArticle = extractedArticle {
if isShowingExtractedArticle {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, theme: theme)
} else {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
}
} else if let article = article {
rendering = ArticleRenderer.articleHTML(article: article, theme: theme)
} else {
rendering = ArticleRenderer.noSelectionHTML(theme: theme)
}
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
"body": rendering.html,
"windowScrollY": String(windowScrollY)
]
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
}
func finalScrollPosition() -> CGFloat {
guard let webView = webView else { return 0 }
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
}
func startArticleExtractor() {
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleExtractorButtonState = .off
}
func reloadArticleImage() {
guard let article = article else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func imageWasClicked(body: String?) {
guard let webView = webView,
let body = body,
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
// coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
}
}
func stopMediaPlayback(_ webView: WKWebView) {
webView.evaluateJavaScript("stopMediaPlayback();")
}
func cancelImageLoad(_ webView: WKWebView) {
webView.evaluateJavaScript("cancelImageLoad();")
}
// func configureTopShowBarsView() {
// topShowBarsView = UIView()
// topShowBarsView.backgroundColor = .clear
// topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(topShowBarsView)
//
// if AppDefaults.shared.articleFullscreenEnabled {
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
// } else {
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
// }
//
// NSLayoutConstraint.activate([
// topShowBarsViewConstraint,
// view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
// view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
// topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
// ])
// topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
// }
//
// func configureBottomShowBarsView() {
// bottomShowBarsView = UIView()
// topShowBarsView.backgroundColor = .clear
// bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(bottomShowBarsView)
// if AppDefaults.shared.articleFullscreenEnabled {
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
// } else {
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
// }
// NSLayoutConstraint.activate([
// bottomShowBarsViewConstraint,
// view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
// view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
// bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
// ])
// bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
// }
// func configureContextMenuInteraction() {
// if isFullScreenAvailable {
// if navigationController?.isNavigationBarHidden ?? false {
// webView?.addInteraction(contextMenuInteraction)
// } else {
// webView?.removeInteraction(contextMenuInteraction)
// }
// }
// }
//
// func contextMenuPreviewProvider() -> UIViewController {
// let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self)
// previewProvider.article = article
// return previewProvider
// }
//
// func prevArticleAction() -> UIAction? {
// guard coordinator.isPrevArticleAvailable else { return nil }
// let title = NSLocalizedString("Previous Article", comment: "Previous Article")
// return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in
// self?.coordinator.selectPrevArticle()
// }
// }
//
// func nextArticleAction() -> UIAction? {
// guard coordinator.isNextArticleAvailable else { return nil }
// let title = NSLocalizedString("Next Article", comment: "Next Article")
// return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in
// self?.coordinator.selectNextArticle()
// }
// }
//
// func toggleReadAction() -> UIAction? {
// guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
//
// let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
// let readImage = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
// return UIAction(title: title, image: readImage) { [weak self] action in
// self?.coordinator.toggleReadForCurrentArticle()
// }
// }
//
// func toggleStarredAction() -> UIAction {
// let starred = article?.status.starred ?? false
// let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
// let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
// return UIAction(title: title, image: starredImage) { [weak self] action in
// self?.coordinator.toggleStarredForCurrentArticle()
// }
// }
//
// func nextUnreadArticleAction() -> UIAction? {
// guard coordinator.isAnyUnreadAvailable else { return nil }
// let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
// return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in
// self?.coordinator.selectNextUnread()
// }
// }
//
// func toggleArticleExtractorAction() -> UIAction {
// let extracted = articleExtractorButtonState == .on
// let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
// let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
// return UIAction(title: title, image: extractorImage) { [weak self] action in
// self?.toggleArticleExtractor()
// }
// }
//
// func shareAction() -> UIAction {
// let title = NSLocalizedString("Share", comment: "Share")
// return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
// self?.showActivityDialog()
// }
// }
}
// MARK: Find in Article
private struct FindInArticleOptions: Codable {
var text: String
var caseSensitive = false
var regex = false
}
internal struct FindInArticleState: Codable {
struct WebViewClientRect: Codable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct FindInArticleResult: Codable {
let rects: [WebViewClientRect]
let bounds: WebViewClientRect
let index: UInt
let matchGroups: [String]
}
let index: UInt?
let results: [FindInArticleResult]
let count: UInt
}
extension WebViewController {
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
return
}
let encoded = json.base64EncodedString()
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") {
(result, error) in
guard error == nil,
let b64 = result as? String,
let rawData = Data(base64Encoded: b64),
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
return
}
completionHandler(findState)
}
}
func endSearch() {
webView?.evaluateJavaScript("endFind()")
}
func selectNextSearchResult() {
webView?.evaluateJavaScript("selectNextResult()")
}
func selectPreviousSearchResult() {
webView?.evaluateJavaScript("selectPreviousResult()")
}
}

View File

@@ -1,45 +0,0 @@
//
// AttributedStringView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/16/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import SwiftUI
struct AttributedStringView: UIViewRepresentable {
let string: NSAttributedString
let preferredMaxLayoutWidth: CGFloat
func makeUIView(context: Context) -> HackedTextView {
return HackedTextView()
}
func updateUIView(_ view: HackedTextView, context: Context) {
view.attributedText = string
view.preferredMaxLayoutWidth = preferredMaxLayoutWidth
view.isScrollEnabled = false
view.textContainer.lineBreakMode = .byWordWrapping
view.isUserInteractionEnabled = true
view.adjustsFontForContentSizeCategory = true
view.font = .preferredFont(forTextStyle: .body)
view.textColor = UIColor.label
view.tintColor = AppAssets.accentColor
view.backgroundColor = UIColor.secondarySystemGroupedBackground
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.setContentCompressionResistancePriority(.required, for: .vertical)
}
}
class HackedTextView: UITextView {
var preferredMaxLayoutWidth = CGFloat.zero
override var intrinsicContentSize: CGSize {
return sizeThatFits(CGSize(width: preferredMaxLayoutWidth, height: .infinity))
}
}

View File

@@ -1,89 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.ranchero.NetNewsWire.FeedRefresh</string>
</array>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Grant permission to save images from the article.</string>
<key>NSUserActivityTypes</key>
<array>
<string>AddWebFeedIntent</string>
<string>NextUnread</string>
<string>ReadArticle</string>
<string>Restoration</string>
<string>SelectFeed</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>remote-notification</string>
</array>
<key>UserAgent</key>
<string>NetNewsWire (RSS Reader; https://netnewswire.com/)</string>
<key>OrganizationIdentifier</key>
<string>$(ORGANIZATION_IDENTIFIER)</string>
<key>DeveloperEntitlements</key>
<string>$(DEVELOPER_ENTITLEMENTS)</string>
<key>AppGroup</key>
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
<key>AppIdentifierPrefix</key>
<string>$(AppIdentifierPrefix)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>mailto</string>
</array>
</dict>
</plist>

View File

@@ -1,63 +0,0 @@
//
// SafariView.swift
// Multiplatform iOS
//
// Created by Stuart Breckenridge on 30/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import SafariServices
private final class Safari: UIViewControllerRepresentable {
var urlToLoad: URL
init(url: URL) {
self.urlToLoad = url
}
func makeUIViewController(context: Context) -> SFSafariViewController {
let viewController = SFSafariViewController(url: urlToLoad)
viewController.delegate = context.coordinator
return viewController
}
func updateUIViewController(_ uiViewController: SFSafariViewController, context: Context) {
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, SFSafariViewControllerDelegate {
var parent: Safari
init(_ parent: Safari) {
self.parent = parent
}
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
}
}
}
struct SafariView: View {
var url: URL
var body: some View {
Safari(url: url)
}
}
struct SafariView_Previews: PreviewProvider {
static var previews: some View {
SafariView(url: URL(string: "https://netnewswire.com/")!)
}
}

View File

@@ -1,12 +0,0 @@
{\rtf1\ansi\ansicpg1252\cocoartf2513
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande-Bold;}
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;\red10\green96\blue255;}
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0;\cssrgb\c0\c47843\c100000\cname systemBlueColor;}
\margl1440\margr1440\vieww8340\viewh9300\viewkind0
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\li363\fi-364\pardirnatural\partightenfactor0
\f0\b\fs28 \cf2 By Brent Simmons and the Ranchero Software team
\fs22 \
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
{\field{\*\fldinst{HYPERLINK "https://ranchero.com/netnewswire/"}}{\fldrslt
\fs28 \cf3 netnewswire.com}}}

View File

@@ -1,20 +0,0 @@
{\rtf1\ansi\ansicpg1252\cocoartf2511
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande;}
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;}
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;}
\margl1440\margr1440\vieww14220\viewh13280\viewkind0
\deftab720
\pard\pardeftab720\li360\fi-360\sa60\partightenfactor0
\f0\fs22 \cf2 iOS app design: {\field{\*\fldinst{HYPERLINK "https://inessential.com/"}}{\fldrslt Brent Simmons}} and {\field{\*\fldinst{HYPERLINK "https://github.com/vincode-io"}}{\fldrslt Maurice Parker}}\
Lead iOS developer: {\field{\*\fldinst{HYPERLINK "https://github.com/vincode-io"}}{\fldrslt Maurice Parker}}\
App icon: {\field{\*\fldinst{HYPERLINK "https://twitter.com/BradEllis"}}{\fldrslt Brad Ellis}}\
\pard\pardeftab720\li366\fi-367\sa60\partightenfactor0
\cf2 Feedly syncing: {\field{\*\fldinst{HYPERLINK "https://twitter.com/kielgillard"}}{\fldrslt Kiel Gillard}}\
Under-the-hood magic and CSS stylin\'92s: {\field{\*\fldinst{HYPERLINK "https://github.com/wevah"}}{\fldrslt Nate Weaver}}\
\pard\pardeftab720\li362\fi-363\sa60\partightenfactor0
\cf2 Newsfoot (JS footnote displayer): {\field{\*\fldinst{HYPERLINK "https://github.com/brehaut/"}}{\fldrslt Andrew Brehaut}}\
\pard\pardeftab720\li355\fi-356\sa60\partightenfactor0
\cf2 Help book: {\field{\*\fldinst{HYPERLINK "https://nostodnayr.net/"}}{\fldrslt Ryan Dotson}}\
\pard\pardeftab720\li358\fi-359\sa60\partightenfactor0
\cf2 And more {\field{\*\fldinst{HYPERLINK "https://github.com/brentsimmons/NetNewsWire/graphs/contributors"}}{\fldrslt contributors}}!}

View File

@@ -1,9 +0,0 @@
{\rtf1\ansi\ansicpg1252\cocoartf2513
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande;}
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;}
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;}
\margl1440\margr1440\vieww9000\viewh8400\viewkind0
\deftab720
\pard\pardeftab720\sa60\partightenfactor0
\f0\fs22 \cf2 NetNewsWire 6 is dedicated to everyone working to save democracy around the world.}

View File

@@ -1,31 +0,0 @@
//
// SettingsAboutModel.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
class SettingsAboutModel: ObservableObject {
var about: NSAttributedString
var credits: NSAttributedString
var thanks: NSAttributedString
var dedication: NSAttributedString
init() {
about = SettingsAboutModel.loadResource("About")
credits = SettingsAboutModel.loadResource("Credits")
thanks = SettingsAboutModel.loadResource("Thanks")
dedication = SettingsAboutModel.loadResource("Dedication")
}
private static func loadResource(_ resource: String) -> NSAttributedString {
let url = Bundle.main.url(forResource: resource, withExtension: "rtf")!
return try! NSAttributedString(url: url, options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.rtf], documentAttributes: nil)
}
}

View File

@@ -1,41 +0,0 @@
//
// SettingsAboutView.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/16/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import SwiftUI
import Combine
struct SettingsAboutView: View {
@StateObject var viewModel = SettingsAboutModel()
var body: some View {
GeometryReader { geometry in
List {
Text("NetNewsWire").font(.largeTitle)
AttributedStringView(string: self.viewModel.about, preferredMaxLayoutWidth: geometry.size.width - 20)
Section(header: Text("CREDITS")) {
AttributedStringView(string: self.viewModel.credits, preferredMaxLayoutWidth: geometry.size.width - 20)
}
Section(header: Text("THANKS")) {
AttributedStringView(string: self.viewModel.thanks, preferredMaxLayoutWidth: geometry.size.width - 20)
}
Section(header: Text("DEDICATION"), footer: Text("Copyright © 2002-2021 Brent Simmons").font(.footnote)) {
AttributedStringView(string: self.viewModel.dedication, preferredMaxLayoutWidth: geometry.size.width - 20)
}
}.listStyle(InsetGroupedListStyle())
}
.navigationTitle(Text("About"))
}
}
struct SettingsAboutView_Previews: PreviewProvider {
static var previews: some View {
SettingsAboutView()
}
}

View File

@@ -1,11 +0,0 @@
{\rtf1\ansi\ansicpg1252\cocoartf2511
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 LucidaGrande;}
{\colortbl;\red255\green255\blue255;\red0\green0\blue0;}
{\*\expandedcolortbl;;\cssrgb\c0\c0\c0\cname textColor;}
\margl1440\margr1440\vieww11780\viewh11640\viewkind0
\deftab720
\pard\pardeftab720\li365\fi-366\sa60\partightenfactor0
\f0\fs22 \cf2 Thanks to Sheila and my family; thanks to my friends in Seattle and around the globe; thanks to the ever-patient and ever-awesome NetNewsWire beta testers. \
\pard\tx0\pardeftab720\li360\fi-361\sa60\partightenfactor0
\cf2 Thanks to {\field{\*\fldinst{HYPERLINK "https://shapeof.com/"}}{\fldrslt Gus Mueller}} for {\field{\*\fldinst{HYPERLINK "https://github.com/ccgus/fmdb"}}{\fldrslt FMDB}} by {\field{\*\fldinst{HYPERLINK "http://flyingmeat.com/"}}{\fldrslt Flying Meat Software}}. Thanks to {\field{\*\fldinst{HYPERLINK "https://github.com"}}{\fldrslt GitHub}} and {\field{\*\fldinst{HYPERLINK "https://slack.com"}}{\fldrslt Slack}} for making open source collaboration easy and fun. Thanks to {\field{\*\fldinst{HYPERLINK "https://benubois.com/"}}{\fldrslt Ben Ubois}} at {\field{\*\fldinst{HYPERLINK "https://feedbin.com/"}}{\fldrslt Feedbin}} for all the extra help with syncing and article rendering \'97\'a0and for {\field{\*\fldinst{HYPERLINK "https://feedbin.com/blog/2019/03/11/the-future-of-full-content/"}}{\fldrslt hosting the server for the Reader view}}.}

View File

@@ -1,37 +0,0 @@
//
// AccountCredentialsError.swift
// Multiplatform iOS
//
// Created by Rizwan on 21/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
enum AccountCredentialsError: CustomStringConvertible, Equatable {
case none, keyChain, invalidCredentials, noNetwork, other(error: Error)
var description: String {
switch self {
case .keyChain:
return NSLocalizedString("Keychain error while storing credentials.", comment: "")
case .invalidCredentials:
return NSLocalizedString("Invalid email/password combination.", comment: "")
case .noNetwork:
return NSLocalizedString("Network error. Try again later.", comment: "")
case .other(let error):
return NSLocalizedString(error.localizedDescription, comment: "Other add account error")
default:
return ""
}
}
static func ==(lhs: AccountCredentialsError, rhs: AccountCredentialsError) -> Bool {
switch (lhs, rhs) {
case (.other(let lhsError), .other(let rhsError)):
return lhsError.localizedDescription == rhsError.localizedDescription
default:
return false
}
}
}

View File

@@ -1,37 +0,0 @@
//
// AccountHeaderImageView.swift
// Multiplatform iOS
//
// Created by Rizwan on 08/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import RSCore
struct AccountHeaderImageView: View {
var image: RSImage
var body: some View {
HStack(alignment: .center) {
Spacer()
Image(rsImage: image)
.resizable()
.scaledToFit()
.frame(height: 48, alignment: .center)
.foregroundColor(Color.primary)
Spacer()
}
.padding(16)
}
}
struct AccountHeaderImageView_Previews: PreviewProvider {
static var previews: some View {
Group {
AccountHeaderImageView(image: AppAssets.image(for: .onMyMac)!)
AccountHeaderImageView(image: AppAssets.image(for: .feedbin)!)
AccountHeaderImageView(image: AppAssets.accountLocalPadImage)
}
}
}

View File

@@ -1,49 +0,0 @@
//
// SettingsAccountLabelView.swift
// Multiplatform iOS
//
// Created by Rizwan on 07/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import RSCore
struct SettingsAccountLabelView: View {
let accountImage: RSImage?
let accountLabel: String
var body: some View {
HStack {
Image(rsImage: accountImage!)
.resizable()
.scaledToFit()
.frame(width: 32, height: 32)
Text(verbatim: accountLabel).font(.title)
}
.foregroundColor(.primary).padding(4.0)
}
}
struct SettingsAccountLabelView_Previews: PreviewProvider {
static var previews: some View {
List {
SettingsAccountLabelView(
accountImage: AppAssets.image(for: .onMyMac),
accountLabel: "On My Device"
)
SettingsAccountLabelView(
accountImage: AppAssets.image(for: .feedbin),
accountLabel: "Feedbin"
)
SettingsAccountLabelView(
accountImage: AppAssets.accountLocalPadImage,
accountLabel: "On My iPad"
)
SettingsAccountLabelView(
accountImage: AppAssets.accountLocalPhoneImage,
accountLabel: "On My iPhone"
)
}
}
}

View File

@@ -1,46 +0,0 @@
//
// SettingsAddAccountModel.swift
// Multiplatform iOS
//
// Created by Rizwan on 09/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
import RSCore
class SettingsAddAccountModel: ObservableObject {
struct SettingsAddAccount: Identifiable {
var id: Int { accountType.rawValue }
let name: String
let accountType: AccountType
var image: RSImage {
AppAssets.image(for: accountType)!
}
}
@Published var accounts: [SettingsAddAccount] = []
@Published var isAddPresented = false
@Published var selectedAccountType: AccountType? = nil {
didSet {
selectedAccountType != nil ? (isAddPresented = true) : (isAddPresented = false)
}
}
init() {
self.accounts = [
SettingsAddAccount(name: Account.defaultLocalAccountName, accountType: .onMyMac),
SettingsAddAccount(name: "Feedbin", accountType: .feedbin),
SettingsAddAccount(name: "Feedly", accountType: .feedly),
SettingsAddAccount(name: "Feed Wrangler", accountType: .feedWrangler),
SettingsAddAccount(name: "iCloud", accountType: .cloudKit),
SettingsAddAccount(name: "NewsBlur", accountType: .newsBlur),
SettingsAddAccount(name: "Fresh RSS", accountType: .freshRSS)
]
}
}

View File

@@ -1,55 +0,0 @@
//
// SettingsAddAccountView.swift
// Multiplatform iOS
//
// Created by Rizwan on 07/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct SettingsAddAccountView: View {
@StateObject private var model = SettingsAddAccountModel()
var body: some View {
List {
ForEach(model.accounts) { account in
Button(action: {
model.selectedAccountType = account.accountType
}) {
SettingsAccountLabelView(
accountImage: account.image,
accountLabel: account.name
)
}
}
}
.listStyle(InsetGroupedListStyle())
.sheet(isPresented: $model.isAddPresented) {
switch model.selectedAccountType! {
case .onMyMac:
AddLocalAccountView()
case .feedbin:
AddFeedbinAccountView()
case .cloudKit:
AddCloudKitAccountView()
case .feedWrangler:
AddFeedWranglerAccountView()
case .newsBlur:
AddNewsBlurAccountView()
case .feedly:
AddFeedlyAccountView()
default:
AddReaderAPIAccountView(accountType: model.selectedAccountType!)
}
}
.navigationBarTitle(Text("Add Account"), displayMode: .inline)
}
}
struct SettingsAddAccountView_Previews: PreviewProvider {
static var previews: some View {
SettingsAddAccountView()
}
}

View File

@@ -1,49 +0,0 @@
//
// SettingsCloudKitAccountView.swift
// Multiplatform iOS
//
// Created by Rizwan on 13/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct SettingsCloudKitAccountView: View {
@Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
List {
Section(header: AccountHeaderImageView(image: AppAssets.image(for: .cloudKit)!)) { }
Section {
HStack {
Spacer()
Button(action: { self.addAccount() }) {
Text("Add Account")
}
Spacer()
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text(verbatim: "iCloud"), displayMode: .inline)
.navigationBarItems(leading: Button(action: { self.dismiss() }) { Text("Cancel") } )
}
}
private func addAccount() {
_ = AccountManager.shared.createAccount(type: .cloudKit)
dismiss()
}
private func dismiss() {
presentationMode.wrappedValue.dismiss()
}
}
struct SettingsCloudKitAccountView_Previews: PreviewProvider {
static var previews: some View {
SettingsCloudKitAccountView()
}
}

View File

@@ -1,281 +0,0 @@
//
// SettingsCredentialsAccountModel.swift
// Multiplatform iOS
//
// Created by Rizwan on 21/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
import Secrets
class SettingsCredentialsAccountModel: ObservableObject {
var account: Account? = nil
var accountType: AccountType
@Published var shouldDismiss: Bool = false
@Published var email: String = ""
@Published var password: String = ""
@Published var apiUrl: String = ""
@Published var busy: Bool = false
@Published var accountCredentialsError: AccountCredentialsError? {
didSet {
accountCredentialsError != AccountCredentialsError.none ? (showError = true) : (showError = false)
}
}
@Published var showError: Bool = false
@Published var showPassword: Bool = false
init(account: Account) {
self.account = account
self.accountType = account.type
if let credentials = try? account.retrieveCredentials(type: .basic) {
self.email = credentials.username
self.password = credentials.secret
}
}
init(accountType: AccountType) {
self.accountType = accountType
}
var isUpdate: Bool {
return account != nil
}
var isValid: Bool {
if apiUrlEnabled {
return !email.isEmpty && !password.isEmpty && !apiUrl.isEmpty
}
return !email.isEmpty && !password.isEmpty
}
var accountName: String {
switch accountType {
case .onMyMac:
return Account.defaultLocalAccountName
case .cloudKit:
return "iCloud"
case .feedbin:
return "Feedbin"
case .feedly:
return "Feedly"
case .feedWrangler:
return "Feed Wrangler"
case .newsBlur:
return "NewsBlur"
default:
return ""
}
}
var emailText: String {
return accountType == .newsBlur ? NSLocalizedString("Username or Email", comment: "") : NSLocalizedString("Email", comment: "")
}
var apiUrlEnabled: Bool {
return accountType == .freshRSS
}
func addAccount() {
switch accountType {
case .feedbin:
addFeedbinAccount()
case .feedWrangler:
addFeedWranglerAccount()
case .newsBlur:
addNewsBlurAccount()
case .freshRSS:
addFreshRSSAccount()
default:
return
}
}
}
extension SettingsCredentialsAccountModel {
// MARK:- Feedbin
func addFeedbinAccount() {
busy = true
accountCredentialsError = AccountCredentialsError.none
let emailAddress = email.trimmingCharacters(in: .whitespaces)
let credentials = Credentials(type: .basic, username: emailAddress, secret: password)
Account.validateCredentials(type: .feedbin, credentials: credentials) { (result) in
self.busy = false
switch result {
case .success(let authenticated):
if (authenticated != nil) {
var newAccount = false
let workAccount: Account
if self.account == nil {
workAccount = AccountManager.shared.createAccount(type: .feedbin)
newAccount = true
} else {
workAccount = self.account!
}
do {
do {
try workAccount.removeCredentials(type: .basic)
} catch {}
try workAccount.storeCredentials(credentials)
if newAccount {
workAccount.refreshAll() { result in }
}
self.shouldDismiss = true
} catch {
self.accountCredentialsError = AccountCredentialsError.keyChain
}
} else {
self.accountCredentialsError = AccountCredentialsError.invalidCredentials
}
case .failure:
self.accountCredentialsError = AccountCredentialsError.noNetwork
}
}
}
// MARK: FeedWrangler
func addFeedWranglerAccount() {
busy = true
let credentials = Credentials(type: .feedWranglerBasic, username: email, secret: password)
Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.busy = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.accountCredentialsError = .invalidCredentials
return
}
let account = AccountManager.shared.createAccount(type: .feedWrangler)
do {
try account.removeCredentials(type: .feedWranglerBasic)
try account.removeCredentials(type: .feedWranglerToken)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.shouldDismiss = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.accountCredentialsError = .other(error: error)
}
})
} catch {
self.accountCredentialsError = .keyChain
}
case .failure:
self.accountCredentialsError = .noNetwork
}
}
}
// MARK:- NewsBlur
func addNewsBlurAccount() {
busy = true
let credentials = Credentials(type: .newsBlurBasic, username: email, secret: password)
Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in
guard let self = self else { return }
self.busy = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.accountCredentialsError = .invalidCredentials
return
}
let account = AccountManager.shared.createAccount(type: .newsBlur)
do {
try account.removeCredentials(type: .newsBlurBasic)
try account.removeCredentials(type: .newsBlurSessionId)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.shouldDismiss = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.accountCredentialsError = .other(error: error)
}
})
} catch {
self.accountCredentialsError = .keyChain
}
case .failure:
self.accountCredentialsError = .noNetwork
}
}
}
// MARK:- Fresh RSS
func addFreshRSSAccount() {
busy = true
let credentials = Credentials(type: .readerBasic, username: email, secret: password)
Account.validateCredentials(type: .freshRSS, credentials: credentials, endpoint: URL(string: apiUrl)!) { [weak self] result in
guard let self = self else { return }
self.busy = false
switch result {
case .success(let validatedCredentials):
guard let validatedCredentials = validatedCredentials else {
self.accountCredentialsError = .invalidCredentials
return
}
let account = AccountManager.shared.createAccount(type: .freshRSS)
do {
try account.removeCredentials(type: .readerBasic)
try account.removeCredentials(type: .readerAPIKey)
try account.storeCredentials(credentials)
try account.storeCredentials(validatedCredentials)
self.shouldDismiss = true
account.refreshAll(completion: { result in
switch result {
case .success:
break
case .failure(let error):
self.accountCredentialsError = .other(error: error)
}
})
} catch {
self.accountCredentialsError = .keyChain
}
case .failure:
self.accountCredentialsError = .noNetwork
}
}
}
}

View File

@@ -1,97 +0,0 @@
//
// SettingsCredentialsAccountView.swift
// Multiplatform iOS
//
// Created by Rizwan on 21/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct SettingsCredentialsAccountView: View {
@Environment(\.presentationMode) var presentationMode
@ObservedObject var settingsModel: SettingsCredentialsAccountModel
init(account: Account) {
self.settingsModel = SettingsCredentialsAccountModel(account: account)
}
init(accountType: AccountType) {
self.settingsModel = SettingsCredentialsAccountModel(accountType: accountType)
}
var body: some View {
NavigationView {
List {
Section(header: AccountHeaderImageView(image: AppAssets.image(for: settingsModel.accountType)!)) {
TextField(settingsModel.emailText, text: $settingsModel.email).textContentType(.emailAddress)
HStack {
if settingsModel.showPassword {
TextField("Password", text:$settingsModel.password)
}
else {
SecureField("Password", text: $settingsModel.password)
}
Button(action: {
settingsModel.showPassword.toggle()
}) {
Text(settingsModel.showPassword ? "Hide" : "Show")
}
}
if settingsModel.apiUrlEnabled {
TextField("API URL", text: $settingsModel.apiUrl)
}
}
Section(footer: errorFooter) {
HStack {
Spacer()
Button(action: { settingsModel.addAccount() }) {
if settingsModel.isUpdate {
Text("Update Account")
} else {
Text("Add Account")
}
}
.disabled(!settingsModel.isValid)
Spacer()
if settingsModel.busy {
ProgressView()
}
}
}
}
.listStyle(InsetGroupedListStyle())
.disabled(settingsModel.busy)
.onReceive(settingsModel.$shouldDismiss, perform: { dismiss in
if dismiss == true {
presentationMode.wrappedValue.dismiss()
}
})
.navigationBarTitle(Text(verbatim: settingsModel.accountName), displayMode: .inline)
.navigationBarItems(leading:
Button(action: { self.dismiss() }) { Text("Cancel") }
)
}
}
var errorFooter: some View {
HStack {
Spacer()
if settingsModel.showError {
Text(verbatim: settingsModel.accountCredentialsError!.description).foregroundColor(.red)
}
Spacer()
}
}
private func dismiss() {
presentationMode.wrappedValue.dismiss()
}
}
struct SettingsCredentialsAccountView_Previews: PreviewProvider {
static var previews: some View {
SettingsCredentialsAccountView(accountType: .feedbin)
}
}

View File

@@ -1,55 +0,0 @@
//
// SettingsDetailAccountModel.swift
// Multiplatform iOS
//
// Created by Rizwan on 08/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
import RSCore
class SettingsDetailAccountModel: ObservableObject {
let account: Account
@Published var name: String {
didSet {
account.name = name.isEmpty ? nil : name
}
}
@Published var isActive: Bool {
didSet {
account.isActive = isActive
}
}
init(_ account: Account) {
self.account = account
self.name = account.name ?? ""
self.isActive = account.isActive
}
var defaultName: String {
account.defaultName
}
var nameForDisplay: String {
account.nameForDisplay
}
var accountImage: RSImage {
AppAssets.image(for: account.type)!
}
var isCredentialsAvailable: Bool {
return account.type != .onMyMac
}
var isDeletable: Bool {
return AccountManager.shared.defaultAccount != account
}
func delete() {
AccountManager.shared.deleteAccount(account)
}
}

View File

@@ -1,94 +0,0 @@
//
// SettingsDetailAccountView.swift
// Multiplatform iOS
//
// Created by Rizwan on 08/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Combine
import Account
import RSWeb
import RSCore
struct SettingsDetailAccountView: View {
@Environment(\.presentationMode) var presentationMode
@ObservedObject var settingsModel: SettingsDetailAccountModel
@State private var isFeedbinCredentialsPresented = false
@State private var isDeleteAlertPresented = false
init(_ account: Account) {
settingsModel = SettingsDetailAccountModel.init(account)
}
var body: some View {
List {
Section(header:AccountHeaderImageView(image: settingsModel.accountImage)) {
HStack {
TextField(settingsModel.defaultName, text: $settingsModel.name)
}
Toggle(isOn: $settingsModel.isActive) {
Text("Active")
}
}
if settingsModel.isCredentialsAvailable {
Section {
HStack {
Spacer()
Button(action: {
self.isFeedbinCredentialsPresented.toggle()
}) {
Text("Credentials")
}
Spacer()
}
}
.sheet(isPresented: $isFeedbinCredentialsPresented) {
self.settingsCredentialsAccountView
}
}
if settingsModel.isDeletable {
Section {
HStack {
Spacer()
Button(action: {
self.isDeleteAlertPresented.toggle()
}) {
Text("Delete Account").foregroundColor(.red)
}
Spacer()
}
.alert(isPresented: $isDeleteAlertPresented) {
Alert(
title: Text("Are you sure you want to delete \"\(settingsModel.nameForDisplay)\"?"),
primaryButton: Alert.Button.default(
Text("Delete"),
action: {
self.settingsModel.delete()
self.dismiss()
}),
secondaryButton: Alert.Button.cancel()
)
}
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text(verbatim: settingsModel.nameForDisplay), displayMode: .inline)
}
var settingsCredentialsAccountView: SettingsCredentialsAccountView {
return SettingsCredentialsAccountView(account: settingsModel.account)
}
func dismiss() {
presentationMode.wrappedValue.dismiss()
}
}
struct SettingsDetailAccountView_Previews: PreviewProvider {
static var previews: some View {
return SettingsDetailAccountView(AccountManager.shared.defaultAccount)
}
}

View File

@@ -1,55 +0,0 @@
//
// SettingsLocalAccountView.swift
// Multiplatform iOS
//
// Created by Rizwan on 07/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct SettingsLocalAccountView: View {
@Environment(\.presentationMode) var presentation
@State var name: String = ""
var body: some View {
NavigationView {
List {
Section(header: AccountHeaderImageView(image: AppAssets.image(for: .onMyMac)!)) {
HStack {
TextField("Name", text: $name)
}
}
Section {
HStack {
Spacer()
Button(action: { self.addAccount() }) {
Text("Add Account")
}
Spacer()
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text(verbatim: Account.defaultLocalAccountName), displayMode: .inline)
.navigationBarItems(leading: Button(action: { self.dismiss() }) { Text("Cancel") } )
}
}
private func addAccount() {
let account = AccountManager.shared.createAccount(type: .onMyMac)
account.name = name
dismiss()
}
private func dismiss() {
presentation.wrappedValue.dismiss()
}
}
struct SettingsLocalAccountView_Previews: PreviewProvider {
static var previews: some View {
SettingsLocalAccountView()
}
}

View File

@@ -1,60 +0,0 @@
//
// ColorPaletteContainerView.swift
// Multiplatform iOS
//
// Created by Rizwan on 02/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
struct ColorPaletteContainerView: View {
private let colorPalettes = UserInterfaceColorPalette.allCases
@EnvironmentObject private var appSettings: AppDefaults
@Environment(\.presentationMode) var presentationMode
var body: some View {
List {
ForEach.init(0 ..< colorPalettes.count) { index in
Button(action: {
onTapColorPalette(at:index)
}) {
ColorPaletteView(colorPalette: colorPalettes[index])
}
}
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle("Color Palette", displayMode: .inline)
}
func onTapColorPalette(at index: Int) {
if let colorPalette = UserInterfaceColorPalette(rawValue: index) {
appSettings.userInterfaceColorPalette = colorPalette
}
self.presentationMode.wrappedValue.dismiss()
}
}
struct ColorPaletteView: View {
var colorPalette: UserInterfaceColorPalette
@EnvironmentObject private var appSettings: AppDefaults
var body: some View {
HStack {
Text(colorPalette.description).foregroundColor(.primary)
Spacer()
if colorPalette == appSettings.userInterfaceColorPalette {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
struct ColorPaletteContainerView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
ColorPaletteContainerView()
}
}
}

View File

@@ -1,109 +0,0 @@
//
// FeedsSettingsModel.swift
// Multiplatform iOS
//
// Created by Rizwan on 04/07/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import SwiftUI
import Account
import UniformTypeIdentifiers
enum FeedsSettingsError: LocalizedError, Equatable {
case none, noActiveAccount, exportFailed(reason: String?), importFailed
var errorDescription: String? {
switch self {
case .noActiveAccount:
return NSLocalizedString("You must have at least one active account.", comment: "Missing active account")
case .exportFailed(let reason):
return reason
case .importFailed:
return NSLocalizedString(
"We were unable to process the selected file. Please ensure that it is a properly formatted OPML file.",
comment: "Import Failed Message"
)
default:
return nil
}
}
var title: String? {
switch self {
case .noActiveAccount:
return NSLocalizedString("Error", comment: "Error Title")
case .exportFailed:
return NSLocalizedString("OPML Export Error", comment: "Export Failed")
case .importFailed:
return NSLocalizedString("Import Failed", comment: "Import Failed")
default:
return nil
}
}
}
class FeedsSettingsModel: ObservableObject {
@Published var exportingFilePath = ""
@Published var feedsSettingsError: FeedsSettingsError? {
didSet {
feedsSettingsError != FeedsSettingsError.none ? (showError = true) : (showError = false)
}
}
@Published var showError: Bool = false
@Published var isImporting: Bool = false
@Published var isExporting: Bool = false
@Published var selectedAccount: Account? = nil
let importingContentTypes: [UTType] = [UTType(filenameExtension: "opml"), UTType("public.xml")].compactMap { $0 }
func checkForActiveAccount() -> Bool {
if AccountManager.shared.activeAccounts.count == 0 {
feedsSettingsError = .noActiveAccount
return false
}
return true
}
func importOPML(account: Account?) {
selectedAccount = account
isImporting = true
}
func exportOPML(account: Account?) {
selectedAccount = account
isExporting = true
}
func generateExportURL() -> URL? {
guard let account = selectedAccount else { return nil }
let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces)
let filename = "Subscriptions-\(accountName).opml"
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
let opmlString = OPMLExporter.OPMLString(with: account, title: filename)
do {
try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8)
} catch {
feedsSettingsError = .exportFailed(reason: error.localizedDescription)
return nil
}
return tempFile
}
func processImportedFiles(_ urls: [URL]) {
urls.forEach{
selectedAccount?.importOPML($0, completion: { [weak self] result in
switch result {
case .success:
break
case .failure:
self?.feedsSettingsError = .importFailed
break
}
})
}
}
}

View File

@@ -1,95 +0,0 @@
//
// SettingsModel.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/4/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
class SettingsModel: ObservableObject {
enum HelpSites {
case netNewsWireHelp, netNewsWire, supportNetNewsWire, github, bugTracker, technotes, netNewsWireSlack, releaseNotes, none
var url: URL? {
switch self {
case .netNewsWireHelp:
return URL(string: "https://netnewswire.com/help/ios/5.0/en/")!
case .netNewsWire:
return URL(string: "https://netnewswire.com/")!
case .supportNetNewsWire:
return URL(string: "https://github.com/brentsimmons/NetNewsWire/blob/main/Technotes/HowToSupportNetNewsWire.markdown")!
case .github:
return URL(string: "https://github.com/brentsimmons/NetNewsWire")!
case .bugTracker:
return URL(string: "https://github.com/brentsimmons/NetNewsWire/issues")!
case .technotes:
return URL(string: "https://github.com/brentsimmons/NetNewsWire/tree/main/Technotes")!
case .netNewsWireSlack:
return URL(string: "https://netnewswire.com/slack")!
case .releaseNotes:
return URL.releaseNotes
case .none:
return nil
}
}
}
@Published var presentSheet: Bool = false
var accounts: [Account] {
get {
AccountManager.shared.sortedAccounts
}
set {
}
}
var activeAccounts: [Account] {
get {
AccountManager.shared.sortedActiveAccounts
}
set {
}
}
// MARK: Init
init() {
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange), name: .DisplayNameDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDidAddAccount), name: .UserDidAddAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount), name: .UserDidDeleteAccount, object: nil)
}
var selectedWebsite: HelpSites = .none {
didSet {
if selectedWebsite == .none {
presentSheet = false
} else {
presentSheet = true
}
}
}
func refreshAccounts() {
objectWillChange.self.send()
}
// MARK:- Notifications
@objc func displayNameDidChange() {
refreshAccounts()
}
@objc func userDidAddAccount() {
refreshAccounts()
}
@objc func userDidDeleteAccount() {
refreshAccounts()
}
}

View File

@@ -1,244 +0,0 @@
//
// SettingsView.swift
// Multiplatform iOS
//
// Created by Stuart Breckenridge on 30/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
import Account
struct SettingsView: View {
@Environment(\.presentationMode) var presentationMode
@StateObject private var viewModel = SettingsModel()
@StateObject private var feedsSettingsModel = FeedsSettingsModel()
@StateObject private var settings = AppDefaults.shared
var body: some View {
NavigationView {
List {
systemSettings
accounts
importExport
timeline
articles
appearance
help
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle("Settings", displayMode: .inline)
.navigationBarItems(leading:
HStack {
Button("Done") {
presentationMode.wrappedValue.dismiss()
}
}
)
}
.fileImporter(
isPresented: $feedsSettingsModel.isImporting,
allowedContentTypes: feedsSettingsModel.importingContentTypes,
allowsMultipleSelection: true,
onCompletion: { result in
if let urls = try? result.get() {
feedsSettingsModel.processImportedFiles(urls)
}
}
)
.fileMover(isPresented: $feedsSettingsModel.isExporting,
file: feedsSettingsModel.generateExportURL()) { _ in }
.sheet(isPresented: $viewModel.presentSheet, content: {
SafariView(url: viewModel.selectedWebsite.url!)
})
}
var systemSettings: some View {
Section(header: Text("Notifications, Badge, Data, & More"), content: {
Button(action: {
UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!)
}, label: {
Text("Open System Settings").foregroundColor(.primary)
})
})
}
var accounts: some View {
Section(header: Text("Accounts"), content: {
ForEach(0..<viewModel.accounts.count, id: \.hashValue , content: { i in
NavigationLink(
destination: SettingsDetailAccountView(viewModel.accounts[i]),
label: {
Text(viewModel.accounts[i].nameForDisplay)
})
})
NavigationLink(
destination: SettingsAddAccountView(),
label: {
Text("Add Account")
})
})
}
var importExport: some View {
Section(header: Text("Feeds"), content: {
if viewModel.activeAccounts.count > 1 {
NavigationLink("Import Subscriptions", destination: importOptions)
}
else {
Button(action:{
if feedsSettingsModel.checkForActiveAccount() {
feedsSettingsModel.importOPML(account: viewModel.activeAccounts.first)
}
}) {
Text("Import Subscriptions")
.foregroundColor(.primary)
}
}
if viewModel.accounts.count > 1 {
NavigationLink("Export Subscriptions", destination: exportOptions)
}
else {
Button(action:{
feedsSettingsModel.exportOPML(account: viewModel.accounts.first)
}) {
Text("Export Subscriptions")
.foregroundColor(.primary)
}
}
Toggle("Confirm When Deleting", isOn: $settings.sidebarConfirmDelete)
.toggleStyle(SwitchToggleStyle(tint: .accentColor))
})
.alert(isPresented: $feedsSettingsModel.showError) {
Alert(
title: Text(feedsSettingsModel.feedsSettingsError!.title ?? "Oops"),
message: Text(feedsSettingsModel.feedsSettingsError!.localizedDescription),
dismissButton: Alert.Button.cancel({
feedsSettingsModel.feedsSettingsError = FeedsSettingsError.none
}))
}
}
var importOptions: some View {
List {
Section(header: Text("Choose an account to receive the imported feeds and folders"), content: {
ForEach(0..<viewModel.activeAccounts.count, id: \.hashValue , content: { i in
Button {
feedsSettingsModel.importOPML(account: viewModel.activeAccounts[i])
} label: {
Text(viewModel.activeAccounts[i].nameForDisplay)
}
})
})
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle("Import Subscriptions", displayMode: .inline)
}
var exportOptions: some View {
List {
Section(header: Text("Choose an account with the subscriptions to export"), content: {
ForEach(0..<viewModel.accounts.count, id: \.hashValue , content: { i in
Button {
feedsSettingsModel.exportOPML(account: viewModel.accounts[i])
} label: {
Text(viewModel.accounts[i].nameForDisplay)
}
})
})
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle("Export Subscriptions", displayMode: .inline)
}
var timeline: some View {
Section(header: Text("Timeline"), content: {
Toggle("Sort Oldest to Newest", isOn: $settings.timelineSortDirection)
Toggle("Group by Feed", isOn: $settings.timelineGroupByFeed)
Toggle("Refresh to Clear Read Articles", isOn: $settings.refreshClearsReadArticles)
NavigationLink(
destination: TimelineLayoutView().environmentObject(settings),
label: {
Text("Timeline Layout")
})
}).toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
var articles: some View {
Section(header: Text("Articles"), content: {
Toggle("Confirm Mark All as Read", isOn: .constant(true))
Toggle(isOn: .constant(true), label: {
VStack(alignment: .leading, spacing: 4) {
Text("Enable Full Screen Articles")
Text("Tap the article top bar to enter Full Screen. Tap the bottom or top to exit.").font(.caption).foregroundColor(.secondary)
}
})
}).toggleStyle(SwitchToggleStyle(tint: .accentColor))
}
var appearance: some View {
Section(header: Text("Appearance"), content: {
NavigationLink(
destination: ColorPaletteContainerView().environmentObject(settings),
label: {
HStack {
Text("Color Palette")
Spacer()
Text(settings.userInterfaceColorPalette.description)
.foregroundColor(.secondary)
}
})
})
}
var help: some View {
Section(header: Text("Help"), footer: Text(appVersion()).font(.caption2), content: {
Button("NetNewsWire Help", action: {
viewModel.selectedWebsite = .netNewsWireHelp
}).foregroundColor(.primary)
Button("Website", action: {
viewModel.selectedWebsite = .netNewsWire
}).foregroundColor(.primary)
Button("How To Support NetNewsWire", action: {
viewModel.selectedWebsite = .supportNetNewsWire
}).foregroundColor(.primary)
Button("Github Repository", action: {
viewModel.selectedWebsite = .github
}).foregroundColor(.primary)
Button("Bug Tracker", action: {
viewModel.selectedWebsite = .bugTracker
}).foregroundColor(.primary)
Button("NetNewsWire Slack", action: {
viewModel.selectedWebsite = .netNewsWireSlack
}).foregroundColor(.primary)
Button("Release Notes", action: {
viewModel.selectedWebsite = .releaseNotes
}).foregroundColor(.primary)
NavigationLink(
destination: SettingsAboutView(),
label: {
Text("About NetNewsWire")
})
})
}
private func appVersion() -> String {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
return "NetNewsWire \(version) (Build \(build))"
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
SettingsView()
}
}

View File

@@ -1,84 +0,0 @@
//
// TimelineLayoutView.swift
// Multiplatform iOS
//
// Created by Stuart Breckenridge on 1/7/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import SwiftUI
struct TimelineLayoutView: View {
@EnvironmentObject private var defaults: AppDefaults
private let sampleTitle = "Lorem dolor sed viverra ipsum. Gravida rutrum quisque non tellus. Rutrum tellus pellentesque eu tincidunt tortor. Sed blandit libero volutpat sed cras ornare. Et netus et malesuada fames ac. Ultrices eros in cursus turpis massa tincidunt dui ut ornare. Lacus sed viverra tellus in. Sollicitudin ac orci phasellus egestas. Purus in mollis nunc sed. Sollicitudin ac orci phasellus egestas tellus rutrum tellus pellentesque. Interdum consectetur libero id faucibus nisl tincidunt eget."
var body: some View {
VStack(spacing: 0) {
List {
Section(header: Text("Icon Size"), content: {
iconSize
})
Section(header: Text("Number of Lines"), content: {
numberOfLines
}) }
.listStyle(InsetGroupedListStyle())
Divider()
timelineRowPreview.padding()
Divider()
}
.navigationBarTitle("Timeline Layout")
}
var iconSize: some View {
Slider(value: $defaults.timelineIconDimensions, in: 20...60, step: 10, minimumValueLabel: Text("Small"), maximumValueLabel: Text("Large"), label: {
Text(String(defaults.timelineIconDimensions))
})
}
var numberOfLines: some View {
Slider(value: $defaults.timelineNumberOfLines, in: 1...5, step: 1, minimumValueLabel: Text("1"), maximumValueLabel: Text("5"), label: {
Text("Article Title")
})
}
var timelineRowPreview: some View {
HStack(alignment: .top) {
Image(systemName: "circle.fill")
.resizable()
.frame(width: 10, height: 10, alignment: .top)
.foregroundColor(.accentColor)
Image(systemName: "paperplane.circle")
.resizable()
.frame(width: CGFloat(defaults.timelineIconDimensions), height: CGFloat(defaults.timelineIconDimensions), alignment: .top)
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 4) {
Text(sampleTitle)
.font(.headline)
.lineLimit(Int(defaults.timelineNumberOfLines))
HStack {
Text("Feed Name")
.foregroundColor(.secondary)
.font(.footnote)
Spacer()
Text("10:31")
.font(.footnote)
.foregroundColor(.secondary)
}
}
}
}
}
struct TimelineLayout_Previews: PreviewProvider {
static var previews: some View {
TimelineLayoutView()
}
}

View File

@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
</array>
</dict>
</plist>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>
</array>
</dict>
</plist>