Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature/open_in] Open in another app (via UIDocumentInteractionController) #132

Merged
merged 34 commits into from
Nov 30, 2018

Conversation

pablocarmu
Copy link
Contributor

@pablocarmu pablocarmu commented Oct 24, 2018

Description

I coded a new action in the card view controller that is launched through the 3 dots in the file list to be able to open in a file in another app.

Related Issue

#130

Motivation and Context

With this PR you're able to open one item in another app

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist:

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • All new and existing tests passed.

BUGS & IMPROVEMENTS

@@ -51,6 +51,8 @@ class ClientQueryViewController: UITableViewController, Themeable {
private var observerContext : UnsafeMutableRawPointer
var refreshController: UIRefreshControl?

var interactionController: UIDocumentInteractionController?

// MARK: - Init & Deinit
public init(core inCore: OCCore, query inQuery: OCQuery) {
observerContext = UnsafeMutableRawPointer(&observerContextValue)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pablocarmu I checked the code and I think observerContext is not really required, since it is used when e.g. you observe two objects for the change of same keypath and want to differentiate between them when the observer iis called.

func openInRow(_ item: OCItem, viewDidAppearHandler: ClientActionVieDidAppearHandler? = nil, completionHandler: ClientActionCompletionHandler? = nil) {

if let progress = self.core?.downloadItem(item, options: nil, resultHandler: { (error, _, _, file) in
if error != nil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pablocarmu If I remember correctly completion handler of downloadItem() is not guaranteed to be called on the main thread, so are we missing dispatch on main thingy here?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pablocarmu I guess I had to wait until you remove [WIP] to start reviewing..

@codecov
Copy link

codecov bot commented Oct 25, 2018

Codecov Report

Merging #132 into master will decrease coverage by 24.67%.
The diff coverage is 8.23%.

Impacted file tree graph

@@             Coverage Diff             @@
##           master     #132       +/-   ##
===========================================
- Coverage   48.84%   24.16%   -24.68%     
===========================================
  Files          91       96        +5     
  Lines        7657     7854      +197     
===========================================
- Hits         3740     1898     -1842     
- Misses       3917     5956     +2039
Impacted Files Coverage Δ
.../Actions/ClientDirectoryPickerViewController.swift 0% <ø> (ø) ⬆️
ownCloud/Client/Actions/NamingViewController.swift 0% <ø> (ø) ⬆️
ownCloud/Client/ClientQueryViewController.swift 0% <0%> (-28.13%) ⬇️
ownCloud/SDK Extensions/OCItem+Extension.swift 3.26% <0%> (-88.82%) ⬇️
ownCloud/Viewer/DisplayHostViewController.swift 0% <0%> (ø) ⬆️
ownCloud/Viewer/DisplayViewController.swift 0% <0%> (ø) ⬆️
...ements/DownloadFileProgressHUDViewController.swift 0% <0%> (ø)
...ient/Actions/Actions+Extensions/OpenInAction.swift 10.25% <10.25%> (ø)
...t/Actions/Actions+Extensions/DuplicateAction.swift 10.52% <10.52%> (ø)
ownCloud/AppDelegate.swift 86.27% <100%> (+2.18%) ⬆️
... and 49 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 2c09514...7edde84. Read the comment docs.

@michaelstingl michaelstingl added the p3-medium Normal priority label Oct 26, 2018
@CLAassistant
Copy link

CLAassistant commented Oct 30, 2018

CLA assistant check
Thank you for your submission, we really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ pablocarmu
❌ felix-schwarz
You have signed the CLA already but the status is still pending? Let us recheck it.

Copy link
Contributor

@felix-schwarz felix-schwarz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did a quick (but not complete) test and code review. Commented about a few findings.

Other than that I also found:

  • the SDK wasn't using the latest master (fixed that with a commit)
  • trying to save an image makes iOS kill the app because NSPhotoLibraryAddUsageDescription is missing from the Info.plist (kind of unexpected for me too, TBH, but… ok):
    2018-10-30 13:52:19.694273+0100 ownCloud[33446:4280224] [access] This app has crashed because it attempted to access privacy-sensitive data without a usage description. The app's Info.plist must contain an NSPhotoLibraryAddUsageDescription key with a string value explaining to the user how the app uses this data.
    bildschirmfoto 2018-10-30 um 13 54 00


let controller = DownloadFileProgressHUDViewController()

if core!.reachabilityMonitor.available {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core.downloadItem will work offline and return the file if the file is already downloaded, so the lack of connectivity shouldn't be a blocker.

Of course, the user should be getting feedback if a file can't be downloaded right now. However, there's no efficient way in the app to do this right now. The approach I'd like to take there is adding an option to OCCore.downloadItem whereby the app can tell the SDK that the download should be cancelled and an appropriate error be returned if network isn't available at the time of the method call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I'll remove the check and leave a TODO and when the SDK has support for this, implement it.

I'll open an issue with the info to track this if you agree with the approach.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

Log.log("Error \(String(describing: error)) downloading \(String(describing: item.path)) in openIn function")
completionHandler?(false)
} else {
controller.dismiss(animated: true, completion: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resultHandler isn't guaranteed to be called on the main thread. So this UI call can (and will) be called on another thread.

Also, there's no guarantee that the resultHandler is called only after OCCore.downloadItem finishes, so this could be called even before controller.present(on: self) further down below.

controller.dismiss(animated: true, completion: {
self.interactionController = UIDocumentInteractionController(url: file!.url)
self.interactionController?.delegate = self
OnMainThread {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UIViewController.dismiss will return on the main thread, so this shouldn't be necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove the main thread call.

})
}
}) {
OnMainThread {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code should already run on the main thread, so this shouldn't be needed.

There's no guarantee that OCCore.downloadItem finishes and returns before the resultHandler is called, so controller.dismiss may already have been called before controller.present.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take the following approach then:

  1. Present the controller.
  2. Call the if let progress = self.core.downloadItem...
  3. Attach the progress if any.
  4. If the completion handler is called before the return, no strange behavior should appear. The controller should be dismissed.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me!

}
}
} else {
OnMainThread {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code should already run on the main thread, so this shouldn't be needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove the main thread call.


class DownloadFileProgressHUDViewController: UIViewController {

private let progressViewSidesConstraintConstant: CGFloat = 20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should look better with more spacing…

Suggested change
private let progressViewSidesConstraintConstant: CGFloat = 20
private let progressViewSidesConstraintConstant: CGFloat = 40

… or, considering iPads, a maximum width.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll test this with an iPad and see how it looks. In case it looks bad, I'll attach new constraints for iPad layouts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

func openInRow(_ item: OCItem, button: UIBarButtonItem? = nil) {

if source == nil {
if !core.reachabilityMonitor.available {
Copy link
Contributor

@felix-schwarz felix-schwarz Oct 30, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please find a good way to fuse this code together with the code in ClientQueryViewController, so it's not as repetitive.

Quick idea: move this (and – in the future – all other actions) code into its own class that takes a core, an item and a viewController to initialize and then allows to perform either actions or presenting an "More" card for the item on another viewController.

At the end, this should allow to also get rid of other "repeatish" code in this file (like optionsBarButtonPressed and openDocumentInteractionController).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been thinking about this and tried a couple of solutions, but I've faced a lot of issues trying to make it work easily.

What I think could be abstracted it's the creation of the card with all the actions and but not the action themselves (or at least not all).

I've tried to make something like this

typealias ActionCompletion = ((_ item: OCItem, _ core: OCCore, _ vcToPresent: UIViewController) -> Void)?

enum ActionType {
	case destructive
	case regular
}

struct Action {
	var name: String
	var type: ActionType
	var completion: ActionCompletion

	init(with name: String, completion: ActionCompletion, type: ActionType) {
		self.name = name
		self.completion = completion
		self.type = type

	}
}

To be able to abstract the actions into an struct, then I created a card using the more style and those actions

	func actionsViewController(with actions: [Action], for item: OCItem, core: OCCore) -> MoreViewController {

		let header = MoreViewHeader(for: item, with: core)
		let tableViewController = MoreStaticTableViewController(style: .grouped)
		let moreViewController: MoreViewController = MoreViewController(item: item, core: core, header: header, viewController: tableViewController)

		var rows: [StaticTableViewRow] = []

		for action in actions {

			var style: StaticTableViewRowButtonStyle
			switch action.type {
			case .destructive:
				style = .destructive
			default:
				style = .plainNonOpaque
			}

			let row: StaticTableViewRow = StaticTableViewRow(buttonWithAction: { (_, _) in
				moreViewController.dismiss(animated: true, completion: {
					action.completion?(item, core, self.vcToPresentIn!)
				})
			}, title: action.name, style: style)

			rows.append(row)
		}

		let title = NSAttributedString(string: "Actions".localized, attributes: [NSAttributedStringKey.font: UIFont.systemFont(ofSize: 20, weight: .heavy)])

		let section = MoreStaticTableViewSection(headerAttributedTitle: title, identifier: "actions-section", rows: rows)

		tableViewController.addSection(section)
		return moreViewController
	}

This works fine for Move and Duplicate because then you can create functions like this one:

func move(completion: (() -> Void)? = nil) -> Action {
		let action = Action(with: "Move".localized, completion: { (item, core, viewController) in

			guard let viewController = viewController as? ClientQueryViewController else {
				return
			}

			let directoryPickerVC = ClientDirectoryPickerViewController(core: core, path: "/", completion: { (selectedDirectory) in

				if let progress = core.move(item, to: selectedDirectory, withName: item.name, options: nil, resultHandler: { (error, _, _, _) in
					if error != nil {
						Log.log("Error \(String(describing: error)) moving \(String(describing: item.path))")
					}
					completion?()
				}) {
					viewController.progressSummarizer?.startTracking(progress: progress)
				}
			})

			let pickerNavigationController = ThemeNavigationController(rootViewController: directoryPickerVC)
			viewController.navigationController?.present(pickerNavigationController, animated: true)
		}, type: .regular)

		return action
	}

But this adds a problem when each view controller that wants the action also wants a custom implementation of what happens on every momment, maybe they want a custom UI or something that with this implementation it's not posible.

So another solution came to my mind, make all the abstraction returns a Result:

enum Result<T> {
	case failure(Error)
	case success(T)
}

and make the call something like:

func move(progress: @escaping (Progress) -> Void, result: (Result<Bool>) -> Void) {}

But this also has the problem of the custom implementation, so I think the best solution pass through abstract the way the card controller is created and create/attach each action inside the view controller it's going to use it. This way we avoid stupid unneded castings inside a common method and gives us the flexibility to insert the actions we want in all the possible scenarios.

I've uploaded a gist with the complete implementation of the first solution I explained here so you can take a look and judge it.

Copy link
Contributor

@felix-schwarz felix-schwarz Nov 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pablocarmu Finally coming around to answer. Thanks a ton for the patience, extensive feedback and thinking on this!

That gist already comes pretty close to what I had in mind last week. I didn't think of actions as their own objects or structs, though. Instead, I'd have tried to simply move over the methods we already have in ClientQueryViewController.

The idea of having actions as fully independent building blocks is appealing, though. And it immediately makes me think of putting them into OCExtensions:

  • a new OCExtension subclass OCActionExtension (or just ActionExtension) could add infos like name and actionType
  • control over where an action is present (i.e. only in the action sheet, or also in the table row actions) could leverage the locations feature of extensions (through different OCExtensionLocationIdentifiers)
  • control over whether an action should be presented for an item could leverage the customMatcher feature of extensions. The same feature could also be used to establish an order of appearance (even taking into account the location of appearance, so f.ex. cards could have a different sorting as table row actions) via the matching score (and sorting by that score).
  • the identifier of the action extension in combination with OCClassSettings and custom matching could be used to allow MDM-side configuration of actions (especially to remove actions). Blocked actions, f.ex. would just not match.
  • a new OCExtensionContext subclass OCActionContext (or just ActionContext) could be used to pass along information required by the actions like items, core, query, viewController, etc. to make matching decisions, but also to present custom UI
  • that context subclass could be extended as needed to accommodate for any special requirements, including completion handlers and results
  • building this on OCExtensions of course also would allow the addition of customer-specific actions without requiring any changes in the app code itself

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a quick PoC of this in a gist

I didn't had time to check if it works inside the app, but I think this almost matches with what you wrote, I'm still need to get better the idea but I think this could be a base to start a discussion about the implementation.

Even if this PR gets more time of what was initially though, I think we should address a correct implementation of the actions.

What do you think about the gist?

Copy link
Contributor

@felix-schwarz felix-schwarz Nov 7, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting the gist together @pablocarmu!

I've forked and extended it (gist here) to include everything I wrote (except OCClassSettings integration, which is just a comment in the spot I'd put it) and test a few ideas.

One of these ideas was to let Action instances directly create StaticTableViewRows and UIContextualActions for use in the UI. The main idea for this was to have convenient access to these through default "builders" in the base Action class, but to also have the flexibility to customize them in the respective Action subclass as needed.

@michaelstingl michaelstingl added p2-high Escalation, on top of current planning, release blocker and removed p3-medium Normal priority labels Oct 30, 2018
@jesmrec
Copy link
Contributor

jesmrec commented Nov 14, 2018


let alertController = UIAlertController(
with: item.name!,
message: "Are you sure you want to delete this item from the server?".localized,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two messages, too, would need to be adapted for multiple files.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

beforeRunHandler?()

let item = context.items[0]
let rootItem = context.items[1]
Copy link
Contributor

@felix-schwarz felix-schwarz Nov 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

items should only contain items to perform the action on, and might have different parent items. Therefore, the parentItem should be retrieved from OCCore dynamically.

I wrote an OCItem category that does this for you:

extension OCItem {
	func parentItem(from core: OCCore, completionHandler: ((_ error: Error?, _ parentItem: OCItem?) -> Void)? = nil) -> OCItem? {
		var parentItem : OCItem?

		if let parentItemIdentifier = self.parentFileID {
			var waitGroup : DispatchGroup?

			if completionHandler == nil {
				waitGroup = DispatchGroup()
				waitGroup?.enter()
			}

			core.retrieveItemFromDatabase(forFileID: parentItemIdentifier) { (error, _, item) in
				if completionHandler == nil {
					parentItem = item
					waitGroup?.leave()
				} else {
					completionHandler?(error, item)
				}
			}

			waitGroup?.wait()
		}

		return parentItem
	}
}

It can be used both synchronous and asynchronously:

 // Synchronous (please avoid if possible)
let parentItem = item.parentItem(from: core)

 // Asynchronous (and preferred)
item.parentItem(from: core) { (_ error, _ parenItem) -> Void in 
    OnMainThread {
        // work with parentItem
    }
}

Same for RenameAction.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made this changed in both places, thanks for the help!

self.completionHandler?(nil)
self.interactionController = UIDocumentInteractionController(url: file!.url)
self.interactionController?.delegate = self
OnMainThread {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This OnMainThread should not be needed as UI completion handler are generally executed on the main thread.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

})
}
}) {
OnMainThread {
Copy link
Contributor

@felix-schwarz felix-schwarz Nov 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This OnMainThread should not be needed as UI completion handler are generally executed on the main thread.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


let controller = DownloadFileProgressHUDViewController()

OnMainThread {
Copy link
Contributor

@felix-schwarz felix-schwarz Nov 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should make it a convention that Action.run() may only be called from the main thread (and log a warning in preRun() if it's called on any other thread, so we catch that early). You can then get rid of OnMainThread here.

override class var name : String { return "Open in".localized }

override class func applicablePosition(forContext: ActionContext) -> ActionPosition {
if forContext.items[0].type == .collection {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in a previous comment, to make Actions truly universally usable throughout the app, it should look at all items. In this case, if any of the items is a .collection, that'd mean this action isn't available.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it depends on the action itself. Of course, a Delete action should delete as many items as you want, but for open in, this action should not be available in multiselection environments because of UIDocumentInteractionController can be only created with one URL and I think we should not open more than one UIDocumentInteractionController. Same for the Rename action.

@@ -307,11 +312,45 @@ class DisplayViewController: UIViewController {
self.noNetworkLabel?.isHidden = true
self.showPreviewButton?.isHidden = false
}

@objc func optionsBarButtonPressed() {
Copy link
Contributor

@felix-schwarz felix-schwarz Nov 15, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be great to see this and moreButtonTapped merged into a single method, f.ex. into a class-level method of the Action class.

pablocarmu and others added 24 commits November 30, 2018 12:47
- Made one Action subclass per action.
- Register the actions in the AppDelegate
- Ask for extensions in ClientQueryViewController.
- Reversed the extensions array because the pction's priority mechanism
needs more work.
- Code new Rename Action.
- Made the actions inside the preview to use the extensions mechanism.
- Use the correct API for get the actions.
OCItem extension.
- Make sure the actions uses all the items when it makes sense.
- Removed some of  main thread calls.
- Removed empty white line to fix the linter warning.
- Removed left-over code from DisplayViewController.
nil) as suggested by @felix-schwarz
- Replace old calls to completionHandlers with these two new functions.
- Removed unneeded code (fixed in the SDK).
- Added code removed while rebasing against.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature: file operation p2-high Escalation, on top of current planning, release blocker
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants