From 685f636d5b63cc7b94d4b49f8d0f6acd778fef30 Mon Sep 17 00:00:00 2001 From: relikd Date: Tue, 11 Aug 2020 19:21:07 +0200 Subject: [PATCH] Recordings: Choose app instead of custom title --- AppCheck.xcodeproj/project.pbxproj | 18 +- main/Base.lproj/Main.storyboard | 135 +++++++++++--- main/DB/DBAppOnly.swift | 34 +++- main/DB/DBExtensions.swift | 1 + main/Data Source/RecordingsDB.swift | 5 + main/Extensions/URL.swift | 33 +++- .../Recordings/App Icons/AppStoreSearch.swift | 52 ++++++ main/Recordings/App Icons/BundleIcon.swift | 106 +++++++++++ main/Recordings/TVCAppSearch.swift | 166 ++++++++++++++++++ main/Recordings/TVCPreviousRecords.swift | 12 +- main/Recordings/TVCRecordingDetails.swift | 2 +- main/Recordings/VCEditRecording.swift | 59 ++++++- main/Recordings/VCRecordings.swift | 4 +- main/unused/AppInfoType.swift | 129 -------------- main/unused/BundleIcon.swift | 78 -------- 15 files changed, 569 insertions(+), 265 deletions(-) create mode 100644 main/Recordings/App Icons/AppStoreSearch.swift create mode 100644 main/Recordings/App Icons/BundleIcon.swift create mode 100644 main/Recordings/TVCAppSearch.swift delete mode 100644 main/unused/AppInfoType.swift delete mode 100644 main/unused/BundleIcon.swift diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index b306d20..28da5c6 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -77,6 +77,7 @@ 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6023E0D69A0054345C /* TVCHosts.swift */; }; 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54953E6E23E44CD00054345C /* TVCHostDetails.swift */; }; 54953E7123E473F10054345C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 54953E7023E473F10054345C /* Settings.bundle */; }; + 549D6ED524D5BFDB0032E498 /* TVCAppSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549D6ED424D5BFDB0032E498 /* TVCAppSearch.swift */; }; 549ECD9D24A7AD550097571C /* CustomAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549ECD9C24A7AD550097571C /* CustomAlert.swift */; }; 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34593240E6343004C53CC /* TVCFilter.swift */; }; 54B34596240F0513004C53CC /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B34595240F0513004C53CC /* TableView.swift */; }; @@ -85,7 +86,7 @@ 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; }; 54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AC241BBB00004C53CC /* DBExtensions.swift */; }; 54B345B0242264F8004C53CC /* third-level.txt in Resources */ = {isa = PBXBuildFile; fileRef = 54B345AF242264F8004C53CC /* third-level.txt */; }; - 54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DA23E9E36E00214A3F /* AppInfoType.swift */; }; + 54C056DB23E9E36E00214A3F /* AppStoreSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DA23E9E36E00214A3F /* AppStoreSearch.swift */; }; 54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DC23E9EEF700214A3F /* BundleIcon.swift */; }; 54CA01D32426B23D003A5E04 /* Resolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA01D22426B23D003A5E04 /* Resolver.swift */; }; 54CA01D52426B252003A5E04 /* SafeDict.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54CA01D42426B251003A5E04 /* SafeDict.swift */; }; @@ -270,6 +271,7 @@ 54953E6023E0D69A0054345C /* TVCHosts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHosts.swift; sourceTree = ""; }; 54953E6E23E44CD00054345C /* TVCHostDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCHostDetails.swift; sourceTree = ""; }; 54953E7023E473F10054345C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; + 549D6ED424D5BFDB0032E498 /* TVCAppSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCAppSearch.swift; sourceTree = ""; }; 549ECD9C24A7AD550097571C /* CustomAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAlert.swift; sourceTree = ""; }; 54B34593240E6343004C53CC /* TVCFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCFilter.swift; sourceTree = ""; }; 54B34595240F0513004C53CC /* TableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableView.swift; sourceTree = ""; }; @@ -279,7 +281,7 @@ 54B345AC241BBB00004C53CC /* DBExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBExtensions.swift; sourceTree = ""; }; 54B345AF242264F8004C53CC /* third-level.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "third-level.txt"; sourceTree = ""; }; 54B7562223D7B2DC008F0C41 /* DBCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBCore.swift; sourceTree = ""; }; - 54C056DA23E9E36E00214A3F /* AppInfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoType.swift; sourceTree = ""; }; + 54C056DA23E9E36E00214A3F /* AppStoreSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStoreSearch.swift; sourceTree = ""; }; 54C056DC23E9EEF700214A3F /* BundleIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleIcon.swift; sourceTree = ""; }; 54CA00D62426A803003A5E04 /* CocoaAsyncSocket.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CocoaAsyncSocket.h; sourceTree = ""; }; 54CA01D22426B23D003A5E04 /* Resolver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Resolver.swift; sourceTree = ""; }; @@ -432,6 +434,8 @@ 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */, 540E67812433483D00871BBE /* VCEditRecording.swift */, 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */, + 549D6ED424D5BFDB0032E498 /* TVCAppSearch.swift */, + 54B345B12422E029004C53CC /* App Icons */, ); path = Recordings; sourceTree = ""; @@ -482,7 +486,6 @@ 540C6454240D5BAE00E948F9 /* Requests */, 540E677E242D2CD200871BBE /* Recordings */, 540C6455240D5BD200E948F9 /* Settings */, - 54B345B12422E029004C53CC /* unused */, 541AC5E02399498B00A769D7 /* LaunchScreen.storyboard */, 541AC5DB2399498A00A769D7 /* Main.storyboard */, 543078C124B60F3B00278F2D /* Settings.storyboard */, @@ -593,13 +596,13 @@ path = Extensions; sourceTree = ""; }; - 54B345B12422E029004C53CC /* unused */ = { + 54B345B12422E029004C53CC /* App Icons */ = { isa = PBXGroup; children = ( 54C056DC23E9EEF700214A3F /* BundleIcon.swift */, - 54C056DA23E9E36E00214A3F /* AppInfoType.swift */, + 54C056DA23E9E36E00214A3F /* AppStoreSearch.swift */, ); - path = unused; + path = "App Icons"; sourceTree = ""; }; 54CA00D52426A7F2003A5E04 /* robbiehanson-CocoaAsyncSocket */ = { @@ -1009,9 +1012,10 @@ 544F912024A67EC5001D4B00 /* TVCOccurrenceContext.swift in Sources */, 54448A30248647D900771C96 /* Time.swift in Sources */, 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */, + 549D6ED524D5BFDB0032E498 /* TVCAppSearch.swift in Sources */, 54751E512423955100168273 /* URL.swift in Sources */, 54953E5F23DEBE840054345C /* TVCDomains.swift in Sources */, - 54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */, + 54C056DB23E9E36E00214A3F /* AppStoreSearch.swift in Sources */, 54D8B98624796E9900EB2414 /* GroupedDomainDataSource.swift in Sources */, 541075CE24C9D43A00D6F1BF /* UNNotification.swift in Sources */, 54953E6F23E44CD00054345C /* TVCHostDetails.swift in Sources */, diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index ba5c7fc..cad61f0 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -1,5 +1,5 @@ - + @@ -893,39 +893,55 @@ - + - + - + - + + + + + + + + + + + + + + - + - + 1. Line 2. Line @@ -987,8 +1003,8 @@ Duration: 60:00 - - + + @@ -1006,22 +1022,30 @@ Duration: 60:00 + + + + - - + - + + + + + + - + @@ -1117,10 +1141,66 @@ Duration: 60:00 + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1151,9 +1231,10 @@ Duration: 60:00 - + + diff --git a/main/DB/DBAppOnly.swift b/main/DB/DBAppOnly.swift index 935cc43..7e10a08 100644 --- a/main/DB/DBAppOnly.swift +++ b/main/DB/DBAppOnly.swift @@ -20,9 +20,13 @@ extension SQLiteDatabase { try ifStep(stmt, SQLITE_ROW) return sqlite3_column_int(stmt, 0) } - if version != 1 { + if version != 2 { // version 0 -> 1: req(domain) -> heap(fqdn, domain) - try run(sql: "PRAGMA user_version = 1;") + // version 1 -> 2: rec(+subtitle) + if version == 1 { + try run(sql: "ALTER TABLE rec ADD COLUMN subtitle TEXT;") + } + try run(sql: "PRAGMA user_version = 2;") } } } @@ -281,7 +285,7 @@ extension SQLiteDatabase { // MARK: - Recordings extension CreateTable { - /// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `notes`: String + /// `id`: Primary, `start`: Timestamp, `stop`: Timestamp, `appid`: String, `title`: String, `subtitle`: String, `notes`: String static var rec: String {""" CREATE TABLE IF NOT EXISTS rec( id INTEGER PRIMARY KEY, @@ -289,6 +293,7 @@ extension CreateTable { stop INTEGER, appid TEXT, title TEXT, + subtitle TEXT, notes TEXT ); """} @@ -300,8 +305,10 @@ struct Recording { let stop: Timestamp? var appId: String? = nil var title: String? = nil + var subtitle: String? = nil var notes: String? = nil } +typealias AppBundleInfo = (bundleId: String, name: String?, author: String?) extension SQLiteDatabase { @@ -328,8 +335,8 @@ extension SQLiteDatabase { /// Update given recording by replacing `title`, `appid`, and `notes` with new values. func recordingUpdate(_ r: Recording) { - try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE id = ? LIMIT 1;", - bind: [BindTextOrNil(r.title), BindTextOrNil(r.appId), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in + try? run(sql: "UPDATE rec SET appid = ?, title = ?, subtitle = ?, notes = ? WHERE id = ? LIMIT 1;", + bind: [BindTextOrNil(r.appId), BindTextOrNil(r.title), BindTextOrNil(r.subtitle), BindTextOrNil(r.notes), BindInt64(r.id)]) { stmt -> Void in sqlite3_step(stmt) } } @@ -353,12 +360,13 @@ extension SQLiteDatabase { stop: end == 0 ? nil : end, appId: col_text(stmt, 3), title: col_text(stmt, 4), - notes: col_text(stmt, 5)) + subtitle: col_text(stmt, 5), + notes: col_text(stmt, 6)) } /// `WHERE stop IS NULL` func recordingGetOngoing() -> Recording? { - try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;") { + try? run(sql: "SELECT id, start, stop, appid, title, subtitle, notes FROM rec WHERE stop IS NULL LIMIT 1;") { try ifStep($0, SQLITE_ROW) return readRecording($0) } @@ -374,18 +382,26 @@ extension SQLiteDatabase { /// `WHERE stop IS NOT NULL` func recordingGetAll() -> [Recording]? { - try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;") { + try? run(sql: "SELECT id, start, stop, appid, title, subtitle, notes FROM rec WHERE stop IS NOT NULL;") { allRows($0) { readRecording($0) } } } /// `WHERE id = ?` private func recordingGet(withID: sqlite3_int64) throws -> Recording { - try run(sql: "SELECT * FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) { + try run(sql: "SELECT id, start, stop, appid, title, subtitle, notes FROM rec WHERE id = ? LIMIT 1;", bind: [BindInt64(withID)]) { try ifStep($0, SQLITE_ROW) return readRecording($0) } } + + func appBundleList() -> [AppBundleInfo]? { + try? run(sql: "SELECT appid, title, subtitle FROM rec WHERE appid IS NOT NULL GROUP BY appid ORDER BY title ASC;") { + allRows($0) { + AppBundleInfo(col_text($0, 0)!, col_text($0, 1), col_text($0, 2)) + } + } + } } diff --git a/main/DB/DBExtensions.swift b/main/DB/DBExtensions.swift index 57c0d3b..a2f5f52 100644 --- a/main/DB/DBExtensions.swift +++ b/main/DB/DBExtensions.swift @@ -35,5 +35,6 @@ extension FilterOptions { extension Recording { var fallbackTitle: String { get { "Unnamed Recording #\(id)" } } var duration: Timestamp? { get { stop == nil ? nil : stop! - start } } + var isLongTerm: Bool { (duration ?? 0) > Timestamp.hours(1) } } diff --git a/main/Data Source/RecordingsDB.swift b/main/Data Source/RecordingsDB.swift index 8a06467..605b7ea 100644 --- a/main/Data Source/RecordingsDB.swift +++ b/main/Data Source/RecordingsDB.swift @@ -52,5 +52,10 @@ enum RecordingsDB { static func deleteSingle(_ r: Recording, domain: String, ts: Timestamp) -> Bool { (try? AppDB?.recordingLogsDelete(r.id, singleEntry: ts, domain: domain)) ?? false } + + /// Return list of previously used apps found in all recordings. + static func appList() -> [AppBundleInfo] { + AppDB?.appBundleList() ?? [] + } } diff --git a/main/Extensions/URL.swift b/main/Extensions/URL.swift index c69a627..6e2a0aa 100644 --- a/main/Extensions/URL.swift +++ b/main/Extensions/URL.swift @@ -1,9 +1,9 @@ import Foundation fileprivate extension FileManager { -// func exportDir() -> URL { -// try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) -// } + func documentDir() -> URL { + try! url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + } func appGroupDir() -> URL { containerURL(forSecurityApplicationGroupIdentifier: "group.de.uni-bamberg.psi.AppCheck")! } @@ -25,7 +25,32 @@ extension FileManager { } extension URL { -// static func exportDir() -> URL { FileManager.default.exportDir() } + static func documentDir() -> URL { FileManager.default.documentDir() } static func appGroupDir() -> URL { FileManager.default.appGroupDir() } static func internalDB() -> URL { FileManager.default.internalDB() } + + static func make(_ base: String, params: [String : String]) -> URL? { + guard var components = URLComponents(string: base) else { + return nil + } + components.queryItems = params.map { + URLQueryItem(name: $0, value: $1) + } + components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") + return components.url + } + + func download(to file: URL, onSuccess: @escaping () -> Void) { + URLSession.shared.downloadTask(with: self) { location, response, error in + if let loc = location { + try? FileManager.default.removeItem(at: file) + do { + try FileManager.default.moveItem(at: loc, to: file) + onSuccess() + } catch { + NSLog("[VPN.ERROR] \(error)") + } + } + }.resume() + } } diff --git a/main/Recordings/App Icons/AppStoreSearch.swift b/main/Recordings/App Icons/AppStoreSearch.swift new file mode 100644 index 0000000..d16190a --- /dev/null +++ b/main/Recordings/App Icons/AppStoreSearch.swift @@ -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) + } + } +} diff --git a/main/Recordings/App Icons/BundleIcon.swift b/main/Recordings/App Icons/BundleIcon.swift new file mode 100644 index 0000000..21951cd --- /dev/null +++ b/main/Recordings/App Icons/BundleIcon.swift @@ -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) + } + } +} diff --git a/main/Recordings/TVCAppSearch.swift b/main/Recordings/TVCAppSearch.swift new file mode 100644 index 0000000..99c7690 --- /dev/null +++ b/main/Recordings/TVCAppSearch.swift @@ -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() + } +} diff --git a/main/Recordings/TVCPreviousRecords.swift b/main/Recordings/TVCPreviousRecords.swift index f4974fe..a904428 100644 --- a/main/Recordings/TVCPreviousRecords.swift +++ b/main/Recordings/TVCPreviousRecords.swift @@ -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 } diff --git a/main/Recordings/TVCRecordingDetails.swift b/main/Recordings/TVCRecordingDetails.swift index 0a5c4b7..dcb1760 100644 --- a/main/Recordings/TVCRecordingDetails.swift +++ b/main/Recordings/TVCRecordingDetails.swift @@ -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) diff --git a/main/Recordings/VCEditRecording.swift b/main/Recordings/VCEditRecording.swift index 3a5a2d5..a4a5fb2 100644 --- a/main/Recordings/VCEditRecording.swift +++ b/main/Recordings/VCEditRecording.swift @@ -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() } } diff --git a/main/Recordings/VCRecordings.swift b/main/Recordings/VCRecordings.swift index 5cf556e..6be63b5 100644 --- a/main/Recordings/VCRecordings.swift +++ b/main/Recordings/VCRecordings.swift @@ -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() diff --git a/main/unused/AppInfoType.swift b/main/unused/AppInfoType.swift deleted file mode 100644 index 10b0a66..0000000 --- a/main/unused/AppInfoType.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation -import UIKit - -private let fm = FileManager.default -private let documentsDir = try! fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) -private let bundleInfoDir = documentsDir.appendingPathComponent("bundleInfo", isDirectory:true) - - -struct AppInfoType : Decodable { - var id: String - var name: String? - var seller: String? - var imageURL: URL? - private var remoteImgURL: String? - private var cache: Bool? - private let localJSON: URL - private let localImgURL: URL - - static func initWorkingDir() { - try? fm.createDirectory(at: bundleInfoDir, withIntermediateDirectories: true, attributes: nil) -// print("init dir: \(bundleInfoDir)") - } - - init(id: String) { - self.id = id - if id == "" { - name = "–?–" - cache = true - localJSON = URL(fileURLWithPath: "") - localImgURL = localJSON - } else { - localJSON = bundleInfoDir.appendingPathComponent("\(id).json") - localImgURL = bundleInfoDir.appendingPathComponent("\(id).img") - reload() - } - } - - mutating func reload() { - if fm.fileExists(atPath: localImgURL.path) { - imageURL = localImgURL - } - guard name == nil, seller == nil, - fm.fileExists(atPath: localJSON.path), - let attr = try? fm.attributesOfItem(atPath: localJSON.path), - attr[FileAttributeKey.size] as! UInt64 > 0 else - { - // process json only if attributes not set yet, - // OR json doesn't exist, OR json is empty - return - } - (name, seller, remoteImgURL) = parseJSON(localJSON) - - if remoteImgURL == nil || imageURL != nil { - cache = true - } - } - - func getImage() -> UIImage? { - if let img = imageURL, let data = try? Data(contentsOf: img) { - return UIImage(data: data, scale: 2.0) - } else if id.hasPrefix("com.apple.") { - return appIconApple - } else { - return appIconUnknown - } - } - - private func parseJSON(_ location: URL) -> (name: String?, seller: String?, image: String?) { - do { - let data = try Data.init(contentsOf: location) - if - let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? [String: Any], - let resAll = json["results"] as? [Any], - let res = resAll.first as? [String: Any] - { - let name = res["trackName"] as? String // trackCensoredName - let seller = res["sellerName"] as? String // artistName - let image = res["artworkUrl60"] as? String // artworkUrl100 - return (name, seller, image) - } else if id.hasPrefix("com.apple.") { - return (String(id.dropFirst(10)), "Apple Inc.", nil) - } - } catch {} - return (nil, nil, nil) - } - - mutating func updateIfNeeded(_ updateClosure: () -> Void) { - guard cache == nil, - let safeId = id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { - return - } - cache = false // meaning: hasn't downloaded yet, but is about to do -// print("downloading \(id)") - _ = downloadURL("https://itunes.apple.com/lookup?bundleId=\(safeId)", toFile: localJSON).flatMap{ -// print("downloading \(id) done.") - reload() - updateClosure() - return downloadURL(remoteImgURL, toFile: localImgURL) - }.map{ -// print("downloading \(id) image done.") - reload() - updateClosure() - } - } - - enum NetworkError: Error { - case url - } - - private func downloadURL(_ urlStr: String?, toFile: URL) -> Result { - guard let urlStr = urlStr, let url = URL(string: urlStr) else { - return .failure(NetworkError.url) - } - var result: Result! - let semaphore = DispatchSemaphore(value: 0) - URLSession.shared.downloadTask(with: url) { location, response, error in - if let loc = location { - try? fm.removeItem(at: toFile) - try? fm.moveItem(at: loc, to: toFile) - result = .success(()) - } else { - result = .failure(error!) - } - semaphore.signal() - }.resume() - _ = semaphore.wait(wallTimeout: .distantFuture) - return result - } -} diff --git a/main/unused/BundleIcon.swift b/main/unused/BundleIcon.swift deleted file mode 100644 index d05298d..0000000 --- a/main/unused/BundleIcon.swift +++ /dev/null @@ -1,78 +0,0 @@ -import UIKit - -let appIconApple = generateAppleIcon() -let appIconUnknown = generateUnknownIcon() - -func generateAppleIcon() -> 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 -} - -func generateUnknownIcon() -> 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 -} - -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)) - } -}