From b898f9d17e023cd8a831c2f17bfe9809d71fc7d1 Mon Sep 17 00:00:00 2001 From: dankito Date: Thu, 30 Jul 2020 14:48:18 +0200 Subject: [PATCH] Implemented SwiftBankIconFinder --- .../dankito/banking/BankingPresenterSwift.kt | 11 +- .../BankingiOSApp.xcodeproj/project.pbxproj | 67 ++++- .../BankIconFinder/Favicon.swift | 23 ++ .../BankIconFinder/FaviconFinder.swift | 186 ++++++++++++ .../BankIconFinder/FaviconType.swift | 18 ++ .../BankingiOSApp/BankIconFinder/Size.swift | 42 +++ .../BankIconFinder/SwiftBankIconFinder.swift | 279 ++++++++++++++++++ .../BankingiOSApp/SceneDelegate.swift | 2 +- .../fints4k/UrlSessionWebClient.swift | 100 ++++++- .../CoreDataBankingPersistence.swift | 11 +- .../BankingiOSApp/ui/SwiftExtensions.swift | 18 ++ .../SwiftBankIconFinderTest.swift | 29 ++ 12 files changed, 772 insertions(+), 14 deletions(-) create mode 100644 ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Favicon.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconFinder.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconType.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Size.swift create mode 100644 ui/BankingiOSApp/BankingiOSApp/BankIconFinder/SwiftBankIconFinder.swift create mode 100644 ui/BankingiOSApp/BankingiOSAppTests/BankIconFinder/SwiftBankIconFinderTest.swift diff --git a/ui/BankingUiNativeIntegration/src/iosMain/kotlin/net/dankito/banking/BankingPresenterSwift.kt b/ui/BankingUiNativeIntegration/src/iosMain/kotlin/net/dankito/banking/BankingPresenterSwift.kt index 8afc3ba6..b3fb395d 100644 --- a/ui/BankingUiNativeIntegration/src/iosMain/kotlin/net/dankito/banking/BankingPresenterSwift.kt +++ b/ui/BankingUiNativeIntegration/src/iosMain/kotlin/net/dankito/banking/BankingPresenterSwift.kt @@ -6,16 +6,15 @@ import net.dankito.banking.persistence.IBankingPersistence import net.dankito.banking.search.IRemitteeSearcher import net.dankito.banking.ui.IRouter import net.dankito.banking.ui.presenter.BankingPresenter -import net.dankito.banking.util.IAsyncRunner -import net.dankito.banking.util.NoOpBankIconFinder -import net.dankito.banking.util.NoOpSerializer +import net.dankito.banking.util.* import net.dankito.banking.util.extraction.NoOpInvoiceDataExtractor import net.dankito.banking.util.extraction.NoOpTextExtractorRegistry import net.dankito.utils.multiplatform.File -class BankingPresenterSwift(dataFolder: File, router: IRouter, webClient: IWebClient, persistence: IBankingPersistence, remitteeSearcher: IRemitteeSearcher, asyncRunner: IAsyncRunner) - : BankingPresenter(fints4kBankingClientCreator(NoOpSerializer(), webClient), InMemoryBankFinder(), dataFolder, persistence, router, - remitteeSearcher, NoOpBankIconFinder(), NoOpTextExtractorRegistry(), NoOpInvoiceDataExtractor(), NoOpSerializer(), asyncRunner) { +class BankingPresenterSwift(dataFolder: File, router: IRouter, webClient: IWebClient, persistence: IBankingPersistence, + remitteeSearcher: IRemitteeSearcher, bankIconFinder: IBankIconFinder, serializer: ISerializer, asyncRunner: IAsyncRunner) + : BankingPresenter(fints4kBankingClientCreator(serializer, webClient), InMemoryBankFinder(), dataFolder, persistence, router, + remitteeSearcher, bankIconFinder, NoOpTextExtractorRegistry(), NoOpInvoiceDataExtractor(), serializer, asyncRunner) { } \ No newline at end of file diff --git a/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj b/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj index e166f1fd..27d37f1d 100644 --- a/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj +++ b/ui/BankingiOSApp/BankingiOSApp.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ @@ -51,6 +51,14 @@ 36BE069124CEF52800CBBB68 /* UpdateButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE069024CEF52800CBBB68 /* UpdateButton.swift */; }; 36BE06B324CF133400CBBB68 /* JsonEncoderSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06B224CF133400CBBB68 /* JsonEncoderSerializer.swift */; }; 36BE06B524CF85A300CBBB68 /* AmountLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06B424CF85A300CBBB68 /* AmountLabel.swift */; }; + 36BE06B824D077EC00CBBB68 /* SwiftBankIconFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06B724D077EC00CBBB68 /* SwiftBankIconFinder.swift */; }; + 36BE06BA24D0783900CBBB68 /* FaviconFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06B924D0783800CBBB68 /* FaviconFinder.swift */; }; + 36BE06C024D07CCD00CBBB68 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 36BE06BF24D07CCD00CBBB68 /* SwiftSoup */; }; + 36BE06C224D07FB100CBBB68 /* Favicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06C124D07FB100CBBB68 /* Favicon.swift */; }; + 36BE06C424D0801A00CBBB68 /* Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06C324D0801A00CBBB68 /* Size.swift */; }; + 36BE06C624D080C900CBBB68 /* FaviconType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06C524D080C900CBBB68 /* FaviconType.swift */; }; + 36BE06C824D0DE7400CBBB68 /* UIKitTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36BE06C724D0DE7400CBBB68 /* UIKitTextField.swift */; }; + 36C4009824D23580005227AD /* SwiftBankIconFinderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36C4009724D23580005227AD /* SwiftBankIconFinderTest.swift */; }; 36E7BA1424B3D05C00757859 /* ViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36E7BA1324B3D05C00757859 /* ViewExtensions.swift */; }; 36FC929C24B39A05002B12E9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FC929B24B39A05002B12E9 /* AppDelegate.swift */; }; 36FC929E24B39A05002B12E9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36FC929D24B39A05002B12E9 /* SceneDelegate.swift */; }; @@ -145,6 +153,13 @@ 36BE069024CEF52800CBBB68 /* UpdateButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateButton.swift; sourceTree = ""; }; 36BE06B224CF133400CBBB68 /* JsonEncoderSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonEncoderSerializer.swift; sourceTree = ""; }; 36BE06B424CF85A300CBBB68 /* AmountLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AmountLabel.swift; sourceTree = ""; }; + 36BE06B724D077EC00CBBB68 /* SwiftBankIconFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftBankIconFinder.swift; sourceTree = ""; }; + 36BE06B924D0783800CBBB68 /* FaviconFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconFinder.swift; sourceTree = ""; }; + 36BE06C124D07FB100CBBB68 /* Favicon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Favicon.swift; sourceTree = ""; }; + 36BE06C324D0801A00CBBB68 /* Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Size.swift; sourceTree = ""; }; + 36BE06C524D080C900CBBB68 /* FaviconType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconType.swift; sourceTree = ""; }; + 36BE06C724D0DE7400CBBB68 /* UIKitTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitTextField.swift; sourceTree = ""; }; + 36C4009724D23580005227AD /* SwiftBankIconFinderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SwiftBankIconFinderTest.swift; path = BankingiOSAppTests/BankIconFinder/SwiftBankIconFinderTest.swift; sourceTree = SOURCE_ROOT; }; 36E7BA1324B3D05C00757859 /* ViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtensions.swift; sourceTree = ""; }; 36E7BA1824B9E70C00757859 /* xcode-frameworks */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "xcode-frameworks"; path = "../../tools/BankFinder/build/xcode-frameworks"; sourceTree = ""; }; 36FC929824B39A05002B12E9 /* BankingiOSApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BankingiOSApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -181,6 +196,7 @@ 36FC92D024B39C47002B12E9 /* fints4k.framework in Frameworks */, 36BCF87024BB0F8A005BEC29 /* fints4kBankingClient.framework in Frameworks */, 36BCF87324BB2706005BEC29 /* BankingUiSwift.framework in Frameworks */, + 36BE06C024D07CCD00CBBB68 /* SwiftSoup in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -213,6 +229,18 @@ path = persistence; sourceTree = ""; }; + 36BE06B624D077B400CBBB68 /* BankIconFinder */ = { + isa = PBXGroup; + children = ( + 36BE06C124D07FB100CBBB68 /* Favicon.swift */, + 36BE06C524D080C900CBBB68 /* FaviconType.swift */, + 36BE06C324D0801A00CBBB68 /* Size.swift */, + 36BE06B924D0783800CBBB68 /* FaviconFinder.swift */, + 36BE06B724D077EC00CBBB68 /* SwiftBankIconFinder.swift */, + ); + path = BankIconFinder; + sourceTree = ""; + }; 36FC928F24B39A05002B12E9 = { isa = PBXGroup; children = ( @@ -237,8 +265,9 @@ 36FC929A24B39A05002B12E9 /* BankingiOSApp */ = { isa = PBXGroup; children = ( - 36BCF87924BFA679005BEC29 /* persistence */, 36FC92D424B3A389002B12E9 /* fints4k */, + 36BCF87924BFA679005BEC29 /* persistence */, + 36BE06B624D077B400CBBB68 /* BankIconFinder */, 36FC92D924B3A479002B12E9 /* ui */, 36FC929B24B39A05002B12E9 /* AppDelegate.swift */, 36FC929D24B39A05002B12E9 /* SceneDelegate.swift */, @@ -321,6 +350,7 @@ 36BE068C24CE41E700CBBB68 /* Styles.swift */, 36BE069024CEF52800CBBB68 /* UpdateButton.swift */, 36BE06B424CF85A300CBBB68 /* AmountLabel.swift */, + 36BE06C724D0DE7400CBBB68 /* UIKitTextField.swift */, ); path = ui; sourceTree = ""; @@ -334,6 +364,7 @@ 36BCF88224C098BB005BEC29 /* BankListItem.swift */, 36BCF88424C098C8005BEC29 /* BankAccountListItem.swift */, 36BCF88A24C0BD2D005BEC29 /* AccountTransactionsDialog.swift */, + 36C4009724D23580005227AD /* SwiftBankIconFinderTest.swift */, 36BE066424CDE62800CBBB68 /* AccountTransactionListItem.swift */, 36BCF88C24C1C1EA005BEC29 /* TransferMoneyDialog.swift */, 366FA4DB24C479120094F009 /* BankInfoListItem.swift */, @@ -366,6 +397,9 @@ dependencies = ( ); name = BankingiOSApp; + packageProductDependencies = ( + 36BE06BF24D07CCD00CBBB68 /* SwiftSoup */, + ); productName = BankingiOSApp; productReference = 36FC929824B39A05002B12E9 /* BankingiOSApp.app */; productType = "com.apple.product-type.application"; @@ -439,6 +473,9 @@ de, ); mainGroup = 36FC928F24B39A05002B12E9; + packageReferences = ( + 36BE06BE24D07CCC00CBBB68 /* XCRemoteSwiftPackageReference "SwiftSoup" */, + ); productRefGroup = 36FC929924B39A05002B12E9 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -505,16 +542,21 @@ buildActionMask = 2147483647; files = ( 36BE065924CA3CAB00CBBB68 /* UIKitSearchBar.swift in Sources */, + 36BE06C824D0DE7400CBBB68 /* UIKitTextField.swift in Sources */, 36BE064F24C9A17F00CBBB68 /* ImageTanView.swift in Sources */, 36BE068D24CE41E700CBBB68 /* Styles.swift in Sources */, 366FA4E224C4ED6C0094F009 /* EnterTanDialog.swift in Sources */, 36FC92DC24B3A4A0002B12E9 /* AccountsTab.swift in Sources */, 36BCF86E24BA691B005BEC29 /* DependencyInjector.swift in Sources */, + 36BE06C224D07FB100CBBB68 /* Favicon.swift in Sources */, + 36BE06B824D077EC00CBBB68 /* SwiftBankIconFinder.swift in Sources */, 36BCF89124C25971005BEC29 /* CoreDataBankingPersistence.swift in Sources */, 36FC92A124B39A05002B12E9 /* BankingiOSApp.xcdatamodeld in Sources */, + 36BE06C624D080C900CBBB68 /* FaviconType.swift in Sources */, 36BCF89324C25BC3005BEC29 /* Mapper.swift in Sources */, 36FC92D724B3A3BA002B12E9 /* NSUrlWebClient.swift in Sources */, 36BE06B324CF133400CBBB68 /* JsonEncoderSerializer.swift in Sources */, + 36BE06BA24D0783900CBBB68 /* FaviconFinder.swift in Sources */, 36BCF89524C31F02005BEC29 /* AppData.swift in Sources */, 36BE065B24CA4B3500CBBB68 /* SelectBankDialog.swift in Sources */, 36BE068924CE288800CBBB68 /* CollapsibleText.swift in Sources */, @@ -532,6 +574,7 @@ 36FC929C24B39A05002B12E9 /* AppDelegate.swift in Sources */, 36BCF88B24C0BD2D005BEC29 /* AccountTransactionsDialog.swift in Sources */, 36BCF87624BF114F005BEC29 /* UrlSessionWebClient.swift in Sources */, + 36BE06C424D0801A00CBBB68 /* Size.swift in Sources */, 36FC92A324B39A05002B12E9 /* ContentView.swift in Sources */, 366FA4E624C6EBF40094F009 /* EnterTanState.swift in Sources */, 36BE065724C9E04800CBBB68 /* UIKitImageView.swift in Sources */, @@ -552,6 +595,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 36C4009824D23580005227AD /* SwiftBankIconFinderTest.swift in Sources */, 36FC92B624B39A08002B12E9 /* BankingiOSAppTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -895,6 +939,25 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 36BE06BE24D07CCC00CBBB68 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/scinfu/SwiftSoup.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.3.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 36BE06BF24D07CCD00CBBB68 /* SwiftSoup */ = { + isa = XCSwiftPackageProductDependency; + package = 36BE06BE24D07CCC00CBBB68 /* XCRemoteSwiftPackageReference "SwiftSoup" */; + productName = SwiftSoup; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ 36FC929F24B39A05002B12E9 /* BankingiOSApp.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Favicon.swift b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Favicon.swift new file mode 100644 index 00000000..3f7a3801 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Favicon.swift @@ -0,0 +1,23 @@ +import SwiftUI + + +class Favicon { + + let url: String + + let iconType: FaviconType + + let size: Size? + + let type: String? + + + init(url: String, iconType: FaviconType, size: Size? = nil, type: String? = nil) { + self.url = url + self.iconType = iconType + + self.size = size + self.type = type + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconFinder.swift b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconFinder.swift new file mode 100644 index 00000000..fcdf5b2b --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconFinder.swift @@ -0,0 +1,186 @@ +import Foundation +import SwiftSoup +import fints4k // TODO: get rid of this + + +class FaviconFinder { + + private let webClient = UrlSessionWebClient() // TODO: create interface and pass from SwiftBankIconFinder + + + func extractFavicons(url: String, callback: @escaping ([Favicon]) -> Void) { + webClient.getAsync(url) { response in + if response.successful, let html = response.body { + callback(self.extractFavicons(url: url, html: html)) + } + else { + callback([]) + } + } + + return callback([]) + } + + func extractFavicons(url: String, html: String) -> [Favicon] { + if let document = try? SwiftSoup.parse(html) { + return extractFavicons(document: document, url: url) + } + + return [] + } + + func extractFavicons(document: Document, url: String) -> [Favicon] { + var extractedFavicons = (try? document.head()?.select("link, meta").map { mapElementToFavicon($0, url) } as? [Favicon]) ?? [] + + tryToFindDefaultFavicon(url, &extractedFavicons) + + return extractedFavicons + } + + private func tryToFindDefaultFavicon(_ url: String, _ extractedFavicons: inout [Favicon]) { + let urlInstance = URL(string: url) + let defaultFaviconUrl = "\(urlInstance?.scheme ?? "https")://\(urlInstance?.host ?? "")/favicon.ico" + + if (containsIconWithUrl(extractedFavicons, defaultFaviconUrl) == false) { + let response = webClient.head(defaultFaviconUrl) + if (response.successful) { + extractedFavicons.append(Favicon(url: defaultFaviconUrl, iconType: FaviconType.ShortcutIcon)) + } + } + } + + private func containsIconWithUrl(_ favicons: [Favicon], _ faviconUrl: String) -> Bool { + return favicons.first(where: { favicon in favicon.url == faviconUrl } ) != nil + } + + /** + * Possible formats are documented here https://stackoverflow.com/questions/21991044/how-to-get-high-resolution-website-logo-favicon-for-a-given-url#answer-22007642 + * and here https://en.wikipedia.org/wiki/Favicon + */ + private func mapElementToFavicon(_ linkOrMetaElement: Element, _ siteUrl: String) -> Favicon? { + if (linkOrMetaElement.nodeName() == "link") { + return mapLinkElementToFavicon(linkOrMetaElement, siteUrl) + } + else if (linkOrMetaElement.nodeName() == "meta") { + return mapMetaElementToFavicon(linkOrMetaElement, siteUrl) + } + + return nil + } + + private func mapLinkElementToFavicon(_ linkElement: Element, _ siteUrl: String) -> Favicon? { + if linkElement.hasAttr("rel") { + if let faviconType = getFaviconTypeForLinkElements(linkElement) { + let href = try? linkElement.attr("href") + let sizes = try? linkElement.attr("sizes") + let type = try? linkElement.attr("type") + + if href?.starts(with: "data:;base64") == false { + return createFavicon(url: href, siteUrl: siteUrl, iconType: faviconType, sizesString: sizes, type: type) + } + } + } + + return nil + } + + private func getFaviconTypeForLinkElements(_ linkElement: Element) -> FaviconType? { + if let relValue = try? linkElement.attr("rel") { + switch relValue { + case "icon": + return FaviconType.Icon + case "apple-touch-icon-precomposed": + return FaviconType.AppleTouchPrecomposed + case "apple-touch-icon": + return FaviconType.AppleTouch + case "shortcut icon": + return FaviconType.ShortcutIcon + default: + return nil + } + } + + return nil + } + + private func mapMetaElementToFavicon(_ metaElement: Element, _ siteUrl: String) -> Favicon? { + if let content = try? metaElement.attr("content") { + if isOpenGraphImageDeclaration(metaElement) { + return Favicon(url: makeLinkAbsolute(url: content, siteUrl: siteUrl), iconType: FaviconType.OpenGraphImage) + } + else if isMsTileMetaElement(metaElement) { + return Favicon(url: makeLinkAbsolute(url: content, siteUrl: siteUrl), iconType: FaviconType.MsTileImage) + } + } + + return nil + } + + private func isOpenGraphImageDeclaration(_ metaElement: Element) -> Bool { + return metaElement.hasAttr("property") + && "og:image" == (try? metaElement.attr("property")) + && metaElement.hasAttr("content") + } + + private func isMsTileMetaElement(_ metaElement: Element) -> Bool { + return metaElement.hasAttr("name") + && "msapplication-TileImage" == (try? metaElement.attr("name")) + && metaElement.hasAttr("content") + } + + + private func createFavicon(url: String?, siteUrl: String, iconType: FaviconType, sizesString: String?, type: String?) -> Favicon? { + if let url = url { + let absoluteUrl = makeLinkAbsolute(url: url, siteUrl: siteUrl) + let size = extractSizesFromString(sizesString) + + return Favicon(url: absoluteUrl, iconType: iconType, size: size, type: type) + } + + return nil + } + + private func makeLinkAbsolute(url: String, siteUrl: String) -> String { + return url // TODO + } + + private func extractSizesFromString(_ sizesString: String?) -> Size? { + if let sizesString = sizesString { + let sizes = extractSizesFromString(sizesString) + + if sizes.isEmpty == false { + return sizes.max(by: { lhs, rhs in lhs >= rhs } ) + } + } + + return nil + } + + private func extractSizesFromString(_ sizesString: String) -> [Size] { + let sizes = sizesString.split(separator: " ").map { mapSizeString(String($0)) } as? [Size] ?? [] + + return sizes + } + + private func mapSizeString(_ sizeString: String) -> Size? { + var parts = sizeString.split(separator: "x") + if parts.count != 2 { + parts = sizeString.split(separator: "×") // actually doesn't meet specification, see https://www.w3schools.com/tags/att_link_sizes.asp, but New York Times uses it + } + if parts.count != 2 { + parts = sizeString.split(separator: "X") + } + + if parts.count == 2 { + let width = Int(parts[0]) + let height = Int(parts[1]) + + if let width = width, let height = height { + return Size(width: width, height: height) + } + } + + return nil + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconType.swift b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconType.swift new file mode 100644 index 00000000..f41ad924 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/FaviconType.swift @@ -0,0 +1,18 @@ +import SwiftUI + + +enum FaviconType { + + case ShortcutIcon + + case Icon + + case OpenGraphImage + + case AppleTouch + + case AppleTouchPrecomposed + + case MsTileImage + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Size.swift b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Size.swift new file mode 100644 index 00000000..9fd6f7be --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/Size.swift @@ -0,0 +1,42 @@ +import SwiftUI + + +class Size : Comparable, CustomStringConvertible { + + + let width: Int + + let height: Int + + + init(width: Int, height: Int) { + self.width = width + self.height = height + } + + + var isSquare: Bool { + return width == height + } + + var displayText: String { + return "\(width) x \(height)" + } + + + static func < (lhs: Size, rhs: Size) -> Bool { + return lhs.width < rhs.width + && lhs.height < rhs.height + } + + static func == (lhs: Size, rhs: Size) -> Bool { + return lhs.width == rhs.width + && lhs.height == rhs.height + } + + + var description: String { + return displayText + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/SwiftBankIconFinder.swift b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/SwiftBankIconFinder.swift new file mode 100644 index 00000000..8bf34eb0 --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSApp/BankIconFinder/SwiftBankIconFinder.swift @@ -0,0 +1,279 @@ +import Foundation +import SwiftSoup +import BankingUiSwift +import MultiplatformUtils + + +class SwiftBankIconFinder : IBankIconFinder { + + static let SearchBankWebsiteBaseUrlQwant = "https://lite.qwant.com/?l=de&t=mobile&q=" + + static let SearchBankWebsiteBaseUrlEcosia = "https://www.ecosia.org/search?q=" + + static let SearchBankWebsiteBaseUrlDuckDuckGo = "https://duckduckgo.com/html/?q=" + + + //static let ReplaceGfRegex = Pattern.compile(" \\(Gf [\\w]+\\)").toRegex() + + + private let log = LoggerFactory.Companion().getLogger(name: "BankIconFinder") + + + private let webClient = UrlSessionWebClient() + + private let faviconFinder = FaviconFinder() + +// private let faviconComparator = FaviconComparator(webClient) + + + func findIconForBankAsync(bankName: String, prefSize: Int32, result: @escaping (String?) -> Void) { + DispatchQueue.global(qos: .background).async { + let bestUrl = self.findIconForBank(bankName: bankName, prefSize: prefSize) + + DispatchQueue.main.async { // dispatch reuslt back to main thread as mutable BankingPresenter / result callback can only be used on the thread they have been created on + result(bestUrl) + } + } + } + + func findIconForBank(bankName: String, prefSize: Int32) -> String? { + if let bankUrl = findBankWebsite(bankName: bankName) { + if let bankHomepageResponse = webClient.get(bankUrl).body { + if let document = try? SwiftSoup.parse(bankHomepageResponse) { + let favicons = faviconFinder.extractFavicons(document: document, url: bankUrl) + + // TODO: + // faviconComparator.getBestIcon(favicons, prefSize, prefSize + 32, true)?.let { prefFavicon -> + // return prefFavicon.url + // } + // + // return faviconComparator.getBestIcon(favicons, 16)?.url + + return favicons.first?.url + } + } + } + + return nil + } + + func findBankWebsite(bankName: String) -> String? { + let adjustedBankName = bankName.replacingOccurrences(of: "-alt-", with: "")/*.replace(ReplaceGfRegex, "") */ // TODO + + if let url = findBankWebsiteWithQwant(adjustedBankName) { + return url + } + + print("Could not find bank website with Qwant for '\(bankName)'") + + if let url = findBankWebsiteWithEcosia(adjustedBankName) { + return url + } + + print("Could not find bank website with Ecosia for '\(bankName)'") + + if let url = findBankWebsiteWithDuckDuckGo(adjustedBankName) { + return url + } + + print("Could not find bank website with DuckDuckGo for '\(bankName)'") + + return nil + } + + private func findBankWebsiteWithQwant(_ bankName: String) -> String? { + return findBankWebsite(bankName, Self.SearchBankWebsiteBaseUrlQwant) { searchResponseDoc in + findLinksInQwantWebpage(searchResponseDoc) + ?? [] + } + } + + private func findLinksInQwantWebpage(_ searchResponseDoc: Document) -> [String]? { + return (try? searchResponseDoc.select(".url"))? + .filter { (try? $0.select("span").first()) == nil } + .map { (try? $0.text()) ?? "" } + .filter { $0.isNotBlank } + } + + private func findBankWebsiteWithEcosia(_ bankName: String) -> String? { + return findBankWebsite(bankName, Self.SearchBankWebsiteBaseUrlEcosia) { searchResponseDoc in + (try? searchResponseDoc.select(".js-result-url"))? + .map { (try? $0.attr("href")) ?? "" } + .filter { $0.isNotBlank } + ?? [] + } + } + + private func findBankWebsiteWithDuckDuckGo(_ bankName: String) -> String? { + return findBankWebsite(bankName, Self.SearchBankWebsiteBaseUrlDuckDuckGo) { searchResponseDoc in + (try? searchResponseDoc.select(".result__url"))? + .map { (try? $0.attr("href")) ?? "" } + .filter { $0.isNotBlank } + ?? [] + } + } + + private func findBankWebsite(_ bankName: String, _ searchBaseUrl: String, extractUrls: (Document) -> [String]) -> String? { + let encodedBankName = bankName.replacingOccurrences(of: " ", with: "+") + + let exactSearchUrl = searchBaseUrl + "\"" + encodedBankName + "\"" + if let searchResponseDocument = getSearchResultForBank(exactSearchUrl) { + if let bestUrl = findBestUrlForBank(bankName: bankName, unmappedUrls: extractUrls(searchResponseDocument)) { + return bestUrl + } + } + + + let searchUrl = searchBaseUrl + encodedBankName + if let searchResponseDocument = getSearchResultForBank(searchUrl) { + return findBestUrlForBank(bankName: bankName, unmappedUrls: extractUrls(searchResponseDocument)) + } + + + return nil + } + + private func getSearchResultForBank(_ searchUrl: String) -> Document? { + let response = webClient.get(searchUrl) + + if let responseBody = response.body { + return try? SwiftSoup.parse(responseBody) + } + + return nil + } + + + private func findBestUrlForBank(bankName: String, unmappedUrls: [String]) -> String? { + let urlCandidates = getUrlCandidates(unmappedUrls) + let urlCandidatesWithoutUnlikely = urlCandidates.filter { isUnlikelyBankUrl(bankName: bankName, urlCandidate: $0) == false } + + let urlForBank = findUrlThatContainsBankName(bankName: bankName, urlCandidates: urlCandidatesWithoutUnlikely) + + // cut off stuff like 'filalsuche' etc., they most like don't contain as many favicons as main page + return getMainPageForBankUrl(urlForBank: urlForBank, urlCandidates: urlCandidatesWithoutUnlikely) ?? urlForBank + } + + private func getUrlCandidates(_ urls: [String?]) -> [String] { + return urls.map { fixUrl($0) }.filter { $0 != nil} as? [String] ?? [] + } + + private func fixUrl(_ url: String?) -> String? { + if let url = url, url.isNotBlank { + let urlEncoded = url.replacingOccurrences(of: " ", with: "%20F") + + if urlEncoded.starts(with: "http") { + return urlEncoded + } + else { + return "https://" + urlEncoded + } + } + + return nil + } + + private func findUrlThatContainsBankName(bankName: String, urlCandidates: [String]) -> String? { + let bankNameParts = bankName.replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: "-", with: " ") // to find 'Sparda-Bank' in 'sparda.de' + .replacingOccurrences(of: "ä", with: "ae").replacingOccurrences(of: "Ä", with: "ae") + .replacingOccurrences(of: "ö", with: "oe").replacingOccurrences(of: "Ö", with: "oe") + .replacingOccurrences(of: "ü", with: "ue").replacingOccurrences(of: "Ü", with: "ue") + .split(separator: " ").map { String($0) } + .filter { $0.isBlank == false } + + var urlsContainsPartsOfBankName = [Int : [String]]() + + urlCandidates.forEach { urlCandidate in + if let containingCountParts = findBankNameInUrlHost(urlCandidate: urlCandidate, bankNameParts: bankNameParts) { + if (urlsContainsPartsOfBankName.keys.contains(containingCountParts) == false) { + urlsContainsPartsOfBankName[containingCountParts] = [urlCandidate] + } + else { + urlsContainsPartsOfBankName[containingCountParts]?.append(urlCandidate) + } + } + } + + if let countMostMatches = urlsContainsPartsOfBankName.keys.max() { + return urlsContainsPartsOfBankName[countMostMatches]?.first + } + + return nil + } + + private func findBankNameInUrlHost(urlCandidate: String, bankNameParts: [String]) -> Int? { + if let candidateHost = URL(string: urlCandidate.replacingOccurrences(of: "onlinebanking-", with: ""))?.host { + return bankNameParts.filter { part in candidateHost.localizedCaseInsensitiveContains(part) }.count + } + + return nil + } + + private func getMainPageForBankUrl(urlForBank: String?, urlCandidates: [String]) -> String? { + if let urlForBank = urlForBank { + if isHomePage(urlForBank) { + return urlForBank + } + + if let bankUri = URL(string: urlForBank) { + if let bankUriHost = bankUri.host { + let candidateUrl = urlCandidates.first { candidateUrl in + return URL(string: candidateUrl)?.host == bankUriHost && isHomePage(candidateUrl) + } + + if let candidateUrl = candidateUrl { + return candidateUrl + } + } + } + } + + if let urlForBank = urlForBank { + if let bankUri = URL(string: urlForBank) { + return "\(bankUri.scheme ?? "")://\(bankUri.host ?? "")" + } + } + + return nil + } + + private func isHomePage(_ url: String) -> Bool { + let uri = URL(string: url) + + if let uri = uri, uri.path.isBlank && uri.host?.starts(with: "www.") == true { + return true + } + + return false + } + + private func isUnlikelyBankUrl(bankName: String, urlCandidate: String) -> Bool { + return urlCandidate.contains("meinprospekt.de/") + || urlCandidate.contains("onlinestreet.de/") + || urlCandidate.contains("iban-blz.de/") + || urlCandidate.contains("bankleitzahlen.ws/") + || urlCandidate.contains("bankleitzahl-finden.de/") + || urlCandidate.contains("bankleitzahl-bic.de/") + || urlCandidate.contains("bankleitzahlensuche.org/") + || urlCandidate.contains("bankleitzahlensuche.com/") + || urlCandidate.contains("bankverzeichnis.com") + || urlCandidate.contains("banksuche.com/") + || urlCandidate.contains("bank-code.net/") + || urlCandidate.contains("thebankcodes.com/") + || urlCandidate.contains("zinsen-berechnen.de/") + || urlCandidate.contains("kredit-anzeiger.com/") + || urlCandidate.contains("kreditbanken.de/") + || urlCandidate.contains("nifox.de/") + || urlCandidate.contains("wikipedia.org/") + || urlCandidate.contains("transferwise.com/") + || urlCandidate.contains("wogibtes.info/") + || urlCandidate.contains("11880.com/") + || urlCandidate.contains("kaufda.de/") + || urlCandidate.contains("boomle.com/") + || urlCandidate.contains("berlin.de/") + || urlCandidate.contains("berliner-zeitung.de") + } + + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift b/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift index 22005f7e..003ff5d0 100644 --- a/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift +++ b/ui/BankingiOSApp/BankingiOSApp/SceneDelegate.swift @@ -42,7 +42,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let dataFolder = URL(fileURLWithPath: "data", isDirectory: true, relativeTo: URL(fileURLWithPath: appDataFolder)) - let presenter = BankingPresenterSwift(dataFolder: dataFolder, router: SwiftUiRouter(), webClient: UrlSessionWebClient(), persistence: persistence, remitteeSearcher: persistence, serializer: JsonEncoderSerializer(), asyncRunner: DispatchQueueAsyncRunner()) + let presenter = BankingPresenterSwift(dataFolder: dataFolder, router: SwiftUiRouter(), webClient: UrlSessionWebClient(), persistence: persistence, remitteeSearcher: persistence, bankIconFinder: SwiftBankIconFinder(), serializer: NoOpSerializer(), asyncRunner: DispatchQueueAsyncRunner()) DependencyInjector.register(dependency: persistence) DependencyInjector.register(dependency: presenter) diff --git a/ui/BankingiOSApp/BankingiOSApp/fints4k/UrlSessionWebClient.swift b/ui/BankingiOSApp/BankingiOSApp/fints4k/UrlSessionWebClient.swift index 689296b5..1ed84201 100644 --- a/ui/BankingiOSApp/BankingiOSApp/fints4k/UrlSessionWebClient.swift +++ b/ui/BankingiOSApp/BankingiOSApp/fints4k/UrlSessionWebClient.swift @@ -6,12 +6,51 @@ import MultiplatformUtils class UrlSessionWebClient : Fints4kIWebClient { func post(url: String, body: String, contentType: String, userAgent: String, callback: @escaping (Fints4kWebClientResponse) -> Void) { - guard let requestUrl = URL(string: url) else { fatalError() } + let request = requestFor(url, "POST", body) + + executeRequestAsync(request, callback) + } + + func getAsync(_ url: String, callback: @escaping (Fints4kWebClientResponse) -> Void) { + let request = requestFor(url, "GET") + + executeRequestAsync(request, callback) + } + + + func get(_ url: String) -> Fints4kWebClientResponse { + let request = requestFor(url, "GET") + + return executeRequestSynchronous(request) + } + + func getData(_ url: String) -> Data? { + let request = requestFor(url, "GET") + + return executeDataRequestSynchronous(request) + } + + func head(_ url: String) -> Fints4kWebClientResponse { + let request = requestFor(url, "HEAD") + + return executeRequestSynchronous(request) + } + + + func requestFor(_ url: String, _ method: String, _ body: String? = nil) -> URLRequest { + guard let requestUrl = URL.encoded(url) else { fatalError() } var request = URLRequest(url: requestUrl) - request.httpMethod = "POST" - request.httpBody = body.data(using: String.Encoding.utf8) + request.httpMethod = method + if let body = body { + request.httpBody = body.data(using: String.Encoding.utf8) + } + + return request + } + + func executeRequestAsync(_ request: URLRequest, _ callback: @escaping (Fints4kWebClientResponse) -> Void) { let dataTask = URLSession.shared.dataTask(with: request) { (data: Data?, response: URLResponse?, error: Error?) in // we have to dispatch response back to main thread as in Kotlin/Native objects can only be changed on one thread -> do all logic on main thread only network access in backbackground thread DispatchQueue.main.async { @@ -28,4 +67,57 @@ class UrlSessionWebClient : Fints4kIWebClient { dataTask.resume() } -} \ No newline at end of file + func executeRequestSynchronous(_ request: URLRequest) -> Fints4kWebClientResponse { + var data: Data?, urlResponse: URLResponse?, error: Error? + + let semaphore = DispatchSemaphore(value: 0) + + let dataTask = URLSession.shared.dataTask(with: request) { + data = $0 + urlResponse = $1 + error = $2 + + semaphore.signal() + } + + dataTask.resume() + + _ = semaphore.wait(timeout: .distantFuture) + + return mapResponse(data, urlResponse, error) + } + + private func mapResponse(_ data: Data?, _ response: URLResponse?, _ error: Error?) -> Fints4kWebClientResponse { + if let httpResponse = response as? HTTPURLResponse { + if let data = data { + return Fints4kWebClientResponse(successful: true, responseCode: Int32(httpResponse.statusCode), error: nil, body: String(data: data, encoding: .ascii)) + } + else { + return Fints4kWebClientResponse(successful: true, responseCode: Int32(httpResponse.statusCode), error: nil, body: nil) + } + } + else { + return Fints4kWebClientResponse(successful: false, responseCode: -1, error: KotlinException(message: error?.localizedDescription), body: nil) + } + } + + + func executeDataRequestSynchronous(_ request: URLRequest) -> Data? { + var data: Data? + + let semaphore = DispatchSemaphore(value: 0) + + let dataTask = URLSession.shared.dataTask(with: request) { receivedData, _, _ in + data = receivedData + + semaphore.signal() + } + + dataTask.resume() + + _ = semaphore.wait(timeout: .distantFuture) + + return data + } + +} diff --git a/ui/BankingiOSApp/BankingiOSApp/persistence/CoreDataBankingPersistence.swift b/ui/BankingiOSApp/BankingiOSApp/persistence/CoreDataBankingPersistence.swift index 2f99e2be..9c9764fa 100644 --- a/ui/BankingiOSApp/BankingiOSApp/persistence/CoreDataBankingPersistence.swift +++ b/ui/BankingiOSApp/BankingiOSApp/persistence/CoreDataBankingPersistence.swift @@ -1,5 +1,6 @@ import Foundation import CoreData +import UIKit import BankingUiSwift @@ -70,7 +71,15 @@ class CoreDataBankingPersistence: IBankingPersistence, IRemitteeSearcher { } func saveUrlToFile(url: String, file: URL) { - // TODO + let response = UrlSessionWebClient().getData(url) + + if let response = response { + do { + try UIImage(data: response)?.pngData()?.write(to: file) + } catch { + NSLog("Could not save url '\(url)' to file '\(file): \(error)") + } + } } diff --git a/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift b/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift index 6dbd6f68..203bf4f5 100644 --- a/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift +++ b/ui/BankingiOSApp/BankingiOSApp/ui/SwiftExtensions.swift @@ -88,3 +88,21 @@ extension Array where Element == NSDecimalNumber { } } + + +extension NSObject { + + var className: String { + return String(describing: type(of: self)) + } + +} + + +extension URL { + + static func encoded(_ url: String) -> URL? { + return URL(string: url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url) + } + +} diff --git a/ui/BankingiOSApp/BankingiOSAppTests/BankIconFinder/SwiftBankIconFinderTest.swift b/ui/BankingiOSApp/BankingiOSAppTests/BankIconFinder/SwiftBankIconFinderTest.swift new file mode 100644 index 00000000..3303ab0a --- /dev/null +++ b/ui/BankingiOSApp/BankingiOSAppTests/BankIconFinder/SwiftBankIconFinderTest.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import BankingiOSApp + + +class SwiftBankIconFinderTest: XCTestCase { + + private let underTest = SwiftBankIconFinder() + + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + + func testBerlinerSparkasse() throws { + + // when + let result = underTest.findBankWebsite(bankName: "Sparkasse Berlin") + + // then + XCTAssertNotNil(result) + XCTAssertEqual(result!, "https://www.berliner-sparkasse.de") + } + +}