Documents tab with file sharing

This commit is contained in:
Arkadiusz Fal 2022-11-13 00:01:04 +01:00
parent ccded28468
commit 4657af2f3d
15 changed files with 474 additions and 59 deletions

185
Model/DocumentsModel.swift Normal file
View File

@ -0,0 +1,185 @@
import Foundation
final class DocumentsModel: ObservableObject {
static var shared = DocumentsModel()
@Published private(set) var directoryURL: URL!
@Published private(set) var refreshID = UUID()
typealias AreInIncreasingOrder = (URL, URL) -> Bool
init(directoryURL: URL! = nil) {
self.directoryURL = directoryURL
}
private var fileManager: FileManager {
.default
}
var directoryLabel: String {
guard let directoryURL else { return "Documents" }
return displayLabelForDocument(directoryURL)
}
var sortPredicates: [AreInIncreasingOrder] {
[
{ self.isDirectory($0) && !self.isDirectory($1) },
{ $0.lastPathComponent.caseInsensitiveCompare($1.lastPathComponent) == .orderedAscending }
]
}
var sortedDirectoryContents: [URL] {
directoryContents.sorted { lhs, rhs in
for predicate in sortPredicates {
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
}
}
var directoryContents: [URL] {
guard let directoryURL else { return [] }
return contents(of: directoryURL)
}
var documentsDirectory: URL? {
if let url = try? fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
return replacePrivateVar(url)
}
return nil
}
func isDocument(_ video: Video) -> Bool {
guard video.isLocal, let url = video.localStream?.localURL, let url = replacePrivateVar(url) else { return false }
return isDocument(url)
}
func isDocument(_ url: URL) -> Bool {
guard let url = replacePrivateVar(url), let documentsDirectory else { return false }
return url.absoluteString.starts(with: documentsDirectory.absoluteString)
}
func isDirectory(_ url: URL) -> Bool {
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
var creationDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}
func creationDate(_ video: Video) -> Date? {
guard video.isLocal, let url = video.localStream?.localURL, let url = replacePrivateVar(url) else { return nil }
return creationDate(url)
}
func creationDate(_ url: URL) -> Date? {
try? url.resourceValues(forKeys: [.creationDateKey]).creationDate
}
func formattedCreationDate(_ video: Video) -> String? {
guard video.isLocal, let url = video.localStream?.localURL, let url = replacePrivateVar(url) else { return nil }
return formattedCreationDate(url)
}
func formattedCreationDate(_ url: URL) -> String? {
if let date = try? url.resourceValues(forKeys: [.creationDateKey]).creationDate {
return creationDateFormatter.string(from: date)
}
return nil
}
var sizeFormatter: ByteCountFormatter {
let formatter = ByteCountFormatter()
formatter.allowedUnits = .useAll
formatter.countStyle = .file
formatter.includesUnit = true
formatter.isAdaptive = true
return formatter
}
func size(_ video: Video) -> Int? {
guard video.isLocal, let url = video.localStream?.localURL, let url = replacePrivateVar(url) else { return nil }
return size(url)
}
func size(_ url: URL) -> Int? {
try? url.resourceValues(forKeys: [.fileAllocatedSizeKey]).fileAllocatedSize
}
func formattedSize(_ video: Video) -> String? {
guard let size = size(video) else { return nil }
return sizeFormatter.string(fromByteCount: Int64(size))
}
func formattedSize(_ url: URL) -> String? {
guard let size = size(url) else { return nil }
return sizeFormatter.string(fromByteCount: Int64(size))
}
func removeDocument(_ url: URL) throws {
guard isDocument(url) else { return }
try fileManager.removeItem(at: url)
URLBookmarkModel.shared.removeBookmark(url)
refresh()
}
private func contents(of directory: URL) -> [URL] {
(try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.creationDateKey, .fileAllocatedSizeKey, .isDirectoryKey],
options: [.includesDirectoriesPostOrder, .skipsHiddenFiles]
)) ?? []
}
private func displayLabelForDocument(_ file: URL) -> String {
let components = file.absoluteString.components(separatedBy: "/Documents/")
if components.count == 2 {
let component = components[1]
return component.isEmpty ? "Documents" : component.removingPercentEncoding ?? component
}
return "Document"
}
var canGoBack: Bool {
guard let directoryURL, let documentsDirectory else { return false }
return replacePrivateVar(directoryURL) != documentsDirectory
}
func goToURL(_ url: URL) {
directoryURL = url
}
func goBack() {
directoryURL = urlToGoBack
}
func goToTop() {
directoryURL = documentsDirectory
}
private var urlToGoBack: URL? {
directoryURL?.deletingLastPathComponent()
}
func replacePrivateVar(_ url: URL) -> URL? {
let urlStringWithPrivateVarRemoved = url.absoluteString.replacingFirstOccurrence(of: "/private/var/", with: "/var/")
return URL(string: urlStringWithPrivateVarRemoved)
}
func refresh() {
refreshID = UUID()
}
}

