«Нельзя управлять тем, что нельзя измерить.»— Питер Друкер

Эта статья посвящена созданию Android-приложения для решения практической задачи — отслеживания местоположения коммерческих представителей в полевых условиях. Статья может быть полезна широкому кругу Android-разработчиков как с точки зрения использования готового решения, так и с точки зрения реализации отдельных компонентов приложения для применения в своих проектах.

Работа коммерческого представителя обычно связана с ежедневным посещением клиентов по заранее спланированному маршруту. Для организации, в которой работают такие сотрудники, важно контролировать маршруты, анализировать посещения клиентов и вести отчётность. Одним из решений является разработка специализированного приложения, которое работает на мобильных устройствах представителей и осуществляет фоновый трекинг перемещений с последующей передачей данных на сервер. В текущей реализации серверная часть и API отсутствуют (используется заглушка для последующей доработки).

Архитектура и основные компоненты приложения

Приложение Open Tracker спроектировано для полностью автоматической работы после первоначальной установки и настройки, что минимизирует необходимость взаимодействия с пользователем. Центральным компонентом архитектуры является Service, обеспечивающий долговременную работу в фоне.

На структурной схеме представлены основные компоненты системы и их взаимодействие:

Структурная схема приложения
Структурная схема приложения

Ключевые компоненты:

  1. Service - реализует основную логику приложения и обеспечивает фоновую работу

  2. TimeManager - определяет рабочее время и дни, активируя трекинг только в рабочие периоды для экономии заряда батареи (с возможностью включения постоянного трекинга для тестирования)

  3. LogManager - объединяет логику сбора, хранения и отправки геоданных

  4. LocationManagerNetSenderFileSaver - отвечают соответственно за сбор координат, их отправку и локальное хранение

  5. BroadcastReceiver - обеспечивает автоматический запуск после перезагрузки устройства и обработку изменений системного времени

  6. Activity - реализует пользовательский интерфейс на основе Compose с архитектурой MVVM и навигацией через Compose Navigation

Реализация ключевых функций

Организация фоновой работы

Для долговременной работы в фоне используется ForegroundService, который отображает уведомление, позволяющее пользователю контролировать состояние трекинга. После окончания рабочего времени Service прекращает показ уведомления и может быть уничтожен системой.

Особенностью реализации является комбинированный подход: Service работает как в режиме Started (автономно), так и Bound (с возможностью связи с UI).

    private fun startForeground() {
        val notification = NotificationUtils.getForegroundNotification(this)
        val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
        } else {
            0
        }
        ServiceCompat.startForeground(
            this,
            NotificationConstants.FOREGROUND_NOTIFICATION_ID,
            notification,
            type
        )
    }

    private fun stopForeground() {
        stopForeground(STOP_FOREGROUND_REMOVE)
    }

Периодический запуск трекинга

Для обеспечения периодического выполнения трекинга используется AlarmManager внутри Service. Функция restartTimer принимает два параметра:

  • Время следующего запуска (в миллисекундах)

  • Интервал повторения (в миллисекундах)

Для стабильной работы в фоне используется режим ELAPSED_REALTIME_WAKEUP, который гарантирует пробуждение устройства через заданные интервалы времени. Важное требование — отключение режима оптимизации энергосбережения для приложения.

    private fun restartTimer(
        futureTriggerTime: Long,
        interval: Long = TRACKER_LOCATION_POINT_INTERVAL
    ) {
        val intent = Intent(this, TrackerReceiver::class.java).apply {
            action = TRACKER_TIMER_ACTION
        }

        val tTime = SystemClock.elapsedRealtime() + futureTriggerTime - System.currentTimeMillis()
        val pIntent = PendingIntent.getBroadcast(
            this, TRACK_TIMER_CODE, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        (getSystemService(ALARM_SERVICE) as AlarmManager).setRepeating(
            AlarmManager.ELAPSED_REALTIME_WAKEUP, tTime, interval, pIntent
        )
    }

Дополнительно применяется WakeLock для гарантированного выполнения всех необходимых операций после пробуждения устройства.

    private val lock: WakeLock by lazy {
        (getSystemService(POWER_SERVICE) as PowerManager).run {
            newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_NAME).apply {
                setReferenceCounted(true)
            }
        }
    }

Асинхронная обработка

