Эх сурвалжийг харах

Merge pull request #554 from deltachat/location_streaming

Location streaming
bjoern 5 жил өмнө
parent
commit
e49acdac98

+ 4 - 0
deltachat-ios.xcodeproj/project.pbxproj

@@ -87,6 +87,7 @@
 		305FE03623A81B4C0053BE90 /* PaddingLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 305FE03523A81B4C0053BE90 /* PaddingLabel.swift */; };
 		3060119C22DDE24000C1CE6F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3060119E22DDE24000C1CE6F /* Localizable.strings */; };
 		306011B622E5E7FB00C1CE6F /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 306011B422E5E7FB00C1CE6F /* Localizable.stringsdict */; };
+		307D822E241669C7006D2490 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 307D822D241669C7006D2490 /* LocationManager.swift */; };
 		3095A351237DD1F700AB07F7 /* MediaPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3095A350237DD1F700AB07F7 /* MediaPicker.swift */; };
 		30A4D9AE2332672700544344 /* QrInviteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30A4D9AD2332672600544344 /* QrInviteViewController.swift */; };
 		30C0D49D237C4908008E2A0E /* CertificateCheckController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30C0D49C237C4908008E2A0E /* CertificateCheckController.swift */; };
@@ -309,6 +310,7 @@
 		306011C722E5E82E00C1CE6F /* lt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = lt; path = lt.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
 		306011C822E5E83100C1CE6F /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
 		306011C922E5E83500C1CE6F /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
+		307D822D241669C7006D2490 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = "<group>"; };
 		3095A350237DD1F700AB07F7 /* MediaPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPicker.swift; sourceTree = "<group>"; };
 		30A4D9AD2332672600544344 /* QrInviteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QrInviteViewController.swift; sourceTree = "<group>"; };
 		30AC265E237F1807002A943F /* AvatarHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarHelper.swift; sourceTree = "<group>"; };
@@ -784,6 +786,7 @@
 				302B84C42396627F001C261F /* RelayHelper.swift */,
 				AE1988A423EB2FBA00B4CD5F /* Errors.swift */,
 				AEFBE23023FF09B20045327A /* TypeAlias.swift */,
+				307D822D241669C7006D2490 /* LocationManager.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -1243,6 +1246,7 @@
 				7092474120B3869500AF8799 /* ContactDetailViewController.swift in Sources */,
 				300C50A1234BDAB800F8AE22 /* TextMediaMessageSizeCalculator.swift in Sources */,
 				30F9B9EC235F2116006E7ACF /* MessageCounter.swift in Sources */,
+				307D822E241669C7006D2490 /* LocationManager.swift in Sources */,
 				305961F12346125100C80F33 /* ContactMessageCell.swift in Sources */,
 				AE851AD0227DF50900ED86F0 /* GroupChatDetailViewController.swift in Sources */,
 				305961D12346125100C80F33 /* Bundle+Extensions.swift in Sources */,

+ 2 - 0
deltachat-ios/AppDelegate.swift

@@ -20,6 +20,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
     private let dcContext = DcContext()
     var appCoordinator: AppCoordinator!
     var relayHelper: RelayHelper!
+    var locationManager: LocationManager!
     // static let appCoordinatorDeprecated = AppCoordinatorDeprecated()
     static var progress: Float = 0 // TODO: delete
     static var lastErrorString: String?
@@ -84,6 +85,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
         appCoordinator = AppCoordinator(window: window, dcContext: dcContext)
         appCoordinator.start()
         RelayHelper.setup(dcContext)
+        locationManager = LocationManager(context: dcContext)
         UIApplication.shared.setMinimumBackgroundFetchInterval(UIApplication.backgroundFetchIntervalMinimum)
         start()
         setStockTranslations()

+ 23 - 0
deltachat-ios/Assets.xcassets/ic_location.imageset/Contents.json

@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "filename" : "ic_location_on_white_24pt_1x.png",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "ic_location_on_white_24pt_2x.png",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "ic_location_on_white_24pt_3x.png",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

BIN
deltachat-ios/Assets.xcassets/ic_location.imageset/ic_location_on_white_24pt_1x.png


BIN
deltachat-ios/Assets.xcassets/ic_location.imageset/ic_location_on_white_24pt_2x.png


BIN
deltachat-ios/Assets.xcassets/ic_location.imageset/ic_location_on_white_24pt_3x.png


