Recordings: Choose app instead of custom title

This commit is contained in:
relikd
2020-08-11 19:21:07 +02:00
parent 4af56b0cb1
commit 685f636d5b
15 changed files with 569 additions and 265 deletions

View File

@@ -0,0 +1,52 @@
import Foundation
import UIKit
extension URL {
static func appStoreSearch(query: String) -> URL {
// https://itunes.apple.com/lookup?bundleId=...
URL.make("https://itunes.apple.com/search", params: [
"media" : "software",
"limit" : "25",
"country" : NSLocale.current.regionCode ?? "DE",
"version" : "2",
"term" : query,
])!
}
}
struct AppStoreSearch {
struct Result {
let bundleId, name: String
let developer, imageURL: String?
}
static func search(_ term: String, _ closure: @escaping ([Result]?) -> Void) {
URLSession.shared.dataTask(with: .init(url: .appStoreSearch(query: term))) { data, response, error in
guard let data = data, error == nil,
let response = response as? HTTPURLResponse,
(200 ..< 300) ~= response.statusCode else {
closure(nil)
return
}
closure(jsonSearchToList(data))
}.resume()
}
private static func jsonSearchToList(_ data: Data) -> [Result]? {
guard let json = (try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)) as? [String: Any],
let resAll = json["results"] as? [Any] else {
return nil
}
return resAll.compactMap {
guard let res = $0 as? [String: Any],
let bndl = res["bundleId"] as? String,
let name = res["trackName"] as? String // trackCensoredName
else {
return nil
}
let seller = res["sellerName"] as? String // artistName
let image = res["artworkUrl60"] as? String // artworkUrl100
return Result(bundleId: bndl, name: name, developer: seller, imageURL: image)
}
}
}

View File

@@ -0,0 +1,106 @@
import UIKit
extension CGContext {
func lineFromTo(x1: CGFloat, y1: CGFloat, x2: CGFloat, y2: CGFloat) {
self.move(to: CGPoint(x: x1, y: y1))
self.addLine(to: CGPoint(x: x2, y: y2))
}
}
struct BundleIcon {
static let unknown : UIImage? = {
let rect = CGRect(x: 0, y: 0, width: 30, height: 30)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
let context = UIGraphicsGetCurrentContext()!
let lineWidth: CGFloat = 0.5
let corner: CGFloat = 6.75
let c = corner / CGFloat.pi + lineWidth/2
let sz: CGFloat = rect.height
let m = sz / 2
let r1 = 0.2 * sz, r2 = sqrt(2 * r1 * r1)
// diagonal
context.lineFromTo(x1: c, y1: c, x2: sz-c, y2: sz-c)
context.lineFromTo(x1: c, y1: sz-c, x2: sz-c, y2: c)
// horizontal
context.lineFromTo(x1: 0, y1: m, x2: sz, y2: m)
context.lineFromTo(x1: 0, y1: m + r1, x2: sz, y2: m + r1)
context.lineFromTo(x1: 0, y1: m - r1, x2: sz, y2: m - r1)
// vertical
context.lineFromTo(x1: m, y1: 0, x2: m, y2: sz)
context.lineFromTo(x1: m + r1, y1: 0, x2: m + r1, y2: sz)
context.lineFromTo(x1: m - r1, y1: 0, x2: m - r1, y2: sz)
// circles
context.addEllipse(in: CGRect(x: m - r1, y: m - r1, width: 2*r1, height: 2*r1))
context.addEllipse(in: CGRect(x: m - r2, y: m - r2, width: 2*r2, height: 2*r2))
let r3 = CGRect(x: c, y: c, width: sz - 2*c, height: sz - 2*c)
context.addEllipse(in: r3)
context.addRect(r3)
UIColor.clear.setFill()
UIColor.gray.setStroke()
let rounded = UIBezierPath(roundedRect: rect.insetBy(dx: lineWidth/2, dy: lineWidth/2), cornerRadius: corner)
rounded.lineWidth = lineWidth
rounded.stroke()
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return img
}()
private static let apple : UIImage? = {
let rect = CGRect(x: 0, y: 0, width: 30, height: 30)
UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0)
// #colorLiteral(red: 0.5843137503, green: 0.8235294223, blue: 0.4196078479, alpha: 1).setFill()
// UIBezierPath(roundedRect: rect, cornerRadius: 0).fill()
// print("drawing")
let fs = 36 as CGFloat
let hFont = UIFont.systemFont(ofSize: fs)
var attrib = [
NSAttributedString.Key.font: hFont,
NSAttributedString.Key.foregroundColor: UIColor.gray
]
let str = "" as NSString
let actualHeight = str.size(withAttributes: attrib).height
attrib[NSAttributedString.Key.font] = hFont.withSize(fs * fs / actualHeight)
let strW = str.size(withAttributes: attrib).width
str.draw(at: CGPoint(x: (rect.size.width - strW) / 2.0, y: -3), withAttributes: attrib)
let img = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return img
}()
private static let cacheDir = URL.documentDir().appendingPathComponent("app-store-search-cache", isDirectory:true)
private static func local(_ bundleId: String) -> URL {
cacheDir.appendingPathComponent("\(bundleId).img")
}
static func initCache() {
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true, attributes: nil)
}
static func image(_ bundleId: String?, ifNotStored: (() -> Void)? = nil) -> UIImage? {
guard let appId = bundleId else {
return unknown
}
guard let data = try? Data(contentsOf: local(appId)),
let img = UIImage(data: data, scale: 2.0) else {
ifNotStored?()
return appId.hasPrefix("com.apple.") ? apple : unknown
}
return img
}
static func download(_ bundleId: String, urlStr: String, whenDone: @escaping () -> Void) {
if let url = URL(string: urlStr) {
url.download(to: local(bundleId), onSuccess: whenDone)
}
}
}

