iOS/Sources/App/Settings/DebugView.swift

479 lines
17 KiB
Swift

import PromiseKit
import RealmSwift
import SFSafeSymbols
import Shared
import SwiftUI
import WebKit
import XCGLogger
struct DebugView: View {
@State private var showShareSheet = false
@State private var logsURL: URL?
@State private var tapsOnCasitaLogo = 0
private let feedbackGenerator = UINotificationFeedbackGenerator()
// Progress views
@State private var loadingLogs = false
@State private var loadingCleaningWebCache = false
@State private var loadingResetApp = false
// Alerts
@State private var showDeleteEntitiesAlert = false
@State private var showResetAppAlert = false
@State private var watchSyncErrorMessage: String?
@State private var showWatchSyncError = false
var body: some View {
List {
AppleLikeListTopRowHeader(
image: .init(uiImage: MaterialDesignIcons.bugIcon.image(
ofSize: .init(width: 120, height: 120),
color: Asset.Colors.haPrimary.color
)),
title: L10n.Settings.Debugging.Header.title,
subtitle: L10n.Settings.Debugging.Header.subtitle
)
Section {
Button(action: {
if let url = Current.Log.archiveURL() {
logsURL = url
if Current.isCatalyst {
if let url = Current.Log.archiveURL() {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
} else {
loadingLogs = true
showShareSheet = true
}
} else {
Current.Log.error("Logs archive URL not available")
}
}, label: {
linkContent(
image: .init(systemSymbol: .filemenuAndSelection),
title: Current.isCatalyst ? L10n.Settings.Developer.ShowLogFiles.title : L10n.Settings.Developer
.ExportLogFiles.title,
showProgressView: loadingLogs
)
})
}
.sheet(isPresented: .init(get: { showShareSheet && logsURL != nil }, set: { showShareSheet = $0 })) {
if let logsURL {
ActivityViewController(shareWrapper: .init(url: logsURL))
.onAppear {
loadingLogs = false
}
.onDisappear {
do {
try FileManager.default.removeItem(at: logsURL)
} catch {
Current.Log.error("Error deleting logs file: \(error)")
}
}
}
}
if #available(iOS 17, *) {
Section {
NavigationLink {
ThreadCredentialsManagementView()
} label: {
linkContent(
image: Image(
uiImage: Asset.SharedAssets.thread.image.withRenderingMode(
.alwaysTemplate
)
),
title: L10n.SettingsDetails.Thread.title,
imageSize: 22
)
}
} footer: {
Text(
L10n.Settings.Debugging.Thread.footer
)
}
}
Section {
NavigationLink {
ClientEventsLogView()
} label: {
linkContent(image: .init(systemSymbol: .listDash), title: L10n.Settings.EventLog.title)
}
NavigationLink {
LocationHistoryListViewControllerWrapper()
} label: {
linkContent(
image: .init(systemSymbol: .map),
title: L10n.Settings.LocationHistory.title
)
}
}
criticalSection
if tapsOnCasitaLogo < 10 {
Button(action: {
feedbackGenerator.notificationOccurred(.success)
tapsOnCasitaLogo += 1
}, label: {
Image(uiImage: Asset.SharedAssets.casita.image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 100, height: 100, alignment: .center)
})
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
} else {
developerSection
}
}
}
private func linkContent(
image: Image,
title: String,
imageSize: CGFloat = 18,
iconColor: Color = Color.asset(Asset.Colors.haPrimary),
textColor: Color = Color(uiColor: .label),
showProgressView: Bool? = nil
) -> some View {
HStack(spacing: Spaces.two) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: imageSize, height: imageSize, alignment: .center)
.foregroundStyle(iconColor)
Text(title)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(textColor)
if let showProgressView {
ProgressView()
.progressViewStyle(.circular)
.opacity(showProgressView ? 1 : 0)
.animation(.easeOut(duration: 2), value: showProgressView)
}
}
}
private var criticalSection: some View {
Section {
Button {
showDeleteEntitiesAlert = true
} label: {
linkContent(
image: .init(systemSymbol: .deleteBackwardFill),
title: L10n.Debug.Reset.EntitiesDatabase.title,
iconColor: .red,
textColor: .red
)
}
.alert(L10n.Alert.Confirmation.Generic.title, isPresented: $showDeleteEntitiesAlert) {
Button(role: .cancel, action: { /* no-op */ }) {
Text(L10n.cancelLabel)
}
Button(role: .destructive, action: {
do {
_ = try Current.database.write { db in
try HAAppEntity.deleteAll(db)
Current.Log.verbose("Deleted all app entities")
}
} catch {
Current.Log.error("Failed to reset app entities, error: \(error)")
}
}) {
Text(L10n.yesLabel)
}
} message: {
Text(L10n.Alert.Confirmation.DeleteEntities.message)
}
Button {
loadingCleaningWebCache = true
WKWebsiteDataStore.default().removeData(
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
modifiedSince: Date(timeIntervalSince1970: 0),
completionHandler: {
Current.Log.verbose("Reset browser caches!")
loadingCleaningWebCache = false
}
)
} label: {
linkContent(
image: .init(systemSymbol: .deleteBackwardFill),
title: L10n.Settings.ResetSection.ResetWebCache.title,
iconColor: .red,
textColor: .red,
showProgressView: loadingCleaningWebCache
)
}
Button {
showResetAppAlert = true
} label: {
linkContent(
image: .init(systemSymbol: .deleteBackwardFill),
title: L10n.Settings.ResetSection.ResetApp.title,
iconColor: .red,
textColor: .red,
showProgressView: loadingResetApp
)
}
.alert(L10n.Alert.Confirmation.Generic.title, isPresented: $showResetAppAlert) {
Button(role: .cancel, action: { /* no-op */ }) {
Text(L10n.cancelLabel)
}
Button(role: .destructive, action: {
Task {
await resetApp()
}
}) {
Text(L10n.yesLabel)
}
} message: {
Text(L10n.Settings.ResetSection.ResetAlert.title)
}
} footer: {
Text(L10n.Settings.Debugging.CriticalSection.footer)
}
}
private var developerSection: some View {
Section {
Button {
if let syncError = HomeAssistantAPI.SyncWatchContext() {
watchSyncErrorMessage = syncError.localizedDescription
showWatchSyncError = true
}
} label: {
linkContent(
image: .init(systemSymbol: .applewatchWatchface),
title: L10n.Settings.Developer.SyncWatchContext.title
)
}
.alert(L10n.errorLabel, isPresented: $showWatchSyncError) {
Button(role: .cancel, action: { /* no-op */ }) {
Text(L10n.okLabel)
}
} message: {
Text(watchSyncErrorMessage ?? "Unknown")
}
Button {
copyRealm()
} label: {
linkContent(
image: .init(systemSymbol: .docOnDoc),
title: L10n.Settings.Developer.CopyRealm.title
)
}
Button {
prefs.set(!prefs.bool(forKey: "showTranslationKeys"), forKey: "showTranslationKeys")
} label: {
linkContent(
image: .init(systemSymbol: .textBubble),
title: L10n.Settings.Developer.DebugStrings.title
)
}
Button {
sendCameraNotification()
} label: {
linkContent(
image: .init(systemSymbol: .camera),
title: L10n.Settings.Developer.CameraNotification.title
)
}
Button {
sendMapNotification()
} label: {
linkContent(
image: .init(systemSymbol: .map),
title: L10n.Settings.Developer.MapNotification.title
)
}
Toggle(isOn: .init(get: {
prefs.bool(forKey: XCGLogger.shouldNotifyUserDefaultsKey)
}, set: { newValue in
prefs.set(newValue, forKey: XCGLogger.shouldNotifyUserDefaultsKey)
})) {
linkContent(
image: .init(systemSymbol: .info),
title: L10n.Settings.Developer.AnnoyingBackgroundNotifications.title
)
}
} header: {
Text(L10n.Settings.Developer.header)
} footer: {
Text(L10n.Settings.Developer.footer)
}
}
private func copyRealm() {
guard let backupURL = Realm.backup() else {
fatalError("Unable to get Realm backup")
}
let containerRealmPath = Realm.Configuration.defaultConfiguration.fileURL!
Current.Log.verbose("Would copy from \(backupURL) to \(containerRealmPath)")
if FileManager.default.fileExists(atPath: containerRealmPath.path) {
do {
_ = try FileManager.default.removeItem(at: containerRealmPath)
} catch {
Current.Log.error("Error occurred, here are the details:\n \(error)")
}
}
do {
_ = try FileManager.default.copyItem(at: backupURL, to: containerRealmPath)
} catch let error as NSError {
// Catch fires here, with an NSError being thrown
Current.Log.error("Error occurred, here are the details:\n \(error)")
}
let msg = L10n.Settings.Developer.CopyRealm.Alert.message(
backupURL.path,
containerRealmPath.path
)
Current.Log.verbose(msg)
}
private func sendMapNotification() {
let content = UNMutableNotificationContent()
content.body = L10n.Settings.Developer.MapNotification.Notification.body
content.sound = .default
var firstPinLatitude = "40.785091"
var firstPinLongitude = "-73.968285"
if Current.appConfiguration == .fastlaneSnapshot,
let lat = prefs.string(forKey: "mapPin1Latitude"),
let lon = prefs.string(forKey: "mapPin1Longitude") {
firstPinLatitude = lat
firstPinLongitude = lon
}
var secondPinLatitude = "40.758896"
var secondPinLongitude = "-73.985130"
if Current.appConfiguration == .fastlaneSnapshot,
let lat = prefs.string(forKey: "mapPin2Latitude"),
let lon = prefs.string(forKey: "mapPin2Longitude") {
secondPinLatitude = lat
secondPinLongitude = lon
}
content.userInfo = [
"homeassistant": [
"latitude": firstPinLatitude,
"longitude": firstPinLongitude,
"second_latitude": secondPinLatitude,
"second_longitude": secondPinLongitude,
],
]
content.categoryIdentifier = "map"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let notificationRequest = UNNotificationRequest(
identifier: "mapContentExtension",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(notificationRequest)
}
private func sendCameraNotification() {
let content = UNMutableNotificationContent()
content.body = L10n.Settings.Developer.CameraNotification.Notification.body
content.sound = .default
var entityID = "camera.amcrest_camera"
if Current.appConfiguration == .fastlaneSnapshot,
let snapshotEntityID = prefs.string(forKey: "cameraEntityID") {
entityID = snapshotEntityID
}
content.userInfo = ["entity_id": entityID]
content.categoryIdentifier = "camera"
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let notificationRequest = UNNotificationRequest(
identifier: "cameraContentExtension",
content: content,
trigger: trigger
)
UNUserNotificationCenter.current().add(notificationRequest)
}
private func resetApp() async {
loadingResetApp = true
Current.Log.verbose("Resetting app!")
for api in Current.apis {
await revokeToken(api: api)
await wait(seconds: 13)
api.connection.disconnect()
}
for server in Current.servers.all {
Current.servers.remove(identifier: server.identifier)
}
resetStores()
setDefaults()
await resetPushID()
loadingResetApp = false
Current.onboardingObservation.needed(.logout)
}
private func wait(seconds: Int) async {
await Task.sleep(UInt64(seconds * 1_000_000_000))
}
private func revokeToken(api: HomeAssistantAPI) async {
await withCheckedContinuation { continuation in
api.tokenManager.revokeToken().pipe { result in
switch result {
case .fulfilled:
break
case let .rejected(error):
Current.Log
.error(
"Failed to revoke token for api \(api.server.info.name) \(api.server.info.connection.activeURL()?.absoluteString ?? "Uknown active URL"), error: \(error.localizedDescription)"
)
}
continuation.resume()
}
}
}
private func resetPushID() async {
await withCheckedContinuation { continuation in
Current.notificationManager.resetPushID().pipe { result in
switch result {
case .fulfilled:
break
case let .rejected(error):
Current.Log.error("Failed to reset push ID, error: \(error.localizedDescription)")
}
continuation.resume()
}
}
}
}
#Preview {
DebugView()
}