Все ресурсоемкие операции в Service выполняются с использованием Kotlin Coroutines. Для перехода между обычным и suspend-кодом используется специально настроенный SharedFlow (commands), а для запуска корутин — CoroutineScope (serviceScope).

class TrackerService : Service() {
    private val serviceScope = CoroutineScope(Dispatchers.Main.immediate)
    private val _trackerHistory = MutableStateFlow(emptyList<PositionData>())
    private val _trackerState = MutableStateFlow(TrackerState())
    private val commands = MutableSharedFlow<String>(
        extraBufferCapacity = 5,
        onBufferOverflow = BufferOverflow.DROP_LATEST
    )

Конфигурация SharedFlow обеспечивает контроль над частотой выполнения фоновых задач, предотвращая перегрузку системы. Команды (Action) поступают из Intent, полученного в методе onStartCommandи далее обрабатываются в handleAction.

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        acquireWakeLock(WAKE_LOCK_TIMEOUT)
        handleAction(intent?.action ?: "")
        return START_NOT_STICKY
    }
    private fun handleAction(action: String) = when (action) {
        Intent.ACTION_BOOT_COMPLETED,
        Intent.ACTION_MY_PACKAGE_REPLACED,
        Intent.ACTION_DATE_CHANGED,
        Intent.ACTION_TIME_CHANGED -> handleSystemAction(action)

        TRACKER_CLIENT_BIND -> handleClientBind()
        TRACKER_TIMER_ACTION -> handleTimerAction()
        else -> Unit
    }

В методе handleTimerAction происходит отправка команды через commands на запуск фоновой задачи

commands.tryEmit("start")

и далее выполнение в методе collect.

        serviceScope.launch(handler) {
            commands.collect { startLogging(logManager) }
        }

Сбор и обработка геоданных

Логика работы с геоданными вынесена в отдельный класс LocationManager что позволяет модифицировать её без изменения базовой логики Service. Для сбора координат используется FusedLocationProvider как наиболее энергоэффективное решение.

Основной метод getPoints является suspend-функцией и поддерживает отмену операций, возвращая список собранных геоданных.

    override suspend fun getPoints() = suspendCancellableCoroutine { continuation ->
        start { positions ->
            if (continuation.isActive) {
                continuation.resume(positions)
            }
        }
        continuation.invokeOnCancellation {
            stop()
        }
    }

Процесс сбора данных начинается с вызова метода start, который:

  1. Запускает подписку на обновления местоположения

  2. Ограничивает время сбора константой COLLECT_TIMEOUT (1 минута)

  3. Возвращает накопленные данные по истечении времени или при достижении лимита точек (POSITIONS_LIMIT)

    private fun start(callBack: (List<PositionData>) -> Unit) {
        resultCallback = callBack
        positionList.clear()

        val permResult = runCatching {
            val builder = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000).apply {
                setMinUpdateDistanceMeters(0f)
                setWaitForAccurateLocation(true)
            }
            fusedManager.requestLocationUpdates(builder.build(), locListener, Looper.getMainLooper())
        }

        if (permResult.isSuccess) {
            handler.postDelayed(handleStop, COLLECT_TIMEOUT)
        } else {
            stop()
            sendResult()
        }
    }

Для обеспечения качества данных реализована фильтрация:

  • Отсеивание подложных координат

  • Игнорирование устаревших данных

  • Возможность использования последних известных координат при отсутствии новых

  • Резервный вариант сбора данных GSM вышек (не реализован, но предусмотрена архитектура)

    private fun handleLocationPoint(it: Location) {
        if (isLocationTooOld(it) || isFake(it)) return
        positionList.add(PositionGpsData(gpsLocation = it))
        if (positionList.size > POSITIONS_LIMIT) stopWork()
    }

Сохранение геоданных: надежность и аналитика

Реализация логики сохранения геоданных в классе FileSaver обеспечивает гибкость и возможность адаптации под специфические требования проекта.

