Як я створив MuBarometer — неочікувано успішний pet-проєкт

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт. Мене звуть Вадим Хохлов, я працюю мобільним розробником, а також викладаю в Херсонському національному технічному університеті. А в якості pet-проєктів я розробляю невеличкі мобільні застосунки. Про один такий проєкт, який став неочікувано для мене доволі успішним, я й хочу розповісти.

Виникнення ідеї

Треба сказати, що розробляти мобільні застосунки я почав задовго до того, як це стало мейнстрімом, ще з 1989 року, коли батьки подарували мені програмований калькулятор МК-61. В цій статті: dev.ua/...​posts/khokhlov-1695328040 я розповідав про свій досвід. Там також є посилання на емулятор калькулятора, тому бажаючі можуть спробувати свої сили (мій внутрішній старий дід зараз бурчіть: «ех молодьожь, молодьожь, от раніше були часи, не те, що зараз).

Якось я побачив програму для цього калькулятора, що по показникам з барометра намагалася робити прогноз погоди. На той час фізичного барометра в мене не було, тому перевірити я її не міг. Історія продовжилася кілька років тому. Одного разу телефон показав мені рекламу програми-барометра для Android. Я зацікавився і встановив її. Вона була цікава, але мала ряд недоліків: мало функціоналу, часті покази реклами і великий розмір. Мені захотілося перевірити, чи зможу я зробити щось подібне, і я вирішив зробити свій барометр. Але, на відміну від робота Бендера, без зайвих шахів та художниць. Майже відразу з’явилась назва проєту «Мікробарометр» чи «μБарометр» та слоган — «Простий, гарний та корисний барометр».

Можливості μБарометр

Я постійно розвиваю і вдосконалюю проєкт. Наразі мій барометр має такі можливості:

  • джерела даних: як вбудований сенсор тиску, так і отримання даних з інтернету;
  • одиниці виміру тиску: мілліБар, мм рт.ст., д рт.ст., атмосфери, гПа;
  • трекінг даних з інтервалом від однієї хвилини до одного дня;
  • альтиметр, що може вимірювати як абсолютну, так і відносну висоту;
  • одиниці виміру висоти: метри та фути;
  • представлення даних в табличній формі чи на графіку;
  • можливість анотацій точок заміру;
  • нотифікації про поточне значення тиску і попередження про різку зміну тиску;
  • експорт/імпорт даних в csv-форматі;
  • три теми оформлення;
  • кілька віджетів для home screen.

Джерела даних

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

Базовий клас виглядає наступним чином (я не показую деякі допоміжні поля і методи для простішого сприйняття коду):

public abstract class DataSource { 
    public interface DataListener { 
        void onValueChanged(double pressure, double temperature); 
        void onError(int messageId, String message); 
    }

    public DataSource(Context context, DataListener valueListener) { 
        mContext = context; 
        mValueListener = valueListener; 
    } 
    … 
    public abstract void start(); 
    public void stop(Context context) {} 
    … 
    protected Context mContext; protected final DataListener mValueListener;
 }

Реалізація класу, що працює з сенсором, не дуже складна:

public class SensorDataSource extends DataSource implements SensorEventListener {
    public SensorDataSource(Context context, DataListener valueListener) {
        super(context, valueListener);
        mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
   }
   @Override
    public void start() {
        mSensorManager.registerListener(this, mSensorManager.getDefaultSensor(Sensor.TYPE_PRESSURE), SensorManager.SENSOR_DELAY_UI);
    }
   @Override
    public void onSensorChanged(SensorEvent event) {
        if (Sensor.TYPE_PRESSURE == event.sensor.getType()) {
            float value = event.values[0];
            mValueListener.onValueChanged((float) value, 0);
       }
    }
    ...
}

Реалізація класу для отримання даних з інтернету більш складна. В якості джерела використовується сервіс www.weatherapi.com. Для мережевих запитів я використовую Retrofit2.

public class NetworkDataSource extends DataSource {
...
 @Override
    public void start() {
       startWithCoord(mLat, mLong);
  }

