Компания
29,70
рейтинг
10 декабря 2014 в 11:55

Разработка → Ведение независимого времени на android девайсе

Здравствуйте!

В один прекрасный день приходит ко мне менеждер и говорит: «Можем ли мы запретить пользователю менять время на телефоне?». И конечно же ответ мой был нет, но это не решало задачу. Необходимо было искать выход из ситуации.
Критерии для решения были следующими:
  • должно работать без частых синхронизаций с сервером, например, достаточно взять время раз в месяц.
  • должно быть устойчиво к переводу времени назад/вперед/смене часового пояса
  • работать при перезагрузке устройства/неожиданном завершении/ вытаскивании батареи
  • не отклоняться от эталонного времени на слишком большие значения, в моем случае было 5 минут.
  • если все же удалось обмануть, то отслеживать этот момент


Мы сели, подумали, и нашелся другой приемлемый вариант — вести свое с блэкджеком и ... независимое от девайса время.



disclaimer
Данное решение не гарантирует точности до миллисекунд. Допускается погрешность 1-4 минуты.
Не защищено от взлома (обхода) особо продвинутыми юзерами. Если уж на то пошло, ломается все. Рассчитано на среднестатистического обывателя.


Итак, начнем.
Для начала создадим класс, который будет отвечать за хранение времени. В качестве места я выбрал SharedPreferences.
Т.к. тут делаются банальные вещи, то спрячу в спойлер, чтобы не мозолило глаза.
класс SettingsMaster
class SettingsMaster
{
    private static final String FILE_SETTINGS = "prop";
    private static final String LOCAL_TIME = "LOCAL_TIME";
    private static final String SYSTEM_TIME = "SYSTEM_TIME";
    private static final String FLASH_BACK = "FLASH_BACK";


    private static SharedPreferences getPreference(final Context context)
    {
        return context.getSharedPreferences(FILE_SETTINGS, Context.MODE_PRIVATE);
    }

    private static SharedPreferences.Editor getEditor(final Context context)
    {
        return getPreference(context).edit(); 
    }

    public static void setTime(final Context context, final long mls)
    {
        getEditor(context).putLong(LOCAL_TIME, mls).apply();
    }

    
    public static long getTime(final Context context)
    {
        return getPreference(context).getLong(LOCAL_TIME, 0);
    }

    public static void setSystemTime(final Context context, final long mls)
    {
        getEditor(context).putLong(SYSTEM_TIME, mls).apply();
    }

    
    public static long getSystemTime(final Context context)
    {
        return getPreference(context).getLong(SYSTEM_TIME, 0);
    }


    public static void setFlashBack(final Context context, final boolean isFlashback)
    {
        getEditor(context).putBoolean(FLASH_BACK, isFlashback).apply();
    }

    public static boolean isFlashBack(final Context context)
    {
        return getPreference(context).getBoolean(FLASH_BACK, false);
    }
}



Далее будет класс, который предоставляет основное api. Он сохранит и отдаст время, сам запустит таймер, который будет обновлять время.
Тоже все довольно обыденно. Единственное, что тут интересно: при установке серверного времени мы должны сначала остановить таймер, сохранить новое серверное время, а затем вновь запустить.
класс IndependentTimeHelper
public class IndependentTimeHelper
{
    public static void setServerTime(final Context context, final long serverTime)
    {
        stopTimer(context);
        SettingsMaster.setTime(context, serverTime);
        SettingsMaster.setFlashBack(context, false);
        SettingsMaster.setSystemTime(context,System.currentTimeMillis());
        startTimer(context);
    }

   
    static void startTimer(final Context context)
    {
        final Intent intent = new Intent(context, TimeReceiver.class);
        intent.setAction(TimeReceiver.ACTION_TO_UPDATE_TIME);

        if (PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_NO_CREATE) == null)
        {
            final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT);
            final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
            alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + TimeReceiver.TIME_PERIOD, TimeReceiver.TIME_PERIOD, pendingIntent);
        }
    }

    
    static void stopTimer(final Context context)
    {
        final Intent intent = new Intent(context, TimeReceiver.class);
        intent.setAction(TimeReceiver.ACTION_TO_UPDATE_TIME);
        final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_NO_CREATE);
        if (pendingIntent != null)
        {
            final AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
            alarmManager.cancel(pendingIntent);
            pendingIntent.cancel();
        }
    }
    
    public static long getTime(final Context context)
    {
        if (SettingsMaster.isFlashBack(context))
            return -1;

        return SettingsMaster.getTime(context);
    }

}



