Use Kotlin multiplatform library for data layer

This commit is contained in:
William Brawner 2022-10-18 18:52:30 -06:00
parent dcba7c3979
commit 811341b139
15 changed files with 285 additions and 853 deletions

View file

@ -17,9 +17,6 @@
2821267C235926EB00072D52 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2821267A235926EB00072D52 /* LaunchScreen.storyboard */; };
28212687235926EB00072D52 /* Pi_HelperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28212686235926EB00072D52 /* Pi_HelperTests.swift */; };
28212692235926EB00072D52 /* Pi_HelperUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28212691235926EB00072D52 /* Pi_HelperUITests.swift */; };
282126A6235BE6EE00072D52 /* PiHoleDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */; };
282126A8235BE72800072D52 /* PiHole.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A7235BE72800072D52 /* PiHole.swift */; };
282126AA235BEE0F00072D52 /* PiHoleApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A9235BEE0F00072D52 /* PiHoleApiService.swift */; };
282126AC235BF09800072D52 /* PiHoleDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AB235BF09800072D52 /* PiHoleDetailsView.swift */; };
282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AD235BF21D00072D52 /* AddPiHoleView.swift */; };
282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */; };
@ -32,6 +29,7 @@
28EC1E3C23A448940088BA26 /* Styles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28EC1E3B23A448940088BA26 /* Styles.swift */; };
80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */; };
80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80A8CF842782190400CCCEC5 /* AsyncData.swift */; };
80B3226928FE010E00CB33B5 /* PihelperStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80B3226828FE010E00CB33B5 /* PihelperStore.swift */; };
80E91FA723DE160100B7FB55 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E91FA623DE160100B7FB55 /* AboutView.swift */; };
/* End PBXBuildFile section */
@ -69,9 +67,6 @@
2821268D235926EB00072D52 /* Pi-helperUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Pi-helperUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
28212691235926EB00072D52 /* Pi_HelperUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pi_HelperUITests.swift; sourceTree = "<group>"; };
28212693235926EB00072D52 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHoleDataStore.swift; sourceTree = "<group>"; };
282126A7235BE72800072D52 /* PiHole.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHole.swift; sourceTree = "<group>"; };
282126A9235BEE0F00072D52 /* PiHoleApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHoleApiService.swift; sourceTree = "<group>"; };
282126AB235BF09800072D52 /* PiHoleDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiHoleDetailsView.swift; sourceTree = "<group>"; };
282126AD235BF21D00072D52 /* AddPiHoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddPiHoleView.swift; sourceTree = "<group>"; };
282126AF235BF52F00072D52 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = "<group>"; };
@ -91,6 +86,7 @@
805148A927D1B2C7008FF0A4 /* Pihelper.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Pihelper.framework; path = "../pihelper-android/shared/build/fat-framework/debug/Pihelper.framework"; sourceTree = "<group>"; };
80654D7A24B0E92A007B1E1A /* DisableCustomTimeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisableCustomTimeView.swift; sourceTree = "<group>"; };
80A8CF842782190400CCCEC5 /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = "<group>"; };
80B3226828FE010E00CB33B5 /* PihelperStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PihelperStore.swift; sourceTree = "<group>"; };
80E91FA623DE160100B7FB55 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -158,9 +154,6 @@
28D7205823808E8C0038D439 /* Extensions.swift */,
280F0D6223DA9B1800D341B7 /* KeyboardHelper.swift */,
28EC1E3923A441F30088BA26 /* OrDivider.swift */,
282126A7235BE72800072D52 /* PiHole.swift */,
282126A9235BEE0F00072D52 /* PiHoleApiService.swift */,
282126A5235BE6EE00072D52 /* PiHoleDataStore.swift */,
282126AB235BF09800072D52 /* PiHoleDetailsView.swift */,
280AA3EA2390B302007841F1 /* RetrieveApiKeyView.swift */,
28D2125B23A59057003B33F2 /* ScanningView.swift */,
@ -171,6 +164,7 @@
282126B6235C0F5400072D52 /* Localizable.strings */,
28212677235926EB00072D52 /* Preview Content */,
80A8CF842782190400CCCEC5 /* AsyncData.swift */,
80B3226828FE010E00CB33B5 /* PihelperStore.swift */,
);
path = "Pi-helper";
sourceTree = "<group>";
@ -368,11 +362,10 @@
files = (
280F0D6323DA9B1800D341B7 /* KeyboardHelper.swift in Sources */,
28D2125C23A59057003B33F2 /* ScanningView.swift in Sources */,
80B3226928FE010E00CB33B5 /* PihelperStore.swift in Sources */,
80E91FA723DE160100B7FB55 /* AboutView.swift in Sources */,
282126AC235BF09800072D52 /* PiHoleDetailsView.swift in Sources */,
28EC1E3C23A448940088BA26 /* Styles.swift in Sources */,
282126A6235BE6EE00072D52 /* PiHoleDataStore.swift in Sources */,
282126AA235BEE0F00072D52 /* PiHoleApiService.swift in Sources */,
280AA3EB2390B302007841F1 /* RetrieveApiKeyView.swift in Sources */,
28EC1E3A23A441F30088BA26 /* OrDivider.swift in Sources */,
282126AE235BF21D00072D52 /* AddPiHoleView.swift in Sources */,
@ -382,7 +375,6 @@
282126B0235BF52F00072D52 /* ActivityIndicatorView.swift in Sources */,
28212674235926E700072D52 /* ContentView.swift in Sources */,
28D7205923808E8C0038D439 /* Extensions.swift in Sources */,
282126A8235BE72800072D52 /* PiHole.swift in Sources */,
80654D7B24B0E92A007B1E1A /* DisableCustomTimeView.swift in Sources */,
80A8CF852782190400CCCEC5 /* AsyncData.swift in Sources */,
);