   public void startWithCoord(double latitude, double longitude) {
        setCoords(latitude, longitude);
        Locale nullLocale = null;
        final String query = String.format(nullLocale, "%f,%f", latitude, longitude);
        mCall = NetworkService.getInstance()
                .api()
                .weatherInfoByQuery(query, mAppId);
        enqueue();
    }

...
    private void enqueue() {
       if (mCall != null) {
          mCall.enqueue(new Callback<JsonElement>() {
               @Override
               public void onResponse(@NonNull Call<JsonElement> call, @NonNull Response<JsonElement> response) {
                   processResponse(response);
               }
               @Override
               public void onFailure(@NonNull Call<JsonElement> call, @NonNull Throwable t) {
                   if (!call.isCanceled()) {
                       processFailure(t);
                   }
               }
           });
       }
    }

    private void processResponse(@NonNull Response<JsonElement> response) {
        JsonElement respBody = response.body();
        ...
       parseBody(respBody.getAsJsonObject());
   }

   private void parseBody(JsonObject jsonObj) {
       JsonObject mainObj = jsonObj.getAsJsonObject("current");
       double temp = mainObj.getAsJsonPrimitive("temp_c").getAsDouble();
       double pressure = mainObj.getAsJsonPrimitive("pressure_mb").getAsDouble();
        ...
        mValueListener.onValueChanged(pressure, temp);
    }
}

Тут я знов-таки пропустив обробку помилок та несуттєві фрагменти коду. Який з цих класів використовувати, визначає об’єкт класу DataSourceManager:

public class DataSourceManager {
    ...
    public boolean setup(SharedPreferences settings) {
        int dsType = Integer.parseInt(settings.getString(PreferencesActivity.PR_KEY_DATA_SOURCE, "-1"));
        mDataSourceType = dsType;
        if (mDataSourceType == kDataSourceSensor) {
                SensorDataSource dataSource = new SensorDataSource(mContext, mValueListener);
                ...
                mDataSource = dataSource;
         } else {
               NetworkDataSource dataSource = new NetworkDataSource(mContext, mValueListener);
               final float lat = settings.getFloat(PreferencesActivity.PR_KEY_DATA_SOURCE_LAT, PreferencesActivity.DATA_SOURCE_WRONG_COORD);
               final float lon = settings.getFloat(PreferencesActivity.PR_KEY_DATA_SOURCE_LON,
PreferencesActivity.DATA_SOURCE_WRONG_COORD);
               dataSource.setCoords(lat, lon);
               mDataSource = dataSource;
         }
        return true;
     }

    public void start() {
       mDataSource.start();
    }
}

Сервісу www.weatherapi.com необхідно вказувати широту та довготу точки, для якої визначається значення тиску. Ці координати визначаються в налаштуваннях програми при виборі інтернету в якості джерела даних. Тому при використанні NetworkDataSource необхідно додатково ініціалізувати створений об’єкт. Програма може з заданою періодичністю визначати поточне значення тиску й зберігати його в БД. Робиться це за допомогою сервісів. Запуск сервісу виконується наступним чином:

public class ServicesUtils { 
    ...
    @RequiresApi(24)
    private static void runServiceL(Context context, int aServiceId, String pKeyInterval, boolean isForce) {
        long when = nearestTimePoint(context, pKeyInterval, isForce);
        AlarmManager alarm = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
        PendingIntent pendingIntent = getPendingIntent(context, aServiceId);
        SharedPreferences settings = XBAUtility.getDefaultSharedPreferences(context);
        if (settings.getBoolean(PreferencesActivity.PR_KEY_USE_ALARM_CLOCK, false)) {
             alarm.setAlarmClock(new AlarmManager.AlarmClockInfo(when, pendingIntent), pendingIntent);
        } else {
             alarm.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, when, pendingIntent);
       }
    }

    private static PendingIntent getPendingIntent(Context ctx, int aServiceId) {
        Intent alarmIntent;
        if (aServiceId == LOGGER_SERVICE_ID) {
            alarmIntent = new Intent(ctx, LoggerBroadcastReceiver.class);
            alarmIntent.setAction(ctx.getString(R.string.logger_intent));
        } else {
            alarmIntent = new Intent(ctx, AlertsBroadcastReceiver.class);
            alarmIntent.setAction(ctx.getString(R.string.alerts_intent));
        }
        return PendingIntent.getBroadcast(ctx, 0, alarmIntent, PendingIntent.FLAG_ONE_SHOT | Utils.intentFlags());
    }
}

Де клас LoggerBroadcastReceiver, зокрема, має наступний вигляд:

public class LoggerBroadcastReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        Intent intentSave = new Intent(context, LoggerJobIntentService.class);
        intentSave.setAction(ServicesUtils.CMD_SAVE);
        context.startForegroundService(intentSave);
    }
}

Я прибрав частину коду, що виконує запуск сервісів на старих версіях Android, який скоро можна буде взагалі видалити з проєкту після підняття мінімальної версії Android SDK. Клас LoggerJobIntentService виглядає наступним чином:

public class LoggerJobIntentService extends BarometerJobIntentService {
    ...
    @Override
    protected void processValue(float mbars) {
        PressureDataManager.saveValue(this, mbars);
       SharedPreferences settings = PreferenceManager.getDefaultSharedPreferences(this);
       if (settings.getBoolean(BarometerNotificationManager.PR_USE_NOTIF_KEY, true)) {
           BarometerNotificationManager.sendNotification(this, mbars);
           BaseBarometerAppWidget.updateWidgets(this);
        }
    }
}

Цей клас записує отримані данні в БД а також показує нову нотифікацію та оновлює віджети. Аналогічний клас AlertsJobIntentService використовується для обробки даних про різку зміну тиску. Їх базовий клас виглядає наступним чином:

public class BarometerJobIntentService extends JobIntentService implements DataSource.DataListener {
   @Override
    protected void onHandleWork(@NonNull Intent intent) {
        ...
        registerListener();
    }

    private boolean registerListener() {
       SharedPreferences settings = XBAUtility.getDefaultSharedPreferences(this);
       return registerDSMListener(settings); 
    }

    private boolean registerDSMListener(SharedPreferences settings) {
          ...
         mDataSourceManager = new DataSourceManager(this, this);
         if (!mDataSourceManager.setup(settings)) {
              return false;
         } else {
             mDataSourceManager.start();
             return true;
        }     
    }

    @Override
    public void onValueChanged(double pressure, double temperature) {
         ...
         processValue((float) pressure);
        finish();
    }
}

Теми застосунку

Спочатку я був схильний до скеворморфізму. Це не те, про що ви могли подумати, а просто спроба імітувати вигляд реальних барометрів. Я переглянув безліч фотографій реальних барометрів, щоб зімітувати аналогічний вигляд в своїй програмі. Тому перші версії використовували лише одну тему оформлення з бронзовим (чи латунним) циферблатом на дерев’яному столі. Я навіть розмістив іконки з варіантами погоди в тих самих місцях, де вони розміщуються на реальних пристроях. Теми в основному налаштовуються в xml-файлах. Для того, щоб можна було в різних темах використовувати зображення різних кольорів, я створив файл attrs.xml:

<?xml version="1.0" encoding="utf-8"?>

<resources>
...
    <attr name="altimeterIcon" format="reference" />
    <attr name="barometerIcon" format="reference" />
...
</resources>

Самі теми налаштовуються в файлі styles.xml:

<style name="LightWoodTheme" parent="Theme.AppCompat.Light.NoActionBar">
    ...
    <item name="altimeterIcon">@drawable/altimeter</item>
    <item name="barometerIcon">@drawable/barometer</item>
    ...
</style>
...

<style name="DarkSimpleTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
    ...
    <item name="altimeterIcon">@drawable/altimeter_white</item>
    <item name="barometerIcon">@drawable/barometer_white</item>
    ...
</style>

Ці зображення використовуються в xml-файлах макетів наступним чином:

<ImageView
            android:id="@+id/butAltimeter"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:src="?attr/altimeterIcon"
            android:contentDescription="@null"
            />

Таким чином в залежності від вибраної теми буде використовуватися темна або біла картинка для кнопки. Сама тема встановлюється наступним чином:

public class ThemeableActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setupEdgeToEdge();

       SharedPreferences settings = XBAUtility.getDefaultSharedPreferences(this);
       setTheme(ThemeManager.themeId(this, settings));
       ...
    }
} 