View File

@@ -0,0 +1,166 @@
import UIKit
protocol TVCAppSearchDelegate {
func appSearch(didSelect bundleId: String, appName: String?, developer: String?)
}
class TVCAppSearch: UITableViewController, UISearchBarDelegate {
private var dataSource: [AppStoreSearch.Result] = []
private var dataSourceLocal: [AppBundleInfo] = []
private var isLoading: Bool = false
private var searchActive: Bool = false
var delegate: TVCAppSearchDelegate?
@IBOutlet private var searchBar: UISearchBar!
override func viewDidLoad() {
super.viewDidLoad()
BundleIcon.initCache()
dataSourceLocal = AppDB?.appBundleList() ?? []
}
override var keyCommands: [UIKeyCommand]? {
[UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(closeThis))]
}
@objc private func closeThis() {
searchBar.endEditing(true)
dismiss(animated: true)
}
// MARK: - Table View Data Source
override func numberOfSections(in _: UITableView) -> Int {
dataSourceLocal.count > 0 ? 2 : 1
}
override func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int {
switch section {
case 0: return max(1, dataSource.count) + (searchActive ? 1 : 0)
case 1: return dataSourceLocal.count
default: preconditionFailure()
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 0: return "AppStore"
case 1: return "Found in other recordings"
default: preconditionFailure()
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "AppStoreSearchCell")!
let bundleId: String
let altLoadUrl: String?
switch indexPath.section {
case 0:
guard dataSource.count > 0, indexPath.row < dataSource.count else {
if indexPath.row == 0 {
cell.textLabel?.text = isLoading ? "Loading …" : "no results"
cell.isUserInteractionEnabled = false
} else {
cell.textLabel?.text = "Create manually …"
}
cell.detailTextLabel?.text = nil
cell.imageView?.image = nil
return cell
}
let src = dataSource[indexPath.row]
bundleId = src.bundleId
altLoadUrl = src.imageURL
cell.textLabel?.text = src.name
cell.detailTextLabel?.text = src.developer
case 1:
let src = dataSourceLocal[indexPath.row]
bundleId = src.bundleId
altLoadUrl = nil
cell.textLabel?.text = src.name
cell.detailTextLabel?.text = src.author
default:
preconditionFailure()
}
cell.imageView?.image = BundleIcon.image(bundleId) {
guard let url = altLoadUrl else { return }
BundleIcon.download(bundleId, urlStr: url) {
DispatchQueue.main.async {
tableView.reloadRows(at: [indexPath], with: .automatic)
}
}
}
cell.isUserInteractionEnabled = true
cell.imageView?.layer.cornerRadius = 6.75
cell.imageView?.layer.masksToBounds = true
return cell
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
switch indexPath.section {
case 0:
guard indexPath.row < dataSource.count else {
let alert = AskAlert(title: "App Name",
text: "Be as descriptive as possible. Preferably use app bundle id if available. Alternatively use app name or a link to a public repository.",
buttonText: "Set") {
self.delegate?.appSearch(didSelect: "un.known", appName: $0.textFields?.first?.text, developer: nil)
self.closeThis()
}
alert.addTextField { $0.placeholder = "com.apple.notes" }
alert.presentIn(self)
return
}
let src = dataSource[indexPath.row]
delegate?.appSearch(didSelect: src.bundleId, appName: src.name, developer: src.developer)
case 1:
let src = dataSourceLocal[indexPath.row]
delegate?.appSearch(didSelect: src.bundleId, appName: src.name, developer: src.author)
default: preconditionFailure()
}
closeThis()
}
// MARK: - Search Bar Delegate
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil)
isLoading = true
tableView.reloadData()
if searchText.count > 0 {
perform(#selector(performSearch), with: nil, afterDelay: 0.4)
} else {
performSearch()
}
}
/// Internal callback function for delayed text evaluation.
/// This way we can avoid unnecessary searches while user is typing.
@objc private func performSearch() {
isLoading = false
let term = searchBar.text?.lowercased() ?? ""
searchActive = term.count > 0
guard searchActive else {
dataSource = []
tableView.reloadData()
return
}
AppStoreSearch.search(term) {
self.dataSource = $0 ?? []
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.endEditing(true)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
closeThis()
}
}