View file

@ -6,9 +6,12 @@
// Copyright © 2020 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct AboutView: View {
@EnvironmentObject var store: PihelperStore
var version: String? {
get {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
@ -30,20 +33,18 @@ struct AboutView: View {
.font(.subheadline)
.padding(.bottom)
Button(action: {
self.dataStore.forgetPihole()
self.store.dispatch(ActionForget())
}, label: { Text("forget_pihole") })
.buttonStyle(PiHelperButtonStyle())
.buttonStyle(PiHelperButtonStyle())
}.padding()
}
@ObservedObject var dataStore: PiHoleDataStore
init(_ dataStore: PiHoleDataStore) {
self.dataStore = dataStore
.onDisappear {
self.store.dispatch(ActionBack())
}
}
}
struct AboutView_Previews: PreviewProvider {
static var previews: some View {
AboutView(PiHoleDataStore())
AboutView()
}
}

View file

@ -6,15 +6,17 @@
// Copyright © 2019 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct AddPiHoleView: View {
@State var ipAddress: String = ""
@State var showScanFailed = false
@State var showConnectFailed = false
@EnvironmentObject var store: PihelperStore
@SwiftUI.State var ipAddress: String = ""
@SwiftUI.State var showScanFailed = false
@SwiftUI.State var showConnectFailed = false
var hideNavigationBar: Bool {
get {
if case .scanning(_) = self.dataStore.pihole {
if case .scan = self.store.state.route {
return true
} else {
return false
@ -27,10 +29,10 @@ struct AddPiHoleView: View {
ScrollView {
VStack(spacing: 30) {
Image("logo")
ScanButton(dataStore: self.dataStore)
ScanButton()
.alert(isPresented: self.$showScanFailed, content: {
Alert(title: Text("scan_failed"), message: Text("try_direct_connection"), dismissButton: .default(Text("OK"), action: {
self.dataStore.pihole = .missingIpAddress
self.store.sideEffect = nil
}))
})
OrDivider()
@ -42,17 +44,14 @@ struct AddPiHoleView: View {
.disableAutocorrection(true)
.autocapitalization(.none)
.onSubmit {
Task {
await self.dataStore.connect(self.ipAddress)
}
self.store.dispatch(ActionScan(deviceIp: self.ipAddress))
}
Button(action: { Task {
await self.dataStore.connect(self.ipAddress)
} }, label: { Text("connect") })
Button(action: { self.store.dispatch(ActionConnect(host: ipAddress))
}, label: { Text("connect") })
.buttonStyle(PiHelperButtonStyle())
.alert(isPresented: self.$showConnectFailed, content: {
Alert(title: Text("connection_failed"), message: Text("verify_ip_address"), dismissButton: .default(Text("OK"), action: {
self.dataStore.pihole = .missingIpAddress
self.store.sideEffect = nil
}))
})
}
@ -63,52 +62,42 @@ struct AddPiHoleView: View {
}
.navigationViewStyle(StackNavigationViewStyle())
}
@ObservedObject var dataStore: PiHoleDataStore
init(_ dataStore: PiHoleDataStore) {
self.dataStore = dataStore
}
}
struct ScanButton: View {
@ObservedObject var dataStore: PiHoleDataStore
var statefulContent: AnyView {
guard let deviceIpAddress = resolver_get_device_ip() else {
return Text("no_wireless_connection")
@EnvironmentObject var store: PihelperStore
@ViewBuilder
var body: some View {
if let deviceIpAddress = resolver_get_device_ip() {
let ipAddress = String(cString: deviceIpAddress)
VStack(spacing: 30) {
Text("scan_for_pihole")
.multilineTextAlignment(.center)
Button(action: {
self.store.dispatch(ActionScan(deviceIp: ipAddress))
}, label: { Text("scan") })
.buttonStyle(PiHelperButtonStyle())
NavigationLink(
destination: ScanningView(),
isActive: .constant(store.state.route == Route.scan),
label: { EmptyView() }
)
NavigationLink(
destination: RetrieveApiKeyView(),
isActive: .constant(store.state.route == Route.auth),
label: { EmptyView() }
)
}
} else {
Text("no_wireless_connection")
.multilineTextAlignment(.center)
.toAnyView()
}
let ipAddress = String(cString: deviceIpAddress)
return VStack(spacing: 30) {
Text("scan_for_pihole")
.multilineTextAlignment(.center)
Button(action: {
self.dataStore.scanTask = Task {
await self.dataStore.beginScanning(ipAddress)
}
}, label: { Text("scan") })
.buttonStyle(PiHelperButtonStyle())
NavigationLink(
destination: ScanningView(dataStore: self.dataStore),
isActive: $dataStore.isScanning,
label: { EmptyView() }
)
NavigationLink(
destination: RetrieveApiKeyView(dataStore: self.dataStore),
isActive: .constant(self.dataStore.pihole == .missingApiKey),
label: { EmptyView() }
)
}.toAnyView()
}
var body: some View { statefulContent }
}
struct AddPiHoleView_Previews: PreviewProvider {
static var previews: some View {
AddPiHoleView(PiHoleDataStore())
AddPiHoleView()
}
}

View file

@ -6,29 +6,24 @@
// Copyright © 2019 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct ContentView: View {
@EnvironmentObject var store: PihelperStore
@ViewBuilder
var body: some View {
stateContent
}
var stateContent: AnyView {
if self.dataStore.baseUrl?.isEmpty ?? true || self.dataStore.apiKey?.isEmpty ?? true {
return AddPiHoleView(self.dataStore).toAnyView()
if [Route.home, Route.about].contains(self.store.state.route) {
PiHoleDetailsView()
} else {
return PiHoleDetailsView(self.dataStore).toAnyView()
AddPiHoleView()
}
}
@ObservedObject var dataStore: PiHoleDataStore
init(_ dataStore: PiHoleDataStore) {
self.dataStore = dataStore
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(PiHoleDataStore())
ContentView()
}
}

View file

@ -6,11 +6,14 @@
// Copyright © 2020 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct DisableCustomTimeView: View {
@State var duration: String = ""
@State var unit: Int = 0
@EnvironmentObject var store: PihelperStore
@Binding var showCustom: Bool
@SwiftUI.State var duration: String = ""
@SwiftUI.State var unit: Int = 0
private let units = ["seconds", "minutes", "hours"]
var body: some View {
@ -20,36 +23,25 @@ struct DisableCustomTimeView: View {
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.numberPad)
Picker("", selection: $unit) {
ForEach(0 ..< units.count) {
ForEach(0 ..< 3) {
Text(LocalizedStringKey(self.units[$0])).tag($0)
}
}
.pickerStyle(SegmentedPickerStyle())
Button(action: { Task {
await self.dataStore.disable(Int(self.duration) ?? 0, unit: self.unit)
} }, label: { Text(LocalizedStringKey("disable")) })
Button(action: {
let multiplier = NSDecimalNumber(decimal: pow(60, unit)).intValue
self.store.dispatch(ActionDisable(duration: KotlinLong(value: Int64((Int(duration) ?? 0) * multiplier))))
self.showCustom = false
}, label: { Text(LocalizedStringKey("disable")) })
.buttonStyle(PiHelperButtonStyle())
}
.padding()
.keyboardAwarePadding()
}
@ObservedObject var dataStore: PiHoleDataStore
init(_ dataStore: PiHoleDataStore) {
self.dataStore = dataStore
}
}
struct DisableCustomTimeView_Previews: PreviewProvider {
static var dataStore: PiHoleDataStore {
get {
let _dataStore = PiHoleDataStore()
_dataStore.pihole = .success(.enabled)
return _dataStore
}
}
static var previews: some View {
DisableCustomTimeView(self.dataStore)
DisableCustomTimeView(showCustom: .constant(true))
}
}

View file

@ -7,15 +7,10 @@
//
import Foundation
import Pihelper
import SwiftUI
import CryptoKit
extension View {
func toAnyView() -> AnyView {
return AnyView(self)
}
}
extension UIApplication {
func endEditing() {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
@ -42,3 +37,56 @@ extension Digest {
.lowercased()
}
}
extension Status {
var localizedStringKey: LocalizedStringKey {
var key: String
switch self {
case .enabled:
key = "enabled"
case .disabled:
key = "disabled"
default:
key = "unknown"
}
return LocalizedStringKey(key)
}
var foregroundColor: Color {
switch self {
case .enabled:
return .green
case .disabled:
return .red
default:
return .gray
}
}
}
extension UInt {
func toDurationString() -> String {
// I add one to the timestamp to prevent showing 0 seconds remaining
if (self < 60) {
return String(self + 1)
}
var seconds: UInt = self + 1
var hours: UInt = 0
if (seconds >= 3600) {
hours = seconds / 3600
seconds -= hours * 3600
}
var minutes: UInt = 0
if (seconds >= 60) {
minutes = seconds / 60
seconds -= minutes * 60
}
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
}

View file

@ -11,23 +11,24 @@ import SwiftUI
struct OrDivider: View {
let orientation: Orientation
@ViewBuilder
var body: some View {
if orientation == .horizontal {
return HStack {
HStack {
Rectangle()
.frame(height: 2)
Text("or")
Rectangle()
.frame(height: 2)
}.toAnyView()
}
} else {
return VStack {
VStack {
Rectangle()
.frame(width: 2)
Text("or")
Rectangle()
.frame(width: 2)
}.toAnyView()
}
}
}

View file

@ -1,188 +0,0 @@
//
// PiHole.swift
// Pi-Helper
//
// Created by Billy Brawner on 10/19/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import SwiftUI
struct PiHole: Codable, Equatable {
let domainsBeingBlocked: Int
let dnsQueriesToday: Int
let adsBlockedToday: Int
let adsPercentageToday: Float
let uniqueDomains: Int
let queriesForwarded: Int
let clientsEverSeen: Int
let uniqueClients: Int
let dnsQueriesAllTypes: Int
let queriesCached: Int
let noDataReplies: Int
let nxDomainReplies: Int
let cnameReplies: Int
let ipReplies: Int
let privacyLevel: Int
let status: String
let gravity: Gravity
enum CodingKeys: String, CodingKey {
case domainsBeingBlocked = "domains_being_blocked"
case dnsQueriesToday = "dns_queries_today"
case adsBlockedToday = "ads_blocked_today"
case adsPercentageToday = "ads_percentage_today"
case uniqueDomains = "unique_domains"
case queriesForwarded = "queries_forwarded"
case queriesCached = "queries_cached"
case clientsEverSeen = "clients_ever_seen"
case uniqueClients = "unique_clients"
case dnsQueriesAllTypes = "dns_queries_all_types"
case noDataReplies = "reply_NODATA"
case nxDomainReplies = "reply_NXDOMAIN"
case cnameReplies = "reply_CNAME"
case ipReplies = "reply_IP"
case privacyLevel = "privacy_level"
case status
case gravity = "gravity_last_updated"
}
func copy(
domainsBeingBlocked: Int? = nil,
dnsQueriesToday: Int? = nil,
adsBlockedToday: Int? = nil,
adsPercentageToday: Float? = nil,
uniqueDomains: Int? = nil,
queriesForwarded: Int? = nil,
clientsEverSeen: Int? = nil,
uniqueClients: Int? = nil,
dnsQueriesAllTypes: Int? = nil,
queriesCached: Int? = nil,
noDataReplies: Int? = nil,
nxDomainReplies: Int? = nil,
cnameReplies: Int? = nil,
ipReplies: Int? = nil,
privacyLevel: Int? = nil,
status: String? = nil,
gravity: Gravity? = nil
) -> PiHole {
return PiHole(
domainsBeingBlocked: domainsBeingBlocked ?? self.domainsBeingBlocked,
dnsQueriesToday: dnsQueriesToday ?? self.dnsQueriesToday,
adsBlockedToday: adsBlockedToday ?? self.adsBlockedToday,
adsPercentageToday: adsPercentageToday ?? self.adsPercentageToday,
uniqueDomains: uniqueDomains ?? self.uniqueDomains,
queriesForwarded: queriesForwarded ?? self.queriesForwarded,
clientsEverSeen: clientsEverSeen ?? self.clientsEverSeen,
uniqueClients: uniqueClients ?? self.uniqueClients,
dnsQueriesAllTypes: dnsQueriesAllTypes ?? self.dnsQueriesAllTypes,
queriesCached: queriesCached ?? self.queriesCached,
noDataReplies: noDataReplies ?? self.noDataReplies,
nxDomainReplies: nxDomainReplies ?? self.nxDomainReplies,
cnameReplies: cnameReplies ?? self.cnameReplies,
ipReplies: ipReplies ?? self.ipReplies,
privacyLevel: privacyLevel ?? self.privacyLevel,
status: status ?? self.status,
gravity: gravity ?? self.gravity
)
}
static func == (lhs: PiHole, rhs: PiHole) -> Bool {
return lhs.domainsBeingBlocked == rhs.domainsBeingBlocked
&& lhs.dnsQueriesToday == rhs.dnsQueriesToday
&& lhs.adsBlockedToday == rhs.adsBlockedToday
&& lhs.adsPercentageToday == rhs.adsPercentageToday
&& lhs.uniqueDomains == rhs.uniqueDomains
&& lhs.queriesForwarded == rhs.queriesForwarded
&& lhs.clientsEverSeen == rhs.clientsEverSeen
&& lhs.uniqueClients == rhs.uniqueClients
&& lhs.dnsQueriesAllTypes == rhs.dnsQueriesAllTypes
&& lhs.queriesCached == rhs.queriesCached
&& lhs.noDataReplies == rhs.noDataReplies
&& lhs.nxDomainReplies == rhs.nxDomainReplies
&& lhs.cnameReplies == rhs.cnameReplies
&& lhs.ipReplies == rhs.ipReplies
&& lhs.privacyLevel == rhs.privacyLevel
&& lhs.status == rhs.status
&& lhs.gravity == rhs.gravity
}
}
struct Gravity: Codable, Equatable {
let fileExists: Bool
let absolute: Int
let relative: Relative
enum CodingKeys: String, CodingKey {
case fileExists = "file_exists"
case absolute
case relative
}
static func == (lhs: Gravity, rhs: Gravity) -> Bool {
return lhs.fileExists == rhs.fileExists
&& lhs.absolute == rhs.absolute
&& lhs.relative == rhs.relative
}
}
struct Relative: Codable, Equatable {
let days: Int
let hours: Int
let minutes: Int
static func == (lhs: Relative, rhs: Relative) -> Bool {
return lhs.days == rhs.days
&& lhs.hours == rhs.hours
&& lhs.minutes == rhs.minutes
}
}
struct StatusUpdate: Codable {
let status: String
}
struct VersionResponse: Codable {
let version: Int
}
struct TopItemsResponse: Codable {
let topQueries: [String:Int]
let topAds: [String:Int]
enum CodingKeys: String, CodingKey {
case topQueries = "top_queries"
case topAds = "top_ads"
}
}
enum Status: Equatable {
case enabled
case disabled(_ duration: String? = nil)
case unknown
var localizedStringKey: LocalizedStringKey {
var key: String
switch self {
case .enabled:
key = "enabled"
case .disabled:
key = "disabled"
default:
key = "unknown"
}
return LocalizedStringKey(key)
}
var foregroundColor: Color {
switch self {
case .enabled:
return .green
case .disabled:
return .red
default:
return .gray
}
}
}

View file

@ -1,41 +0,0 @@
//
// PiHoleApiService.swift
// Pi-Helper
//
// Created by Billy Brawner on 10/19/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
enum NetworkError: Error, Equatable {
case loading
case cancelled
case badRequest
case notFound
case unauthorized
case unknown(Error?)
case invalidUrl
case jsonParsingFailed(Error)
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
switch (lhs, rhs) {
case (.cancelled, .cancelled):
return true
case (.badRequest, .badRequest):
return true
case (.notFound, .notFound):
return true
case (.unauthorized, .unauthorized):
return true
case (.unknown(let error1), .unknown(let error2)):
return error1?.localizedDescription == error2?.localizedDescription
case (.invalidUrl, .invalidUrl):
return true
case (.jsonParsingFailed(let error1), .jsonParsingFailed(let error2)):
return error1.localizedDescription == error2.localizedDescription
default:
return false
}
}
}

View file

@ -1,352 +0,0 @@
//
// PiHoleDataStore.swift
// Pi-Helper
//
// Created by Billy Brawner on 10/19/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import Combine
import UIKit
import Pihelper
@MainActor
class PiHoleDataStore: ObservableObject {
private let IP_MIN = 0
private let IP_MAX = 255
var isScanning: Bool {
get {
if case .scanning(_) = self.pihole {
return true
} else {
return false
}
}
set {
// No op
}
}
@Published var showCustomDisableView = false
@Published var pihole: PiholeStatus = .empty
@Published var error: PiHoleError? = nil
@Published var apiKey: String? = nil {
didSet {
UserDefaults.standard.set(apiKey, forKey: PiHoleDataStore.API_KEY)
apiService.apiKey = apiKey
}
}
@Published var baseUrl: String? = nil {
didSet {
UserDefaults.standard.set(baseUrl, forKey: PiHoleDataStore.HOST_KEY)
apiService.baseUrl = baseUrl
}
}
private var shouldMonitorStatus = false
var scanTask: Task<Any, Error>? = nil
func monitorStatus() async {
while apiKey != nil && baseUrl != nil && !Task.isCancelled {
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
await loadSummary()
} catch {
break
}
}
}
func beginScanning(_ ipAddress: String) async {
var addressParts = ipAddress.split(separator: ".")
let lastOctal = Int(addressParts[3])
for octal in 0..<IP_MAX {
if Task.isCancelled {
return
}
if octal == lastOctal {
// Don't scan the current device
continue
}
addressParts[3] = Substring(String(octal))
if await scan(addressParts.joined(separator: ".")) {
return
}
}
self.error = .scanFailed
}
func cancelScanning() {
scanTask?.cancel()
scanTask = nil
self.pihole = .missingIpAddress
}
func connect(_ ipAddress: String) async {
self.apiService.baseUrl = ipAddress
do {
_ = try await self.apiService.getVersion()
self.baseUrl = ipAddress
self.pihole = .missingApiKey
} catch {
self.error = .connectionFailed(ipAddress)
}
}
func scan(_ ipAddress: String) async -> Bool {
self.pihole = PiholeStatus.scanning(ipAddress)
do {
self.apiService.baseUrl = ipAddress
_ = try await self.apiService.getVersion()
self.baseUrl = self.apiService.baseUrl
self.pihole = PiholeStatus.missingApiKey
return true
} catch {
return false
}
}
func forgetPihole() {
self.baseUrl = nil
self.apiKey = nil
self.pihole = PiholeStatus.missingIpAddress
}
func connectWithPassword(_ password: String) async {
if let hash = password.sha256Hash()?.sha256Hash() {
await connectWithApiKey(hash)
} else {
self.error = .invalidCredentials
}
}
func connectWithApiKey(_ apiToken: String) async {
self.apiService.apiKey = apiToken
do {
_ = try await self.apiService.getTopItems()
self.apiKey = apiToken
} catch {
self.error = .invalidCredentials
}
}
func loadSummary() async {
var loadingTask: Task<Void, Error>? = nil
if case let .success(previousState) = self.pihole {
// Avoid showing the loading spinner immediately
loadingTask = Task {
try await Task.sleep(nanoseconds: 1_000_000_000)
self.pihole = .loading(previousState)
}
} else {
self.pihole = .loading()
}
do {
let pihole = try await apiService.getSummary()
await MainActor.run {
UIApplication.shared.shortcutItems = [
UIApplicationShortcutItem(
type: ShortcutAction.enable.rawValue,
localizedTitle: Bundle.main.localizedString(forKey: "enable", value: "Enable", table: nil),
localizedSubtitle: nil,
icon: UIApplicationShortcutIcon(type: .play),
userInfo: nil
),
UIApplicationShortcutItem(
type: ShortcutAction.disable.rawValue,
localizedTitle: Bundle.main.localizedString(forKey: "disable_10_sec", value: "Disable 10 Secs", table: nil),
localizedSubtitle: nil,
icon: UIApplicationShortcutIcon(type: .pause),
userInfo: ["forSeconds": 10 as NSSecureCoding]
),
UIApplicationShortcutItem(
type: ShortcutAction.disable.rawValue,
localizedTitle: Bundle.main.localizedString(forKey: "disable_30_sec", value: "Disable 30 Secs", table: nil),
localizedSubtitle: nil,
icon: UIApplicationShortcutIcon(type: .pause),
userInfo: ["forSeconds": 30 as NSSecureCoding]
),
UIApplicationShortcutItem(
type: ShortcutAction.disable.rawValue,
localizedTitle: Bundle.main.localizedString(forKey: "disable_5_min", value: "Disable 5 Min", table: nil),
localizedSubtitle: nil,
icon: UIApplicationShortcutIcon(type: .pause),
userInfo: ["forSeconds": 300 as NSSecureCoding]
)
]
}
await self.updateStatus(pihole.status.name)
loadingTask?.cancel()
} catch {
if let error = error as? NetworkError {
self.error = .networkError(error)
} else {
print("Unhandled error! \(error)")
}
}
}
func enable() async {
var previousStatus: Status? = nil
if case let .success(status) = self.pihole {
previousStatus = status
}
self.pihole = .loading(previousStatus)
do {
let status = try await self.apiService.enable()
await self.updateStatus(status.status.name)
} catch {
self.error = .networkError(error as! NetworkError)
}
}
func disable(_ forDuration: Int, unit: Int) async {
let multiplier = NSDecimalNumber(decimal: pow(60, unit)).intValue
await disable(forDuration * multiplier)
}
func disable(_ forSeconds: Int? = nil) async {
var previousStatus: Status? = nil
if case let .success(status) = self.pihole {
previousStatus = status
}
self.pihole = .loading(previousStatus)
do {
var duration: KotlinLong? = nil
if let seconds = forSeconds {
duration = KotlinLong(integerLiteral: seconds)
}
let status = try await self.apiService.disable(duration: duration)
await self.updateStatus(status.status.name)
} catch {
if let error = error as? NetworkError {
self.error = .networkError(error)
}
}
}
private func updateStatus(_ status: String) async {
switch status {
case "disabled":
await self.getDisabledDuration()
default:
self.pihole = .success(.enabled)
}
}
private var customDisableTimeRequest: AnyCancellable? = nil
private func getDisabledDuration() async {
self.pihole = .success(.disabled())
// do {
// let timestamp = try await self.apiService.getCustomDisableTimer()
// let disabledUntil = TimeInterval(round(Double(timestamp) / 1000.0))
// let now = Date().timeIntervalSince1970
// if now > disabledUntil {
// self.pihole = .success(.disabled())
// } else {
// self.pihole = .success(.disabled(UInt(disabledUntil - now).toDurationString()))
// }
// self.customDisableTimeRequest = nil
// } catch {
// self.pihole = .success(.disabled())
// }
}
let apiService = PiholeAPIService.companion.create()
static let HOST_KEY = "host"
static let API_KEY = "apiKey"
init(baseUrl: String? = nil, apiKey: String? = nil) {
self.baseUrl = baseUrl
self.apiKey = apiKey
if baseUrl == nil {
self.pihole = .missingIpAddress
} else if apiKey == nil {
self.pihole = .missingApiKey
} else {
self.pihole = .loading(nil)
}
}
}
enum ShortcutAction: String {
case enable = "EnableAction"
case disable = "DisableAction"
}
enum PiholeStatus: Equatable {
case empty
case loading(_ previousStatus: Status? = nil)
case error(_ error: Error)
case scanning(_ ipAddress: String)
case missingIpAddress
case missingApiKey
case success(_ status: Status)
static func == (lhs: PiholeStatus, rhs: PiholeStatus) -> Bool {
switch (lhs, rhs) {
case (.loading(let left), .loading(let right)):
return left == right
case (.scanning(let left), .scanning(let right)):
return left == right
case (.missingIpAddress, .missingIpAddress):
return true
case (.missingApiKey, .missingApiKey):
return true
case (.success(let left), .success(let right)):
return left == right
default:
return false
}
}
}
enum PiHoleError : Error, Equatable {
case networkError(_ error: NetworkError)
case invalidCredentials
case scanFailed
case connectionFailed(_ host: String)
static func == (lhs: PiHoleError, rhs: PiHoleError) -> Bool {
switch (lhs, rhs) {
case (.networkError(let error1), .networkError(let error2)):
return error1 == error2
case (.invalidCredentials, .invalidCredentials):
return true
case (.scanFailed, .scanFailed):
return true
case (.connectionFailed(let lIp), .connectionFailed(let rIp)):
return lIp == rIp
default:
return false
}
}
}
extension UInt {
func toDurationString() -> String {
// I add one to the timestamp to prevent showing 0 seconds remaining
if (self < 60) {
return String(self + 1)
}
var seconds: UInt = self + 1
var hours: UInt = 0
if (seconds >= 3600) {
hours = seconds / 3600
seconds -= hours * 3600
}
var minutes: UInt = 0
if (seconds >= 60) {
minutes = seconds / 60
seconds -= minutes * 60
}
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%02d:%02d", minutes, seconds)
}
}

View file

@ -6,51 +6,32 @@
// Copyright © 2019 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct PiHoleDetailsView: View {
@EnvironmentObject var store: PihelperStore
@ViewBuilder
func stateContent() -> some View {
switch self.dataStore.pihole {
case .success(let pihole):
PiHoleActionsView(dataStore: self.dataStore, status: pihole)
case .loading(let previousStatus):
if let status = previousStatus {
PiHoleActionsView(dataStore: self.dataStore, status: status)
} else {
ActivityIndicatorView()
}
case .error(let error):
switch (error) {
default:
PiHoleActionsView(dataStore: self.dataStore, status: .unknown)
}
default:
var stateContent: some View {
if self.store.state.loading {
ActivityIndicatorView()
} else {
PiHoleActionsView()
}
}
var body: some View {
NavigationView {
stateContent()
.animation(.default, value: self.dataStore.pihole)
stateContent
.animation(.default, value: self.store.state.status)
.navigationBarTitle("Pi-helper")
.navigationBarItems(trailing: NavigationLink(destination: AboutView(self.dataStore), label: {
.navigationBarItems(trailing: NavigationLink(destination: AboutView(), label: {
Image(systemName: "info.circle")
.padding()
}))
}
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
Task {
await self.dataStore.monitorStatus()
}
}
}
@ObservedObject var dataStore: PiHoleDataStore
init(_ dataStore: PiHoleDataStore) {
self.dataStore = dataStore
}
}
@ -59,107 +40,90 @@ struct PiHoleActionsView: View {
VStack {
HStack {
Text("status")
PiholeStatusView(status)
PiholeStatusView()
}
PiHoleActions(self.dataStore, status: status)
PiHoleActions()
}
}
let dataStore: PiHoleDataStore
let status: Status
}
struct PiholeStatusView: View {
@EnvironmentObject var store: PihelperStore
@ViewBuilder
var body: some View {
HStack {
Text(status.localizedStringKey)
.foregroundColor(status.foregroundColor)
if case let .disabled(duration) = status, let durationString = duration {
Text("(\(durationString))")
.monospacedDigit()
if let status = store.state.status {
Text(status.localizedStringKey)
.foregroundColor(status.foregroundColor)
}
// if case let .disabled(duration) = store.state.status, let durationString = duration {
// Text("(\(durationString))")
// .monospacedDigit()
// .foregroundColor(store.state.status.foregroundColor)
// }
}
}
let status: Status
init(_ status: Status) {
self.status = status
}
}
struct PiHoleActions: View {
@EnvironmentObject var store: PihelperStore
@SwiftUI.State var showCustomDisable: Bool = false
var body: some View {
stateContent.padding()
}
var stateContent: AnyView {
switch status {
case .disabled:
return Button(action: { Task {
await self.dataStore.enable()
} }, label: { Text("enable") })
@ViewBuilder
var stateContent: some View {
switch self.store.state.status {
case Pihelper.Status.disabled:
Button(action: {
self.store.dispatch(ActionEnable())
}, label: { Text("enable") })
.buttonStyle(PiHelperButtonStyle(.green))
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
.toAnyView()
case .enabled:
return VStack {
Button(action: { Task {
await self.dataStore.disable(10)
} }, label: { Text("disable_10_sec") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
Button(action: { Task{
await self.dataStore.disable(30)
} }, label: { Text("disable_30_sec") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
Button(action: { Task {
await self.dataStore.disable(300)
} }, label: { Text("disable_5_min") })
case Pihelper.Status.enabled:
VStack {
Button(action: {
self.store.dispatch(ActionDisable(duration: 10))
}, label: { Text("disable_10_sec") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
Button(action: {
self.dataStore.showCustomDisableView = true
self.store.dispatch(ActionDisable(duration: 30))
}, label: { Text("disable_30_sec") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
Button(action: {
self.store.dispatch(ActionDisable(duration: 300))
}, label: { Text("disable_5_min") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
Button(action: {
self.showCustomDisable = true
}, label: { Text("disable_custom") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
Button(action: { Task {
await self.dataStore.disable()
} }, label: { Text("disable_permanent") })
Button(action: {
self.store.dispatch(ActionDisable(duration: nil))
}, label: { Text("disable_permanent") })
.buttonStyle(PiHelperButtonStyle())
.padding(Edge.Set(arrayLiteral: [.bottom, .top]), 5.0)
NavigationLink(
destination: DisableCustomTimeView(self.dataStore),
isActive: self.$dataStore.showCustomDisableView,
destination: DisableCustomTimeView(showCustom: self.$showCustomDisable),
isActive: self.$showCustomDisable,
label: { EmptyView() }
)
}.toAnyView()
}
default:
return Text("Unable to load Pi-hole status. Please verify your credentials and ensure the Pi-hole is accessible from your current network.")
.toAnyView()
Text("Unable to load Pi-hole status. Please verify your credentials and ensure the Pi-hole is accessible from your current network.")
}
}
@ObservedObject var dataStore: PiHoleDataStore
let status: Status
init(_ dataStore: PiHoleDataStore, status: Status) {
self.dataStore = dataStore
self.status = status
}
}
struct PiHoleDetailsView_Previews: PreviewProvider {
static var dataStore: PiHoleDataStore {
get {
let _dataStore = PiHoleDataStore()
_dataStore.pihole = PiholeStatus.success(Status.disabled("20"))
return _dataStore
}
}
static var previews: some View {
PiHoleDetailsView(self.dataStore)
PiHoleDetailsView()
}
}

View file

@ -0,0 +1,44 @@
//
// PihelperDataStore.swift
// Pi-helper
//
// Created by William Brawner on 10/17/22.
// Copyright © 2022 William Brawner. All rights reserved.
//
import Foundation
import Pihelper
import SwiftUI
class PihelperStore: ObservableObject {
@Published public var state: Pihelper.State = State(apiKey: nil, host: nil, status: nil, scanning: nil, loading: false, route: Route.connect, initialRoute: Route.connect)
@Published public var sideEffect: Effect? {
didSet {
print("sideEffect: \(sideEffect)")
}
}
let store: Store
var stateWatcher : Closeable?
var sideEffectWatcher : Closeable?
init(store: Store) {
self.store = store
stateWatcher = self.store.watchState { [weak self] state in
self?.state = state
}
sideEffectWatcher = self.store.watchEffects { [weak self] effect in
self?.sideEffect = effect
}
}
public func dispatch(_ action: Action) {
store.dispatch(action: action)
}
deinit {
stateWatcher?.close()
sideEffectWatcher?.close()
}
}

View file

@ -6,14 +6,17 @@
// Copyright © 2019 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct RetrieveApiKeyView: View {
@State var apiKey: String = ""
@State var password: String = ""
@EnvironmentObject var store: PihelperStore
@SwiftUI.State var apiKey: String = ""
@SwiftUI.State var password: String = ""
var showAlert: Bool {
get {
if case .invalidCredentials = self.dataStore.error {
if let error = self.store.sideEffect, error is EffectError {
return true
} else {
return false
@ -32,49 +35,47 @@ struct RetrieveApiKeyView: View {
SecureField("prompt_password", text: self.$password)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
Task {
await self.dataStore.connectWithPassword(self.password)
}
// self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringPassword(value: password)))
// TODO: Fix shared implementation and then use that instead
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: password.sha256Hash()?.sha256Hash() ?? "")))
}
Button(action: { Task {
await self.dataStore.connectWithPassword(self.password)
} }, label: {
Button(action: {
// self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringPassword(value: password)))
// TODO: Fix shared implementation and then use that instead
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: password.sha256Hash()?.sha256Hash() ?? "")))
}, label: {
Text("connect_with_password")
})
.buttonStyle(PiHelperButtonStyle())
.buttonStyle(PiHelperButtonStyle())
OrDivider()
SecureField("prompt_api_key", text: self.$apiKey)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
Task {
await self.dataStore.connectWithApiKey(self.apiKey)
}
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: apiKey)))
}
Button(action: { Task {
await self.dataStore.connectWithApiKey(self.apiKey)
} }, label: {
Button(action: {
self.store.dispatch(ActionAuthenticate(authString: AuthenticationStringToken(value: apiKey)))
}, label: {
Text("connect_with_api_key")
})
.buttonStyle(PiHelperButtonStyle())
.buttonStyle(PiHelperButtonStyle())
}
.padding()
.keyboardAwarePadding()
.alert(isPresented: .constant(showAlert), content: {
Alert(title: Text("connection_failed"), message: Text("verify_credentials"), dismissButton: .default(Text("OK"), action: {
self.dataStore.pihole = .missingApiKey
self.store.sideEffect = nil
}))
})
}
.onDisappear {
self.dataStore.pihole = .missingIpAddress
self.store.dispatch(ActionBack())
}
}
@ObservedObject var dataStore: PiHoleDataStore
}
struct RetrieveApiKeyView_Previews: PreviewProvider {
static var previews: some View {
RetrieveApiKeyView(dataStore: PiHoleDataStore())
RetrieveApiKeyView()
}
}

View file

@ -6,45 +6,36 @@
// Copyright © 2019 William Brawner. All rights reserved.
//
import Pihelper
import SwiftUI
struct ScanningView: View {
@Environment(\.presentationMode) var presentationMode
@ObservedObject var dataStore: PiHoleDataStore
@EnvironmentObject var store: PihelperStore
@ViewBuilder
var body: some View {
switch dataStore.pihole {
case .scanning(let ipAddress):
ScrollView {
VStack(spacing: 10) {
ActivityIndicatorView()
Text("scanning_ip_address")
ScrollView {
VStack(spacing: 10) {
ActivityIndicatorView()
Text("scanning_ip_address")
if let ipAddress = store.state.scanning {
Text(verbatim: ipAddress)
Button(action: {
self.dataStore.cancelScanning()
}, label: { Text("cancel") })
.buttonStyle(PiHelperButtonStyle())
}.padding()
}
default:
EmptyView().onAppear {
self.presentationMode.wrappedValue.dismiss()
}
}
Button(action: {
self.store.dispatch(ActionBack())
}, label: { Text("cancel") })
.buttonStyle(PiHelperButtonStyle())
}.padding()
}
.onDisappear {
store.dispatch(ActionBack())
}
}
}
struct ScanningView_Previews: PreviewProvider {
static var dataStore: PiHoleDataStore {
get {
let dataStore = PiHoleDataStore()
dataStore.pihole = .scanning("127.0.0.1")
return dataStore
}
}
static var previews: some View {
ScanningView(dataStore: self.dataStore)
ScanningView()
}
}

View file

@ -7,30 +7,22 @@
//
import UIKit
import Pihelper
import SwiftUI
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var dataStore: PiHoleDataStore?
var store: PihelperStore?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
if let host = UserDefaults.standard.string(forKey: PiHoleDataStore.HOST_KEY) {
// If we already have the address of a Pi we've previously connected to, try that
let apiKey = UserDefaults.standard.string(forKey: PiHoleDataStore.API_KEY)
self.dataStore = PiHoleDataStore(baseUrl: host, apiKey: apiKey)
} else {
self.dataStore = PiHoleDataStore()
Task {
await self.dataStore?.scan("pi.hole")
}
}
let store = PihelperStore(store: Pihelper.Store.companion.create())
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView(self.dataStore!)
let contentView = ContentView()
.environmentObject(store)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
@ -55,14 +47,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Is there a shortcut item that has not yet been processed?
if let shortcutItem = (UIApplication.shared.delegate as! AppDelegate).shortcutItemToProcess {
if shortcutItem.type == ShortcutAction.enable.rawValue {
Task {
await self.dataStore?.enable()
}
self.store?.dispatch(ActionEnable())
} else {
let amount = shortcutItem.userInfo?["forSeconds"] as? Int
Task {
await self.dataStore?.disable(amount)
var kAmount: KotlinLong? = nil
if let amount = shortcutItem.userInfo?["forSeconds"] as? Int {
kAmount = KotlinLong(value: Int64(amount))
}
self.store?.dispatch(ActionDisable(duration: kAmount))
}
// Reset the shorcut item so it's never processed twice.
@ -92,3 +83,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}
enum ShortcutAction: String {
case enable = "EnableAction"
case disable = "DisableAction"
}