История реверс-инженеринга китайского фитнес-браслета

Купи китайский браслет, разочаруйся в официальном ПО, напиши свое!

a19e525192024d25973173562f432455.png

Эта история ждала своей публикации больше полугода, за это время многое изменилось, прошивки и ПО обновились и многие мои наработки уже устарели.

Предисловие


Активная работа большого количества компаний в области носимой техники и умных часов не оставляла покоя моей душе. Я видел в носимых устройствах с экраном большой потенциал. Нет, я не говорю о подсчете шагов и других фитнес-штуках, они безусловно классные, но пока кроме банальных «Поздравляем! Вы прошли 4 км, сделали 20к+ шагов!» и красивых графиков прогресса и регресса, ничего особенного не придумали.
А вот то, что я могу получать уведомления прямо на дисплей на запястье — это удобно. Если я могу еще и как-то взаимодействовать с ним или с чем-то поблизости нажимая 1–2–3 кнопки — это еще круче.

В очередной раз бороздя просторы aliexpress, я наткнулся на фитнес-браслет iWown i5. Он сразу привлек моё внимание невероятно низкой ценой (на тот момент около 800р с бесплатной доставкой) и наличием OLED дисплея. Внимательно почитав описание продавца и отзывы покупателей, я решил заказать сие чудо.

Заявленные характеристики (перевод описания с aliexpress):

  • Дисплей: OLED
  • Батарея: литий-полимерная
  • Зарядка: стандартная USB зарядка
  • Работа в режиме ожидания: более 72-х часов
  • Размеры: 69.1×15.8×11.2mm
  • Вес: 18g
  • Материал: Ремешок из ABS, стальная застежка
  • Водонепроницаемость: IP55
  • Рабочая температура: -20 ° C ~ + 45 ° C
  • Рабочая температура флеш носителя: -40 ° C ~ + 45 ° C

Возможности:

  • Спортивный монитор: все время записывает шаги и движения, пройденное расстояние и сожженные калории, все цифры рассчитываются с учетом Вашего веса и роста.
  • Мониторинг качества сна: Пока Вы спите, трекер записывает фазы сна, определяя глубокий и быстрый сон, 8 групп бесшумных будильников позволяют будить Вас не тревожа других членов семьи
  • Bluetooth 4.0 low-power беспроводная синхронизация
  • Поддержка синхронизации с PC через USB
  • Защита IP55: защищает устройство под сильным дождем, но не более


и другие «надуманные» плюсы в стиле китайского маркетинга

Меня сильно заинтересовала возможность трекать сон и будить в нужную фазу. Многие мои знакомые покупали недорогие фитнес трекеры именно из-за этой функции и были довольны mi band и тому подобными штуками. Мне в них всегда не хватало экрана, а тут все-в-одном.
В моей работе частенько приходиться разрабатывать простые приложения для Android, я решил, что если мне не хватит функционала родного приложения, напишу своё.

Посылка пришла довольно быстро и я тут же бросился изучать замечательный браслет. После часа игры с приложением Zeroner, которое по инструкции необходимо поставить на свой Android девайс, я понял, что функционал довольно скуден и печален. Zeroner как и все остальные производители делал акцент на подсчет шагов и калорий, выводя красивые графики, имеет функцию поиска телефона (об этом позже расскажу), может оповещать о входящем вызове, о приходе сообщения в facebook и whatsapp и пересылает уведомления с ОДНОГО любого выбранного приложения, которое будет считать как приложение для SMS.
Вибрация у браслета весьма спорная, на форумах пишут что слабовата, некоторые говорят, нормальная. По мне так, можно было бы и по сильнее. У браслета есть реакция на жест «Посмотреть на часы», если посмотреть на браслет как на наручные часы, поднимая руку и сгибая в локте, автоматически включится экран и покажет время или пропущенное уведомление.

5ecf12b954c74a6eab31c27f747daf51.jpg

В общем, не долго думая, я решил писать своё приложение, с уведомлениями, вибрацией и синхронизацией. Забегу вперед, на это ушло 4 выходных дня и несколько долгих вечеров…

К делу


Учитывая что с Bluetooth я не в-синий-зуб-ногой, с дуру решил попытаться перехватить данные, которыми обменивается телефон и браслет. Для этого я полез во вкладку для разработчиков, и включил галку «Включить журнал трансляции HCI Bluetooth». После включения этой опции, весь дамп общения андроида с любыми Bluetooth устройствами складывается в файл /sdcard/Android/data/btsnoop_hci.log (у разных устройств путь может меняться, имя файла вроде всегда одинаковое).
Скачав WireShark я принялся изучать логи общения с браслетом и увидел что-то похожее на это:

bcce1e5e907549e1993177833e2c9b9c.png

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

Так как мой телефон все-же интерпретировал браслет как обычное BLE устройство и показывал его в разделе подключенных устройств, я решил воспользоваться примерами работы с BLE из Android SDK.
Склонировав репозиторий https://github.com/googlesamples/android-BluetoothLeGatt, натравил Android Studio на пупку с исходниками, собрал и запустил приложение. (Ссылка на описание работы Android SDK с Bluetooth LE)

Получилось как на картинках с гитхаба:
1090e27231624e4eb554b3faf5445f3e.png

Запустив сканирование, приложение не увидело устройство. Оказалось, что родное приложение подключившись к браслету не давало BLE найти устройство. Все решилось простым удалением Zeroner, можно было просто отключить, но надежнее снести полностью.

И так, Bluetooth LE — это технология которая строится на устройствах с малым потреблением энергии, используется в новомодных датчиках, метках и многих других устройствах. Основой этой технологии служит Generic Attribute Profile (GATT), это Bluetooth профиль, позволяющий обмениваться маленькими порциями данных, «атрибутами». Не буду долго расписывать как это все работает, на хабре и в инете есть куча информации, которую мне также пришлось перерыть в поисках решений.

Я понадеялся, что все нужные мне данные хранятся в характеристиках и дескрипторах браслета, и я смогу получать и записывать данные безо всяких проблем. Я ошибался…

Тестовое BLE приложение показывало мне всего 4 сервиса:
0000180f-0000–1000–8000–00805f9b34fb
00001800–0000–1000–8000–00805f9b34fb
0000ff20–0000–1000–8000–00805f9b34fb
00001801–0000–1000–8000–00805f9b34fb

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

Далее, я решил, что в слепую действовать не получиться и решил препарировать приложение Zeroner. Накопав в интернете пару онлайн APK декомпиляторов, я скормил им zeroner.apk и получил на выходе 2 zip архива.
Первый был JADX вариант, а воторой содержал результат работы apktool.

Роясь в исходниках я ужасался китайскому коду (хотя в работе я часто с ним сталкиваюсь в виде бэендов для сайтов и сервисов, но он не перестает удивлять своей извилистостью и изобретательностью, но как ни крути, он ужасно тяжело читается)
После долгих изысканий, я наконец наткнулся на файл WristBandDevice.java, который находился по пути com.kunekt/bluetooth.
В этом классе как раз и скрывалась вся работа с устройством, но опять таки, меня ждала засада.
Как позже выяснилось, в предыдущих прошивках браслета использовалось больше сервисов в характеристик (как я ранее и предполагал), но позже, разработчики оставили всего 2, одна на чтение, вторая на запись. Все команды передаются в одном пакете.

Понять как должен выглядеть пакет оказалось не так просто, я решил четко определиться, чего я хочу от браслета в первую очередь, что бы начать прослеживать вызовы функций. А хотел я, отображать кастомные сообщения на браслете.
Не долго думая, я полез в com.kunekt/receiver/CallReceiver.java, так как входящие вызовы отображались очень стабильно и даже русскими символами, я решил что это отличное начало, учитывая что я уже сталкивался с событием входящих вызовов в Android, представление о том, как это может работать уже было.

Открыв файл я увидел это:

Большой кусок китайского кода
public void onReceive(Context context, Intent intent) {
        Log.e(this.TAG, "+++ ON RECEIVE +++");
        switch (((TelephonyManager) context.getSystemService("phone")).getCallState()) {
            case C08571.POSITION_OPEN /*0*/:
                if (ZeronerApplication.newAPI) {
                    BackgroundThreadManager.getInstance().addTask(new WriteOneDataTask(context, WristBandDevice.getInstance(context).setPhoneStatue()));
                }
            case BitmapCacheManagementTask.MESSAGE_INIT_DISK_CACHE /*1*/:
                incomingNumber = intent.getStringExtra("incoming_number");
                Contact contact = getContact(context, incomingNumber);
                if (!WristBandDevice.getInstance(context).isConnected() || !ZeronerApplication.phoneAlert) {
                    return;
                }
                if (ZeronerApplication.newAPI) {
                    this.fMdeviceInfo = jsonToFMdeviceInfo(UserConfig.getInstance(context).getDevicesInfo());
                    if (this.fMdeviceInfo.getModel().indexOf("5+") != -1) {
                        if (UserConfig.getInstance(context).getFont_lib() == 1 || UserConfig.getInstance(context).getFont_lib() == 2 || UserConfig.getInstance(context).getSysFont().equalsIgnoreCase("en") || UserConfig.getInstance(context).getSysFont().equalsIgnoreCase("es")) {
                            if (contact.getDisplayName().length() > 11) {
                                WristBandDevice.getInstance(context).writeWristBandFontLibrary(context, 1, contact.getDisplayName().substring(0, 11));
                            } else if (contact.getDisplayName().length() <= 6 || contact.getDisplayName().length() > 11) {
                                WristBandDevice.getInstance(context).writeWristBandFontLibrary(context, 1, contact.getDisplayName());
                            } else {
                                WristBandDevice.getInstance(context).writeWristBandFontLibrary(context, 1, contact.getDisplayName().substring(0, contact.getDisplayName().length()));
                            }
                        } else if (contact.getDisplayName().length() > 11) {
                            WristBandDevice.getInstance(context).writeWristBandPhoneAlertNew(context, contact.getDisplayName().substring(0, 11));
                        } else if (contact.getDisplayName().length() <= 6 || contact.getDisplayName().length() > 11) {
                            WristBandDevice.getInstance(context).writeWristBandPhoneAlertNew(context, contact.getDisplayName());
                        } else {
                            WristBandDevice.getInstance(context).writeWristBandPhoneAlertNew(context, contact.getDisplayName().substring(0, contact.getDisplayName().length()));
                        }
                    } else if (contact.getDisplayName().length() > 11) {
                        WristBandDevice.getInstance(context).writeWristBandPhoneAlertNew(context, contact.getDisplayName().substring(0, 11));
                    } else if (contact.getDisplayName().length() <= 6 || contact.getDisplayName().length() > 11) {
                        WristBandDevice.getInstance(context).writeWristBandPhoneAlertNew(context, contact.getDisplayName());
                    } else {
                        WristBandDevice.getInstance(context).writeWristBandPhoneAlertNew(context, contact.getDisplayName().substring(0, contact.getDisplayName().length()));
                    }
                } else if (contact.getDisplayName().length() > 11) {
                    WristBandDevice.getInstance(context).writeWristBandPhoneAlert(context, contact.getDisplayName().substring(0, 11));
                } else if (contact.getDisplayName().length() <= 6 || contact.getDisplayName().length() > 11) {
                    WristBandDevice.getInstance(context).writeWristBandPhoneAlert(context, contact.getDisplayName());
                } else {
                    WristBandDevice.getInstance(context).writeWristBandPhoneAlert(context, contact.getDisplayName().substring(0, contact.getDisplayName().length()));
                }
            case BitmapCacheManagementTask.MESSAGE_FLUSH /*2*/:
                if (ZeronerApplication.newAPI) {
                    BackgroundThreadManager.getInstance().addTask(new WriteOneDataTask(context, WristBandDevice.getInstance(context).setPhoneStatue()));
                }
            default:
        }
    }


Тут мы явно видим, что существует 2 варианта API и названия у них очень логичные newAPI, а второе соответственно oldAPI. Во всем этом обилии условий, меня заинтересовала только одна, повторяющаяся строка:
WristBandDevice.getInstance (context).writeWristBandPhoneAlertNew (context, contact.getDisplayName…)

Это было то самое, что я искал. Забегая вперед, скажу, что у iWown есть еще модели i5+ и i6, у них экран больше и соответственно символов помещается больше, для этого и нужны все эти проверки. непонятно почему они не написали класс или что-то вроде того, возможно это шалости декомпелятора, но данный код повторяется во многих местах.
Перейдя к определению этой функции, я увидел это:

    public void writeWristBandPhoneAlertNew(Context context, String displayName) {
        writeAlertNew(context, displayName, 1);
    }

    public void writeWristBandSmsAlertNew(Context context, String displayName) {
        writeAlertNew(context, displayName, 2);
    }

Отлично, используется одна и та же функция для отправки текста, просто с разными параметрами. Все функции со словом New — это как раз наш вариант, потому что как выяснилось выше, API у меня new.

Радостно перейдя к определению функции writeAlertNew, я увидел следующее:

private void writeAlertNew(Context context, String displayName, int type) {
        ArrayList datas = new ArrayList();
        datas.add(Byte.valueOf((byte) type));
        int i = 0;
        while (i < displayName.length()) {
            if (displayName.charAt(i) < '@' || (displayName.charAt(i) < '\u0080' && displayName.charAt(i) > '`')) {
                char e = displayName.charAt(i);
                datas.add(Byte.valueOf((byte) 0));
                for (byte valueOf : PebbleBitmap.fromString(context, String.valueOf(e), 8, 1).data) {
                    datas.add(Byte.valueOf(valueOf));
                }
            } else {
                char c = displayName.charAt(i);
                datas.add(Byte.valueOf((byte) 1));
                for (byte valueOf2 : PebbleBitmap.fromString(context, String.valueOf(c), 16, 1).data) {
                    datas.add(Byte.valueOf(valueOf2));
                }
            }
            i++;
        }
        byte[] data = writeWristBandDataByte(true, form_Header(3, 1), datas);
        for (i = 0; i < data.length; i += 20) {
            byte[] writeData;
            if (i + 20 > data.length) {
                writeData = Arrays.copyOfRange(data, i, data.length);
            } else {
                writeData = Arrays.copyOfRange(data, i, i + 20);
            }
            NewAgreementBackgroundThreadManager.getInstance().addTask(new WriteOneDataTask(context, writeData));
        }
    }

Было понятно, что от профита меня отделяет пара функций, которые используются здесь.
writeWristBandDataByte — формирует пакет с сообщением для браслета, интересно, что есть специальная функция form_Header (3, 1), которая формирует заголовок пакета, по которому браслет понимает чего от него хотят. 3 — это номер группы команд, а 1 — это сама команда

public static byte form_Header(int grp, int cmd) {
        return (byte) (((((byte) grp) & 15) << 4) | (((byte) cmd) & 15));
    }

Функция простая, скопировал себе в проект без изменений. Следующее было это

NewAgreementBackgroundThreadManager.getInstance ().addTask (new WriteOneDataTask (context, writeData));

Как оказалось, ничего необычного, приложение создает поток, в котором постоянно проверяется очередь пакетов на отправку, если в очереди появляется пакет, поток выполняет запись в заданную характеристику устройства, если пакетов больше чем один, она отправляет их с задержкой в 240 миллисекунд.
Далее шло самое непонятное:

PebbleBitmap.fromString (context, String.valueOf (e), 8, 1).data)

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

Исходник класса PebbleBitmap
public class PebbleBitmap {
    public static boolean f1285D;
    public final byte[] data;
    public final UnsignedInteger flags;
    public final short height;
    public int index;
    public int offset;
    public final UnsignedInteger rowLengthBytes;
    public final short width;
    public final short f1286x;
    public final short f1287y;

    static {
        f1285D = true;
    }

    private PebbleBitmap(UnsignedInteger _rowLengthBytes, UnsignedInteger _flags, short _x, short _y, short _width, short _height, byte[] _data) {
        this.offset = 0;
        this.index = 0;
        this.rowLengthBytes = _rowLengthBytes;
        this.flags = _flags;
        this.f1286x = _x;
        this.f1287y = _y;
        this.width = _width;
        this.height = _height;
        this.data = _data;
    }

    public static PebbleBitmap fromString(Context context, String text, int w, int l) {
        TextPaint textPaint = new TextPaint();
        textPaint.setAntiAlias(true);
        textPaint.setTextSize(16.5f);
        if (w == 32) {
            textPaint.setTextAlign(Align.CENTER);
        }
        textPaint.setTypeface(ZeronerApplication.unifont);
        StaticLayout sl = new StaticLayout(text, textPaint, w, Alignment.ALIGN_NORMAL, 1.0f, 0.49f, false);
        int h = sl.getHeight();
        if (h > l * 16) {
            h = l * 16;
        }
        Bitmap newBitmap = Bitmap.createBitmap(w, h, Config.ARGB_8888);
        sl.draw(new Canvas(newBitmap));
        return fromAndroidBitmap(newBitmap);
    }

