mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
parent
18075308dd
commit
30aa66bf2a
6 changed files with 16 additions and 344 deletions
|
|
@ -1,115 +0,0 @@
|
|||
//
|
||||
// BackupData.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 25/05/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct BackupData: Codable {
|
||||
let version: String
|
||||
let timestamp: Date
|
||||
let userData: [String: Any]
|
||||
|
||||
init(userData: [String: Any]) {
|
||||
self.version = "1.0"
|
||||
self.timestamp = Date()
|
||||
self.userData = userData
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version, timestamp, userData
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
version = try container.decode(String.self, forKey: .version)
|
||||
timestamp = try container.decode(Date.self, forKey: .timestamp)
|
||||
|
||||
let userDataContainer = try container.nestedContainer(keyedBy: DynamicKey.self, forKey: .userData)
|
||||
var userData: [String: Any] = [:]
|
||||
|
||||
for key in userDataContainer.allKeys {
|
||||
userData[key.stringValue] = try userDataContainer.decode(AnyCodable.self, forKey: key).value
|
||||
}
|
||||
|
||||
self.userData = userData
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encode(timestamp, forKey: .timestamp)
|
||||
|
||||
var userDataContainer = container.nestedContainer(keyedBy: DynamicKey.self, forKey: .userData)
|
||||
for (key, value) in userData {
|
||||
let dynamicKey = DynamicKey(stringValue: key)!
|
||||
try userDataContainer.encode(AnyCodable(value), forKey: dynamicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicKey: CodingKey {
|
||||
var stringValue: String
|
||||
var intValue: Int?
|
||||
|
||||
init?(stringValue: String) {
|
||||
self.stringValue = stringValue
|
||||
}
|
||||
|
||||
init?(intValue: Int) {
|
||||
self.intValue = intValue
|
||||
self.stringValue = String(intValue)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnyCodable: Codable {
|
||||
let value: Any
|
||||
|
||||
init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if let bool = try? container.decode(Bool.self) {
|
||||
value = bool
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
value = int
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
value = double
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
value = string
|
||||
} else if let array = try? container.decode([AnyCodable].self) {
|
||||
value = array.map { $0.value }
|
||||
} else if let dictionary = try? container.decode([String: AnyCodable].self) {
|
||||
value = dictionary.mapValues { $0.value }
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
switch value {
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
case let array as [Any]:
|
||||
try container.encode(array.map { AnyCodable($0) })
|
||||
case let dictionary as [String: Any]:
|
||||
try container.encode(dictionary.mapValues { AnyCodable($0) })
|
||||
default:
|
||||
throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "Unsupported type"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
//
|
||||
// BackupManager.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 25/05/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class BackupManager: ObservableObject {
|
||||
static let shared = BackupManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
func createBackup() -> BackupData {
|
||||
var userData: [String: Any] = [:]
|
||||
|
||||
let userDefaults = UserDefaults.standard
|
||||
let defaultsDict = userDefaults.dictionaryRepresentation()
|
||||
|
||||
let appKeys = defaultsDict.keys.filter { key in
|
||||
!key.hasPrefix("Apple") &&
|
||||
!key.hasPrefix("NS") &&
|
||||
!key.hasPrefix("com.apple") &&
|
||||
!key.contains("Keyboard")
|
||||
}
|
||||
|
||||
for key in appKeys {
|
||||
userData[key] = defaultsDict[key]
|
||||
}
|
||||
|
||||
return BackupData(userData: userData)
|
||||
}
|
||||
|
||||
func exportBackup() -> URL? {
|
||||
let backup = createBackup()
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
|
||||
let data = try encoder.encode(backup)
|
||||
|
||||
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let fileName = "sora_backup_\(DateFormatter.backupFormatter.string(from: Date())).json"
|
||||
let fileURL = documentsPath.appendingPathComponent(fileName)
|
||||
|
||||
try data.write(to: fileURL)
|
||||
return fileURL
|
||||
|
||||
} catch {
|
||||
print("Failed to export backup: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func importBackup(from url: URL) -> Bool {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
|
||||
let backup = try decoder.decode(BackupData.self, from: data)
|
||||
|
||||
let userDefaults = UserDefaults.standard
|
||||
for (key, value) in backup.userData {
|
||||
userDefaults.set(value, forKey: key)
|
||||
}
|
||||
|
||||
userDefaults.synchronize()
|
||||
|
||||
|
||||
NotificationCenter.default.post(name: .backupRestored, object: nil)
|
||||
|
||||
return true
|
||||
|
||||
} catch {
|
||||
print("Failed to import backup: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func shareBackup() -> URL? {
|
||||
return exportBackup()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
//
|
||||
// DateFormatter.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 25/05/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension DateFormatter {
|
||||
static let backupFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
|
@ -13,5 +13,4 @@ extension Notification.Name {
|
|||
static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate")
|
||||
static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate")
|
||||
static let modulesSyncDidComplete = Notification.Name("modulesSyncDidComplete")
|
||||
static let backupRestored = Notification.Name("backupRestored")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SettingsViewData: View {
|
||||
@State private var showEraseAppDataAlert = false
|
||||
|
|
@ -19,41 +18,14 @@ struct SettingsViewData: View {
|
|||
@State private var movPkgSize: Int64 = 0
|
||||
@State private var showRemoveMovPkgAlert = false
|
||||
|
||||
// State bindings for cache settings
|
||||
@State private var isMetadataCachingEnabled: Bool = true
|
||||
@State private var isImageCachingEnabled: Bool = true
|
||||
@State private var isMemoryOnlyMode: Bool = false
|
||||
|
||||
@StateObject private var backupManager = BackupManager.shared
|
||||
@State private var showingExportSuccess = false
|
||||
@State private var showingImportSuccess = false
|
||||
@State private var showingError = false
|
||||
@State private var errorMessage = ""
|
||||
@State private var showingFilePicker = false
|
||||
@State private var showingShareSheet = false
|
||||
@State private var backupURL: URL?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(header: Text("Backup & Restore"), footer: Text("Create backups to transfer your data to another device or restore from a previous backup.")) {
|
||||
Button(action: exportBackup) {
|
||||
HStack {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundColor(.blue)
|
||||
Text("Create Backup")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: { showingFilePicker = true }) {
|
||||
HStack {
|
||||
Image(systemName: "square.and.arrow.down")
|
||||
.foregroundColor(.green)
|
||||
Text("Restore from Backup")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New section for cache settings
|
||||
Section(header: Text("Cache Settings"), footer: Text("Caching helps reduce network usage and load content faster. You can disable it to save storage space.")) {
|
||||
Toggle("Enable Metadata Caching", isOn: $isMetadataCachingEnabled)
|
||||
.onChange(of: isMetadataCachingEnabled) { newValue in
|
||||
|
|
@ -76,6 +48,7 @@ struct SettingsViewData: View {
|
|||
.onChange(of: isMemoryOnlyMode) { newValue in
|
||||
MetadataCacheManager.shared.isMemoryOnlyMode = newValue
|
||||
if newValue {
|
||||
// Clear disk cache when switching to memory-only
|
||||
MetadataCacheManager.shared.clearAllCache()
|
||||
calculateCacheSize()
|
||||
}
|
||||
|
|
@ -145,42 +118,13 @@ struct SettingsViewData: View {
|
|||
.navigationTitle("App Data")
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.onAppear {
|
||||
// Initialize state with current values
|
||||
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
|
||||
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
|
||||
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showingFilePicker,
|
||||
allowedContentTypes: [UTType.json],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
handleFileImport(result)
|
||||
}
|
||||
.sheet(isPresented: $showingShareSheet) {
|
||||
if let url = backupURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
.alert("Backup Created", isPresented: $showingExportSuccess) {
|
||||
Button("Share") {
|
||||
showingShareSheet = true
|
||||
}
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text("Your backup has been created successfully. You can share it or find it in your Files app.")
|
||||
}
|
||||
.alert("Backup Restored", isPresented: $showingImportSuccess) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text("Your data has been restored successfully. The app will refresh with your restored settings.")
|
||||
}
|
||||
.alert("Error", isPresented: $showingError) {
|
||||
Button("OK") { }
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
.alert(isPresented: $showEraseAppDataAlert) {
|
||||
Alert(
|
||||
title: Text("Erase App Data"),
|
||||
|
|
@ -213,51 +157,24 @@ struct SettingsViewData: View {
|
|||
}
|
||||
}
|
||||
|
||||
private func exportBackup() {
|
||||
guard let url = backupManager.exportBackup() else {
|
||||
errorMessage = "Failed to create backup file"
|
||||
showingError = true
|
||||
return
|
||||
}
|
||||
|
||||
backupURL = url
|
||||
showingExportSuccess = true
|
||||
}
|
||||
|
||||
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
guard let url = urls.first else { return }
|
||||
|
||||
if backupManager.importBackup(from: url) {
|
||||
showingImportSuccess = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isMetadataCachingEnabled = MetadataCacheManager.shared.isCachingEnabled
|
||||
isImageCachingEnabled = KingfisherCacheManager.shared.isCachingEnabled
|
||||
isMemoryOnlyMode = MetadataCacheManager.shared.isMemoryOnlyMode
|
||||
}
|
||||
} else {
|
||||
errorMessage = "Failed to restore backup. Please check if the file is a valid Sora backup."
|
||||
showingError = true
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
errorMessage = "Failed to read backup file: \(error.localizedDescription)"
|
||||
showingError = true
|
||||
}
|
||||
}
|
||||
// Calculate and update the combined cache size
|
||||
func calculateCacheSize() {
|
||||
isCalculatingSize = true
|
||||
cacheSizeText = "Calculating..."
|
||||
|
||||
// Group all cache size calculations
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
// Get metadata cache size
|
||||
let metadataSize = MetadataCacheManager.shared.getCacheSize()
|
||||
totalSize += metadataSize
|
||||
|
||||
// Get image cache size asynchronously
|
||||
KingfisherCacheManager.shared.calculateCacheSize { imageSize in
|
||||
totalSize += Int64(imageSize)
|
||||
|
||||
// Update the UI on the main thread
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSizeText = KingfisherCacheManager.formatCacheSize(UInt(totalSize))
|
||||
self.isCalculatingSize = false
|
||||
|
|
@ -266,9 +183,14 @@ struct SettingsViewData: View {
|
|||
}
|
||||
}
|
||||
|
||||
// Clear all caches (both metadata and images)
|
||||
func clearAllCaches() {
|
||||
// Clear metadata cache
|
||||
MetadataCacheManager.shared.clearAllCache()
|
||||
|
||||
// Clear image cache
|
||||
KingfisherCacheManager.shared.clearCache {
|
||||
// Update cache size after clearing
|
||||
calculateCacheSize()
|
||||
}
|
||||
|
||||
|
|
@ -392,13 +314,3 @@ struct SettingsViewData: View {
|
|||
return totalSize
|
||||
}
|
||||
}
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@
|
|||
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1222D9995C300A0140B /* JSController-Details.swift */; };
|
||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; };
|
||||
132FC5B32DE31DAE009A80F7 /* SettingsViewAlternateAppIconPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */; };
|
||||
132FC5BB2DE333D3009A80F7 /* BackupData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5BA2DE333D3009A80F7 /* BackupData.swift */; };
|
||||
132FC5BD2DE333F4009A80F7 /* DateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5BC2DE333F4009A80F7 /* DateFormatter.swift */; };
|
||||
132FC5BF2DE33410009A80F7 /* BackupManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132FC5BE2DE33410009A80F7 /* BackupManager.swift */; };
|
||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
|
||||
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
|
||||
|
|
@ -101,9 +98,6 @@
|
|||
132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = "<group>"; };
|
||||
132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = "<group>"; };
|
||||
132FC5B22DE31DAD009A80F7 /* SettingsViewAlternateAppIconPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewAlternateAppIconPicker.swift; sourceTree = "<group>"; };
|
||||
132FC5BA2DE333D3009A80F7 /* BackupData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupData.swift; sourceTree = "<group>"; };
|
||||
132FC5BC2DE333F4009A80F7 /* DateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatter.swift; sourceTree = "<group>"; };
|
||||
132FC5BE2DE33410009A80F7 /* BackupManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupManager.swift; sourceTree = "<group>"; };
|
||||
133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
|
||||
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -219,15 +213,6 @@
|
|||
path = Analytics;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
132FC5B92DE333BD009A80F7 /* Backups */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
132FC5BA2DE333D3009A80F7 /* BackupData.swift */,
|
||||
132FC5BE2DE33410009A80F7 /* BackupManager.swift */,
|
||||
);
|
||||
path = Backups;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
133D7C612D2BE2500075467E = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -309,10 +294,9 @@
|
|||
133D7C852D2BE2640075467E /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13D842532D45266900EBBFA6 /* Drops */,
|
||||
7205AEDA2DCCEF9500943F3F /* Cache */,
|
||||
13D842532D45266900EBBFA6 /* Drops */,
|
||||
1399FAD12D3AB33D00E97C31 /* Logger */,
|
||||
132FC5B92DE333BD009A80F7 /* Backups */,
|
||||
133D7C882D2BE2640075467E /* Modules */,
|
||||
133D7C8A2D2BE2640075467E /* JSLoader */,
|
||||
1327FBA52D758CEA00FC6689 /* Analytics */,
|
||||
|
|
@ -331,7 +315,6 @@
|
|||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
|
||||
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
|
||||
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
|
||||
132FC5BC2DE333F4009A80F7 /* DateFormatter.swift */,
|
||||
13637B892DE0EA1100BDA2FC /* UserDefaults.swift */,
|
||||
133D7C872D2BE2640075467E /* URLSession.swift */,
|
||||
1359ED132D76F49900C13034 /* finTopView.swift */,
|
||||
|
|
@ -637,7 +620,6 @@
|
|||
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */,
|
||||
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
|
||||
13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */,
|
||||
132FC5BF2DE33410009A80F7 /* BackupManager.swift in Sources */,
|
||||
13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */,
|
||||
13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */,
|
||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
||||
|
|
@ -680,8 +662,6 @@
|
|||
72AC3A032DD4DAEB00C60B96 /* PerformanceMonitor.swift in Sources */,
|
||||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */,
|
||||
13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */,
|
||||
132FC5BB2DE333D3009A80F7 /* BackupData.swift in Sources */,
|
||||
132FC5BD2DE333F4009A80F7 /* DateFormatter.swift in Sources */,
|
||||
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */,
|
||||
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */,
|
||||
13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */,
|
||||
|
|
|
|||
Loading…
Reference in a new issue