Implement device link revocation UI

This commit is contained in:
Niels Andriesse 2019-11-20 12:06:41 +11:00
parent 3518561814
commit 98fcfce5d1
10 changed files with 309 additions and 36 deletions

View File

@ -560,6 +560,9 @@
B6B226971BE4B7D200860F4D /* ContactsUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6B226961BE4B7D200860F4D /* ContactsUI.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
B6F509971AA53F760068F56A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B6F509951AA53F760068F56A /* Localizable.strings */; };
B6FE7EB71ADD62FA00A6D22F /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6FE7EB61ADD62FA00A6D22F /* PushKit.framework */; };
B80C6B572384A56D00FDBC8B /* DeviceLinksVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80C6B562384A56D00FDBC8B /* DeviceLinksVC.swift */; };
B80C6B592384C4E700FDBC8B /* DeviceNameModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80C6B582384C4E700FDBC8B /* DeviceNameModal.swift */; };
B80C6B5B2384C7F900FDBC8B /* DeviceNameModalDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80C6B5A2384C7F900FDBC8B /* DeviceNameModalDelegate.swift */; };
B8162F0322891AD600D46544 /* FriendRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8162F0222891AD600D46544 /* FriendRequestView.swift */; };
B8162F0522892C5F00D46544 /* FriendRequestViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8162F0422892C5F00D46544 /* FriendRequestViewDelegate.swift */; };
B821F2F82272CED3002C88C0 /* DisplayNameVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B821F2F72272CED3002C88C0 /* DisplayNameVC.swift */; };
@ -1371,6 +1374,9 @@
B6BC3D0C1AA544B100C2907F /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = translations/da.lproj/Localizable.strings; sourceTree = "<group>"; };
B6F509961AA53F760068F56A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = translations/en.lproj/Localizable.strings; sourceTree = "<group>"; };
B6FE7EB61ADD62FA00A6D22F /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = System/Library/Frameworks/PushKit.framework; sourceTree = SDKROOT; };
B80C6B562384A56D00FDBC8B /* DeviceLinksVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLinksVC.swift; sourceTree = "<group>"; };
B80C6B582384C4E700FDBC8B /* DeviceNameModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceNameModal.swift; sourceTree = "<group>"; };
B80C6B5A2384C7F900FDBC8B /* DeviceNameModalDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceNameModalDelegate.swift; sourceTree = "<group>"; };
B8162F0222891AD600D46544 /* FriendRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendRequestView.swift; sourceTree = "<group>"; };
B8162F0422892C5F00D46544 /* FriendRequestViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendRequestViewDelegate.swift; sourceTree = "<group>"; };
B821F2F72272CED3002C88C0 /* DisplayNameVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayNameVC.swift; sourceTree = "<group>"; };
@ -2670,6 +2676,9 @@
children = (
B885D5F3233491AB00EE0D8E /* DeviceLinkingModal.swift */,
B894D0702339D6F300B4D94D /* DeviceLinkingModalDelegate.swift */,
B80C6B562384A56D00FDBC8B /* DeviceLinksVC.swift */,
B80C6B582384C4E700FDBC8B /* DeviceNameModal.swift */,
B80C6B5A2384C7F900FDBC8B /* DeviceNameModalDelegate.swift */,
B894D0742339EDCF00B4D94D /* NukeDataModal.swift */,
B86BD08023399883000F5AE3 /* QRCodeModal.swift */,
B86BD08523399CEF000F5AE3 /* SeedModal.swift */,
@ -3730,6 +3739,7 @@
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
3496957421A301A100DCFE74 /* OWSBackupAPI.swift in Sources */,
34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */,
B80C6B5B2384C7F900FDBC8B /* DeviceNameModalDelegate.swift in Sources */,
340FC8B8204DAC8D007AEB0F /* AddToGroupViewController.m in Sources */,
341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */,
B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */,
@ -3770,6 +3780,7 @@
346E9D5421B040B700562252 /* RegistrationController.swift in Sources */,
340FC8AD204DAC8D007AEB0F /* OWSLinkedDevicesTableViewController.m in Sources */,
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */,
B80C6B592384C4E700FDBC8B /* DeviceNameModal.swift in Sources */,
4C090A1B210FD9C7001FD7F9 /* HapticFeedback.swift in Sources */,
3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */,
34B3F8931E8DF1710035BE1A /* SignalsNavigationController.m in Sources */,
@ -3780,6 +3791,7 @@
3403B95D20EA9527001A1F44 /* OWSContactShareButtonsView.m in Sources */,
34B0796D1FCF46B100E248C2 /* MainAppContext.m in Sources */,
34E3EF101EFC2684007F6822 /* DebugUIPage.m in Sources */,
B80C6B572384A56D00FDBC8B /* DeviceLinksVC.swift in Sources */,
34A8B3512190A40E00218A25 /* MediaAlbumCellView.swift in Sources */,
34D1F0AE1F867BFC0066283D /* OWSMessageCell.m in Sources */,
4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */,