Ключевые особенности:

  1. Формат данных

    • Основной метод save является suspend-функцией, принимающей:

      • log — список координат и системных событий (например, изменение системного времени, ошибки GPS и тд.).

      • dirPath — путь для сохранения файлов.

      • makeNow  — признак формирования пакета для отправки.

    • Сохранение не только координат, но и системных событий позволяет:

      • Анализировать работу приложения в фоне.

      • Выявлять потенциальные проблемы (например, отключение GPS пользователем).

  2. Механизм записи

    • Данные сохраняются в файлы с контролируемым жизненным циклом:

      1. Сначала создается временный файл с расширением .wrk.

      2. После интервала TIME_INTERVAL_TO_CHANGE_FILE файл переименовывается в .txt — это гарантирует целостность данных при передаче на сервер.

    • Возвращаемое значение — список готовых к отправке файлов (пакетов).

    override suspend fun save(dirPath: String, log: List<String>, makeNow: Boolean): Result<List<String>> {
        return runCatching {
            if (currentLogFile == null) {
                currentLogFile = makeNewLogFile(dirPath)
            }
            saveToFile(log)

            if (makeNow || System.currentTimeMillis() > nextRenameTime || abs(System.currentTimeMillis() - nextRenameTime) > TIME_INTERVAL_TO_CHANGE_FILE) {
                renameLogFile(dirPath)
                nextRenameTime = System.currentTimeMillis() + TIME_INTERVAL_TO_CHANGE_FILE
            }
            currentLogFile = null

            // Возвращаем список файлов с расширением .txt в директории
            File(dirPath).listFiles()?.filter { it.name.endsWith(DATA_FILE_EXT_TXT) }?.map { it.absolutePath } ?: emptyList()
        }
    }

Отправка пакетов на сервер: архитектура и заглушка

Логика передачи данных на сервер реализована в классе NetSender. В текущей версии проекта API сервера не определён, поэтому класс имитирует отправку пакетов — после обработки файлы удаляются, что позволяет тестировать основной функционал приложения без реального бэкенда.

Ключевые особенности реализации

  1. Гибкость интеграции

    • Класс NetSender спроектирован так, чтобы в будущем его можно было адаптировать под любой протокол (REST, WebSocket, gRPC) или формат данных (JSON, Protobuf).

    • В текущей реализации метод send принимает список файлов и "успешно отправляет" их, после чего удаляет с устройства.

  2. Заглушка вместо реального API

    • Такой подход позволяет:

      • Проверять работу цепочки LoсationManager → FileSaver → NetSender.

      • Тестировать обработку ошибок (например, отсутствие интернета) в будущем.

    Заключение по первой части

    В этой части статьи рассмотрено ядро приложения Service — от сбора геоданных до их сохранения и отправки на сервер. Ключевые особенности решения:

    • Энергоэффективность: Фоновый Service, AlarmManager и оптимизация GPS-запросов.

    • Гибкость: Модульная архитектура (LoсationManager, FileSaver, NetSender) позволяет адаптировать логику под разные бизнес-задачи.

    • Масштабируемость: Заглушки вместо реального API упрощают тестирование и будущую интеграцию с бэкендом.

    Исходный код открыт для доработки

В части 2 статьи будет рассмотрена реализация пользовательского интерфейса приложения.

Комментарии (2)


  1. GuryanovIlya
    22.07.2025 05:46

    Как обрабатываете попытки скама с стороны пользователей?

    Например: отобрать разрешение на работу с координатами в фоне, попытки руками остановить сервис и самое жесткое и не приятное это режим ультра экономии энергии?


    1. colalike Автор
      22.07.2025 05:46

      На личных мобильных устройствах пользователь всегда может сознательно или случайно нарушить работу трекера: отключить разрешения, активировать режим энергосбережения или вовсе удалить приложение. Поскольку это персональное устройство, администрация не может полностью исключить такие ситуации - приложение работает в рамках предоставленных пользователем прав.

      Для корпоративных устройств, управляемых через MDM-системы (Mobile Device Management), эти риски минимизированы. Администратор может:

      • заблокировать изменение критических настроек

      • запретить удаление приложения

      • дистанционно восстановить необходимые разрешения

      С точки зрения трудовой дисциплины, коммерческий представитель должен предоставлять ежедневный отчет о маршруте. Намеренное препятствование работе трекера противоречит его профессиональным обязанностям и может рассматриваться как нарушение.

      Трекер должен фиксировать и передавать на сервер дополнительную информацию: текущий статус разрешений, изменениях системных настроек и тд. Эти данные позволяют отличать сбои от намеренных действий, оказывать адресную помощь сотруднику.