View File

@@ -64,12 +64,22 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
dataSource.count
}
// override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
// let lbl = QuickUI.label("Previous Recordings", align: .center)
// lbl.font = lbl.font.bold()
// lbl.backgroundColor = .sysBackground
// return lbl
// }
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordCell")!
let x = dataSource[indexPath.row]
cell.textLabel?.text = x.title ?? x.fallbackTitle
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
cell.detailTextLabel?.text = "at \(DateFormat.seconds(x.start)), duration: \(TimeFormat.from(x.duration ?? 0))"
cell.detailTextLabel?.text = "at \(DateFormat.minutes(x.start)), duration: \(TimeFormat.from(x.duration ?? 0))"
cell.imageView?.image = x.isLongTerm ? nil : BundleIcon.image(x.appId)
cell.imageView?.layer.cornerRadius = 6.75
cell.imageView?.layer.masksToBounds = true
return cell
}

View File

@@ -2,7 +2,7 @@ import UIKit
class TVCRecordingDetails: UITableViewController, EditActionsRemove {
var record: Recording!
private lazy var isLongRecording: Bool = (record.duration ?? 0) > Timestamp.hours(1)
private lazy var isLongRecording: Bool = record.isLongTerm
private var showRaw: Bool = false
/// Sorted by `ts` in ascending order (oldest first)

View File

@@ -1,19 +1,42 @@
import UIKit
class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate {
class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate, TVCAppSearchDelegate {
var record: Recording!
var deleteOnCancel: Bool = false
var appId: String?
@IBOutlet private var buttonCancel: UIBarButtonItem!
@IBOutlet private var buttonSave: UIBarButtonItem!
@IBOutlet private var inputTitle: UITextField!
@IBOutlet private var appTitle: UILabel!
@IBOutlet private var appDeveloper: UILabel!
@IBOutlet private var appIcon: UIImageView!
@IBOutlet private var inputNotes: UITextView!
@IBOutlet private var inputDetails: UITextView!
@IBOutlet private var noteBottom: NSLayoutConstraint!
@IBOutlet private var chooseAppTap: UITapGestureRecognizer!
override func viewDidLoad() {
inputTitle.placeholder = record.fallbackTitle
inputTitle.text = record.title
if record.isLongTerm {
appId = nil
appIcon.image = nil
appTitle.text = "Background Recording"
appDeveloper.text = nil
chooseAppTap.isEnabled = false
} else {
appId = record.appId
appIcon.image = BundleIcon.image(record.appId)
appIcon.layer.cornerRadius = 6.75
appIcon.layer.masksToBounds = true
if record.appId == nil {
appTitle.text = "Tap here to choose app"
appDeveloper.text = record.title
} else {
appTitle.text = record.title ?? record.fallbackTitle
appDeveloper.text = record.subtitle
}
}
inputNotes.text = record.notes
inputDetails.text = """
Start: \(DateFormat.seconds(record.start))
@@ -31,6 +54,11 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
UIResponder.keyboardWillHideNotification.observe(call: #selector(keyboardWillHide), on: self)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let tvc = segue.destination as? TVCAppSearch {
tvc.delegate = self
}
}
// MARK: Save & Cancel Buttons
@@ -41,7 +69,15 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
deleteOnCancel = false
}
QLog.Debug("updating record #\(record.id)")
record.title = (inputTitle.text == "") ? nil : inputTitle.text
if let id = appId, id != "" {
record.appId = id
record.title = (appTitle.text == "") ? nil : appTitle.text
record.subtitle = (appDeveloper.text == "") ? nil : appDeveloper.text
} else {
record.appId = nil
record.title = nil
record.subtitle = nil
}
record.notes = (inputNotes.text == "") ? nil : inputNotes.text
dismiss(animated: true) {
RecordingsDB.update(self.record)
@@ -121,11 +157,18 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
func textViewDidChange(_ _: UITextView) { validateSaveButton() }
private func validateSaveButton() {
let changed = (inputTitle.text != record.title ?? "" || inputNotes.text != record.notes ?? "")
let changed = (appId != record.appId
|| (appTitle.text != record.title && appTitle.text != "Tap here to choose app")
|| appDeveloper.text != record.subtitle
|| inputNotes.text != record.notes ?? "")
buttonSave.isEnabled = changed || deleteOnCancel // always allow save for new recordings
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField == inputTitle ? inputNotes.becomeFirstResponder() : true
func appSearch(didSelect bundleId: String, appName: String?, developer: String?) {
appId = bundleId
appTitle.text = appName
appDeveloper.text = developer
appIcon.image = BundleIcon.image(bundleId)
validateSaveButton()
}
}

View File

@@ -23,6 +23,8 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
super.viewWillAppear(animated)
if currentRecording != nil { startTimer(animate: false) }
navigationController?.setNavigationBarHidden(true, animated: animated)
// set hidden in will appear causes UITableViewAlertForLayoutOutsideViewHierarchy
// but otherwise navBar is visible during transition
}
override func viewWillDisappear(_ animated: Bool) {
@@ -120,7 +122,7 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
"Use the App as you would normally. Try to get to all corners and functionality the App provides. " +
"When you feel that you have captured enough content, come back to ").italic("AppCheck").normal(" and stop the recording." +
"\n\n" +
"Upon completion you will find your recording in the 'Previous Recordings' section. " +
"Upon completion you will find your recording in the section below. " +
"You can review your results and remove user specific information if necessary.")
))
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()