NewsBlur-viq/clients/ios/Widget Extension/WidgetExtensionViewController.swift
David Sinclair ae070a4d29 #1288 (widget thumbnail)
- Thumbnails should match now.
2020-03-10 20:47:38 -07:00

676 lines
22 KiB
Swift

//
// WidgetViewController.swift
// Widget Extension
//
// Created by David Sinclair on 2019-11-26.
// Copyright © 2019 NewsBlur. All rights reserved.
//
import UIKit
import NotificationCenter
enum WidgetError: String, Error {
case notLoggedIn
case loading
case noFeeds
case noStories
}
class WidgetExtensionViewController: UITableViewController, NCWidgetProviding {
/// The base URL of the NewsBlur server.
var host: String?
/// The secret token for authentication.
var token: String?
/// An array of feeds to load.
var feeds = [Feed]()
/// Loaded stories.
var stories = [Story]()
/// An error to display instead of the stories, or `nil` if the stories should be displayed.
var error: WidgetError?
/// Paragraph style for title and content labels.
lazy var paragraphStyle: NSParagraphStyle = {
let paragraph = NSMutableParagraphStyle()
paragraph.lineBreakMode = .byTruncatingTail
paragraph.alignment = .left
paragraph.lineHeightMultiple = 0.95
return paragraph
}()
struct Constant {
static let group = "group.com.newsblur.NewsBlur-Group"
static let token = "share:token"
static let host = "share:host"
static let feeds = "widget:feeds_array"
static let widgetFolder = "Widget"
static let storiesFilename = "Stories.json"
static let feedImagesFilename = "Feed Images"
static let storyImagesFilename = "Story Images"
static let imageExtension = "png"
static let limit = 5
static let defaultRowHeight: CGFloat = 110
static let storyImageSize: CGFloat = 64 * 3
static let storyImageLimit: CGFloat = 200
static let thumbnailHiddenConstant: CGFloat = -50
static let thumbnailShownConstant: CGFloat = 20
}
// MARK: - View lifecycle
override func viewDidLoad() {
super.viewDidLoad()
// Allow the today widget to be expanded or contracted.
extensionContext?.widgetLargestAvailableDisplayMode = .expanded
loadCachedStories()
// Register the table view cell.
let widgetTableViewCellNib = UINib(nibName: "WidgetTableViewCell", bundle: nil)
tableView.register(widgetTableViewCellNib, forCellReuseIdentifier: WidgetTableViewCell.reuseIdentifier)
let errorTableViewCellNib = UINib(nibName: "WidgetErrorTableViewCell", bundle: nil)
tableView.register(errorTableViewCellNib, forCellReuseIdentifier: WidgetErrorTableViewCell.reuseIdentifier)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
extensionContext?.widgetLargestAvailableDisplayMode = error == nil ? .expanded : .compact
tableView.reloadData()
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
feedImageCache.removeAll()
storyImageCache.removeAll()
}
// MARK: - Widget provider protocol
typealias AnyDictionary = [String : Any]
typealias WidgetCompletion = (NCUpdateResult) -> Void
private var widgetCompletion: WidgetCompletion?
private typealias ImageDictionary = [String : UIImage]
private var feedImageCache = ImageDictionary()
private var storyImageCache = ImageDictionary()
private typealias LoaderDictionary = [String : Loader]
private var loaders = LoaderDictionary()
func widgetPerformUpdate(completionHandler: (@escaping WidgetCompletion)) {
if feeds.isEmpty {
if error == .noFeeds {
completionHandler(.noData)
} else {
error = .noFeeds
completionHandler(.newData)
}
return
}
let feedIds = feeds.map { $0.id }
let combinedFeeds = feedIds.joined(separator: "&f=")
guard let url = hostURL(with: "/reader/river_stories/?include_hidden=false&page=1&infrequent=false&order=newest&read_filter=unread&limit=\(Constant.limit)&f=\(combinedFeeds)") else {
completionHandler(.failed)
return
}
error = nil
widgetCompletion = completionHandler
loaders[Constant.storiesFilename] = Loader(url: url, completion: storyLoaderCompletion(result:))
}
func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
switch activeDisplayMode {
case .compact:
// The compact view is a fixed size.
preferredContentSize = maxSize
case .expanded:
let height: CGFloat = rowHeight * CGFloat(numberOfTableRowsToDisplay)
preferredContentSize = CGSize(width: maxSize.width, height: min(height, maxSize.height))
@unknown default:
preconditionFailure("Unexpected value for activeDisplayMode.")
}
}
// MARK: - Content container protocol
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
tableView.reloadData()
}
// MARK: - Table view data source
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return numberOfTableRowsToDisplay
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
precondition(Thread.isMainThread, "Table access not on the main thread")
if let error = error {
guard let cell = tableView.dequeueReusableCell(withIdentifier: WidgetErrorTableViewCell.reuseIdentifier, for: indexPath) as? WidgetErrorTableViewCell else {
preconditionFailure("Expected to dequeue a WidgetErrorTableViewCell")
}
switch error {
case .notLoggedIn:
cell.errorLabel.text = "Please log in to NewsBlur"
case .loading:
cell.errorLabel.text = "Tap to set up in NewsBlur"
case .noFeeds:
cell.errorLabel.text = "Please choose sites to show"
case .noStories:
cell.errorLabel.text = "No stories for selected sites"
}
return cell
}
guard let cell = tableView.dequeueReusableCell(withIdentifier: WidgetTableViewCell.reuseIdentifier, for: indexPath) as? WidgetTableViewCell
else {
preconditionFailure("Expected to dequeue a WidgetTableViewCell")
}
let story = stories[indexPath.row]
let feed = feeds.first(where: { $0.id == story.feed })
let isSmall = rowHeight < Constant.defaultRowHeight
let baseDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .caption1)
let sizedDescriptor = baseDescriptor.withSize(isSmall ? 12 : 13)
let boldDescriptor = sizedDescriptor.withSymbolicTraits(.traitBold) ?? sizedDescriptor
let titleFont = UIFont(descriptor: boldDescriptor, size: sizedDescriptor.pointSize)
let titleColor = UIColor.label
let contentFont = UIFont(descriptor: sizedDescriptor, size: isSmall ? 11 : 0)
let contentColor = UIColor.secondaryLabel
cell.barView.leftColor = feed?.leftColor
cell.barView.rightColor = feed?.rightColor
cell.barView.setNeedsDisplay()
cell.feedImageView.image = nil
// Completion handler passes the feed to confirm that this cell still wants that image (i.e. hasn't been reused).
feedImage(for: story.feed) { (image, feed) in
if story.feed == feed {
cell.feedImageView.image = image
}
}
if let title = feed?.title {
cell.feedLabel.text = title
} else {
cell.feedLabel.text = ""
}
cell.feedLabel.textColor = UIColor.secondaryLabel
cell.titleLabel.attributedText = attributed(story.title, with: titleFont, color: titleColor)
cell.contentLabel.attributedText = attributed(story.content, with: contentFont, color: contentColor)
cell.authorLabel.text = cleaned(story.author).uppercased()
cell.authorLabel.textColor = UIColor.tertiaryLabel
cell.dateLabel.text = story.date
cell.dateLabel.textColor = UIColor.secondaryLabel
cell.thumbnailImageView.image = nil
cell.thumbnailImageView.isHidden = true
cell.thumbnailTrailingConstraint.constant = Constant.thumbnailHiddenConstant
cell.setNeedsLayout()
storyImage(for: story.id, imageURL: story.imageURL) { (image, id) in
if story.id == id {
cell.thumbnailImageView.image = image
cell.thumbnailImageView.isHidden = image == nil
cell.thumbnailTrailingConstraint.constant = image == nil ? Constant.thumbnailHiddenConstant : Constant.thumbnailShownConstant
cell.setNeedsLayout()
cell.setNeedsDisplay()
}
}
return cell
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return rowHeight
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let error = error {
if let appURL = URL(string: "newsblurwidget://?error=\(error.rawValue)") {
extensionContext?.open(appURL, completionHandler: nil)
}
} else {
let story = stories[indexPath.row]
if let appURL = URL(string: "newsblurwidget://?feedId=\(story.feed)&storyHash=\(story.id)") {
extensionContext?.open(appURL, completionHandler: nil)
}
}
tableView.deselectRow(at: indexPath, animated: true)
}
}
// MARK: - Helpers
private extension WidgetExtensionViewController {
var numberOfTableRowsToDisplay: Int {
if stories.isEmpty, error == nil {
error = .loading
}
if error != nil {
return 1
} else if isCompact {
return 1
} else {
return min(stories.count, Constant.limit)
}
}
var isCompact: Bool {
return extensionContext?.widgetActiveDisplayMode == NCWidgetDisplayMode.compact
}
var rowHeight: CGFloat {
guard let context = extensionContext else {
return Constant.defaultRowHeight
}
let height = context.widgetMaximumSize(for: .compact).height
if isCompact {
return height
}
let expandedHeight = context.widgetMaximumSize(for: .expanded).height
if height * CGFloat(Constant.limit) > expandedHeight {
return expandedHeight / CGFloat(Constant.limit)
}
return height
}
func cleaned(_ string: String) -> String {
let clean = string.prefix(1000).replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "<[^>]+>|&[^;]+;", with: " ", options: .regularExpression, range: nil)
.trimmingCharacters(in: .whitespaces)
return clean.isEmpty ? " " : clean
}
func attributed(_ string: String, with font: UIFont, color: UIColor) -> NSAttributedString {
let attributes: [NSAttributedString.Key : Any] = [.font : font, .foregroundColor: color, .paragraphStyle: paragraphStyle]
return NSAttributedString(string: cleaned(string), attributes: attributes)
}
func hostURL(with path: String) -> URL? {
guard let host = host else {
return nil
}
if let token = token {
return URL(string: host + path + "&secret_token=\(token)")
} else {
return URL(string: host + path)
}
}
func storyLoaderCompletion(result: Result<Data, Error>) {
defer {
widgetCompletion = nil
loaders[Constant.storiesFilename] = nil
}
if case .failure = result {
widgetCompletion?(.failed)
return
}
guard case .success(let data) = result else {
return
}
guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? AnyDictionary else {
widgetCompletion?(.failed)
return
}
guard let storyArray = dictionary["stories"] as? [AnyDictionary] else {
widgetCompletion?(.failed)
return
}
stories.removeAll()
for storyDict in storyArray {
stories.append(Story(from: storyDict))
}
saveStories()
flushStoryImages()
if stories.isEmpty, error == nil {
error = .noStories
}
// Keep a local copy, since the property will be cleared before the async closure is called.
let localCompletion = widgetCompletion
DispatchQueue.main.async {
self.extensionContext?.widgetLargestAvailableDisplayMode = self.error == nil ? .expanded : .compact
self.tableView.reloadData()
self.tableView.setNeedsDisplay()
localCompletion?(.newData)
}
}
var groupContainerURL: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constant.group)
}
var widgetFolderURL: URL? {
return groupContainerURL?.appendingPathComponent(Constant.widgetFolder)
}
var storiesURL: URL? {
return widgetFolderURL?.appendingPathComponent(Constant.storiesFilename)
}
var feedImagesURL: URL? {
return widgetFolderURL?.appendingPathComponent(Constant.feedImagesFilename)
}
var storyImagesURL: URL? {
return widgetFolderURL?.appendingPathComponent(Constant.storyImagesFilename)
}
func createWidgetFolder(url: URL? = nil) {
guard let url = url ?? widgetFolderURL else {
return
}
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
}
func loadCachedStories() {
feeds = []
stories = []
guard let defaults = UserDefaults.init(suiteName: Constant.group) else {
return
}
host = defaults.string(forKey: Constant.host)
token = defaults.string(forKey: Constant.token)
if let array = defaults.array(forKey: Constant.feeds) as? [Feed.Dictionary] {
feeds = array.map { Feed(from: $0) }
}
guard let url = storiesURL else {
return
}
do {
let json = try Data(contentsOf: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
stories = try decoder.decode([Story].self, from: json)
} catch {
print("Error \(error)")
}
}
func saveStories() {
guard let url = storiesURL else {
return
}
createWidgetFolder()
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = .prettyPrinted
do {
let json = try encoder.encode(stories)
try json.write(to: url)
} catch {
print("Error \(error)")
}
}
func save(feedImage: UIImage, for identifier: String) {
feedImageCache[identifier] = feedImage
save(image: feedImage, to: feedImagesURL, for: identifier)
}
func save(storyImage: UIImage, for identifier: String) {
storyImageCache[identifier] = storyImage
save(image: storyImage, to: storyImagesURL, for: identifier)
}
func save(image: UIImage, to folderURL: URL?, for identifier: String) {
guard let folderURL = folderURL else {
return
}
createWidgetFolder(url: folderURL)
do {
let imageURL = folderURL.appendingPathComponent(identifier).appendingPathExtension(Constant.imageExtension)
try image.pngData()?.write(to: imageURL)
} catch {
print("Image error: \(error)")
}
}
func flushStoryImages() {
guard let folderURL = storyImagesURL else {
return
}
do {
let manager = FileManager.default
let contents = try manager.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: [], options: .skipsHiddenFiles)
for imageURL in contents {
let identifier = imageURL.deletingPathExtension().lastPathComponent
if stories.contains(where: { $0.id == identifier }) {
continue
}
try manager.removeItem(at: imageURL)
storyImageCache[identifier] = nil
}
} catch {
print("Flush story images error: \(error)")
}
}
typealias ImageCompletion = (UIImage?, String?) -> Void
func feedImage(for feed: String, completion: @escaping ImageCompletion) {
guard let url = hostURL(with: "/reader/favicons?feed_ids=\(feed)") else {
completion(nil, feed)
return
}
if let image = cachedFeedImage(for: feed) {
completion(image, feed)
return
}
loaders[feed] = Loader(url: url) { (result) in
DispatchQueue.main.async {
defer {
self.loaders[feed] = nil
}
switch result {
case .success(let data):
guard let dictionary = try? JSONSerialization.jsonObject(with: data, options: []) as? AnyDictionary,
let base64 = dictionary[feed] as? String,
let imageData = Data(base64Encoded: base64, options: .ignoreUnknownCharacters),
let image = UIImage(data: imageData) else {
completion(nil, feed)
return
}
self.save(feedImage: image, for: feed)
completion(image, feed)
case .failure:
completion(nil, feed)
}
}
}
}
func storyImage(for identifier: String, imageURL: URL?, completion: @escaping ImageCompletion) {
guard let url = imageURL else {
completion(nil, identifier)
return
}
if let image = cachedStoryImage(for: identifier) {
completion(image, identifier)
return
}
loaders[identifier] = Loader(url: url) { (result) in
DispatchQueue.main.async {
defer {
self.loaders[identifier] = nil
}
switch result {
case .success(let data):
guard let loadedImage = UIImage(data: data) else {
completion(nil, identifier)
return
}
let size = loadedImage.size
guard size.width >= 50, size.height >= 50 else {
completion(nil, identifier)
return
}
let scaledImage = self.scale(image: loadedImage)
self.save(storyImage: scaledImage, for: identifier)
completion(scaledImage, identifier)
case .failure:
completion(nil, identifier)
}
}
}
}
func cachedFeedImage(for feed: String) -> UIImage? {
if let image = feedImageCache[feed] {
return image
}
guard let image = loadCachedImage(folderURL: feedImagesURL, identifier: feed) else {
return nil
}
feedImageCache[feed] = image
return image
}
func cachedStoryImage(for identifier: String) -> UIImage? {
if let image = storyImageCache[identifier] {
return image
}
guard let image = loadCachedImage(folderURL: storyImagesURL, identifier: identifier) else {
return nil
}
storyImageCache[identifier] = image
return image
}
func loadCachedImage(folderURL: URL?, identifier: String) -> UIImage? {
guard let folderURL = folderURL else {
return nil
}
do {
let imageURL = folderURL.appendingPathComponent(identifier).appendingPathExtension(Constant.imageExtension)
let data = try Data(contentsOf: imageURL)
guard let image = UIImage(data: data) else {
return nil
}
return image
} catch {
print("Image error: \(error)")
}
return nil
}
func scale(image: UIImage) -> UIImage {
let oldSize = image.size
guard oldSize.width > Constant.storyImageLimit || oldSize.height > Constant.storyImageLimit else {
return image
}
let scale: CGFloat
if oldSize.width < oldSize.height {
scale = Constant.storyImageSize / oldSize.width
} else {
scale = Constant.storyImageSize / oldSize.height
}
let newSize = CGSize(width: oldSize.width * scale, height: oldSize.height * scale)
UIGraphicsBeginImageContextWithOptions(newSize, true, 1)
image.draw(in: CGRect(origin: .zero, size: newSize))
defer {
UIGraphicsEndImageContext()
}
return UIGraphicsGetImageFromCurrentImageContext() ?? image
}
}