+ 1 - 1
deltachat-ios/Controller/ChatListController.swift

@@ -216,7 +216,7 @@ extension ChatListController: UITableViewDataSource, UITableViewDelegate {
 
         cell.subtitleLabel.text = result
         cell.setTimeLabel(summary.timestamp)
-        cell.setStatusIndicators(unreadCount: unreadMessages, status: summary.state, visibility: chat.visibility)
+        cell.setStatusIndicators(unreadCount: unreadMessages, status: summary.state, visibility: chat.visibility, isLocationStreaming: chat.isSendingLocations)
         return cell
     }
 

+ 48 - 4
deltachat-ios/Controller/ChatViewController.swift

@@ -149,9 +149,8 @@ class ChatViewController: MessagesViewController {
         // this will be removed in viewWillDisappear
         navigationController?.navigationBar.addGestureRecognizer(navBarTap)
 
-        let chat = DcChat(id: chatId)
         if showCustomNavBar {
-            updateTitle(chat: chat)
+            updateTitle(chat: DcChat(id: chatId))
         }
 
         configureMessageMenu()
@@ -175,7 +174,7 @@ class ChatViewController: MessagesViewController {
                     }
                 }
                 if self.showCustomNavBar {
-                    self.updateTitle(chat: chat)
+                    self.updateTitle(chat: DcChat(id: self.chatId))
                 }
             }
         }
@@ -233,6 +232,15 @@ class ChatViewController: MessagesViewController {
         stopTimer()
     }
 
+    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
+        coordinator.animate(alongsideTransition: { (_) -> Void in
+            if self.showCustomNavBar, let titleView = self.navigationItem.titleView as? ChatTitleView {
+                titleView.hideLocationStreamingIndicator() }},
+                            completion: { (_) -> Void in
+                                self.updateTitle(chat: DcChat(id: self.chatId)) })
+        super.viewWillTransition(to: size, with: coordinator)
+    }
+
     private func updateTitle(chat: DcChat) {
         let titleView =  ChatTitleView()
 
@@ -250,7 +258,7 @@ class ChatViewController: MessagesViewController {
             }
         }
         
-        titleView.updateTitleView(title: chat.name, subtitle: subtitle)
+        titleView.updateTitleView(title: chat.name, subtitle: subtitle, isLocationStreaming: chat.isSendingLocations)
         navigationItem.titleView = titleView
 
         let badge: InitialsBadge
@@ -1094,11 +1102,16 @@ extension ChatViewController: MessagesLayoutDelegate {
         let cameraAction = PhotoPickerAlertAction(title: String.localized("camera"), style: .default, handler: cameraButtonPressed(_:))
         let documentAction = UIAlertAction(title: String.localized("documents"), style: .default, handler: documentActionPressed(_:))
         let voiceMessageAction = UIAlertAction(title: String.localized("voice_message"), style: .default, handler: voiceMessageButtonPressed(_:))
+        let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
+        let locationStreamingAction = UIAlertAction(title: isLocationStreaming ? String.localized("stop_sharing_location") : String.localized("location"),
+                                                    style: isLocationStreaming ? .destructive : .default,
+                                                    handler: locationStreamingButtonPressed(_:))
 
         alert.addAction(cameraAction)
         alert.addAction(galleryAction)
         alert.addAction(documentAction)
         alert.addAction(voiceMessageAction)
+        alert.addAction(locationStreamingAction)
         alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
         self.present(alert, animated: true, completion: nil)
     }
@@ -1119,6 +1132,37 @@ extension ChatViewController: MessagesLayoutDelegate {
         coordinator?.showPhotoVideoLibrary(delegate: self)
     }
 