    public static PebbleBitmap fromAndroidBitmap(Bitmap bitmap) {
        int width = bitmap.getWidth();
        int height = bitmap.getHeight();
        int rowLengthBytes = width / 8;
        ByteBuffer data = ByteBuffer.allocate(rowLengthBytes * height);
        data.order(ByteOrder.LITTLE_ENDIAN);
        StringBuffer stringBuffer = new StringBuffer(StatConstants.MTA_COOPERATION_TAG);
        for (int y = 0; y < height; y++) {
            int[] pixels = new int[width];
            bitmap.getPixels(pixels, 0, width * 2, 0, y, width, 1);
            stringBuffer = new StringBuffer(StatConstants.MTA_COOPERATION_TAG);
            for (int x = 0; x < width; x++) {
                if (pixels[x] == 0) {
                    stringBuffer.append(Constants.VIA_RESULT_SUCCESS);
                    if (f1285D) {
                        stringBuffer.append("-");
                    }
                } else {
                    stringBuffer.append(Constants.VIA_TO_TYPE_QQ_GROUP);
                    if (f1285D) {
                        stringBuffer.append("#");
                    }
                }
            }
            for (int k = 0; k < rowLengthBytes * 8; k += 8) {
                ByteBuffer byteBuffer = data;
                byteBuffer.put(Byte.valueOf((byte) new BigInteger(stringBuffer.substring(k, k + 8), 2).intValue()).byteValue());
            }
            if (f1285D) {
                stringBuffer.append("\n");
            }
            Log.i("info", stringBuffer.toString());
        }
        if (f1285D) {
            System.out.println(stringBuffer.toString());
        }
        if (!(bitmap == null || bitmap.isRecycled())) {
            bitmap.recycle();
        }
        System.gc();
        return new PebbleBitmap(UnsignedInteger.fromIntBits(rowLengthBytes), UnsignedInteger.fromIntBits(DfuSettingsConstants.SETTINGS_DEFAULT_MBR_SIZE), (short) 0, (short) 0, (short) width, (short) height, data.array());
    }

    public static PebbleBitmap fromPng(InputStream paramInputStream) throws IOException {
        return fromAndroidBitmap(BitmapFactory.decodeStream(paramInputStream));
    }
}


После долгого осмысления я пришел к выводу, что fromString создает картинку с буквой используя определенный шрифт (который вшит в приложение), а потом конвертирует пиксели в 0 или 1 в зависимости от заполнения, таким образом, буква О, будет выглядеть примерно так:
00011100
01100011
01100011
01100011
00011100

Не особо вникая в подробности, я скопировал все в свой проект использовав BLE GATT пример от гугла.
И… О чудо!!! Браслет завибрировал! Но вот сообщение не отобразилось, пустая строка и значок входящего вызова.
Оказалось, что куча проверок размеров не спроста, браслет тупо игнорит черезчур длинные сообщения и сообщения, длина которых 11 символов, хотя 12 отображает нормально. Пару часов танцев вокруг этих функций наконец дали результат, я научился отображать и русский и английский текст, а заодно узнал, что в группе сообщений есть несколько режимов работы:

  1. Входящий вызов. Отображается трубка, имя звонящего и браслет вибрирует
  2. Сообщение. Отображается текст и значок конверта. При появлении вибрирует 2 раза
  3. Облачко. Тоже самое что и 2, только вместо конвертика, иконка облачка
  4. Ошибка. Тоже что и 2, что только иконка с восклицательным знаком.

9724975cb18144cc8654da86f1cc290a.jpg

Научив своё приложение пересылать мне уведомления от разных приложений, whatsapp, vk, viber, telegram и других, я решил, что пора научить браслет реагировать на входящие вызовы и уже, в конце-концов, задействовать единственную кнопку для сброса входящих.

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

Все входящие сообщения от браслета, Zeroner перехватывал в специальном классе. входящий пакет имел заголовок группы команд и номер команды, после долгого дебага и тестов я выудил используемые группы, а потом нашел описание в коде Zeroner.

Группы и команды браслета
// HEADER GROUPS //
DEVICE = 0
CONFIG = 1
DATALOG = 2
MSG = 3
PHONE_MSG = 4

// CONFIG = 1 ///
CMD_ID_CONFIG_GET_AC = 5
CMD_ID_CONFIG_GET_BLE = 3
CMD_ID_CONFIG_GET_HW_OPTION = 9
CMD_ID_CONFIG_GET_NMA = 7
CMD_ID_CONFIG_GET_TIME = 1

CMD_ID_CONFIG_SET_AC = 4
CMD_ID_CONFIG_SET_BLE = 2
CMD_ID_CONFIG_SET_HW_OPTION = 8
CMD_ID_CONFIG_SET_NMA = 6
CMD_ID_CONFIG_SET_TIME = 0

// DATALOG = 2 //
CMD_ID_DATALOG_CLEAR_ALL = 2
CMD_ID_DATALOG_GET_BODY_PARAM = 1
CMD_ID_DATALOG_SET_BODY_PARAM = 0

CMD_ID_DATALOG_GET_CUR_DAY_DATA = 7

CMD_ID_DATALOG_START_GET_DAY_DATA = 3
CMD_ID_DATALOG_START_GET_MINUTE_DATA = 5
CMD_ID_DATALOG_STOP_GET_DAY_DATA = 4
CMD_ID_DATALOG_STOP_GET_MINUTE_DATA = 6

// DEVICE = 0 //
CMD_ID_DEVICE_GET_BATTERY = 1
CMD_ID_DEVICE_GET_INFORMATION = 0
CMD_ID_DEVICE_RESE = 2
CMD_ID_DEVICE_UPDATE = 3

// MSG = 3 //
CMD_ID_MSG_DOWNLOAD = 1
CMD_ID_MSG_MULTI_DOWNLOAD_CONTINUE = 3
CMD_ID_MSG_MULTI_DOWNLOAD_END = 4
CMD_ID_MSG_MULTI_DOWNLOAD_START = 2
CMD_ID_MSG_UPLOAD = 0

// PHONE_MSG = 4 //
CMD_ID_PHONE_ALERT = 1
CMD_ID_PHONE_PRESSKEY = 0


Благодаря этому, я смог реализовать полноценную работу с браслетом. Могу получать данные о шагах, о сне. Могу управлять настройками, ставить будильники. Обозначение байт самого пакета удалось достать из классов, сохраняющих данные в БД, все их я реализовал и у себя.

В итоге

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

WiliX iWown for Geek

С тех пор прошло много времени, и у многих после обновления до Android 6, приложение перестало работать. Оно так же не стабильно работает с прошивками браслетов 2-й версии. Но я надеюсь найти время на доработку.

Исходный код выложен на GitHub. Можно форкать и развлекаться как угодно. Все pull-request после review будут приниматься, и после тестов сразу же заливаться на Google Play.

На данный момент приложение умеет:

  • Отображать уведомления от любого приложения
  • Отображать входящий звонок
  • Сбрасывать входящий при нажатии на кнопку
  • Искать телефон если он в зоне действия BT
  • Управлять настройками браслета
  • И некоторые другие мелкие функции

Реализовано подключение к Google Fit для сохранения данных о тренировках, но, как я не ковырял SDK к Fit, перерыл кучу ссылок и форумов, но так и не понял, как заставить фит отображать данные с кастомных устройств. Непонятно тогда, зачем эта функция вообще есть.
Если кто-то работал с Google Fit, и знает как заставить его использовать данные с кастомного сенсора для отображения графиков, расскажите в коментах или напишите мне, пользователи и я будем очень благодарны!

Так же была идея, подключить браслет к Sleep as Adnroid. Собственно для мониторинга сна и покупался браслет. Но, как оказалось, iWown умеет возвращать только продолжительность фаз сна. То есть уже посчитанные данные с акселерометра.
А Sleep as Android требует голые данные с акселерометра, причем с желательной периодичностью в 10 секунд.

В общем итоге. Приглашаю разработчиков и владельцев поддержать проект своим кодом, советами и чем угодно. Оставляйте pull-requist, делайте issue на Github.
Приложение оказалось очень популярно за рубежом, мне часто пишут иностранцы, просят что-то добавить/исправить/перевести.

Кстати, у iWown i5 есть несколько клонов, со схожими прошивками:
Vidonn X5
Harper BFB-301
Excelvan i5

Ссылки
Google Play — iWown for Geek
Репозиторий на GitHub
Обсуждение на 4pda

P.S. Начиная с 5-й версии, в андроидах появилась дополнительная категория в шторке, которая не отображается на экране блокировке.
Может кто-то подскажет, как перенести моё уведомление в эту категорию? Спасибо!

18c723b95ca441a0a34c8d76a9748308.png

© Geektimes