class ThemeManager {
    private static final int[] sThemeIds = {
            R.style.LightWoodTheme,
            R.style.DarkWoodTheme,
            R.style.DarkSimpleTheme
    };

    static int themeId(Context context, SharedPreferences settings) {
            int idx = themeIndex(settings);
            return sThemeIds[idx];
    }
}

Головний екран μБарометр:

Головний екран  μБарометр

Альтиметр

Як відомо, існує доволі багато способів визначити висоту маяку за допомогою барометра. Але найпростіший варіант — по різниці тиску у двох точках. Спочатку я обчислював висоту просто по різниці в даний точці і умовного значення на рівні моря. Для визначення відносної висоти цього достатньо. Я використовував цей функціонал, наприклад, на екскурсіях — перевіряв слова екскурсовода на кшталт: «Висота цієї вежі 63 метри». Зараз можна використовувати барометричну формулу, що враховує поточну температуру, однак значення температури поки що треба вводити вручну.

Екран альтиметра:

Екран альтиметра

Детальний графік

Затрекані дані зберігаються в БД. Також до записаних значень можна додавати свої анотації, які можуть бути нейтральними, негативними чи позитивними. Наприклад: «Боліла голова», «Крутило ноги» чи «Почувався добре». Цю інформацію потім можна експортувати, відображати у вигляді таблиці чи графіку. На головному екрані або віджеті у Pro-версії можна розмістити міні-графік. Також є окремий екран з детальним графіком.

Екран детального графіку:

Екран детального графіку

Піки на цьому графіку — це нагадування про той період, коли русня в 2022 втекла з Херсону, але перед цим підірвала лінії електропередач. Тому в місті кілька тижнів не було електрики і як результат тепла і води. Тому доводилося з району, де я жив, який знаходиться доволі високо, їздити в низину на берег Дніпра набрати хоча б річкової води.

Ще однією корисною можливістю є відправка нотифікації про поточний тиск. З одного боку це просто інформативно — бачити значення в області нотифікацій, а з іншого є можливість налаштування критичних нотифікацій про різку зміну тиску. Мої метеозалежні користувачі пишуть, що це дуже корисна функція, яка їм допомагає.

Проблеми, виклики й помилки

Зчитати дані з вбудованого сенсору тиску виявилося найпростішою задачею в цьому проєкті. Найбільше проблем виникало і виникає з належною роботою трекінгу тиску. Фактично вся історія цього проєкту — це історія боротьби з все новими й новими обмеженнями Google на фонові процеси в Android. Спочатку я запускав періодичний таймер, який з заданим інтервалом трекінгу спрацьовував і записував данні про тиск. Але відразу стало помітно, що Android не є real-time operating system (RTOS) — з часом накопичувалася помилка в часових інтервалах. Тому зараз я використовую більш складний підхід.

Запускається one-shot таймер, який спрацьовує, записує значення тиску і запускає наступний таймер. При цьому інтервал може трохи корегуватися. Наприклад, якщо для інтервалу в одну годину перший таймер спрацював в 14:00, а другий в 14:59, то в БД запишеться час 15:00, але для наступного таймеру буде встановлений інтервал 61 хвилина. Це дозволяє доволі точно витримувати інтервали трекінгу. Я якось намагався для запуску таких тасків використовувати WorkManager, але результати були дуже погані, тому зараз для підвищення точності є можливість використовувати системний будильник. Однак для цього мені довелося після чергового оновлення версії Android додати необхідні атрибути до опису сервісу в AndroidManifest.xml:

<service
    android:name=".tasks.LoggerJobIntentService"
    android:foregroundServiceType="specialUse"
    android:exported="false"
    android:permission="android.permission.BIND_JOB_SERVICE" >
    <property
         android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
         android:value="This service tracks the air pressure value for the specific periods" />
</service>

А при публікації нової версії барометра пояснювати, навіщо цей застосунок використовує foreground services. На щастя, моє пояснення задовольнило ревьюверів. Ще цікавою проблемкою було відображення значення тиску в іконці нотифікації. На той момент в Android не було можливості створювати такі іконки програмно. Тому я згенерував багато зображень на різні значення тиску і одиниці виміру і вибираю те чи інше зображення залежно від поточного значення тиску.

Також одного разу я зробив доволі дурну помилку. Спочатку я хотів лише відмальовувати мініграфік за пару днів на головному екрані, тому вирішив зберігати дані просто в текстовому файлі. Не знаю, чому тоді я вирішив так зробити. Очевидно, що доволі скоро мені довелося зробити по-нормальному і мігрувати на SQLite. Банальна порада: якщо працюєте з даними, зберігайте їх в БД.

Різні цікавинки і неочікувані проблеми

Я починав цей проєкт просто з бажання погратися сенсором тиску. Однак дуже скоро він перетворився на цікаву і корисну програму. Можливо, гарні коментарі від користувачів — це найприємніше, що можна отримати. Це дуже приємно, коли бачиш лист з текстом: «Дякую за програму, я страждаю метеозалежністю, а вона мені допомагає підготуватися до різких змін погоди й простіше їх пережити». Для відстежування аналітики я інтегрував Google Anlytics і час від часу спостерігаю, як програма захоплює світ :).

Якось бачив, що цим барометром користувалися в Сомалі. А трохи пізніше майже в тій самій точці — іншою моєї програмою «Картковий Гольф». В мене є припущення, що це були сомалійські пірати, які спочатку перевіряли, чи варто виходити в море, а після успішного рейду за допомогою гральних карт ділили награбоване :). Неочікуваним наслідком цього проєкту стало те, що зараз я, по-перше, змушений купляти телефони обов’язково з вбудованим сенсором тиску. А по-друге, купляти новий телефон не тоді, коли мій вже стане застарілим, а коли для нього перестають виходити нові версії Android, бо треба ж тестувати застосунок на останній версії цієї ОС. На щастя, Google заявляє про продовжену підтримку своїх Pixel. Сподіваюся, тепер мені не треба буде надо часто оновлювати телефон.

Плани на майбутнє

Я продовжую потроху розвивати проєкт. Є кілька нових функцій, які б я хотів додати в майбутньому. Наприклад, альтиметр може автоматично визначати температуру повітря за допомогою даних з інтернету. Також є сенс додати можливість генерувати різні попередження в залежності від значення зміни тиску. Ще одна майбутня функція — розрахунок MSL — the average pressure at mean sea level, але для цього треба буде трекати не лише тиск, а й поточну температуру. Час від часу я роздумую додати можливість працювати в парі з розумними годинниками й отримувати з них данні про тиск. Необхідна інфраструктура вже підготовлена і додавання ще одного DataSource вже не буде складною задачею. Але я все ж намагаюся слідувати початковій ідеї — утримувати розмір програми якомога меншим.

Де взяти

Трохи додаткової інформації інформації можна прочитати ще на xvadim.github.io/...​mubarometer/index_uk.html. Там же можна знайти посилання на Google Play та Samsung Apps. Або відразу скачати з Google Play play.google.com/...​d=org.xbasoft.mubarometer і почати експериментувати. Сподіваюся, μБарометр стане в нагоді комусь з читачів цієї статті.

👍ПодобаєтьсяСподобалось27
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Телефон не має вбудованоно барометра( Колись мав Cronos watch, точно відображав зміну висоти. В саморобну метеостанцію додав bmp085, із програмою різниця 2 мм.р.с. res.cloudinary.com/...​/cx1sr2o0lurpigunx0uq.jpg

Якщо не використовується вбудований сенсор, а якись погодний сервіс з Internet, то програма показує тиск не в даній точці простору, а в тій, де були зняти покази. Скоріше за все — це найближчий аеропорт. До того ж, при цьому не враховується поточна висота. Якщо правильно пам’ятаю, то кожні 3 метри висоти дають зміну в 1 мм рт.ст. Тобто на першому поверсі і на дев’ятому в одному й тому ж будинку буде трохи різний тиск. Саме так я вимірюю абсолютну висоту.
Тому, на жаль, Internet-джерело недостатньо точне. З цієї ж причини я планую в майбутньому додати ще джерела даних. На приклад, розумні годинники чи навіть погодні станції. Щоб можна було отримувати данні про тиск саме в цій конкретній точці.

Реліз був дуже давно. В git перший коміт від 2016.

Підписатись на коментарі