+    private func locationStreamingButtonPressed(_ action: UIAlertAction) {
+        let isLocationStreaming = dcContext.isSendingLocationsToChat(chatId: chatId)
+        if isLocationStreaming {
+            locationStreamingFor(seconds: 0)
+        } else {
+            let alert = UIAlertController(title: String.localized("title_share_location"), message: nil, preferredStyle: .safeActionSheet)
+            addDurationSelectionAction(to: alert, key: "share_location_for_5_minutes", duration: Time.fiveMinutes)
+            addDurationSelectionAction(to: alert, key: "share_location_for_30_minutes", duration: Time.thirtyMinutes)
+            addDurationSelectionAction(to: alert, key: "share_location_for_one_hour", duration: Time.oneHour)
+            addDurationSelectionAction(to: alert, key: "share_location_for_two_hours", duration: Time.twoHours)
+            addDurationSelectionAction(to: alert, key: "share_location_for_six_hours", duration: Time.sixHours)
+            alert.addAction(UIAlertAction(title: String.localized("cancel"), style: .cancel, handler: nil))
+            self.present(alert, animated: true, completion: nil)
+        }
+    }
+
+    private func addDurationSelectionAction(to alert: UIAlertController, key: String, duration: Int) {
+        let action = UIAlertAction(title: String.localized(key), style: .default, handler: { _ in
+            self.locationStreamingFor(seconds: duration)
+        })
+        alert.addAction(action)
+    }
+
+    private func locationStreamingFor(seconds: Int) {
+            guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else {
+                return
+            }
+            self.dcContext.sendLocationsToChat(chatId: self.chatId, seconds: seconds)
+            appDelegate.locationManager.shareLocation(chatId: self.chatId, duration: seconds)
+    }
+
 }
 
 // MARK: - MessageCellDelegate

+ 16 - 0
deltachat-ios/DC/Wrapper.swift

@@ -221,6 +221,18 @@ class DcContext {
     func imex(what: Int32, directory: String) {
         dc_imex(contextPointer, what, directory, nil)
     }
+
+    func isSendingLocationsToChat(chatId: Int) -> Bool {
+        return dc_is_sending_locations_to_chat(contextPointer, UInt32(chatId)) == 1
+    }
+
+    func sendLocationsToChat(chatId: Int, seconds: Int) {
+        dc_send_locations_to_chat(contextPointer, UInt32(chatId), Int32(seconds))
+    }
+
+    func setLocation(latitude: Double, longitude: Double, accuracy: Double) {
+        dc_set_location(contextPointer, latitude, longitude, accuracy)
+    }
 }
 
 class DcConfig {
@@ -552,6 +564,10 @@ class DcChat {
         }
         return nil
         }()
+
+    var isSendingLocations: Bool {
+        return dc_chat_is_sending_locations(chatPointer) == 1
+    }
 }
 
 class DcArray {

+ 1 - 0
deltachat-ios/Helper/Colors.swift

@@ -14,6 +14,7 @@ struct DcColors {
     static let defaultTextColor = UIColor.themeColor(light: .darkText, dark: .white)
     static let grayTextColor = UIColor.themeColor(light: .darkGray, dark: .lightGray)
     static let grayDateColor = UIColor.themeColor(lightHex: "999999", darkHex: "bbbbbb") // slight variations of lightGray (#aaaaaa)
+    static let middleGray = UIColor(hexString: "848ba7")
     static let secondaryTextColor = UIColor.themeColor(lightHex: "848ba7", darkHex: "a5abc0")
     static let inputFieldColor =  UIColor.themeColor(light: UIColor(red: 245 / 255, green: 245 / 255, blue: 245 / 255, alpha: 1),
                                                      dark: UIColor(red: 10 / 255, green: 10 / 255, blue: 10 / 255, alpha: 1))

+ 11 - 0
deltachat-ios/Helper/Constants.swift

@@ -11,6 +11,8 @@ struct Constants {
         static let deltachatImapPasswordKey = "__DELTACHAT_IMAP_PASSWORD_KEY__"
     }
 
+
+
     static let defaultShadow = UIImage(color: UIColor(hexString: "ff2b82"), size: CGSize(width: 1, height: 1))
     static let onlineShadow = UIImage(color: UIColor(hexString: "3ed67e"), size: CGSize(width: 1, height: 1))
 
@@ -19,3 +21,12 @@ struct Constants {
     static let defaultCellHeight: CGFloat = 48
     static let defaultHeaderHeight: CGFloat = 20
 }
+
+struct Time {
+    static let twoMinutes = 2 * 60
+    static let fiveMinutes = 5 * 60
+    static let thirtyMinutes = 30 * 6
+    static let oneHour = 60 * 60
+    static let twoHours = 2 * 60 * 60
+    static let sixHours = 6 * 60 * 60
+}

+ 132 - 0
deltachat-ios/Helper/LocationManager.swift

@@ -0,0 +1,132 @@
+import Foundation
+import CoreLocation
+
+class LocationManager: NSObject, CLLocationManagerDelegate {
+
+    let locationManager: CLLocationManager
+    let dcContext: DcContext
+    var lastLocation: CLLocation?
+
+    init(context: DcContext) {
+        dcContext = context
+        locationManager = CLLocationManager()
+        locationManager.distanceFilter = 25
+        locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
+        locationManager.allowsBackgroundLocationUpdates = true
+        locationManager.pausesLocationUpdatesAutomatically = false
+        locationManager.activityType = CLActivityType.fitness
+        super.init()
+        locationManager.delegate = self
+
+    }
+
+    func shareLocation(chatId: Int, duration: Int) {
+        dcContext.sendLocationsToChat(chatId: chatId, seconds: duration)
+        if duration > 0 {
+            locationManager.requestAlwaysAuthorization()
+        } else {
+            if !dcContext.isSendingLocationsToChat(chatId: 0) {
+                locationManager.stopUpdatingLocation()
+            }
+        }
+    }
+
+    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
+        logger.debug("LOCATION: didUpdateLocations")
+
+        guard let newLocation = locations.last else {
+            logger.debug("LOCATION: new location is emtpy")
+            return
+        }
+
+        let isBetter = isBetterLocation(newLocation: newLocation, lastLocation: lastLocation)
+        logger.debug("LOCATION: isBetterLocation: \(isBetter)")
+        if isBetter {
+            if dcContext.isSendingLocationsToChat(chatId: 0) {
+                dcContext.setLocation(latitude: newLocation.coordinate.latitude,
+                                      longitude: newLocation.coordinate.longitude,
+                                      accuracy: newLocation.horizontalAccuracy)
+                lastLocation = newLocation
+            } else {
+                locationManager.stopUpdatingLocation()
+            }
+        }
+    }
+
+    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
+        if let error = error as? CLError, error.code == .denied {
+            logger.warning("LOCATION MANAGER: didFailWithError: \(error.localizedDescription)")
+           // Location updates are not authorized.
+           disableLocationStreamingInAllChats()
+           return
+        }
+    }
+
+    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
+        logger.debug("LOCATION MANAGER: didChangeAuthorization: \(status)")
+        switch status {
+        case .denied, .restricted:
+            disableLocationStreamingInAllChats()
+        case .authorizedWhenInUse:
+            logger.warning("Location streaming will only work as long as the app is in foreground.")
+            locationManager.startUpdatingLocation()
+        case .authorizedAlways:
+            locationManager.startUpdatingLocation()
+        default:
+            break
+        }
+    }
+
+    func disableLocationStreamingInAllChats() {
+        if dcContext.isSendingLocationsToChat(chatId: 0) {
+            let dcChatlist = dcContext.getChatlist(flags: 0, queryString: nil, queryId: 0)
+            for i in 0...dcChatlist.length {
+                let chatId = dcChatlist.getChatId(index: i)
+                if dcContext.isSendingLocationsToChat(chatId: chatId) {
+                    dcContext.sendLocationsToChat(chatId: chatId, seconds: 0)
+                }
+            }
+            locationManager.stopUpdatingLocation()
+        }
+    }
+
+    func isBetterLocation(newLocation: CLLocation, lastLocation: CLLocation?) -> Bool {
+        guard let lastLocation = lastLocation else {
+            return !isNewLocationOutdated(newLocation: newLocation) && hasValidAccuracy(newLocation: newLocation)
+        }
+
+        return !isNewLocationOutdated(newLocation: newLocation) &&
+            hasValidAccuracy(newLocation: newLocation) &&
+            (isMoreAccurate(newLocation: newLocation, lastLocation: lastLocation) && hasLocationChanged(newLocation: newLocation, lastLocation: lastLocation) ||
+            hasLocationSignificantlyChanged(newLocation: newLocation, lastLocation: lastLocation))
+    }
+
+    func hasValidAccuracy(newLocation: CLLocation) -> Bool {
+        return newLocation.horizontalAccuracy >= 0
+    }
+
+    func isMoreAccurate(newLocation: CLLocation, lastLocation: CLLocation) -> Bool {
+//        logger.debug("LOCATION: isMoreAccurate \(lastLocation.horizontalAccuracy - newLocation.horizontalAccuracy > 0)")
+        return lastLocation.horizontalAccuracy - newLocation.horizontalAccuracy > 0
+    }
+
+    func hasLocationChanged(newLocation: CLLocation, lastLocation: CLLocation) -> Bool {
+//        logger.debug("LOCATION: hasLocationChanged \(newLocation.distance(from: lastLocation) > 10)")
+        return newLocation.distance(from: lastLocation) > 10
+    }
+
+    func hasLocationSignificantlyChanged(newLocation: CLLocation, lastLocation: CLLocation) -> Bool {
+//        logger.debug("LOCATION: hasLocationSignificantlyChanged \(newLocation.distance(from: lastLocation) > 30)")
+        return newLocation.distance(from: lastLocation) > 30
+    }
+
+    /**
+        Locations can be cached by iOS, timestamp comparison checks if the location has been tracked within the last 5 minutes
+     */
+    func isNewLocationOutdated(newLocation: CLLocation) -> Bool {
+        let timeDelta = DateUtils.getRelativeTimeInSeconds(timeStamp: Double(newLocation.timestamp.timeIntervalSince1970))
+ //       logger.debug("LOCATION: isLocationOutdated timeDelta: \(timeDelta) -> \(Double(Time.fiveMinutes)) -> \(timeDelta < Double(Time.fiveMinutes))")
+        return timeDelta > Double(Time.fiveMinutes)
+    }
+    
+}

+ 2 - 2
deltachat-ios/Helper/Utils.swift

@@ -131,7 +131,7 @@ struct Utils {
         }
 
         do {
-            let timestamp = Int(Date().timeIntervalSince1970)
+            let timestamp = Double(Date().timeIntervalSince1970)
             let path = directory.appendingPathComponent("\(timestamp).jpg")
             try data.write(to: path!)
             return path?.relativePath
@@ -178,7 +178,7 @@ class DateUtils {
     static let day: Double = 86400
     static let year: Double = 365 * day
 
-    private static func getRelativeTimeInSeconds(timeStamp: Double) -> Double {
+    static func getRelativeTimeInSeconds(timeStamp: Double) -> Double {
         let unixTime = Double(Date().timeIntervalSince1970)
         return unixTime - timeStamp
     }

+ 5 - 0
deltachat-ios/Info.plist

@@ -42,6 +42,10 @@
 	<string>Delta Chat uses your camera to take and send photos and videos and to scan QR codes.</string>
 	<key>NSContactsUsageDescription</key>
 	<string>Delta Chat uses your contacts to show a list of email addresses you can write to. Delta Chat has no server, your contacts are not sent anywhere.</string>
+	<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
+	<string>Delta Chat needs the location permission in order to share your location for the timespan you have enabled location sharing.</string>
+	<key>NSLocationWhenInUseUsageDescription</key>
+	<string>Delta Chat needs the location permission in order to share your location for the timespan you have enabled location sharing.</string>
 	<key>NSMicrophoneUsageDescription</key>
 	<string>Delta Chat uses your microphone to record and send voice messages and videos with sound.</string>
 	<key>NSPhotoLibraryUsageDescription</key>
@@ -49,6 +53,7 @@
 	<key>UIBackgroundModes</key>
 	<array>
 		<string>fetch</string>
+		<string>location</string>
 	</array>
 	<key>UIFileSharingEnabled</key>
 	<true/>

+ 44 - 13
deltachat-ios/View/ChatTitleView.swift

@@ -4,6 +4,7 @@ class ChatTitleView: UIView {
 
     private var titleLabel: UILabel = {
         let titleLabel = UILabel()
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
         titleLabel.backgroundColor = UIColor.clear
         titleLabel.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
         titleLabel.textAlignment = .center
@@ -13,11 +14,26 @@ class ChatTitleView: UIView {
 
     private var subtitleLabel: UILabel = {
         let subtitleLabel = UILabel()
+        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
         subtitleLabel.font = UIFont.systemFont(ofSize: 12)
         subtitleLabel.textAlignment = .center
         return subtitleLabel
     }()
 
+    private let locationStreamingIndicator: UIImageView = {
+        let view = UIImageView()
+        view.tintColor = DcColors.checkmarkGreen
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.constraintHeightTo(28).isActive = true
+        view.constraintWidthTo(28).isActive = true
+        view.image = #imageLiteral(resourceName: "ic_location").withRenderingMode(.alwaysTemplate)
+        view.isHidden = true
+        return view
+    }()
+
+    private let paddingNaviationButtons = 120
+    private let sizeStreamingIndicator = 28
+
     init() {
         super.init(frame: .zero)
         setupSubviews()
@@ -29,25 +45,40 @@ class ChatTitleView: UIView {
     }
 
     private func setupSubviews() {
-        addSubview(titleLabel)
-        titleLabel.translatesAutoresizingMaskIntoConstraints = false
-        titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
-        titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
-        titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
-        titleLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
+        let containerView = UIView()
+        containerView.translatesAutoresizingMaskIntoConstraints = false
+        addSubview(containerView)
+        addConstraints([ containerView.constraintAlignTopTo(self),
+                         containerView.constraintAlignBottomTo(self),
+                         containerView.constraintCenterXTo(self),
+                         containerView.constraintWidthTo(UIScreen.main.bounds.width - CGFloat(paddingNaviationButtons + sizeStreamingIndicator)) ])
 
-        addSubview(subtitleLabel)
-        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
-        subtitleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0).isActive = true
-        subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 0).isActive = true
-        subtitleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: 0).isActive = true
-        subtitleLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0).isActive = true
+        containerView.addSubview(titleLabel)
+        containerView.addConstraints([ titleLabel.constraintAlignLeadingTo(containerView),
+                                       titleLabel.constraintAlignTrailingTo(containerView),
+                                       titleLabel.constraintAlignTopTo(containerView) ])
+
+        containerView.addSubview(subtitleLabel)
+        containerView.addConstraints([ subtitleLabel.constraintToBottomOf(titleLabel),
+                                       subtitleLabel.constraintAlignLeadingTo(containerView),
+                                       subtitleLabel.constraintAlignTrailingTo(containerView),
+                                       subtitleLabel.constraintAlignBottomTo(containerView)])
+        addSubview(locationStreamingIndicator)
+        addConstraints([
+                         locationStreamingIndicator.constraintCenterYTo(self),
+                         locationStreamingIndicator.constraintAlignTrailingTo(self),
+                         locationStreamingIndicator.constraintToTrailingOf(containerView)])
     }
 
-    func updateTitleView(title: String, subtitle: String?, baseColor: UIColor = DcColors.defaultTextColor) {
+    func updateTitleView(title: String, subtitle: String?, baseColor: UIColor = DcColors.defaultTextColor, isLocationStreaming: Bool) {
         subtitleLabel.textColor = baseColor.withAlphaComponent(0.95)
         titleLabel.textColor = baseColor
         titleLabel.text = title
         subtitleLabel.text = subtitle
+        locationStreamingIndicator.isHidden = !isLocationStreaming
+    }
+
+    func hideLocationStreamingIndicator() {
+        locationStreamingIndicator.isHidden = true
     }
 }

+ 20 - 8
deltachat-ios/View/ContactCell.swift

@@ -15,7 +15,7 @@ class ContactCell: UITableViewCell {
     private let imgSize: CGFloat = 20
 
     lazy var toplineStackView: UIStackView = {
-        let stackView = UIStackView(arrangedSubviews: [titleLabel, pinnedIndicator, timeLabel])
+        let stackView = UIStackView(arrangedSubviews: [titleLabel, pinnedIndicator, timeLabel, locationStreamingIndicator])
         stackView.axis = .horizontal
         stackView.spacing = 4
         return stackView
@@ -51,7 +51,7 @@ class ContactCell: UITableViewCell {
         view.translatesAutoresizingMaskIntoConstraints = false
         view.heightAnchor.constraint(equalToConstant: 16).isActive = true
         view.widthAnchor.constraint(equalToConstant: 16).isActive = true
-        view.tintColor = UIColor(hexString: "848ba7")
+        view.tintColor = DcColors.middleGray
         view.image = #imageLiteral(resourceName: "pinned_chatlist").withRenderingMode(.alwaysTemplate)
         view.isHidden = true
         return view
@@ -60,16 +60,27 @@ class ContactCell: UITableViewCell {
     private let timeLabel: UILabel = {
         let label = UILabel()
         label.font = UIFont.systemFont(ofSize: 14)
-        label.textColor = UIColor(hexString: "848ba7")
+        label.textColor = DcColors.middleGray
         label.textAlignment = .right
         label.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 2), for: NSLayoutConstraint.Axis.horizontal)
         return label
     }()
 
+    private let locationStreamingIndicator: UIImageView = {
+        let view = UIImageView()
+        view.translatesAutoresizingMaskIntoConstraints = false
+        view.heightAnchor.constraint(equalToConstant: 16).isActive = true
+        view.widthAnchor.constraint(equalToConstant: 16).isActive = true
+        view.tintColor = DcColors.checkmarkGreen
+        view.image = #imageLiteral(resourceName: "ic_location").withRenderingMode(.alwaysTemplate)
+        view.isHidden = true
+        return view
+    }()
+
     let subtitleLabel: UILabel = {
         let label = UILabel()
         label.font = UIFont.systemFont(ofSize: 14)
-        label.textColor = UIColor(hexString: "848ba7")
+        label.textColor = DcColors.middleGray
         label.lineBreakMode = .byTruncatingTail
         label.setContentCompressionResistancePriority(UILayoutPriority(rawValue: 1), for: NSLayoutConstraint.Axis.horizontal)
         return label
@@ -77,7 +88,6 @@ class ContactCell: UITableViewCell {
 
     private let deliveryStatusIndicator: UIImageView = {
         let view = UIImageView()
-        view.tintColor = UIColor.green
         view.isHidden = true
         return view
     }()
@@ -171,7 +181,7 @@ class ContactCell: UITableViewCell {
         avatar.setName(name)
     }
 
-    func setStatusIndicators(unreadCount: Int, status: Int, visibility: Int32) {
+    func setStatusIndicators(unreadCount: Int, status: Int, visibility: Int32, isLocationStreaming: Bool) {
         if visibility==DC_CHAT_VISIBILITY_ARCHIVED {
             pinnedIndicator.isHidden = true
             unreadMessageCounter.isHidden = true
@@ -203,6 +213,8 @@ class ContactCell: UITableViewCell {
             deliveryStatusIndicator.isHidden = deliveryStatusIndicator.image == nil ? true : false
             archivedIndicator.isHidden = true
         }
+
+        locationStreamingIndicator.isHidden = !isLocationStreaming
     }
 
     func setTimeLabel(_ timestamp: Int64?) {
@@ -250,14 +262,14 @@ class ContactCell: UITableViewCell {
             }
             setVerified(isVerified: chat.isVerified)
             setTimeLabel(chatData.summary.timestamp)
-            setStatusIndicators(unreadCount: chatData.unreadMessages, status: chatData.summary.state, visibility: chat.visibility)
+            setStatusIndicators(unreadCount: chatData.unreadMessages, status: chatData.summary.state, visibility: chat.visibility, isLocationStreaming: chat.isSendingLocations)
 
         case .CONTACT(let contactData):
             let contact = DcContact(id: contactData.contactId)
             titleLabel.attributedText = cellViewModel.title.boldAt(indexes: cellViewModel.titleHighlightIndexes, fontSize: titleLabel.font.pointSize)
             avatar.setName(cellViewModel.title)
             avatar.setColor(contact.color)
-            setStatusIndicators(unreadCount: 0, status: 0, visibility: 0)
+            setStatusIndicators(unreadCount: 0, status: 0, visibility: 0, isLocationStreaming: false)
         }
     }
 }

+ 2 - 0
deltachat-ios/en.lproj/InfoPlist.strings

@@ -2,3 +2,5 @@ NSCameraUsageDescription = "Delta Chat uses your camera to take and send photos
 NSContactsUsageDescription = "Delta Chat uses your contacts to show a list of email addresses you can write to. Delta Chat has no server, your contacts are not sent anywhere.";
 NSMicrophoneUsageDescription = "Delta Chat uses your microphone to record and send voice messages and videos with sound.";
 NSPhotoLibraryUsageDescription = "Delta Chat will let you choose which photos from your library to send.";
+NSLocationAlwaysAndWhenInUseUsageDescription = "Delta Chat needs the location permission in order to share your location for the timespan you have enabled location sharing.";
+NSLocationWhenInUseUsageDescription = "Delta Chat needs the location permission in order to share your location for the timespan you have enabled location sharing.";

+ 1 - 0
deltachat-ios/en.lproj/Localizable.strings

@@ -621,3 +621,4 @@
 
 "import_contacts" = "Import device contacts";
 "import_contacts_message" = "To chat with contacts from your device open Settings and enable Contacts.";
+"stop_sharing_location" = "Stop sharing location";

+ 1 - 0
tools/untranslated.xml

@@ -4,4 +4,5 @@
     <!-- iOS specific untranslated strings -->
     <string name="import_contacts">Import device contacts</string>
     <string name="import_contacts_message">To chat with contacts from your device open Settings and enable Contacts.</string>
+    <string name="stop_sharing_location">Stop sharing location</string>
 </resources>