Skip to content

Commit

Permalink
Merge pull request #291 from covid-be-app/develop
Browse files Browse the repository at this point in the history
New Release
  • Loading branch information
ir-fuel authored Oct 30, 2020
2 parents 53a5d53 + 5c2501f commit 591e596
Show file tree
Hide file tree
Showing 83 changed files with 2,995 additions and 473 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,5 @@ src/xcode/screenshots
/src/xcode/.idea
/src/xcode/vendor
/src/xcode/.bundle
src/xcode/screenshots_dark
src/xcode/screenshots_light
4 changes: 2 additions & 2 deletions src/xcode/ENA/BE/BEEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import Foundation

enum BEEnvironment: String {
enum BEEnvironment: String, CaseIterable {
case production = "production"
case staging = "staging"
case test = "test"
Expand All @@ -38,7 +38,7 @@ enum BEEnvironment: String {
static var current: BEEnvironment {
if let value = Bundle.main.infoDictionary?["BEEnvironment"] as? String {
guard let environment = BEEnvironment(rawValue: value) else {
fatalError()
fatalError("Should never happen")
}

return environment
Expand Down
95 changes: 95 additions & 0 deletions src/xcode/ENA/BE/DynamicText/BEDynamicTextDownloadService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// Coronalert
//
// Devside and all other contributors
// copyright owners license this file to you under the Apache
// License, Version 2.0 (the "License"); you may not use this
// file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//

import Foundation

class BEDynamicTextDownloadService {
typealias DynamicTextLoader = () -> Void

private static

var dynamicText:BEDynamicText!

private let client: Client
private var textService: BEDynamicTextService
private let outdatedTimeInterval: TimeInterval

init(client: Client, textService: BEDynamicTextService, textOutdatedTimeInterval:TimeInterval = .textOutdatedTimeInterval) {
self.client = client
self.textService = textService
self.outdatedTimeInterval = textOutdatedTimeInterval
}

func downloadTextsIfNeeded(completion: @escaping DynamicTextLoader) {
if
let attributes = try? FileManager.default.attributesOfItem(atPath: BEDynamicTextService.cacheURL.path),
let modificationDate = attributes[.modificationDate] as? Date {

if modificationDate.timeIntervalSinceNow > -self.outdatedTimeInterval {
DispatchQueue.main.async {
log(message: "Cached file \(modificationDate) too recent. Will not update")
completion()
}

return
}
}

downloadTexts(completion)
}

/// Download the texts from the server
/// We ignore errors here in the callback since there isn't much we can do and we'll fallback to the previous version of the text anyway
private func downloadTexts(_ completion: @escaping DynamicTextLoader) {
log(message: "Downloading texts")
client.getDynamicTexts { result in

/// Since dynamic texts are used from the main thread (UI) we make sure there is no other thread manipulating
/// files that while they are being read from the main thread.
DispatchQueue.main.async {
switch result {
case .success(let data):
do {
try self.textService.updateTexts(data)
log(message: "Download texts success")
} catch {
logError(message: "Failed saving text from server: \(error.localizedDescription)")
}
case .failure(let error):
logError(message: "Failed loading text from server: \(error.localizedDescription)")
}

completion()
}
}
}
}

extension TimeInterval {
static var textOutdatedTimeInterval: TimeInterval {
switch BEEnvironment.current {
case .production:
return TimeInterval(24 * 60 * 60)
case .staging:
return TimeInterval(60)
case .test:
return TimeInterval(60)
}
}
}
268 changes: 268 additions & 0 deletions src/xcode/ENA/BE/DynamicText/BEDynamicTextService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
//
// Coronalert
//
// Devside and all other contributors
// copyright owners license this file to you under the Apache
// License, Version 2.0 (the "License"); you may not use this
// file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//

import Foundation

enum BEDynamicTextServiceError: Error {
case cachingError
case missingLanguage
case missingScreen
case missingScreenSection
case wrongSectionFields
}

class BEDynamicTextService {
static let cacheURL = FileManager.default.applicationSupportURL("dynamicTexts.json")
static let defaultBundleURL = Bundle.main.url(forResource: "dynamicTexts", withExtension: "json")!

var dynamicText:BEDynamicText!
var bundleURL: URL!

// if this throws there is a big issue with the data stored inside the bundle
init(_ defaultFileURL:URL = defaultBundleURL) {
bundleURL = defaultFileURL

do {
try copyBundleToCacheIfMoreRecent()
dynamicText = try Self.loadTextFromCache()
return
} catch {
logError(message: "\(error.localizedDescription)")
}

// if that fails, copy the data in the bundle and try again
do {
try copyBundleTextsToCache()
dynamicText = try Self.loadTextFromCache()
} catch {
logError(message: "\(error.localizedDescription)")
fatalError("Should never happen")
}
}

func updateTexts(_ data: Data) throws {
if let text = try? Self.loadTextFromData(data) {
do {
try data.write(to: Self.cacheURL, options: .atomic)
dynamicText = text
} catch {
}
} else {
logError(message: "Failed updating text")
}
}

func screen(_ screenName: BEDynamicTextScreenName, language: BEDynamicTextLanguage = .current) -> [BEDynamicTextScreenSectionName:[BEDynamicTextScreenSection]] {
guard
let screen = dynamicText.structure[screenName],
let translations = dynamicText.texts[language] else {
fatalError("Should never happen")
}

let returnValue = screen.mapValues{ sections in
return sections.map{ $0.translate(translations) }
}

return returnValue
}

func sections(_ screenName: BEDynamicTextScreenName, section: BEDynamicTextScreenSectionName,language: BEDynamicTextLanguage = .current) -> [BEDynamicTextScreenSection] {
let dynamicScreen = screen(screenName, language: language)
guard
let dynamicSections: [BEDynamicTextScreenSection] = dynamicScreen[section] else {
fatalError("Should never happen")
}

return dynamicSections
}

static func validateLoadedText(_ dynamicText: BEDynamicText) throws {
try BEDynamicTextLanguage.allCases.forEach{ language in
if dynamicText.texts[language] == nil {
logError(message: "Missing language \(language)")
throw BEDynamicTextServiceError.missingLanguage
}
}

try BEDynamicTextScreenName.allCases.forEach{ screenName in
if dynamicText.structure[screenName] == nil {
logError(message: "Missing screen \(screenName)")
throw BEDynamicTextServiceError.missingScreen
}
}

guard
let standard = dynamicText.structure[.standard],
let highRisk = dynamicText.structure[.highRisk],
let positiveTestResultCard = dynamicText.structure[.positiveTestResultCard],
let positiveTestResult = dynamicText.structure[.positiveTestResult],
let negativeTestResult = dynamicText.structure[.negativeTestResult],
let thankYou = dynamicText.structure[.thankYou] else {
throw BEDynamicTextServiceError.missingScreen
}

try validateRiskScreen(standard)
try validateRiskScreen(highRisk)
try validatePositiveTestResultCard(positiveTestResultCard)
try validateTestResult(positiveTestResult)
try validateTestResult(negativeTestResult)
try validateThankYou(thankYou)
}

private func copyBundleToCacheIfMoreRecent() throws {
let sourceAttributes = try FileManager.default.attributesOfItem(atPath: bundleURL.path)
let cacheAttributes = try FileManager.default.attributesOfItem(atPath: Self.cacheURL.path)

guard
let sourceModificationDate = sourceAttributes[.modificationDate] as? Date,
let destinationModificationDate = cacheAttributes[.modificationDate] as? Date else {
throw BEDynamicTextServiceError.cachingError
}

if sourceModificationDate > destinationModificationDate {
log(message:"Bundle file is more recent than cache. Overwriting cache")
try copyBundleTextsToCache()
}
}

private func copyBundleTextsToCache() throws {
log(message: "Copy text to cache")
guard
let data = try? Data(contentsOf: bundleURL)
else {
fatalError("Should never happen")
}

try data.write(to: Self.cacheURL, options: .atomic)

// also copy the modification date
let sourceAttributes = try FileManager.default.attributesOfItem(atPath: bundleURL.path)
var destinationAttributes = try FileManager.default.attributesOfItem(atPath: Self.cacheURL.path)

guard let modificationDate = sourceAttributes[.modificationDate] as? Date else {
throw BEDynamicTextServiceError.cachingError
}

destinationAttributes[.modificationDate] = modificationDate

try FileManager.default.setAttributes(destinationAttributes, ofItemAtPath: Self.cacheURL.path)
}

static private func loadTextFromCache() throws -> BEDynamicText {
log(message: "Load text from cache")
let data = try Data(contentsOf: Self.cacheURL)

return try loadTextFromData(data)
}

static private func loadTextFromData(_ data: Data) throws -> BEDynamicText {
let decoder = JSONDecoder()
let result = try decoder.decode(BEDynamicText.self, from: data)

// do some sanity checks
try Self.validateLoadedText(result)

return result
}

static private func validateRiskScreen(_ screen:[BEDynamicTextScreenSectionName:[BEDynamicTextScreenSection]]) throws {
guard let preventiveMeasures = screen[.preventiveMeasures] else {
throw BEDynamicTextServiceError.missingScreenSection
}

try preventiveMeasures.forEach{ entry in
if entry.icon == nil || entry.text == nil {
throw BEDynamicTextServiceError.wrongSectionFields
}

if entry.title != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
}
}

static private func validatePositiveTestResultCard(_ screen:[BEDynamicTextScreenSectionName:[BEDynamicTextScreenSection]]) throws {
guard let explanation = screen[.explanation] else {
throw BEDynamicTextServiceError.missingScreenSection
}

try explanation.forEach{ entry in
if entry.icon == nil || entry.text == nil {
throw BEDynamicTextServiceError.wrongSectionFields
}

if entry.title != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}

if entry.paragraphs != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
}
}

static private func validateTestResult(_ screen:[BEDynamicTextScreenSectionName:[BEDynamicTextScreenSection]]) throws {
guard let explanation = screen[.explanation] else {
throw BEDynamicTextServiceError.missingScreenSection
}

try explanation.forEach{ entry in
if entry.icon == nil || entry.text == nil || entry.title == nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
}
}

static private func validateThankYou(_ screen:[BEDynamicTextScreenSectionName:[BEDynamicTextScreenSection]]) throws {
guard
let pleaseNote = screen[.pleaseNote],
let otherInformation = screen[.otherInformation] else {
throw BEDynamicTextServiceError.missingScreenSection
}

try pleaseNote.forEach{ entry in
if entry.icon == nil || entry.text == nil {
throw BEDynamicTextServiceError.wrongSectionFields
}

if entry.title != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}

if entry.paragraphs != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
}

try otherInformation.forEach{ entry in
if entry.icon != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
if entry.text != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
if entry.title != nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
if entry.paragraphs == nil {
throw BEDynamicTextServiceError.wrongSectionFields
}
}
}
}
Loading

0 comments on commit 591e596

Please sign in to comment.