View File

@ -6,6 +6,7 @@ final class NavigationModel: ObservableObject {
enum TabSelection: Hashable {
case home
case documents
case subscriptions
case popular
case trending

View File

@ -52,6 +52,17 @@ struct URLBookmarkModel {
}
}
func removeBookmark(_ url: URL) {
logger.info("removing bookmark for \(url.absoluteString)")
guard let defaults = CacheModel.shared.bookmarksDefaults else {
logger.error("could not open bookmarks defaults")
return
}
defaults.removeObject(forKey: url.absoluteString)
}
var bookmarkCreationOptions: URL.BookmarkCreationOptions {
#if os(macOS)
return [.withSecurityScope, .securityScopeAllowOnlyReadAccess]

View File

@ -172,6 +172,19 @@ struct Video: Identifiable, Equatable, Hashable {
return streams.first
}
var localStreamImageSystemName: String {
guard localStream != nil else { return "" }
if localStreamIsDirectory {
return "folder"
}
if localStreamIsFile {
return "doc"
}
return "globe"
}
var localStreamIsFile: Bool {
guard let localStream else { return false }
return localStream.localURL.isFileURL
@ -182,6 +195,15 @@ struct Video: Identifiable, Equatable, Hashable {
return !localStream.localURL.isFileURL
}
var localStreamIsDirectory: Bool {
guard let localStream else { return false }
#if os(iOS)
return DocumentsModel.shared.isDirectory(localStream.localURL)
#else
return false
#endif
}
var remoteUrlHost: String? {
localStreamURLComponents?.host
}

View File

@ -22,6 +22,7 @@ extension Defaults.Keys {
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
static let showHome = Key<Bool>("showHome", default: true)
static let showDocuments = Key<Bool>("showDocuments", default: true)
static let showOpenActionsInHome = Key<Bool>("showOpenActionsInHome", default: true)
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)

View File

@ -0,0 +1,82 @@
import SwiftUI
struct DocumentsView: View {
@ObservedObject private var model = DocumentsModel.shared
var body: some View {
BrowserPlayerControls {
ScrollView(.vertical, showsIndicators: false) {
if model.directoryContents.isEmpty {
VStack(alignment: .center, spacing: 20) {
HStack {
Image(systemName: "doc")
Text("No documents")
}
Text("Share files from Finder on a Mac\nor iTunes on Windows")
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.foregroundColor(.secondary)
} else {
ForEach(model.sortedDirectoryContents, id: \.absoluteString) { url in
let video = Video.local(model.replacePrivateVar(url) ?? url)
PlayerQueueRow(
item: PlayerQueueItem(video)
)
.contextMenu {
VideoContextMenuView(video: video)
}
}
.id(model.refreshID)
.transition(.opacity)
}
Color.clear.padding(.bottom, 50)
}
.onAppear {
if model.directoryURL.isNil {
model.goToTop()
}
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
withAnimation {
model.goBack()
}
} label: {
HStack(spacing: 6) {
Label("Go back", systemImage: "chevron.left")
}
}
.transaction { t in t.animation = .none }
.disabled(!model.canGoBack)
}
}
.navigationTitle(model.directoryLabel)
.padding()
.navigationBarTitleDisplayMode(RefreshControl.navigationBarTitleDisplayMode)
.backport
.refreshable {
DispatchQueue.main.async {
self.refresh()
}
}
}
}
func refresh() {
withAnimation {
model.refresh()
}
}
}
struct DocumentsView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
DocumentsView()
}
.injectFixtureEnvironmentObjects()
.navigationViewStyle(.stack)
}
}

View File