Перейдем к интересному. Вся основная логика пришлась на ресивер.
Ресивер подписан на три события: стартовать при загрузке, стартовать при выключении, стартовать при обновление времени.

Что должно происходить при обновлении времени — ясно, должно инкрементиться время.
    private void incrementTimeAndSaveSystemTime(final Context context)
    {
        final long localTime = SettingsMaster.getTime(context) + TIME_PERIOD;
        SettingsMaster.setTime(context, localTime);
        SettingsMaster.setSystemTime(context, System.currentTimeMillis());
    }

Значение для TIME_PERIOD было выбрано 30 секунд. И нет, это не влияет на батарею. Приложение, в котором это работает, всегда установлено на моем устройстве, и все клёво.

Следующий шаг — запоминать системное время, чтобы мы могли знать примерное время, которое устройство было выключено.
if (action.equals(Intent.ACTION_SHUTDOWN))
       SettingsMaster.setSystemTime(context, System.currentTimeMillis());


И, наконец, самое важное — вычисление времени, которое девайс находился в выключенном состоянии.
Сначала получим последнее сохраненное время системы
final long systemTime = SettingsMaster.getSystemTime(context);

и вычислим время в выключенном состоянии
final long offTime = System.currentTimeMillis() - systemTime;

если оно меньше или равно нулю, значит, мы наткнулись на перевод времени назад. Перевод вперед нас не особо интересовал, да и отследить его весьма трудно.
if (offTime <= 0)
    SettingsMaster.setFlashBack(context, true);


добавляем его к текущему и запускаем таймер
final long localTime = SettingsMaster.getTime(context);
final long newLocalTime = localTime + offTime;
SettingsMaster.setTime(context, newLocalTime);
IndependentTimeHelper.startTimer(context);

полный код ресивера
public class TimeReceiver extends BroadcastReceiver
{
    public static final String ACTION_TO_UPDATE_TIME = "com.useit.independenttime.ACTION_TO_UPDATE_TIME";
    public static final long TIME_PERIOD = 30 * 1000;

    @Override
    public void onReceive(Context context, Intent intent)
    {
        if (SettingsMaster.getTime(context) <= 0)
        {
            IndependentTimeHelper.stopTimer(context);
            return;
        }

        final String action = intent.getAction();
        if (action.equals(Intent.ACTION_BOOT_COMPLETED))
            startReceiverAfterBootComplete(context);

        if (action.equals(Intent.ACTION_SHUTDOWN))
            SettingsMaster.setSystemTime(context, System.currentTimeMillis());

        if (action.equals(ACTION_TO_UPDATE_TIME))
            incrementTimeAndSaveSystemTime(context);
    }


    private void startReceiverAfterBootComplete(final Context context)
    {
        final long systemTime = SettingsMaster.getSystemTime(context);
        if (systemTime > 0)
        {
            final long offTime = System.currentTimeMillis() - systemTime;

            if (offTime <= 0)
                SettingsMaster.setFlashBack(context, true);

            final long localTime = SettingsMaster.getTime(context);
            final long newLocalTime = localTime + offTime;
            SettingsMaster.setTime(context, newLocalTime);
            IndependentTimeHelper.startTimer(context);
        }

    }


    private void incrementTimeAndSaveSystemTime(final Context context)
    {
        final long localTime = SettingsMaster.getTime(context) + TIME_PERIOD;
        SettingsMaster.setTime(context, localTime);
        SettingsMaster.setSystemTime(context, System.currentTimeMillis());
    }
}



Вот и все. Готово.
Не забываем про добавление разрешения в манифест
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />


Исходники и пример

Итогом стала работающая система ведения времени на устройстве. Да, она не идеальна, но поставленную задачу решает хорошо.