View File

@ -145,7 +145,9 @@ final class DeviceLinkingModal : Modal, DeviceLinkingSessionDelegate {
dismiss(animated: true, completion: nil)
let master = DeviceLink.Device(hexEncodedPublicKey: deviceLink.master.hexEncodedPublicKey, signature: linkingAuthorizationMessage.masterSignature)
let signedDeviceLink = DeviceLink(between: master, and: deviceLink.slave)
LokiStorageAPI.addDeviceLink(signedDeviceLink).catch { error in
LokiStorageAPI.addDeviceLink(signedDeviceLink).done { [weak self] in
self?.delegate?.handleDeviceLinkAuthorized(signedDeviceLink)
}.catch { error in
print("[Loki] Failed to add device link due to error: \(error).")
}
Timer.scheduledTimer(withTimeInterval: 8, repeats: false) { _ in

View File

@ -0,0 +1,192 @@
// MARK: - Device Links View Controller
@objc(LKDeviceLinksVC)
final class DeviceLinksVC : UIViewController, UITableViewDataSource, UITableViewDelegate, DeviceLinkingModalDelegate, DeviceNameModalDelegate {
private var deviceLinks: [DeviceLink] = [] { didSet { updateUI() } }
// MARK: Components
private lazy var tableView: UITableView = {
let result = UITableView()
result.dataSource = self
result.delegate = self
result.register(Cell.self, forCellReuseIdentifier: "Cell")
result.separatorStyle = .none
result.backgroundColor = .clear
return result
}()
private lazy var callToActionView : UIStackView = {
let explanationLabel = UILabel()
explanationLabel.textColor = Theme.primaryColor
explanationLabel.font = UIFont.ows_dynamicTypeSubheadlineClamped
explanationLabel.numberOfLines = 0
explanationLabel.lineBreakMode = .byWordWrapping
explanationLabel.textAlignment = .center
explanationLabel.text = NSLocalizedString("You don't have any linked devices yet", comment: "")
let linkNewDeviceButtonFont = UIFont.ows_dynamicTypeBodyClamped.ows_mediumWeight()
let linkNewDeviceButtonHeight = linkNewDeviceButtonFont.pointSize * 48 / 17
let linkNewDeviceButton = OWSFlatButton.button(title: NSLocalizedString("Link a Device", comment: ""), font: linkNewDeviceButtonFont, titleColor: .lokiGreen(), backgroundColor: .clear, target: self, selector: #selector(linkNewDevice))
linkNewDeviceButton.setBackgroundColors(upColor: .clear, downColor: .clear)
linkNewDeviceButton.autoSetDimension(.height, toSize: linkNewDeviceButtonHeight)
linkNewDeviceButton.button.contentHorizontalAlignment = .left
let result = UIStackView(arrangedSubviews: [ explanationLabel, linkNewDeviceButton ])
result.axis = .vertical
result.spacing = 4
result.alignment = .center
return result
}()
// MARK: Lifecycle
override func viewDidLoad() {
title = NSLocalizedString("Linked Devices", comment: "")
let masterDeviceHexEncodedPublicKey = UserDefaults.standard.string(forKey: "masterDeviceHexEncodedPublicKey")
let isMasterDevice = (masterDeviceHexEncodedPublicKey == nil)
if isMasterDevice {
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(linkNewDevice))
}
view.backgroundColor = Theme.backgroundColor
view.addSubview(tableView)
tableView.pin(to: view)
view.addSubview(callToActionView)
callToActionView.center(in: view)
updateDeviceLinks()
}
// MARK: Data
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return deviceLinks.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell") as! Cell
let deviceLink = deviceLinks[indexPath.row]
cell.deviceLink = deviceLink
return cell
}
// MARK: Updating
private func updateDeviceLinks() {
let storage = OWSPrimaryStorage.shared()
let userHexEncodedPublicKey = OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey
var deviceLinks: [DeviceLink] = []
storage.dbReadConnection.read { transaction in
deviceLinks = storage.getDeviceLinks(for: userHexEncodedPublicKey, in: transaction).sorted { lhs, rhs in
return lhs.other.hexEncodedPublicKey > rhs.other.hexEncodedPublicKey
}
}
self.deviceLinks = deviceLinks
}
private func updateUI() {
tableView.reloadData()
UIView.animate(withDuration: 0.25) {
self.callToActionView.isHidden = !self.deviceLinks.isEmpty
}
}
func handleDeviceLinkAuthorized(_ deviceLink: DeviceLink) {
updateDeviceLinks()
}
func handleDeviceLinkingModalDismissed() {
// Do nothing
}
// MARK: Interaction
@objc private func linkNewDevice() {
if deviceLinks.isEmpty {
let deviceLinkingModal = DeviceLinkingModal(mode: .master, delegate: nil)
deviceLinkingModal.modalPresentationStyle = .overFullScreen
present(deviceLinkingModal, animated: true, completion: nil)
} else {
let alert = UIAlertController(title: NSLocalizedString("Multi Device Limit Reached", comment: ""), message: NSLocalizedString("It's currently not allowed to link more than one device.", comment: ""), preferredStyle: .alert)
alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let deviceLink = deviceLinks[indexPath.row]
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
sheet.addAction(UIAlertAction(title: NSLocalizedString("Change Name", comment: ""), style: .default) { [weak self] _ in
guard let self = self else { return }
let deviceNameModal = DeviceNameModal()
deviceNameModal.deviceLink = deviceLink
deviceNameModal.delegate = self
self.present(deviceNameModal, animated: true, completion: nil)
})
sheet.addAction(UIAlertAction(title: NSLocalizedString("Unlink", comment: ""), style: .destructive) { _ in
// TODO: Implement
})
present(sheet, animated: true, completion: nil)
}
@objc func handleDeviceNameChanged(to name: String, for device: DeviceLink.Device) {
updateUI()
}
}
// MARK: - Cell
private extension DeviceLinksVC {
final class Cell : UITableViewCell {
var deviceLink: DeviceLink! { didSet { update() } }
// MARK: Components
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.textColor = Theme.primaryColor
result.font = UIFont.ows_dynamicTypeSubheadlineClamped
result.lineBreakMode = .byTruncatingTail
return result
}()
private lazy var subtitleLabel: UILabel = {
let result = UILabel()
result.textColor = Theme.primaryColor
result.font = UIFont.ows_dynamicTypeCaption1Clamped
result.lineBreakMode = .byTruncatingTail
return result
}()
// MARK: Initialization
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setUpViewHierarchy()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpViewHierarchy()
}
private func setUpViewHierarchy() {
backgroundColor = .clear
let stackView = UIStackView(arrangedSubviews: [ titleLabel, subtitleLabel ])
stackView.axis = .vertical
stackView.distribution = .equalCentering
stackView.spacing = 4
stackView.set(.height, to: 36)
contentView.addSubview(stackView)
stackView.pin(.leading, to: .leading, of: contentView, withInset: 16)
stackView.pin(.top, to: .top, of: contentView, withInset: 8)
contentView.pin(.trailing, to: .trailing, of: stackView, withInset: 16)
contentView.pin(.bottom, to: .bottom, of: stackView, withInset: 8)
stackView.set(.width, to: UIScreen.main.bounds.width - 2 * 16)
}
// MARK: Updating
private func update() {
if let displayName = deviceLink.displayName {
titleLabel.text = displayName
subtitleLabel.text = deviceLink.other.hexEncodedPublicKey
subtitleLabel.isHidden = false
} else {
titleLabel.text = deviceLink.other.hexEncodedPublicKey
subtitleLabel.isHidden = true
}
}
}
}

View File

@ -0,0 +1,64 @@
@objc(LKDeviceNameModal)
final class DeviceNameModal : Modal {
@objc public var deviceLink: DeviceLink!
@objc public var delegate: DeviceNameModalDelegate?
// MARK: Components
private lazy var nameTextView: UITextField = {
let result = UITextField()
result.textColor = Theme.primaryColor
result.font = .ows_dynamicTypeBodyClamped
let placeholder = NSMutableAttributedString(string: NSLocalizedString("Enter a Name", comment: ""))
placeholder.addAttribute(.foregroundColor, value: Theme.placeholderColor, range: NSRange(location: 0, length: placeholder.length))
result.attributedPlaceholder = placeholder
result.tintColor = .lokiGreen()
result.keyboardAppearance = .dark
return result
}()
// MARK: Lifecycle
override func populateContentView() {
// Label
let titleLabel = UILabel()
titleLabel.textColor = Theme.primaryColor
titleLabel.font = UIFont.ows_dynamicTypeHeadlineClamped
titleLabel.text = NSLocalizedString("Change Device Name", comment: "")
titleLabel.numberOfLines = 0
titleLabel.lineBreakMode = .byWordWrapping
titleLabel.textAlignment = .center
// Explanation label
let explanationLabel = UILabel()
explanationLabel.font = UIFont.ows_dynamicTypeCaption1Clamped
explanationLabel.text = NSLocalizedString("Enter the new display name for your device below", comment: "")
explanationLabel.numberOfLines = 0
explanationLabel.textAlignment = .center
explanationLabel.lineBreakMode = .byWordWrapping
explanationLabel.textColor = UIColor.ows_white
// Button stack view
let okButton = OWSFlatButton.button(title: NSLocalizedString("OK", comment: ""), font: .ows_dynamicTypeBodyClamped, titleColor: .white, backgroundColor: .clear, target: self, selector: #selector(changeName))
okButton.setBackgroundColors(upColor: .clear, downColor: .clear)
let buttonStackView = UIStackView(arrangedSubviews: [ okButton, cancelButton ])
buttonStackView.axis = .horizontal
buttonStackView.distribution = .fillEqually
let buttonHeight = cancelButton.button.titleLabel!.font.pointSize * 48 / 17
okButton.set(.height, to: buttonHeight)
cancelButton.set(.height, to: buttonHeight)
// Stack view
let stackView = UIStackView(arrangedSubviews: [ UIView.spacer(withHeight: 2), titleLabel, explanationLabel, nameTextView, buttonStackView ])
stackView.axis = .vertical
stackView.spacing = 16
contentView.addSubview(stackView)
stackView.pin(.leading, to: .leading, of: contentView, withInset: 16)
stackView.pin(.top, to: .top, of: contentView, withInset: 16)
contentView.pin(.trailing, to: .trailing, of: stackView, withInset: 16)
contentView.pin(.bottom, to: .bottom, of: stackView, withInset: 16)
}
// MARK: Interaction
@objc private func changeName() {
let name = nameTextView.text!.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
UserDefaults.standard.set(name, forKey: "\(deviceLink.other.hexEncodedPublicKey)_display_name")
delegate?.handleDeviceNameChanged(to: name, for: deviceLink.other)
}
}

View File

@ -0,0 +1,5 @@
@objc protocol DeviceNameModalDelegate {
func handleDeviceNameChanged(to name: String, for device: DeviceLink.Device)
}

View File

@ -1,4 +1,3 @@
import NVActivityIndicatorView
@objc(LKNukeDataModal)
final class NukeDataModal : Modal {

View File

@ -45,6 +45,11 @@ extension UIView {
}
}
func center(in view: UIView) {
center(.horizontal, in: view)
center(.vertical, in: view)
}
func set(_ dimension: Dimension, to size: CGFloat) {
translatesAutoresizingMaskIntoConstraints = false
switch dimension {

View File

@ -202,14 +202,13 @@
actionBlock:^{
[weakSelf showNotifications];
}]];
[section addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"Linked Devices", @"")
accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"linked_devices")
actionBlock:^{
[weakSelf showLinkedDevices];
}]];
// Loki: Original code
// ========
// [section addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"LINKED_DEVICES_TITLE",
// @"Menu item and navbar title for the device manager")
// accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"linked_devices")
// actionBlock:^{
// [weakSelf showLinkedDevices];
// }]];
// [section addItem:[OWSTableItem disclosureItemWithText:NSLocalizedString(@"SETTINGS_ADVANCED_TITLE", @"")
// accessibilityIdentifier:ACCESSIBILITY_IDENTIFIER_WITH_NAME(self, @"advanced")
// actionBlock:^{
@ -248,25 +247,6 @@
[section addItem:[OWSTableItem itemWithTitle:NSLocalizedString(@"Share Public Key", @"") actionBlock:^{ [weakSelf sharePublicKey]; }]];
[section addItem:[OWSTableItem itemWithTitle:NSLocalizedString(@"Show QR Code", @"") actionBlock:^{ [weakSelf showQRCode]; }]];
if (isMasterDevice) {
NSString *userHexEncodedPublicKey = OWSIdentityManager.sharedManager.identityKeyPair.hexEncodedPublicKey;
__block BOOL hasLinkedDevice;
[OWSPrimaryStorage.sharedManager.dbReadConnection readWithBlock:^(YapDatabaseReadTransaction *transaction) {
NSSet<LKDeviceLink *> *deviceLinks = [LKDatabaseUtilities getDeviceLinksFor:userHexEncodedPublicKey in:transaction];
hasLinkedDevice = deviceLinks.count > 0;
}];
[section addItem:[OWSTableItem itemWithTitle:NSLocalizedString(@"Link Device", @"") actionBlock:^{
if (!hasLinkedDevice) {
[weakSelf linkDevice];
} else {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:NSLocalizedString(@"Multi Device Limit Reached", @"") message:NSLocalizedString(@"It's currently not allowed to link more than one device.", @"") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:NSLocalizedString(@"OK", @"") style:UIAlertActionStyleDefault handler:nil]];
[weakSelf presentViewController:alert animated:YES completion:nil];
}
}]];
[section addItem:[OWSTableItem itemWithTitle:NSLocalizedString(@"Show Seed", @"") actionBlock:^{ [weakSelf showSeed]; }]];
}
[section addItem:[OWSTableItem itemWithTitle:NSLocalizedString(@"Clear All Data", @"") actionBlock:^{ [weakSelf clearAllData]; }]];
if (TSAccountManager.sharedInstance.isDeregistered) {
@ -429,8 +409,8 @@
- (void)showLinkedDevices
{
OWSLinkedDevicesTableViewController *vc = [OWSLinkedDevicesTableViewController new];
[self.navigationController pushViewController:vc animated:YES];
LKDeviceLinksVC *deviceLinksVC = [LKDeviceLinksVC new];
[self.navigationController pushViewController:deviceLinksVC animated:YES];
}
- (void)showProfile
@ -542,13 +522,6 @@
[self presentViewController:qrCodeModal animated:YES completion:nil];
}
- (void)linkDevice
{
LKDeviceLinkingModal *deviceLinkingModal = [[LKDeviceLinkingModal alloc] initWithMode:@"master" delegate:nil];
deviceLinkingModal.modalPresentationStyle = UIModalPresentationOverFullScreen;
[self presentViewController:deviceLinkingModal animated:YES completion:nil];
}
- (void)showSeed
{
LKSeedModal *seedModal = [LKSeedModal new];

View File

@ -2655,3 +2655,11 @@
"Clear Profile Picture" = "Clear Profile Picture";
"Invalid QR Code" = "Invalid QR Code";
"Please make sure the QR code you scanned is correct and try again." = "Please make sure the QR code you scanned is correct and try again.";
"Linked Devices" = "Linked Devices";
"You don't have any linked devices yet" = "You don't have any linked devices yet";
"Link a Device" = "Link a Device";
"Unlink" = "Unlink";
"Change Name" = "Change Name";
"Change Device Name" = "Change Device Name";
"Enter the new display name for your device below" = "Enter the new display name for your device below";
"Enter a Name" = "Enter a Name";

View File

@ -4,8 +4,21 @@ public final class DeviceLink : NSObject, NSCoding {
@objc public let master: Device
@objc public let slave: Device
@objc public var displayName: String? {
if let customDisplayName = UserDefaults.standard.string(forKey: "\(other.hexEncodedPublicKey)_display_name") {
return customDisplayName
} else {
return Mnemonic.encode(hexEncodedString: other.hexEncodedPublicKey.removing05PrefixIfNeeded()).split(separator: " ")[0..<3].joined(separator: " ")
}
}
@objc public var isAuthorized: Bool { return master.signature != nil }
@objc public var other: Device {
let userHexEncodedPublicKey = OWSIdentityManager.shared().identityKeyPair()!.hexEncodedPublicKey
return (userHexEncodedPublicKey == master.hexEncodedPublicKey) ? slave : master
}
// MARK: Types
@objc(LKDevice)
public final class Device : NSObject, NSCoding {