@ -14,6 +14,7 @@ struct AppTabNavigation: View {
@EnvironmentObject<ThumbnailsModel> private var thumbnailsModel
@Default(.showHome) private var showHome
@Default(.showDocuments) private var showDocuments
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
@Default(.visibleSections) private var visibleSections
@ -26,6 +27,10 @@ struct AppTabNavigation: View {
homeNavigationView
}
if showDocuments {
documentsNavigationView
}
if !accounts.isEmpty {
if subscriptionsVisible {
subscriptionsNavigationView
@ -73,6 +78,18 @@ struct AppTabNavigation: View {
.tag(TabSelection.home)
}
private var documentsNavigationView: some View {
NavigationView {
LazyView(DocumentsView())
.toolbar { toolbarContent }
}
.tabItem {
Label("Documents", systemImage: "folder")
.accessibility(label: Text("Documents"))
}
.tag(TabSelection.documents)
}
private var subscriptionsNavigationView: some View {
NavigationView {
LazyView(SubscriptionsView())

View File

@ -6,6 +6,7 @@ struct Sidebar: View {
@EnvironmentObject<NavigationModel> private var navigation
@Default(.showHome) private var showHome
@Default(.showDocuments) private var showDocuments
@Default(.visibleSections) private var visibleSections
var body: some View {
@ -48,6 +49,18 @@ struct Sidebar: View {
}
.id("favorites")
}
#if os(iOS)
if showDocuments {
NavigationLink(destination: LazyView(DocumentsView()), tag: TabSelection.documents, selection: $navigation.tabSelection) {
Label("Documents", systemImage: "folder")
.accessibility(label: Text("Documents"))
}
.id("documents")
}
#endif
}
if !accounts.isEmpty {
if visibleSections.contains(.subscriptions),
accounts.app.supportsSubscriptions && accounts.signedIn

View File

@ -29,6 +29,22 @@ struct PlayerQueueRow: View {
var body: some View {
Button {
#if os(iOS)
guard !item.video.localStreamIsDirectory else {
if let url = item.video?.localStream?.localURL {
withAnimation {
DocumentsModel.shared.goToURL(url)
}
}
return
}
#endif
}
if item.video.localStreamIsFile, let url = item.video.localStream?.localURL {
URLBookmarkModel.shared.saveBookmark(url)
}
player.prepareCurrentItemForHistory()
player.avPlayerBackend.startPictureInPictureOnPlay = player.playingInPictureInPicture

View File

@ -14,6 +14,7 @@ struct BrowsingSettings: View {
@Default(.channelOnThumbnail) private var channelOnThumbnail
@Default(.timeOnThumbnail) private var timeOnThumbnail
@Default(.showHome) private var showHome
@Default(.showDocuments) private var showDocuments
@Default(.showFavoritesInHome) private var showFavoritesInHome
@Default(.showOpenActionsInHome) private var showOpenActionsInHome
@Default(.showOpenActionsToolbarItem) private var showOpenActionsToolbarItem
@ -85,6 +86,7 @@ struct BrowsingSettings: View {
}
}
.multilineTextAlignment(.trailing)
if !accounts.isEmpty {
Toggle("Show Favorites", isOn: $showFavoritesInHome)
@ -124,6 +126,8 @@ struct BrowsingSettings: View {
Toggle("Show Open Videos toolbar button", isOn: $showOpenActionsToolbarItem)
#endif
#if os(iOS)
Toggle("Show Documents", isOn: $showDocuments)
Toggle("Lock portrait mode", isOn: $lockPortraitWhenBrowsing)
.onChange(of: lockPortraitWhenBrowsing) { lock in
if lock {

View File

@ -22,7 +22,7 @@ struct VideoBanner: View {
var body: some View {
HStack(alignment: stackAlignment, spacing: 12) {
VStack(spacing: thumbnailStackSpacing) {
ZStack(alignment: .bottom) {
smallThumbnail
#if !os(tvOS)
@ -55,6 +55,20 @@ struct VideoBanner: View {
if let video {
if !video.isLocal || video.localStreamIsRemoteURL {
Text(video.displayAuthor)
} else {
#if os(iOS)
if DocumentsModel.shared.isDocument(video) {
HStack(spacing: 6) {
if let date = DocumentsModel.shared.formattedCreationDate(video) {
Text(date)
}
if let size = DocumentsModel.shared.formattedSize(video) {
Text("")
Text(size)
}
}
}
#endif
}
} else {
Text("Video Author")
@ -69,8 +83,10 @@ struct VideoBanner: View {
progressView
#endif
Text((videoDuration ?? video?.length ?? 0).formattedAsPlaybackTime() ?? PlayerTimeModel.timePlaceholder)
.fontWeight(.light)
if !(video?.localStreamIsDirectory ?? false) {
Text(videoDurationLabel)
.fontWeight(.light)
}
}
.foregroundColor(.secondary)
}
@ -98,24 +114,15 @@ struct VideoBanner: View {
#endif
}
private var thumbnailStackSpacing: Double {
#if os(tvOS)
8
#else
2
#endif
}
@ViewBuilder private var smallThumbnail: some View {
Group {
ZStack {
Color("PlaceholderColor")
if let video {
if let thumbnail = video.thumbnailURL(quality: .medium) {
WebImage(url: thumbnail, options: [.lowPriority])
.resizable()
} else if video.localStreamIsFile {
Image(systemName: "folder")
} else if video.localStreamIsRemoteURL {
Image(systemName: "globe")
} else if video.isLocal {
Image(systemName: video.localStreamImageSystemName)
}
} else {
Image(systemName: "ellipsis")
@ -146,6 +153,11 @@ struct VideoBanner: View {
#endif
}
private var videoDurationLabel: String {
guard videoDuration != 0 else { return PlayerTimeModel.timePlaceholder }
return (videoDuration ?? video?.length ?? 0).formattedAsPlaybackTime() ?? PlayerTimeModel.timePlaceholder
}
private var progressView: some View {
Group {
if !playbackTime.isNil, !(video?.live ?? false) {
@ -157,11 +169,13 @@ struct VideoBanner: View {
}
private var progressViewValue: Double {
[playbackTime?.seconds, videoDuration].compactMap { $0 }.min() ?? 0
guard videoDuration != 0 else { return 1 }
return [playbackTime?.seconds, videoDuration].compactMap { $0 }.min() ?? 0
}
private var progressViewTotal: Double {
videoDuration ?? video?.length ?? 1
guard videoDuration != 0 else { return 1 }
return videoDuration ?? video?.length ?? 1
}
}

View File

@ -288,11 +288,7 @@ struct ControlsBar: View {
Group {
if let video = model.currentItem?.video, video.isLocal {
if video.localStreamIsFile {
Image(systemName: "folder")
} else if video.localStreamIsRemoteURL {
Image(systemName: "globe")
}
Image(systemName: video.localStreamImageSystemName)
} else {
Image(systemName: "play.rectangle")
}

View File

@ -37,54 +37,64 @@ struct VideoContextMenuView: View {
}
@ViewBuilder var contextMenu: some View {
if saveHistory {
Section {
if let watchedAtString {
Text(watchedAtString)
}
if !video.localStreamIsDirectory {
if saveHistory {
Section {
if let watchedAtString {
Text(watchedAtString)
}
if !watch.isNil, !watch!.finished, !watchingNow {
continueButton
}
if !watch.isNil, !watch!.finished, !watchingNow {
continueButton
}
if !(watch?.finished ?? false) {
markAsWatchedButton
}
if !(watch?.finished ?? false) {
markAsWatchedButton
}
if !watch.isNil, !watchingNow {
removeFromHistoryButton
if !watch.isNil, !watchingNow {
removeFromHistoryButton
}
}
}
Section {
playNowButton
#if !os(tvOS)
playNowInPictureInPictureButton
playNowInMusicMode
#endif
}
Section {
playNextButton
addToQueueButton
}
if accounts.app.supportsUserPlaylists, accounts.signedIn, !video.isLocal {
Section {
addToPlaylistButton
addToLastPlaylistButton
if let id = navigation.tabSelection?.playlistID ?? playlistID {
removeFromPlaylistButton(playlistID: id)
}
}
}
}
Section {
playNowButton
#if !os(tvOS)
playNowInPictureInPictureButton
playNowInMusicMode
Section {
ShareButton(contentItem: .init(video: video))
}
#endif
}
Section {
playNextButton
addToQueueButton
}
if accounts.app.supportsUserPlaylists, accounts.signedIn, !video.isLocal {
Section {
addToPlaylistButton
addToLastPlaylistButton
if let id = navigation.tabSelection?.playlistID ?? playlistID {
removeFromPlaylistButton(playlistID: id)
#if os(iOS)
if video.isLocal, let url = video.localStream?.localURL, DocumentsModel.shared.isDocument(url) {
Section {
removeDocumentButton
}
}
}
#if !os(tvOS)
Section {
ShareButton(contentItem: .init(video: video))
}
#endif
if !inChannelView, !inChannelPlaylistView, !video.isLocal {
@ -215,6 +225,31 @@ struct VideoContextMenuView: View {
}
}
#if os(iOS)
private var removeDocumentButton: some View {
Button {
if let url = video.localStream?.localURL {
NavigationModel.shared.presentAlert(
Alert(
title: Text("Are you sure you want to remove this document?"),
primaryButton: .destructive(Text("Remove")) {
do {
try DocumentsModel.shared.removeDocument(url)
} catch {
NavigationModel.shared.presentAlert(title: "Could not delete document", message: error.localizedDescription)
}
},
secondaryButton: .cancel()
)
)
}
} label: {
Label("Remove...", systemImage: "trash.fill")
.foregroundColor(Color("AppRedColor"))
}
}
#endif
private var openChannelButton: some View {
Button {
NavigationModel.openChannel(

View File

@ -304,6 +304,8 @@
37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
37484C3226FCB8F900287258 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
37484C3326FCB8F900287258 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
37494EA529200B14000DF176 /* DocumentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37494EA429200B14000DF176 /* DocumentsView.swift */; };
37494EA729200E0B000DF176 /* DocumentsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37494EA629200E0B000DF176 /* DocumentsModel.swift */; };
374AB3D728BCAF0000DF56FB /* SeekModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374AB3D628BCAF0000DF56FB /* SeekModel.swift */; };
374AB3D828BCAF0000DF56FB /* SeekModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374AB3D628BCAF0000DF56FB /* SeekModel.swift */; };
374AB3D928BCAF0000DF56FB /* SeekModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374AB3D628BCAF0000DF56FB /* SeekModel.swift */; };
@ -1093,6 +1095,8 @@
37484C2826FC83FF00287258 /* AccountForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountForm.swift; sourceTree = "<group>"; };
37484C2C26FC844700287258 /* InstanceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSettings.swift; sourceTree = "<group>"; };
37484C3026FCB8F900287258 /* AccountValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidator.swift; sourceTree = "<group>"; };
37494EA429200B14000DF176 /* DocumentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsView.swift; sourceTree = "<group>"; };
37494EA629200E0B000DF176 /* DocumentsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentsModel.swift; sourceTree = "<group>"; };
3749BF6F27ADA135000480FF /* client.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = client.h; sourceTree = "<group>"; };
3749BF7027ADA135000480FF /* stream_cb.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = stream_cb.h; sourceTree = "<group>"; };
3749BF7127ADA135000480FF /* qthelper.hpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.h; path = qthelper.hpp; sourceTree = "<group>"; };
@ -1821,6 +1825,14 @@
path = Settings;
sourceTree = "<group>";
};
37494EA329200AD4000DF176 /* Documents */ = {
isa = PBXGroup;
children = (
37494EA429200B14000DF176 /* DocumentsView.swift */,
);
path = Documents;
sourceTree = "<group>";
};
3749BF6C27ADA135000480FF /* mpv */ = {
isa = PBXGroup;
children = (
@ -2022,6 +2034,7 @@
37D4B0C12671614700C925CA /* Shared */ = {
isa = PBXGroup;
children = (
37494EA329200AD4000DF176 /* Documents */,
3788AC2126F683AB00F6BAA9 /* Home */,
3761AC0526F0F96100AA496F /* Modifiers */,
371AAE2326CEB9E800901972 /* Navigation */,
@ -2124,6 +2137,7 @@
373C8FE3275B955100CB5936 /* CommentsPage.swift */,
37FB28402721B22200A57617 /* ContentItem.swift */,
37141672267A8E10006CA35D /* Country.swift */,
37494EA629200E0B000DF176 /* DocumentsModel.swift */,
37599F2F272B42810087F250 /* FavoriteItem.swift */,
37599F33272B44000087F250 /* FavoritesModel.swift */,
37BC50AB2778BCBA00510953 /* HistoryModel.swift */,
@ -2767,6 +2781,7 @@
buildActionMask = 2147483647;
files = (
374710052755291C00CE0F87 /* SearchField.swift in Sources */,
37494EA529200B14000DF176 /* DocumentsView.swift in Sources */,
374DE88028BB896C0062BBF2 /* PlayerDragGesture.swift in Sources */,
37ECED56289FE166002BC2C9 /* SafeArea.swift in Sources */,
37CEE4BD2677B670005A1EFE /* SingleAssetStream.swift in Sources */,
@ -2883,6 +2898,7 @@
37E2EEAB270656EC00170416 /* BrowserPlayerControls.swift in Sources */,
370B79CC286279BA0045DB77 /* UIViewController+HideHomeIndicator.swift in Sources */,
37E8B0EC27B326C00024006F /* TimelineView.swift in Sources */,
37494EA729200E0B000DF176 /* DocumentsModel.swift in Sources */,
37136CAC286273060095C0CF /* PersistentSystemOverlays+Backport.swift in Sources */,
374C053527242D9F009BDDBE /* SponsorBlockSettings.swift in Sources */,
376A33E42720CB35000C1D6B /* Account.swift in Sources */,

View File

@ -61,5 +61,7 @@
<array>
<string>audio</string>
</array>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</plist>