PS. менеджер доволен.
Автор: @andreich
ЕТранспорт
рейтинг 29,70
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Похожие публикации

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

  • 0
    В этом посте прекрасно все. От постановки задачи до решения. Самое главное менеджер доволен, в этом и есть цель любого проекта.
  • +5
    Остаётся один вопрос: зачем?
    • 0
      Все делается с какой-то целью. Есть приложение для постановки задач, которе должно работать оффлайн. В них есть роль менеджера и исполнителя. Так вот, чтобы исполнитель не жульничал со временем, делается такая вещь.
      • 0
        Считать время на сервере?
        • +1
          нельзя, время должно фиксироваться в момент завершения задачи, а не в момент отправки на сервер.
    • +4
      DRM, чтоб его.

      Я пользуюсь несколькими сервисами предоставляющими по подписке видео для просмотра в оффлайне. Т.е. скачал кино или сериал, потом в метро, в отпуске или на даче смотришь.

      Всё вроде ОК, но правообладатели и/или владельцы сервиса ставят ограничения типа:
      1. Устройство должно минимум раз в неделю выходить в интернет
      2. Фильмы можно хранить на устройстве 7, 14 или 28 дней
      3. С момента начала просмотра фильм можно смотреть неограниченно в течение 48 часов, потом он удаляется.
      4. По истечении подписки всё удаляется.
      5. Просмотр разрешён только в одной стране

      Сперва я этого не знал и удивлялся, почему вдруг пропадают фильмы и сериалы, которых я залил на планшет с расчётом посмотреть их в течение недели, ну и проверил каждую серию, воспроизводится ли.

      Обычно помогает обычная перемотка времени назад, а чтобы программа не вела лог с временем, устройство следует переводить в режим самолёта отключая интернет и GPS, процесс приложения после просмотра нужно убивать, а при следующем просмотре выставлять время закрытия приложения. К счастью, они пока ещё не догадываются, или у них нет возможности узнавать время другим способом, кроме системных часов.
      • 0
        > Обычно помогает обычная перемотка времени назад, а чтобы программа не вела лог с временем, устройство следует переводить в режим самолёта отключая интернет и GPS, процесс приложения после просмотра нужно убивать, а при следующем просмотре выставлять время закрытия приложения.

        И они еще удивляются, почему пиратство процветает
        • 0
          С моей точки зрения, пиратство процветает по другой причине. Есть 2 понятия: удобство установки и качество продукта.

          1. Мне удобнее скачать игру в стиме (пусть и заплатив при этом), чем лазать по торрентам/копипастить ключи из кейгенов/любой другой способ.
          2. Я купил IntelliJ IDEA потому, что ребята сделали очень качественный продукт, который стоит своих денег. И это при том, что крякнутые версии вполне себе существуют.

          А вот платить за сомнительное приложение, которое установить не проще, чем сломанное, бессмысленно.
  • 0
    Интересно, а можно вопрос: Я правильно понимаю, что перевод назад можно поймать только если пользователь переведет время до последнего запуска? То есть, выключили программу на два дня, перевели на полтора — программа будет думать что прошло только полдня?
    • 0
      Насколько помню в виндовс была такая методика — как проверка самых часто используемых временных каталогов (вроде tmp) на последнее время файлов. Вот тогда практически невозможно обмануть переводом назад, не знаю насколько такое легко реализовать на андроиде.
    • 0
      Да, если перевести время в момент между включением системы и запуском ресивера, то выйдет так. но это только при перезагрузке устройства.
      • 0
        А, понял, спасибо
  • 0
    Перевод вперед нас не особо интересовал, да и отследить его весьма трудно.

    А вот в играх как раз наоборот, т.к. перевод времени вперед позволяет «ускорять» постройку зданий :)
    • 0
      да, но данный метод обходится только если перевести время в момент включения приложения до запуска ресивера, не все пользователи на это способны.
      • 0
        дуалбут, функция установки времени в рековери, как я понимаю надёжные способы обмануть вашу систему.
        • 0
          Не защищено от взлома (обхода) особо продвинутыми юзерами. Если уж на то пошло, ломается все. Рассчитано на среднестатистического обывателя.

          Ага, все верно
  • +1
    не пробовали принимать во внимание время прошедшее с момента загрузки устройства? В сочетании с текущей датой можно делать довольно далеко идущие выводы и даже про перевод времени вперед. при введении логики которая способна отдавать возможность текущей даты принимая во внимание прошлые показания аптайма и даты можно делать вывод о том можно ли доверять текущему времени или воспользоваться внутренним таймером.
    чтобы это обойти надо будет неплохо заморочаться подбивая перезагрузку с моментом запуска приложения и моментом перевода времени.
    • 0
      Хм, про время старта системы думал, но пока что такой логики вполне хватает, возможно в будущем сделаю и это.
  • +1
    Есть одна особенность, столкнулся с ней недавно. Функция setRepeating начиная с KitKat работает неточно, т.е., аларм может в Вашем случае вызваться в промежутки +[1;2) c. Это может потенциально почти в 2 раза снизить погрешность измерений.
    • 0
      Странно, не замечал. Спасибо, понаблюдаю.
      Но в приложении, где это работает, связь с сервером осуществляется достаточно часто, поэтому время будет постоянно корректироваться.
      • 0
        Я вообще не понимаю что вы написали. setRepeating alarm вообще может не вызываться если девайс спит (то что ваш не спит, это другой вопрос). System.currentTimeMillis() и RTC_WAKEUP это wall clock, то есть я переведу время на 10с назад и все аларм сработает на 10с раньше, чем должен. я вообще могу не трогая ваш код постоянно ускорять или замедлять ход времени у вас написав простенький скрипт. Еще ваш времямер будет работать только если приложение не на карточке. вы всегда делаете incrementTimeAndSaveSystemTime на фиксированный интервал, и пофиг что ночью был спил и мы не вызывались часов 7 подрят, мы просто приплюсуем +=TIME_PERIOD. А зачем вы всегда используете currentTimeMillis? То есть я честный пользователь, приехал к бабушке в другой часовой пояс, а мне ваша система все блокирует и говорит что я хакет и пытаюсь вас обмануть и перевожу часы назад? Почему бы не использовать nanoTime и аларм шедьюлить тоже в nanoTime?
        • 0
          И еще в новых андроидах есть elapsedRealtimeNanos то есть почти все что вы сделали, можно выкинуть, а в старых есть почти полноценная замена uptimeMillis. Его на нерутованном девайсе уже хрен изменишь.
        • +1
          Вот описание параметра RTC_WAKEUP
          Alarm time in System.currentTimeMillis() (wall clock time in UTC), which will wake up the device when it goes off.

          И если кратко, то событие произойдет, даже когда телефон в спящем режиме(режим ожидания).

          Что касается System.currentTimeMillis(), то оно возвращает время в UTC, а значит не зависит от часового пояса.

          то есть я переведу время на 10с назад и все аларм сработает на 10с раньше, чем должен

          Про перевод времени сложнее, нет обоснованного ответа. Но практические тесты показали, что все отлично работает
          • 0
            да ваша правда, стормозил. теперь наш девайс еще и просыпается каждые 30 секунд. ну ладно у нас одно такое приложение. а теперь ставим 10 таких приложений с вашим подходом… да это какоето зло, постоянно просыпаемся, пишем в файловую систему. а если девайс у нас слабенький и андоид решает выгрузить наше приложение… то создаем процесс, грузим в него VM, стартуем потоки, инициализируем все, ставим маленький флажок (читаем и пишем на файловую систему) потом все это прибиваем, выгружаем… а теперь поставим 20 таких приложений, тут уже и хороший девайс начнет прибивать аппы и постоянно их создавать чтобы обработать аларм…
      • 0
        В моем случае промежутки времени были намного больше (30 минут). Я и заметил это только из-за того, что событие не происходило в указанное время. Посмотрите в документацию (setInexactRepeating и setRepeating note), там сейчас это уже явно прописано:
        Your alarm's first trigger will not be before the requested time, but it might not occur for almost a full interval after that time. In addition, while the overall period of the repeating alarm will be as requested, the time between any two successive firings of the alarm may vary. If your application demands very low jitter, use one-shot alarms with an appropriate window instead; see setWindow(int, long, long, PendingIntent) and setExact(int, long, PendingIntent).

        Note: as of API 19, all repeating alarms are inexact. If your application needs precise delivery times then it must use one-time exact alarms, rescheduling each time as described above. Legacy applications whose targetSdkVersion is earlier than API 19 will continue to have all of their alarms, including repeating alarms, treated as exact.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка