Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/LocationPicker.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

//  Originally based on https://github.com/almassapargali/LocationPicker
//
//  Created by Almas Sapargali on 7/29/15.
//  Parts Copyright (c) 2015 almassapargali. All rights reserved.

import Contacts
import CoreLocation
import CoreServices
public import MapKit
import SignalServiceKit
import SignalUI
import UniformTypeIdentifiers

public protocol LocationPickerDelegate: AnyObject {
    func didPickLocation(_ locationPicker: LocationPicker, location: Location)
    func locationPickerDidCancel()
}

public class LocationPicker: UIViewController {

    public weak var delegate: LocationPickerDelegate?
    public var location: Location? { didSet { updateAnnotation() } }

    private let locationManager = CLLocationManager()
    private let geocoder = CLGeocoder()
    private var localSearch: MKLocalSearch?

    private lazy var mapView = MKMapView()

    private lazy var resultsController: LocationSearchResults = {
        let locationResults = LocationSearchResults()
        locationResults.onSelectLocation = { [weak self] in self?.selectedLocation($0) }
        return locationResults
    }()

    private lazy var searchController: UISearchController = {
        let searchController = UISearchController(searchResultsController: resultsController)
        searchController.searchResultsUpdater = self
        searchController.hidesNavigationBarDuringPresentation = false
        return searchController
    }()

    private static let SearchTermKey = "SearchTermKey"
    private var searchTimer: Timer?

    deinit {
        searchTimer?.invalidate()
        localSearch?.cancel()
        geocoder.cancelGeocode()
    }

    override open func loadView() {
        view = mapView

        let currentLocationButton = UIButton()
        currentLocationButton.backgroundColor = UIColor.black.withAlphaComponent(0.7)
        currentLocationButton.clipsToBounds = true
        currentLocationButton.layer.cornerRadius = 24

        // This icon doesn't look right when it's actually centered due to its odd shape.
        currentLocationButton.setTemplateImageName("location", tintColor: .white)
        currentLocationButton.ows_contentEdgeInsets = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 2)

        view.addSubview(currentLocationButton)
        currentLocationButton.autoSetDimensions(to: CGSize(square: 48))
        currentLocationButton.autoPinEdge(toSuperviewSafeArea: .trailing, withInset: 15)
        currentLocationButton.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 15)

        currentLocationButton.addTarget(self, action: #selector(didPressCurrentLocation), for: .touchUpInside)
    }

    override open func viewDidLoad() {
        super.viewDidLoad()

        title = OWSLocalizedString("LOCATION_PICKER_TITLE", comment: "The title for the location picker view")

        navigationItem.rightBarButtonItem = .button(
            icon: .buttonX,
            style: .plain,
        ) { [weak delegate] in
            delegate?.locationPickerDidCancel()
        }

        locationManager.delegate = self
        mapView.delegate = self

        let searchBar = self.searchController.searchBar
        searchBar.placeholder = OWSLocalizedString(
            "LOCATION_PICKER_SEARCH_PLACEHOLDER",
            comment: "A string indicating that the user can search for a location",
        )

        navigationItem.searchController = searchController
        definesPresentationContext = true

        // Select a new location by long pressing
        let locationSelectGesture = UILongPressGestureRecognizer(target: self, action: #selector(addLocation))
        mapView.addGestureRecognizer(locationSelectGesture)

        // If we don't have location access granted, this does nothing.
        // If we do, this will start the map at the user's current location.
        mapView.showsUserLocation = true
        showCurrentLocation(requestAuthorizationIfNecessary: false)
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.navigationBar.isTranslucent = true
    }

    override public func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(true)
        navigationController?.navigationBar.isTranslucent = true
    }

    @objc
    private func didPressCurrentLocation() {
        showCurrentLocation()
    }

    func showCurrentLocation(requestAuthorizationIfNecessary: Bool = true) {
        if requestAuthorizationIfNecessary { requestAuthorization() }
        locationManager.startUpdatingLocation()
    }

    func requestAuthorization() {
        switch locationManager.authorizationStatus {
        case .authorizedWhenInUse, .authorizedAlways:
            // We are already authorized, do nothing!
            break
        case .notDetermined:
            locationManager.requestWhenInUseAuthorization()
        case .denied, .restricted:
            // The user previous explicitly denied access. Point them to settings to re-enable.
            let alert = ActionSheetController(
                title: OWSLocalizedString(
                    "MISSING_LOCATION_PERMISSION_TITLE",
                    comment: "Alert title indicating the user has denied location permissions",
                ),
                message: OWSLocalizedString(
                    "MISSING_LOCATION_PERMISSION_MESSAGE",
                    comment: "Alert body indicating the user has denied location permissions",
                ),
            )
            let openSettingsAction = ActionSheetAction(
                title: CommonStrings.openSystemSettingsButton,
                style: .default,
            ) { _ in UIApplication.shared.openSystemSettings() }
            alert.addAction(openSettingsAction)

            let dismissAction = ActionSheetAction(title: CommonStrings.dismissButton, style: .cancel, handler: nil)
            alert.addAction(dismissAction)
            presentActionSheet(alert)
        @unknown default:
            owsFailDebug("Unknown")
        }
    }

    func updateAnnotation() {
        mapView.removeAnnotations(mapView.annotations)
        if let location {
            mapView.addAnnotation(location)
            mapView.selectAnnotation(location, animated: true)
        }
    }

    func showCoordinates(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
        // The amount of meters +/- the selected coordinate we want to ensure are visible on screen.
        let metersOffset: CLLocationDistance = 600
        let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: metersOffset, longitudinalMeters: metersOffset)
        mapView.setRegion(region, animated: animated)
    }

    func selectLocation(location: CLLocation) {
        // add point annotation to map
        let annotation = MKPointAnnotation()
        annotation.coordinate = location.coordinate
        mapView.addAnnotation(annotation)

        geocoder.cancelGeocode()
        geocoder.reverseGeocodeLocation(location) { response, error in
            let error = error as NSError?
            let geocodeCanceled = error?.domain == kCLErrorDomain && error?.code == CLError.Code.geocodeCanceled.rawValue

            if let error, !geocodeCanceled {
                // show error and remove annotation
                let alert = ActionSheetController(title: nil, message: error.userErrorDescription)
                alert.addAction(ActionSheetAction(
                    title: CommonStrings.okayButton,
                    style: .cancel,
                    handler: { _ in },
                ))
                self.present(alert, animated: true) {
                    self.mapView.removeAnnotation(annotation)
                }
            } else if let placemark = response?.first {
                // get POI name from placemark if any
                let name = placemark.areasOfInterest?.first

                // pass user selected location too
                self.location = Location(name: name, location: location, placemark: placemark)
            }
        }
    }
}

extension LocationPicker: CLLocationManagerDelegate {
    public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        // The user requested we select their current location, do so and then stop listening for location updates.
        guard let location = locations.first else {
            return owsFailDebug("Unexpectedly received location update with no location")
        }
        // Only animate if this is not the first location we're showing.
        let shouldAnimate = self.location != nil
        showCoordinates(location.coordinate, animated: shouldAnimate)
        selectLocation(location: location)
        manager.stopUpdatingLocation()
    }

    public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        // If location permission was just granted, show the current location
        guard status == .authorizedWhenInUse else { return }
        showCurrentLocation()
    }
}

// MARK: Searching

extension LocationPicker: UISearchResultsUpdating {
    public func updateSearchResults(for searchController: UISearchController) {
        guard let term = searchController.searchBar.text else { return }

        // clear old results
        showItemsForSearchResult(nil)

        searchTimer?.invalidate()
        searchTimer = nil

        let searchTerm = term.trimmingCharacters(in: CharacterSet.whitespaces)
        if !searchTerm.isEmpty {
            // Search after a slight delay to debounce while the user is typing.
            searchTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in
                self?.searchFromTimer(searchTerm)
            }
        }
    }

    private func searchFromTimer(_ term: String) {
        let request = MKLocalSearch.Request()
        request.naturalLanguageQuery = term

        if let location = locationManager.location {
            // If we have a currently selected location, search relative to that location,
            // where the +/- degrees of the region we're searching around is reflected
            // by the latitude and longitude delta below.
            let latlongDelta: CLLocationDegrees = 2

            request.region = MKCoordinateRegion(
                center: location.coordinate,
                span: MKCoordinateSpan(latitudeDelta: latlongDelta, longitudeDelta: latlongDelta),
            )
        }

        localSearch?.cancel()
        localSearch = MKLocalSearch(request: request)
        localSearch?.start { [weak self] response, _ in
            self?.showItemsForSearchResult(response)
        }
    }

    func showItemsForSearchResult(_ searchResult: MKLocalSearch.Response?) {
        resultsController.locations = searchResult?.mapItems.map { Location(name: $0.name, placemark: $0.placemark) } ?? []
        resultsController.tableView.reloadData()
    }

    func selectedLocation(_ location: Location) {
        // dismiss search results
        dismiss(animated: true) {
            // set location, this also adds annotation
            self.location = location
            self.showCoordinates(location.coordinate)
        }
    }
}

// MARK: Selecting location with gesture

extension LocationPicker {
    @objc
    private func addLocation(_ gestureRecognizer: UIGestureRecognizer) {
        if gestureRecognizer.state == .began {
            let point = gestureRecognizer.location(in: mapView)
            let coordinates = mapView.convert(point, toCoordinateFrom: mapView)
            let location = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)
            selectLocation(location: location)
        }
    }
}

// MARK: MKMapViewDelegate

extension LocationPicker: MKMapViewDelegate {
    public func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
        if annotation is MKUserLocation { return nil }

        let pin = MKPinAnnotationView(annotation: annotation, reuseIdentifier: "annotation")
        pin.pinTintColor = Theme.accentBlueColor
        pin.animatesDrop = annotation is MKPointAnnotation
        pin.rightCalloutAccessoryView = sendLocationButton()
        pin.canShowCallout = true
        return pin
    }

    func sendLocationButton() -> UIButton {
        let button = UIButton(type: .system)
        button.setImage(UIImage(imageLiteralResourceName: "send-blue-30"), for: .normal)
        button.sizeToFit()
        return button
    }

    public func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
        if let location {
            delegate?.didPickLocation(self, location: location)
        }

        if let navigation = navigationController, navigation.viewControllers.count > 1 {
            navigation.popViewController(animated: true)
        } else {
            presentingViewController?.dismiss(animated: true, completion: nil)
        }
    }

    public func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
        if let userPin = views.first(where: { $0.annotation is MKUserLocation }) {
            userPin.canShowCallout = false
        }
    }
}

// MARK: UISearchBarDelegate

class LocationSearchResults: UITableViewController {
    var locations: [Location] = []
    var onSelectLocation: ((Location) -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()
        extendedLayoutIncludesOpaqueBars = true
        tableView.backgroundColor = Theme.backgroundColor
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return locations.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "LocationCell")
            ?? UITableViewCell(style: .subtitle, reuseIdentifier: "LocationCell")

        let location = locations[indexPath.row]
        cell.textLabel?.text = location.name
        cell.textLabel?.textColor = Theme.primaryTextColor
        cell.detailTextLabel?.text = location.singleLineAddress
        cell.detailTextLabel?.textColor = Theme.secondaryTextAndIconColor
        cell.backgroundColor = Theme.backgroundColor

        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        onSelectLocation?(locations[indexPath.row])
    }
}

public class Location: NSObject {
    public let name: String?

    // difference from placemark location is that if location was reverse geocoded,
    // then location point to user selected location
    public let location: CLLocation
    public let placemark: CLPlacemark

    static let postalAddressFormatter = CNPostalAddressFormatter()

    public var address: String? {
        guard let postalAddress = placemark.postalAddress else {
            return nil
        }
        return Location.postalAddressFormatter.string(from: postalAddress)
    }

    public var singleLineAddress: String? {
        guard let formattedAddress = address else {
            return nil
        }
        let addressLines = formattedAddress.components(separatedBy: .newlines)
        return ListFormatter.localizedString(byJoining: addressLines)
    }

    public var urlString: String {
        return "https://maps.google.com/maps?q=\(coordinate.latitude)%2C\(coordinate.longitude)"
    }

    enum LocationError: Error {
        case assertion
    }

    @MainActor
    private func generateSnapshot() async throws -> UIImage {
        let options = MKMapSnapshotter.Options()

        // this is the plus/minus meter range from the given coordinate that we'd
        // like to capture in our map snapshot.
        let metersOffset: CLLocationDistance = 300

        options.region = MKCoordinateRegion(
            center: self.coordinate,
            latitudinalMeters: metersOffset,
            longitudinalMeters: metersOffset,
        )

        // The output size will be 256 * the device's scale. We don't adjust the
        // scale directly on the options to ensure a consistent size because it
        // produces poor results on some devices.
        options.size = CGSize(square: 256)

        let snapshot = try await MKMapSnapshotter(options: options).start()

        // Draw our location pin on the snapshot

        UIGraphicsBeginImageContextWithOptions(options.size, true, 0)
        snapshot.image.draw(at: .zero)

        let pinView = MKPinAnnotationView(annotation: nil, reuseIdentifier: nil)
        pinView.pinTintColor = Theme.accentBlueColor
        let pinImage = pinView.image

        var point = snapshot.point(for: self.coordinate)

        let pinCenterOffset = pinView.centerOffset
        point.x -= pinView.bounds.size.width / 2
        point.y -= pinView.bounds.size.height / 2
        point.x += pinCenterOffset.x
        point.y += pinCenterOffset.y
        pinImage?.draw(at: point)

        let image = UIGraphicsGetImageFromCurrentImageContext()

        UIGraphicsEndImageContext()

        guard let image else {
            throw OWSAssertionError("image unexpectedly nil")
        }

        return image
    }

    public init(name: String?, location: CLLocation? = nil, placemark: CLPlacemark) {
        self.name = name
        self.location = location ?? placemark.location!
        self.placemark = placemark
    }

    func prepareAttachment() async throws -> SendableAttachment {
        let image = try await generateSnapshot()
        let normalizedImage = try NormalizedImage.forImage(image)
        let previewableAttachment = PreviewableAttachment.imageAttachmentForNormalizedImage(normalizedImage)
        return try await SendableAttachment.forPreviewableAttachment(previewableAttachment, imageQualityLevel: .one)
    }

    public var messageText: String {
        // The message body will look something like:
        //
        // Place Name, 123 Street Name
        //
        // https://maps.google.com/maps

        if let address {
            return address + "\n\n" + urlString
        } else {
            return urlString
        }
    }
}

extension Location: MKAnnotation {

    public var coordinate: CLLocationCoordinate2D {
        return location.coordinate
    }

    public var title: String? {
        if let name {
            return name
        } else if
            let postalAddress = placemark.postalAddress,
            let firstAddressLine = Location.postalAddressFormatter.string(from: postalAddress).components(separatedBy: .newlines).first
        {
            return firstAddressLine
        } else {
            return "\(coordinate.latitude), \(coordinate.longitude)"
        }
    }
}