├── .gitignore
├── esp32
├── .gitignore
├── preferences.h
├── config.h
├── .vscode
│ ├── arduino.json
│ └── settings.json
├── local_fonts.h
├── scheduler.h
├── keyval.h
├── lcd.h
├── theme.h
├── splash.h
├── esp32.ino
├── registers.h
├── lcd.cpp
├── ble.h
└── .clang-format
├── android-app
├── app
│ ├── .gitignore
│ ├── src
│ │ ├── main
│ │ │ ├── res
│ │ │ │ ├── values
│ │ │ │ │ ├── arrays.xml
│ │ │ │ │ ├── dimens.xml
│ │ │ │ │ ├── colors.xml
│ │ │ │ │ ├── themes.xml
│ │ │ │ │ └── strings.xml
│ │ │ │ ├── drawable
│ │ │ │ │ ├── bg.jpg
│ │ │ │ │ ├── catface.png
│ │ │ │ │ ├── roundabout.bmp
│ │ │ │ │ ├── ic_home_black_24dp.xml
│ │ │ │ │ ├── ic_dashboard_black_24dp.xml
│ │ │ │ │ ├── ic_notifications_black_24dp.xml
│ │ │ │ │ └── ic_launcher_background.xml
│ │ │ │ ├── mipmap-hdpi
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-mdpi
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xhdpi
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xxhdpi
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-xxxhdpi
│ │ │ │ │ ├── ic_launcher.webp
│ │ │ │ │ └── ic_launcher_round.webp
│ │ │ │ ├── mipmap-anydpi-v26
│ │ │ │ │ ├── ic_launcher.xml
│ │ │ │ │ └── ic_launcher_round.xml
│ │ │ │ ├── menu
│ │ │ │ │ └── bottom_nav_menu.xml
│ │ │ │ ├── xml
│ │ │ │ │ ├── backup_rules.xml
│ │ │ │ │ ├── data_extraction_rules.xml
│ │ │ │ │ └── root_preferences.xml
│ │ │ │ ├── navigation
│ │ │ │ │ └── mobile_navigation.xml
│ │ │ │ ├── values-night
│ │ │ │ │ └── themes.xml
│ │ │ │ ├── layout
│ │ │ │ │ ├── activity_ble_selection.xml
│ │ │ │ │ ├── activity_main.xml
│ │ │ │ │ ├── device_row_item.xml
│ │ │ │ │ └── fragment_home.xml
│ │ │ │ └── drawable-v24
│ │ │ │ │ └── ic_launcher_foreground.xml
│ │ │ ├── java
│ │ │ │ └── com
│ │ │ │ │ └── maisonsmd
│ │ │ │ │ └── catdrive
│ │ │ │ │ ├── lib
│ │ │ │ │ ├── BleCharacteristics.kt
│ │ │ │ │ ├── NavigationNotification.kt
│ │ │ │ │ ├── BleWriteQueue.kt
│ │ │ │ │ ├── Intents.kt
│ │ │ │ │ ├── NavigationData.kt
│ │ │ │ │ ├── ParserHelper.kt
│ │ │ │ │ ├── IntrospectionUtils.kt
│ │ │ │ │ ├── BitmapHelper.kt
│ │ │ │ │ ├── GMapsNotification.kt
│ │ │ │ │ └── CustomSerializers.kt
│ │ │ │ │ ├── ui
│ │ │ │ │ ├── ActivityViewModel.kt
│ │ │ │ │ ├── home
│ │ │ │ │ │ └── HomeFragment.kt
│ │ │ │ │ ├── BleDeviceSelectionActivity.kt
│ │ │ │ │ └── settings
│ │ │ │ │ │ └── SettingsFragment.kt
│ │ │ │ │ ├── GoogleMapNotificationListener.kt
│ │ │ │ │ ├── utils
│ │ │ │ │ ├── ServiceManager.kt
│ │ │ │ │ └── PermissionCheck.kt
│ │ │ │ │ ├── service
│ │ │ │ │ └── NavigationListener.kt
│ │ │ │ │ └── MainActivity.kt
│ │ │ └── AndroidManifest.xml
│ │ └── debug
│ │ │ ├── ic_launcher-playstore.png
│ │ │ └── res
│ │ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_round.png
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_round.png
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_round.png
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_round.png
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.png
│ │ │ ├── ic_launcher_round.png
│ │ │ └── ic_launcher_foreground.png
│ │ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ │ └── drawable
│ │ │ └── ic_launcher_background.xml
│ ├── proguard-rules.pro
│ └── build.gradle
├── gradle
│ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
├── build.gradle
├── settings.gradle
├── .gitignore
├── gradle.properties
├── gradlew.bat
└── gradlew
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/esp32/.gitignore:
--------------------------------------------------------------------------------
1 | build
--------------------------------------------------------------------------------
/android-app/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/android-app/app/src/main/res/values/arrays.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable/bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/drawable/bg.jpg
--------------------------------------------------------------------------------
/android-app/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable/catface.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/drawable/catface.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable/roundabout.bmp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/drawable/roundabout.bmp
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/maisonsmd/esp32-google-maps/HEAD/android-app/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/esp32/preferences.h:
--------------------------------------------------------------------------------
1 | #ifndef PREFERENCES_H
2 | #define PREFERENCES_H
3 |
4 | namespace Pref {
5 | bool lightTheme = false;
6 | int brightness = 40;
7 | int speedLimit = 60;
8 | } // namespace Pref
9 |
10 | #endif // PREFERENCES_H
--------------------------------------------------------------------------------
/android-app/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 16dp
4 | 16dp
5 |
--------------------------------------------------------------------------------
/android-app/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/android-app/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | id 'com.android.application' version '8.10.0' apply false
4 | id 'com.android.library' version '8.10.0' apply false
5 | id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
6 | }
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/esp32/config.h:
--------------------------------------------------------------------------------
1 | #ifndef CONFIG_H
2 | #define CONFIG_H
3 |
4 | #define SCHEDULER_SOURCE millis()
5 |
6 | #define PIN_MISO 5
7 | #define PIN_MOSI 6
8 | #define PIN_SCLK 7
9 | #define PIN_LCD_CS 14
10 | #define PIN_LCD_DC 15
11 | #define PIN_LCD_RST 21
12 | #define PIN_BACKLIGHT 22
13 |
14 | #define HORIZONTAL
15 |
16 | #endif
--------------------------------------------------------------------------------
/esp32/.vscode/arduino.json:
--------------------------------------------------------------------------------
1 | {
2 | "port": "COM17",
3 | "board": "esp32:esp32:esp32c6",
4 | "programmer": "esptool",
5 | "sketch": "esp32.ino",
6 | "output": "build",
7 | "configuration": "JTAGAdapter=default,CDCOnBoot=cdc,PartitionScheme=no_ota,CPUFreq=160,FlashMode=qio,FlashFreq=80,FlashSize=4M,UploadSpeed=921600,DebugLevel=warn,EraseFlash=none,ZigbeeMode=default"
8 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable/ic_home_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/android-app/settings.gradle:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | mavenCentral()
6 | }
7 | }
8 | dependencyResolutionManagement {
9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 | repositories {
11 | google()
12 | mavenCentral()
13 | }
14 | }
15 | rootProject.name = "CatDrive"
16 | include ':app'
17 |
--------------------------------------------------------------------------------
/esp32/local_fonts.h:
--------------------------------------------------------------------------------
1 | #ifndef LOCAL_FONTS_H
2 | #define LOCAL_FONTS_H
3 |
4 | #include "lvgl.h"
5 |
6 | #ifdef __cplusplus
7 | extern "C" {
8 | #endif
9 | lv_font_t* get_montserrat_24();
10 | lv_font_t* get_montserrat_bold_32();
11 | lv_font_t* get_montserrat_number_bold_48();
12 | lv_font_t* get_montserrat_semibold_24();
13 | lv_font_t* get_montserrat_semibold_28();
14 | #ifdef __cplusplus
15 | }
16 | #endif
17 |
18 | #endif // LOCAL_FONTS_H
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable/ic_dashboard_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/menu/bottom_nav_menu.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/lib/BleCharacteristics.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.lib
2 |
3 | class BleCharacteristics {
4 | companion object {
5 | const val SERVICE_UUID = "ec91d7ab-e87c-48d5-adfa-cc4b2951298a"
6 | const val CHA_SETTINGS = "9d37a346-63d3-4df6-8eee-f0242949f59f"
7 | const val CHA_NAV = "0b11deef-1563-447f-aece-d3dfeb1c1f20"
8 | const val CHA_NAV_TBT_ICON = "d4d8fcca-16b2-4b8e-8ed5-90137c44a8ad"
9 | const val CHA_GPS_SPEED = "98b6073a-5cf3-4e73-b6d3-f8e05fa018a9"
10 | }
11 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable/ic_notifications_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/android-app/.gitignore:
--------------------------------------------------------------------------------
1 | # Gradle files
2 | .gradle/
3 | build/
4 |
5 | # Local configuration file (sdk path, etc)
6 | local.properties
7 |
8 | # Log/OS Files
9 | *.log
10 |
11 | # Android Studio generated files and folders
12 | captures/
13 | .externalNativeBuild/
14 | .cxx/
15 | *.apk
16 | output.json
17 |
18 | # IntelliJ
19 | *.iml
20 | .idea/
21 | misc.xml
22 | deploymentTargetDropDown.xml
23 | render.experimental.xml
24 |
25 | # Keystore files
26 | *.jks
27 | *.keystore
28 |
29 | # Google Services (e.g. APIs or Firebase)
30 | google-services.json
31 |
32 | # Android Profiling
33 | *.hprof
--------------------------------------------------------------------------------
/android-app/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/esp32/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[cpp]": {
3 | "editor.formatOnSave": true
4 | },
5 | "[c]": {
6 | "editor.formatOnSave": false
7 | },
8 | "files.exclude": {
9 | "**/.git": true,
10 | "**/.svn": true,
11 | "**/.hg": true,
12 | "**/CVS": true,
13 | "**/.DS_Store": true,
14 | "**/Thumbs.db": true,
15 | "build": true,
16 | },
17 | "files.associations": {
18 | "array": "cpp",
19 | "string_view": "cpp",
20 | "initializer_list": "cpp",
21 | "deque": "cpp",
22 | "list": "cpp",
23 | "string": "cpp",
24 | "unordered_map": "cpp",
25 | "unordered_set": "cpp",
26 | "vector": "cpp",
27 | "*.tcc": "cpp"
28 | },
29 | }
30 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/ui/ActivityViewModel.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.ui
2 |
3 | import android.bluetooth.BluetoothDevice
4 | import androidx.lifecycle.MutableLiveData
5 | import androidx.lifecycle.ViewModel
6 | import com.maisonsmd.catdrive.lib.NavigationData
7 |
8 | class ActivityViewModel : ViewModel() {
9 | val permissionUpdatedTimestamp = MutableLiveData().apply { value = 0 }
10 | val navigationData = MutableLiveData().apply { value = NavigationData() }
11 | val speed = MutableLiveData().apply { value = 0 }
12 | val connectedDevice = MutableLiveData().apply { value = null }
13 | val serviceRunInBackground = MutableLiveData().apply { value = false }
14 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/res/navigation/mobile_navigation.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
13 |
17 |
--------------------------------------------------------------------------------
/android-app/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/esp32/scheduler.h:
--------------------------------------------------------------------------------
1 | #ifndef SCHEDULER_H
2 | #define SCHEDULER_H
3 |
4 | #include "config.h"
5 |
6 | #define SCHEDULER_GUARD(__current__, __var__) \
7 | if (__var__ > __current__) \
8 | __var__ = __current__
9 | #define SCHEDULER_CREATE(__name__) static uint32_t __name__ = 0
10 |
11 | #define TOKENPASTE(x, y) x##y
12 | #define TOKENPASTE2(x, y) TOKENPASTE(x, y)
13 | #define DO_EVERY(duration) \
14 | SCHEDULER_CREATE(TOKENPASTE2(scheduler_, __LINE__)); \
15 | if (onSchedule(SCHEDULER_SOURCE, TOKENPASTE2(scheduler_, __LINE__), duration))
16 |
17 | // this function already guard timekeeper variable
18 | static bool onSchedule(const uint32_t& current, uint32_t& var, const uint32_t& interval) {
19 | SCHEDULER_GUARD(current, var);
20 | if (current < var + interval)
21 | return false;
22 |
23 | var = current;
24 | return true;
25 | }
26 |
27 | #endif
--------------------------------------------------------------------------------
/android-app/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/lib/NavigationNotification.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.lib
2 |
3 | import android.app.Notification
4 | import android.content.Context
5 | import android.service.notification.StatusBarNotification
6 |
7 | open class NavigationNotification(context: Context, statusBarNotification: StatusBarNotification) {
8 | protected val mNotification: Notification = statusBarNotification.notification
9 | protected val mContext = context
10 | protected var mAppSourceContext: Context = mContext.createPackageContext(
11 | statusBarNotification.packageName,
12 | Context.CONTEXT_IGNORE_SECURITY
13 | )
14 |
15 | private var mNavigationData: NavigationData = NavigationData()
16 | var navigationData
17 | get() = mNavigationData
18 | set(value) {
19 | if (value == mNavigationData)
20 | return
21 | mNavigationData = value
22 | }
23 |
24 | init {
25 | mNavigationData.postTime = NavigationTimestamp(statusBarNotification.postTime)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Son H. Mai (Mason)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/lib/BleWriteQueue.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.lib
2 |
3 | class BleWriteQueue {
4 | data class QueueItem(val uuid: String, val data: ByteArray, val overwrite: Boolean = true) {
5 | override fun equals(other: Any?): Boolean {
6 | if (this === other) return true
7 | if (javaClass != other?.javaClass) return false
8 |
9 | other as QueueItem
10 |
11 | if (uuid != other.uuid) return false
12 | if (!data.contentEquals(other.data)) return false
13 |
14 | return true
15 | }
16 |
17 | override fun hashCode(): Int {
18 | var result = uuid.hashCode()
19 | result = 31 * result + data.contentHashCode()
20 | return result
21 | }
22 | }
23 |
24 | private var mQueue: MutableList = mutableListOf()
25 |
26 | fun add(newItem: QueueItem) {
27 | if (newItem.overwrite && mQueue.find { it.uuid == newItem.uuid } != null) {
28 | mQueue.removeAll { it.uuid == newItem.uuid }
29 | }
30 |
31 | mQueue.add(newItem)
32 | }
33 |
34 | fun pop(): QueueItem {
35 | return mQueue.removeAt(0)
36 | }
37 |
38 | val size get() = mQueue.size
39 |
40 | fun clear() {
41 | mQueue.clear()
42 | }
43 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/lib/Intents.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.lib
2 |
3 | import com.maisonsmd.catdrive.BuildConfig
4 |
5 | class Intents {
6 | companion object {
7 | private const val APP_ID = BuildConfig.APPLICATION_ID
8 |
9 | const val ENABLE_SERVICES = "${APP_ID}.intent.ENABLE_SERVICES"
10 | const val DISABLE_SERVICES = "${APP_ID}.intent.DISABLE_SERVICES"
11 | const val BIND_LOCAL_SERVICE = "${APP_ID}.intent.LOCAL_BIND"
12 | const val BACKGROUND_SERVICE_STATUS = "${APP_ID}.intent.SERVICE_RUNNING"
13 |
14 | const val DISCONNECT_DEVICE = "${APP_ID}.intent.DISCONNECT_DEVICE"
15 | const val CONNECT_DEVICE = "${APP_ID}.intent.CONNECT_DEVICE"
16 | const val CONNECTION_UPDATE = "${APP_ID}.intent.CONNECTION_UPDATE"
17 |
18 | const val NAVIGATION_UPDATE = "${APP_ID}.intent.NAVIGATION_UPDATE"
19 | const val GPS_UPDATE = "${APP_ID}.intent.GPS_UPDATE"
20 |
21 | const val OPEN_NOTIFICATION_LISTENER_SETTINGS =
22 | "android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS"
23 |
24 | const val ACTION_GATT_CONNECTED = "com.maisonsmd.bluetooth.le.ACTION_GATT_CONNECTED"
25 | const val ACTION_GATT_DISCONNECTED = "com.maisonsmd.bluetooth.le.ACTION_GATT_DISCONNECTED"
26 | const val ACTION_GATT_SERVICES_DISCOVERED =
27 | "com.maisonsmd.bluetooth.le.ACTION_GATT_SERVICES_DISCOVERED"
28 |
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/esp32/keyval.h:
--------------------------------------------------------------------------------
1 | #include
2 |
3 | struct KeyValue {
4 | String key;
5 | String value;
6 | };
7 |
8 | struct KvParseResult {
9 | std::vector pairs;
10 |
11 | String getOrDefault(const String& key, const String& valueIfNull = "") const {
12 | const auto it = std::find_if(pairs.begin(), pairs.end(), [&key](const KeyValue& kv) { return kv.key == key; });
13 |
14 | if (it != pairs.end())
15 | return it->value;
16 |
17 | return valueIfNull;
18 | }
19 |
20 | bool contains(const String& key) const {
21 | return std::find_if(pairs.begin(), pairs.end(), [&key](const KeyValue& kv) { return kv.key == key; }) != pairs.end();
22 | }
23 | };
24 |
25 | std::vector splitString(const String& input, const String& delimiter) {
26 | std::vector result;
27 |
28 | int start = 0;
29 | int end = input.indexOf(delimiter);
30 |
31 | while (end != -1) {
32 | result.push_back(input.substring(start, end));
33 | start = end + delimiter.length();
34 | end = input.indexOf(delimiter, start);
35 | }
36 |
37 | result.push_back(input.substring(start));
38 |
39 | return result;
40 | }
41 |
42 | KvParseResult kvParseMultiline(const String& input) {
43 | KvParseResult result{};
44 |
45 | const auto lines = splitString(input, "\n");
46 |
47 | for (const auto& line : lines) {
48 | const auto parts = splitString(line, "=");
49 |
50 | if (parts.size() < 2)
51 | continue;
52 |
53 | result.pairs.push_back({parts[0], parts[1]});
54 | }
55 |
56 | return result;
57 | }
--------------------------------------------------------------------------------
/android-app/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
24 | android.defaults.buildfeatures.buildconfig=true
25 | android.nonFinalResIds=false
--------------------------------------------------------------------------------
/android-app/app/src/main/res/layout/activity_ble_selection.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
20 |
21 |
34 |
35 |
--------------------------------------------------------------------------------
/esp32/lcd.h:
--------------------------------------------------------------------------------
1 | #ifndef _DISPLAY_ST7789_H_
2 | #define _DISPLAY_ST7789_H_
3 |
4 | #include
5 | #include
6 |
7 | class SimpleSt7789 {
8 | public:
9 | enum Rotation { ROTATION_0, ROTATION_90, ROTATION_180, ROTATION_270 };
10 |
11 | SimpleSt7789(SPIClass* spi,
12 | const SPISettings& spiSettings,
13 | uint16_t width,
14 | uint16_t height,
15 | uint8_t cs,
16 | uint8_t dc,
17 | uint8_t rst,
18 | uint8_t backlight = -1,
19 | Rotation rotation = ROTATION_0);
20 | void init();
21 | void reset();
22 | void setRotation(Rotation rotation);
23 | void setOffset(uint16_t xOffset, uint16_t yOffset);
24 | void setBrightness(uint8_t percent);
25 | void flushWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t* color);
26 | void invertDisplay(bool invert);
27 |
28 | private:
29 | void setAddrWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2);
30 | void sendCommand(uint8_t command, const uint8_t* data = nullptr, size_t size = 0);
31 |
32 | template = 1)>>
33 | void sendCommandFixed(uint8_t command, const uint8_t (&dataArray)[N]) {
34 | sendCommand(command, dataArray, N);
35 | }
36 |
37 | void sendData(const uint8_t* data, size_t size);
38 |
39 | template = 1)>> void sendDataFixed(const uint8_t (&dataArray)[N]) {
40 | sendData(dataArray, N);
41 | }
42 |
43 | SPIClass* _spi;
44 | SPISettings _spiSettings;
45 | uint16_t _width;
46 | uint16_t _height;
47 | uint8_t _pinCs;
48 | uint8_t _pinDc;
49 | uint8_t _pinRst;
50 | uint8_t _pinBacklight;
51 | Rotation _rotation;
52 | uint16_t _xOffset;
53 | uint16_t _yOffset;
54 | };
55 |
56 | #endif
--------------------------------------------------------------------------------
/android-app/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
20 |
21 |
35 |
36 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | CatDrive
3 | Home
4 | Settings
5 | Devices
6 |
7 |
8 | Device
9 | Service
10 | Run services to receive navigation data
11 | Permissions
12 | Grant all these permissions for the app to work
13 |
14 | Select a device
15 | No device connected
16 |
17 |
18 | Run services
19 | Requires all permissions allowed & bluetooth turned on
20 | Go to notification settings
21 |
22 | Accessing notifications
23 | Posting notifications
24 | Accessing location
25 | Accessing bluetooth
26 | Turn device display light theme on or off
27 | LCD light theme
28 | Change device LCD brightness to improve visibility
29 | Display brightness
30 | Speed warning limit
31 | Scan
32 |
33 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/GoogleMapNotificationListener.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive
2 |
3 | import android.content.Intent
4 | import android.os.Binder
5 | import android.os.IBinder
6 | import androidx.localbroadcastmanager.content.LocalBroadcastManager
7 | import com.maisonsmd.catdrive.lib.Intents
8 | import com.maisonsmd.catdrive.lib.NavigationNotification
9 | import com.maisonsmd.catdrive.service.NavigationListener
10 |
11 | class GoogleMapNotificationListener : NavigationListener() {
12 | private val mBinder = LocalBinder()
13 |
14 | /**
15 | * Class used for the client Binder. Because we know this service always
16 | * runs in the same process as its clients, we don't need to deal with IPC.
17 | */
18 | inner class LocalBinder : Binder() {
19 | // Return this instance of LocalService so clients can call public methods
20 | fun getService(): GoogleMapNotificationListener = this@GoogleMapNotificationListener
21 | }
22 |
23 | override fun onBind(intent: Intent?): IBinder? {
24 | // Bind by activity
25 | if (intent?.action == Intents.BIND_LOCAL_SERVICE) {
26 | return mBinder
27 | }
28 | // Bind by OS
29 | return super.onBind(intent)
30 | }
31 |
32 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
33 | if (intent?.action == Intents.ENABLE_SERVICES) {
34 | enabled = true
35 | }
36 | if (intent?.action == Intents.DISABLE_SERVICES) {
37 | enabled = false
38 | }
39 |
40 | return super.onStartCommand(intent, flags, startId)
41 | }
42 |
43 | override fun onNavigationNotificationUpdated(navNotification: NavigationNotification) {
44 | LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
45 | Intent(Intents.NAVIGATION_UPDATE).apply {
46 | putExtra("navigation_data", navNotification.navigationData)
47 | }
48 | )
49 | }
50 |
51 | override fun onNavigationNotificationRemoved(navNotification: NavigationNotification) {
52 | LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(
53 | // Empty data (no extras)
54 | Intent(Intents.NAVIGATION_UPDATE)
55 | )
56 | }
57 | }
--------------------------------------------------------------------------------
/esp32/theme.h:
--------------------------------------------------------------------------------
1 | #ifndef BACKLIGHT_H
2 | #define BACKLIGHT_H
3 |
4 | #include "lcd.h"
5 | #include "scheduler.h"
6 |
7 | extern SimpleSt7789 lcd;
8 |
9 | namespace ThemeControl {
10 | namespace detail {
11 | uint32_t lastFlashRequest_ms = 0;
12 | uint32_t offWithTimerStart_ms = 0;
13 | int8_t toggleCount = -1;
14 | bool isLight = false;
15 | bool isLightHardware = false;
16 | bool isWaitingForDark = false;
17 |
18 | void writeLight(bool value) {
19 | if (isLightHardware == value)
20 | return;
21 |
22 | isLightHardware = value;
23 | value ? lcd.invertDisplay(true) : lcd.invertDisplay(false);
24 | }
25 |
26 | bool isHardwareLight() {
27 | return isLightHardware;
28 | }
29 | } // namespace detail
30 |
31 | void flashScreen() {
32 | if (millis() > detail::lastFlashRequest_ms + 5000) {
33 | detail::lastFlashRequest_ms = millis();
34 | detail::toggleCount = 0;
35 | }
36 | }
37 |
38 | void darkWithTimer() {
39 | if (detail::isLight == false || detail::isWaitingForDark)
40 | return;
41 | detail::isWaitingForDark = true;
42 |
43 | // Cancel the flashing
44 | detail::toggleCount = -1;
45 | detail::writeLight(detail::isLight);
46 |
47 | detail::offWithTimerStart_ms = millis();
48 | }
49 |
50 | void light() {
51 | detail::isLight = true;
52 | detail::toggleCount = -1;
53 | detail::isWaitingForDark = false;
54 | detail::writeLight(true);
55 | }
56 |
57 | void dark() {
58 | detail::isLight = false;
59 | detail::toggleCount = -1;
60 | detail::isWaitingForDark = false;
61 | detail::writeLight(false);
62 | }
63 |
64 | void update() {
65 | if (detail::isWaitingForDark) {
66 | constexpr uint32_t TIMEOUT = 5000;
67 | if (millis() > detail::offWithTimerStart_ms + TIMEOUT) {
68 | dark();
69 | }
70 | }
71 |
72 | if (detail::toggleCount != -1) {
73 | constexpr int8_t MAX_CYCLE = 2;
74 | DO_EVERY(100) {
75 | detail::toggleCount++;
76 | // End of cycle, make sure the backlight is in the correct state
77 | if (detail::toggleCount == MAX_CYCLE * 2) {
78 | detail::writeLight(detail::isLight);
79 | detail::toggleCount = -1;
80 | }
81 | // Toggle the backlight
82 | else {
83 | detail::writeLight(!detail::isHardwareLight());
84 | }
85 | }
86 | }
87 | }
88 | } // namespace ThemeControl
89 |
90 | #endif // BACKLIGHT_H
--------------------------------------------------------------------------------
/android-app/app/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'org.jetbrains.kotlin.android'
4 | id 'org.jetbrains.kotlin.plugin.serialization' version '1.7.20'
5 | id 'kotlin-parcelize'
6 | }
7 |
8 | ext {
9 | kotlin_version = "1.7.20"
10 | kotlix_serialization_version = "1.4.1"
11 | kotlix_coroutines_version = "1.4.2"
12 | lifecycle_version = "2.5.1"
13 | catdrive_version = '1.0.0'
14 | }
15 |
16 | android {
17 | namespace 'com.maisonsmd.catdrive'
18 | compileSdk 32
19 |
20 | defaultConfig {
21 | applicationId "com.maisonsmd.catdrive"
22 | minSdk 31
23 | targetSdk 32
24 | versionCode 1
25 | versionName "1.0"
26 | }
27 |
28 | buildTypes {
29 | release {
30 | minifyEnabled false
31 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
32 | }
33 | }
34 | compileOptions {
35 | sourceCompatibility JavaVersion.VERSION_1_8
36 | targetCompatibility JavaVersion.VERSION_1_8
37 | }
38 | kotlinOptions {
39 | jvmTarget = '1.8'
40 | }
41 | buildFeatures {
42 | viewBinding true
43 | }
44 | }
45 |
46 | dependencies {
47 | implementation 'androidx.core:core-ktx:1.7.0'
48 | implementation 'androidx.appcompat:appcompat:1.5.1'
49 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
50 | implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
51 | implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"
52 | implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
53 | implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
54 | implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
55 | implementation 'androidx.preference:preference:1.2.0'
56 |
57 | implementation 'com.jakewharton.timber:timber:4.7.1'
58 |
59 | implementation 'com.google.android.gms:play-services-awareness:19.0.1'
60 | implementation 'com.google.android.material:material:1.7.0'
61 |
62 | implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlix_coroutines_version"
63 | implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlix_serialization_version"
64 | implementation "org.jetbrains.kotlinx:kotlinx-serialization-cbor:$kotlix_serialization_version"
65 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
66 | implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
67 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/lib/NavigationData.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.lib
2 |
3 | import android.graphics.Bitmap
4 | import android.os.Parcelable
5 | import kotlinx.parcelize.Parcelize
6 | import kotlinx.serialization.Serializable
7 |
8 | @Parcelize
9 | @Serializable
10 | data class NavigationDirection(
11 | val nextRoad: String? = null,
12 | val nextRoadAdditionalInfo: String? = null,
13 | val distance: String? = null,
14 | ) : Parcelable
15 |
16 | @Parcelize
17 | @Serializable
18 | data class NavigationEta(
19 | val eta: String? = null,
20 | val ete: String? = null,
21 | val distance: String? = null
22 | ) : Parcelable
23 |
24 | @Parcelize
25 | @Serializable
26 | data class NavigationIcon(
27 | @Serializable(with = BitmapSerializer::class)
28 | val bitmap: Bitmap? = null,
29 | ) : Parcelable, AutoCloseable {
30 | override fun equals(other: Any?): Boolean {
31 | return if (other !is NavigationIcon || bitmap !is Bitmap)
32 | super.equals(other)
33 | else bitmap.sameAs(other.bitmap)
34 | }
35 |
36 | override fun close() {
37 | bitmap?.recycle()
38 | }
39 |
40 | override fun hashCode(): Int {
41 | return bitmap?.hashCode() ?: 0
42 | }
43 | }
44 |
45 | @Parcelize
46 | @Serializable
47 | data class NavigationTimestamp(
48 | @Mutable
49 | var timestamp: Long = 0,
50 | ) : Parcelable, MutableContent() {
51 | override fun equals(other: Any?): Boolean {
52 | return super.equals(other)
53 | }
54 |
55 | override fun hashCode(): Int {
56 | var result = super.hashCode()
57 | result = 31 * result + timestamp.hashCode()
58 | return result
59 | }
60 | }
61 |
62 | @Parcelize
63 | @Serializable
64 | data class NavigationData(
65 | var nextDirection: NavigationDirection = NavigationDirection(),
66 | var eta: NavigationEta = NavigationEta(),
67 | var actionIcon: NavigationIcon = NavigationIcon(),
68 | @Mutable
69 | var postTime: NavigationTimestamp = NavigationTimestamp(),
70 | ) : Parcelable, Introspectable, MutableContent() {
71 | override fun equals(other: Any?): Boolean {
72 | return super.equals(other)
73 | }
74 |
75 | override fun hashCode(): Int {
76 | var result = super.hashCode()
77 | result = 31 * result + nextDirection.hashCode()
78 | result = 31 * result + eta.hashCode()
79 | result = 31 * result + actionIcon.hashCode()
80 | result = 31 * result + postTime.hashCode()
81 | return result
82 | }
83 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/res/layout/device_row_item.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
17 |
18 |
30 |
31 |
44 |
45 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | CatDrive is a fun project that displays Google Map navigation on a small screen that I attach to my motorbike.
2 |
3 | [](https://www.youtube.com/watch?v=bleMd7QEXfQ)
4 |
5 |
6 | [Read blog](https://maisonsmd.dev/blog/google-maps-on-esp32)
7 |
8 | ## HELP WANTED!
9 |
10 | The current state of this project works fine for me, but if you can make it even better, for example:
11 |
12 | - Make the app UI look more professional
13 | - Display incomming call / messages notifications
14 | - Clean up the code (I wrote it in just some days so it's kinda messy)
15 |
16 | I really appeciate it!
17 |
18 | # How it works
19 |
20 | The Android app must be installed and running on your phone, it will read the current navigation data from Google Maps and send it to the ESP32 over Bluetooth Low Energy (BLE).
21 |
22 | ## Hardware
23 |
24 | You will need a ESP32-C6 (or other with BLE capabilities) with a small screen.
25 |
26 | Mine is [ESP32-C6 1.47inch Display Development Board](https://www.waveshare.com/esp32-c6-lcd-1.47.htm) from Waveshare. If you use a different screen, you will need to adapt the code to your screen.
27 |
28 | ## Software
29 |
30 | ### ESP32
31 |
32 | - The code is built with Arduino (v2), you need to install the ESP32 board support in Arduino IDE first.
33 | - Install the required libraries: `lvgl`
34 | - Copy `lv_conf.h` to Arduino's libraries folder (not inside `lvgl` folder).
35 | - Make sure the screen works by using the example sketch provided by [Waveshare](https://www.waveshare.com/wiki/ESP32-C6-LCD-1.47)
36 | - Build the code and upload it to your ESP32.
37 |
38 | ### Android
39 |
40 | Currently I make the app specificly for my Pixel 4a phone running Android 13, so most APIs are level 33 or above.
41 |
42 | - Install Android Studio and SDK for Android 13.
43 | - Open `android-app` folder in Android Studio.
44 |
45 | ### Note
46 |
47 | The fonts are generated with only a small subset of Unicode characters, I made it specificly for Vietnamese, the characters are:
48 |
49 | ```plain
50 | !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~
51 | áàảãạăắằẳẵặâấầẩẫậéèẻẽẹêếềểễệíìỉĩịóòỏõọôốồổỗộơớờởỡợúùủũụưứừửữựýỳỷỹỵđ
52 | ÁÀẢÃẠĂẮẰẲẴẶÂẤẦẨẪẬÉÈẺẼẸÊẾỀỂỄỆÍÌỈĨỊÓÒỎÕỌÔỐỒỔỖỘƠỚỜỞỠỢÚÙỦŨỤƯỨỪỬỮỰÝỲỶỸỴĐ
53 | ```
54 |
55 | If you want to use different fonts or different set of characters, generate it using this tool https://lvgl.io/tools/fontconverter
56 |
57 | Options:
58 | - Size: check each font file name, different sizes and weights used for different purposes
59 | - Bbp: 4
60 | - Enable Font compression: No
--------------------------------------------------------------------------------
/android-app/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
22 |
28 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
43 |
44 |
45 |
46 |
47 |
48 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/android-app/app/src/main/res/xml/root_preferences.xml:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 |
9 |
14 |
22 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
40 |
44 |
48 |
52 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/lib/ParserHelper.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.lib
2 |
3 | import android.graphics.Typeface
4 | import android.text.Spanned
5 | import android.text.style.StyleSpan
6 | import androidx.core.text.getSpans
7 | import timber.log.Timber
8 |
9 | data class Span(
10 | val begin: Int,
11 | val end: Int,
12 | val text: String,
13 | val style: Int = Typeface.NORMAL, // style from Typeface
14 | )
15 |
16 | object ParserHelper {
17 | /**
18 | * NOTE: Currently only support one span type: StyleSpan
19 | */
20 | private fun findSpans(input: Spanned): ArrayList {
21 | val results = ArrayList()
22 | var spanBegin = 0
23 | var spanEnd = 0
24 | val len = input.length
25 | while (spanEnd < len) {
26 | spanEnd = input.nextSpanTransition(spanBegin, len, StyleSpan::class.java)
27 | val s = input.substring(spanBegin, spanEnd).trim()
28 | val spans = input.getSpans(spanBegin, spanEnd)
29 | if (spans.isNotEmpty())
30 | results.add(Span(spanBegin, spanEnd, s, spans.first().style))
31 | else
32 | results.add(Span(spanBegin, spanEnd, s))
33 | spanBegin = spanEnd
34 | }
35 | return results
36 | }
37 |
38 | data class SpanSplitResult(
39 | val text: String,
40 | val isKeySpan: Boolean
41 | )
42 |
43 | public fun splitByStyleSpan(input: Spanned, keyStyle: Int, minSpanLength: Int = 0): ArrayList {
44 | val result = ArrayList()
45 | val spans = findSpans(input)
46 |
47 | var begin: Int = 0
48 | var end: Int
49 | var previousSegmentMatched = false
50 | for (span in spans) {
51 | var segmentMatched = false
52 | val segment = input.substring(span.begin, span.end)
53 | if (span.style == keyStyle && (segment.trim().length >= minSpanLength)) {
54 | segmentMatched = true
55 | }
56 |
57 | if (segmentMatched != previousSegmentMatched) {
58 | end = span.begin
59 | val prevSegment = input.substring(begin, end).trim()
60 | if (prevSegment.isNotEmpty())
61 | result.add(SpanSplitResult(prevSegment, previousSegmentMatched))
62 | begin = end
63 | }
64 |
65 | if (span == spans.last()) {
66 | end = span.end
67 | val prevSegment = input.substring(begin, end).trim()
68 | if (prevSegment.isNotEmpty())
69 | result.add(SpanSplitResult(prevSegment, segmentMatched))
70 | }
71 | previousSegmentMatched = segmentMatched
72 | }
73 |
74 | // Timber.w(result.toString())
75 | return result
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/esp32/splash.h:
--------------------------------------------------------------------------------
1 | #ifndef SPLASH_H
2 | #define SPLASH_H
3 |
4 | const unsigned char splash[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
5 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
6 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
7 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7C, 0x07,
8 | 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x03, 0xC0, 0x00, 0x00, 0x00,
9 | 0x00, 0x00, 0x78, 0x01, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x78, 0x01, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x01,
10 | 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x01, 0xE0, 0x00, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x01,
11 | 0xF0, 0x00, 0x00, 0x01, 0xF0, 0x00, 0x00, 0x03, 0xF0, 0x00, 0x00, 0x00, 0xFC, 0xFF, 0xC0, 0x07, 0xF0, 0xE0, 0x00, 0x00,
12 | 0x7F, 0xFF, 0xF8, 0x0F, 0xF3, 0xE0, 0x00, 0x00, 0x3F, 0xFF, 0xFE, 0x1F, 0xF7, 0xE0, 0x00, 0x00, 0x07, 0xBF, 0xFF, 0x1F,
13 | 0xFB, 0xE0, 0x00, 0x00, 0x3C, 0xFF, 0xFF, 0xDF, 0xFC, 0xC0, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xEF, 0xFE, 0x40, 0x00, 0x00,
14 | 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0x80, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xC0, 0x00, 0x01, 0xFF, 0xFF, 0xFF, 0xFB,
15 | 0xFF, 0xC0, 0x00, 0x03, 0xFF, 0x7F, 0xFF, 0xFD, 0xFF, 0xE0, 0x00, 0x03, 0xFE, 0x1F, 0xFF, 0xFD, 0x3F, 0xF0, 0x00, 0x07,
16 | 0xFC, 0x7F, 0xFF, 0xFE, 0x3F, 0xF0, 0x00, 0x07, 0xFE, 0xBF, 0xFF, 0xFE, 0xFF, 0xF0, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF,
17 | 0x7F, 0x98, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0x79, 0x98, 0x00, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0xF8, 0x00, 0x0F,
18 | 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF,
19 | 0xFF, 0xF0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xDF, 0xE6, 0x00, 0x1F,
20 | 0xFF, 0xFF, 0xFF, 0xFF, 0xDF, 0xD0, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xDF, 0xDF, 0x88, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xDF,
21 | 0xDE, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xDF, 0xC0, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xDF, 0xC0, 0x00, 0x00, 0x1F,
22 | 0xFF, 0xFF, 0xFF, 0xDF, 0xC0, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0xFF, 0xDE, 0xC0, 0x00, 0x00, 0x1F, 0xFF, 0xFF, 0x7F, 0xFE,
23 | 0x80, 0x00, 0x00, 0x0F, 0xBF, 0xFF, 0x7F, 0xBD, 0x80, 0x00, 0x00, 0x0F, 0xDF, 0xFF, 0x7F, 0xB9, 0x80, 0x00, 0x00, 0x0F,
24 | 0xE1, 0xFF, 0x7F, 0xB3, 0x80, 0x00, 0x00, 0x07, 0xE0, 0x00, 0xFF, 0x67, 0x00, 0x00, 0x00, 0x07, 0xF0, 0x00, 0xFF, 0x0F,
25 | 0x00, 0x00, 0x00, 0x07, 0xF0, 0x00, 0xFE, 0x1F, 0x00, 0x00, 0x00, 0x03, 0xF0, 0x00, 0xFE, 0x1F, 0x00, 0x00, 0x00, 0x03,
26 | 0xF8, 0x01, 0xFC, 0x0F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
27 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
28 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
29 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
30 |
31 | #endif // SPLASH_H
--------------------------------------------------------------------------------
/android-app/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/utils/ServiceManager.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.utils
2 |
3 | import android.app.ActivityManager
4 | import android.bluetooth.BluetoothDevice
5 | import android.content.Context
6 | import android.content.Intent
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.lifecycle.ViewModelProvider
9 | import com.maisonsmd.catdrive.GoogleMapNotificationListener
10 | import com.maisonsmd.catdrive.lib.Intents
11 | import com.maisonsmd.catdrive.service.BleService
12 | import com.maisonsmd.catdrive.ui.ActivityViewModel
13 | import timber.log.Timber
14 |
15 | class ServiceManager {
16 | companion object {
17 | fun startBroadcastService(activity: AppCompatActivity) {
18 | Timber.i("start services")
19 | PermissionCheck.requestEnableBluetooth(activity)
20 |
21 | val action = Intents.ENABLE_SERVICES
22 | activity.startService(
23 | Intent(
24 | activity,
25 | BleService::class.java
26 | ).apply { setAction(action) })
27 | activity.startService(
28 | Intent(
29 | activity, GoogleMapNotificationListener::class.java
30 | ).apply { setAction(action) })
31 | }
32 |
33 | fun requestConnectDevice(activity: AppCompatActivity, device: BluetoothDevice) {
34 | Timber.d("requestConnectDevice: $device")
35 | val action = Intents.CONNECT_DEVICE
36 | val intent = Intent(activity, BleService::class.java).apply {
37 | setAction(action)
38 | putExtra("device", device)
39 | }
40 | activity.startService(intent)
41 | }
42 |
43 | fun stopBroadcastService(activity: AppCompatActivity) {
44 | Timber.i("stop services")
45 |
46 | activity.startService(
47 | Intent(
48 | activity,
49 | BleService::class.java
50 | ).apply { action = (Intents.DISCONNECT_DEVICE) })
51 |
52 | // Expect the target service to stop itself
53 | activity.startService(
54 | Intent(
55 | activity, BleService::class.java
56 | ).apply { action = Intents.DISABLE_SERVICES })
57 | activity.startService(
58 | Intent(
59 | activity, GoogleMapNotificationListener::class.java
60 | ).apply { action = Intents.DISABLE_SERVICES })
61 | }
62 |
63 | @Suppress("DEPRECATION")
64 | private fun isServiceRunningInBackground(
65 | activity: AppCompatActivity,
66 | service: Class
67 | ): Boolean {
68 | val running =
69 | (activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getRunningServices(
70 | Integer.MAX_VALUE
71 | ).any { it.service.className == service.name }
72 | val viewModel = ViewModelProvider(activity)[ActivityViewModel::class.java]
73 | return running && viewModel.serviceRunInBackground.value == true
74 | }
75 |
76 | fun isBroadcastServiceRunningInBackground(activity: AppCompatActivity): Boolean {
77 | return isServiceRunningInBackground(activity, BleService::class.java)
78 | }
79 |
80 | }
81 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/ui/home/HomeFragment.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.ui.home
2 |
3 | import android.os.Bundle
4 | import android.util.Size
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import androidx.core.graphics.drawable.toBitmap
9 | import androidx.core.graphics.drawable.toDrawable
10 | import androidx.core.graphics.scale
11 | import androidx.fragment.app.Fragment
12 | import androidx.lifecycle.ViewModelProvider
13 | import com.maisonsmd.catdrive.MainActivity
14 | import com.maisonsmd.catdrive.R
15 | import com.maisonsmd.catdrive.databinding.FragmentHomeBinding
16 | import com.maisonsmd.catdrive.lib.BitmapHelper
17 | import com.maisonsmd.catdrive.lib.NavigationData
18 | import com.maisonsmd.catdrive.ui.ActivityViewModel
19 | import com.maisonsmd.catdrive.utils.ServiceManager
20 | import timber.log.Timber
21 |
22 |
23 | class HomeFragment : Fragment() {
24 | private var mUiBinding: FragmentHomeBinding? = null
25 | private var mDebugImage = false
26 |
27 | private val binding get() = mUiBinding!!
28 |
29 | private fun displayNavigationData(data: NavigationData?) {
30 | val bitmap =
31 | if (!mDebugImage) data?.actionIcon?.bitmap
32 | else resources.getDrawable(R.drawable.roundabout).toBitmap()
33 |
34 | binding.imgTurnIcon.setImageBitmap(bitmap)
35 | /*
36 | val bh = BitmapHelper()
37 | val compressed = bh.compressBitmap(bitmap, Size(32, 32))
38 | binding.imgScaled.setImageDrawable(
39 | BitmapHelper.AliasingDrawableWrapper(
40 | bitmap?.scale(32, 32, false)?.toDrawable(resources)
41 | )
42 | )
43 | binding.imgFinal.setImageDrawable(BitmapHelper.AliasingDrawableWrapper(compressed.toDrawable(resources)))
44 | // Timber.e(BitmapHelper().toBase64(compressed))
45 | */
46 |
47 | if (data == null) {
48 | binding.txtRoadName.text = "---"
49 | binding.txtRoadAdditionalInfo.text = "---"
50 | binding.txtDistance.text = "---"
51 | binding.txtEta.text = "---"
52 | return
53 | }
54 |
55 | binding.txtRoadName.text = data.nextDirection.nextRoad
56 | binding.txtRoadAdditionalInfo.text = data.nextDirection.nextRoadAdditionalInfo
57 | binding.txtDistance.text = data.nextDirection.distance
58 | binding.txtEta.text = "${data.eta.ete} - ${data.eta.eta} - ${data.eta.distance}"
59 | }
60 |
61 | override fun onCreateView(
62 | inflater: LayoutInflater,
63 | container: ViewGroup?,
64 | savedInstanceState: Bundle?
65 | ): View {
66 | mUiBinding = FragmentHomeBinding.inflate(inflater, container, false)
67 |
68 | mUiBinding!!.btnDisconnect.setOnClickListener {
69 | Timber.i("Disconnect request");
70 | ServiceManager.stopBroadcastService(activity as MainActivity)
71 | }
72 |
73 | val viewModel = ViewModelProvider(requireActivity())[ActivityViewModel::class.java]
74 | viewModel.navigationData.observe(viewLifecycleOwner) {
75 | displayNavigationData(it)
76 | }
77 | viewModel.speed.observe(viewLifecycleOwner) {
78 | binding.txtSpeed.text = "$it km/h"
79 | }
80 |
81 | return binding.root
82 | }
83 |
84 | override fun onDestroyView() {
85 | super.onDestroyView()
86 | mUiBinding = null
87 | }
88 | }
--------------------------------------------------------------------------------
/android-app/app/src/main/res/layout/fragment_home.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
19 |
20 |
31 |
32 |
42 |
43 |
53 |
54 |
64 |
65 |
75 |
76 |
86 |
87 |
95 |
96 |
--------------------------------------------------------------------------------
/esp32/esp32.ino:
--------------------------------------------------------------------------------
1 | #include "ble.h"
2 | #include "config.h"
3 | #include "keyval.h"
4 | #include "preferences.h"
5 | #include "scheduler.h"
6 | #include "theme.h"
7 | #include "ui.h"
8 |
9 | #include
10 |
11 | std::queue navigationQueue{};
12 | bool connectionChanged = true;
13 | bool oldIsOverspeed = false;
14 |
15 | void onCharacteristicWrite(const String& uuid, uint8_t* data, size_t length) {
16 | String value = (uuid != CHA_NAV_TBT_ICON) ? String((char*)(data)) : String();
17 |
18 | if (uuid == CHA_SETTINGS) {
19 | const auto kv = kvParseMultiline(value);
20 |
21 | Pref::lightTheme = kv.getOrDefault("lightTheme", "false") == "true";
22 | Pref::brightness = kv.getOrDefault("brightness", "100").toInt();
23 | Pref::speedLimit = kv.getOrDefault("speedLimit", "60").toInt();
24 |
25 | lcd.setBrightness(Pref::brightness);
26 | Pref::lightTheme ? ThemeControl::light() : ThemeControl::dark();
27 |
28 | if (kv.contains("removeAllFiles")) {
29 | Data::removeAllFiles();
30 | }
31 | }
32 |
33 | if (uuid == CHA_NAV) {
34 | navigationQueue.push(value);
35 | pongNavigation();
36 | }
37 |
38 | if (uuid == CHA_NAV_TBT_ICON) {
39 | int semicolonIndex = -1;
40 | for (uint8_t i = 0; i < 16; i++) {
41 | if (data[i] == ';') {
42 | semicolonIndex = i;
43 | break;
44 | }
45 | }
46 |
47 | if (semicolonIndex <= 0) {
48 | Serial.println("No hash found");
49 | return;
50 | }
51 |
52 | String iconHash = String((char*)data, semicolonIndex);
53 | auto iconSize = length - semicolonIndex - 1;
54 | Serial.println(String("Received icon w/ hash: ") + iconHash + " size: " + iconSize);
55 |
56 | if (iconSize != ICON_BITMAP_BUFFER_SIZE) {
57 | Serial.println("Invalid icon size");
58 | return;
59 | }
60 |
61 | Data::receiveNewIcon(iconHash, data + semicolonIndex + 1);
62 |
63 | pongNavigation();
64 | }
65 |
66 | if (uuid == CHA_GPS_SPEED) {
67 | navigationQueue.push(String("speed=") + value);
68 |
69 | pongSpeed();
70 | }
71 | }
72 |
73 | void onConnectionChange(bool connected) {
74 | connectionChanged = true;
75 | }
76 |
77 | uint32_t gLastNavigationDataReceived_ms = 0;
78 | uint32_t gLastSpeedDataReceived_ms = 0;
79 |
80 | void pongNavigation() {
81 | gLastNavigationDataReceived_ms = millis();
82 | }
83 |
84 | void pongSpeed() {
85 | gLastSpeedDataReceived_ms = millis();
86 | }
87 |
88 | void processQueue() {
89 | if (navigationQueue.empty())
90 | return;
91 |
92 | const auto& data = navigationQueue.front();
93 | const auto kv = kvParseMultiline(data);
94 |
95 | if (kv.contains("nextRd")) {
96 | Data::setNextRoad(kv.getOrDefault("nextRd"));
97 | }
98 | if (kv.contains("nextRdDesc")) {
99 | Data::setNextRoadDesc(kv.getOrDefault("nextRdDesc"));
100 | }
101 | if (kv.contains("distToNext")) {
102 | Data::setDistanceToNextTurn(kv.getOrDefault("distToNext"));
103 | }
104 | if (kv.contains("totalDist")) {
105 | Data::setTotalDistance(kv.getOrDefault("totalDist"));
106 | }
107 | if (kv.contains("eta")) {
108 | Data::setEta(kv.getOrDefault("eta"));
109 | }
110 | if (kv.contains("ete")) {
111 | Data::setEte(kv.getOrDefault("ete"));
112 | }
113 | if (kv.contains("iconHash")) {
114 | Data::setIconHash(kv.getOrDefault("iconHash"));
115 | }
116 | if (kv.contains("speed")) {
117 | Data::setSpeed(kv.getOrDefault("speed").toInt());
118 | }
119 |
120 | navigationQueue.pop();
121 | }
122 |
123 | void setup() {
124 | delay(2000);
125 |
126 | Serial.begin(115200);
127 | Serial.println("Initializing BLE...");
128 | initBle();
129 |
130 | Serial.println("Initializing UI...");
131 | UI::init();
132 |
133 | Data::init();
134 |
135 | lcd.setBrightness(Pref::brightness);
136 | ThemeControl::dark();
137 |
138 | Serial.println("Init done");
139 | }
140 |
141 | bool isOverspeed(int speed) {
142 | return speed >= Pref::speedLimit;
143 | }
144 |
145 | void loop() {
146 | UI::update();
147 | ThemeControl::update();
148 | Data::update();
149 |
150 | processQueue();
151 |
152 | // Overspeed check
153 | const auto newIsOverspeed = isOverspeed(Data::speed());
154 | if (newIsOverspeed != oldIsOverspeed) {
155 | oldIsOverspeed = newIsOverspeed;
156 |
157 | if (newIsOverspeed)
158 | ThemeControl::flashScreen();
159 | }
160 |
161 | DO_EVERY(10000) {
162 | if (isOverspeed(Data::speed())) {
163 | ThemeControl::flashScreen();
164 | }
165 | }
166 |
167 | // Connection status
168 | if (connectionChanged) {
169 | connectionChanged = false;
170 |
171 | if (!deviceConnected) {
172 | navigationQueue = std::queue();
173 | Data::clearNavigationData();
174 | Data::clearSpeedData();
175 | Data::setNextRoadDesc("Disconnected!");
176 | }
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/esp32/registers.h:
--------------------------------------------------------------------------------
1 | /**
2 | * ST7789 display registers
3 | */
4 |
5 | // Table 1
6 | #define REG_NOP 0x00 // No Operation
7 | #define REG_SWRESET 0x01 // Software Reset
8 | #define REG_RDDID 0x04 // Read Display ID
9 | #define REG_RDDST 0x09 // Read Display Status
10 | #define REG_RDDPM 0x0A // Read Display Power Mode
11 | #define REG_RDDMADCTL 0x0B // Read Display MADCTL
12 | #define REG_RDDCOLMOD 0x0C // Read Display Pixel Format
13 | #define REG_RDDIM 0x0D // Read Display Image Mode
14 | #define REG_RDDSM 0x0E // Read Display Signal Mode
15 | #define REG_RDDSR 0x0F // Read Display Self-Diagnostic Result (ST7789V)
16 | #define REG_SLPIN 0x10 // Sleep In
17 | #define REG_SLPOUT 0x11 // Sleep Out
18 | #define REG_PTLON 0x12 // Partial Display Mode ON
19 | #define REG_NORON 0x13 // Normal Display Mode ON
20 |
21 | #define REG_INVOFF 0x20 // Display Inversion OFF
22 | #define REG_INVON 0x21 // Display Inversion ON
23 | #define REG_GAMSET 0x26 // Gamma Set
24 | #define REG_DISPOFF 0x28 // Display OFF
25 | #define REG_DISPON 0x29 // Display ON
26 | #define REG_CASET 0x2A // Column Address Set
27 | #define REG_RASET 0x2B // Row Address Set
28 | #define REG_RAMWR 0x2C // Memory Write
29 | #define REG_RAMRD 0x2E // Memory Read
30 |
31 | #define REG_PTLAR 0x30 // Partial Area
32 | #define REG_VSCRDEF 0x33 // Vertical Scrolling Definition (ST7789V)
33 | #define REG_TEOFF 0x34 // Tearing Effect Line OFF
34 | #define REG_TEON 0x35 // Tearing Effect Line ON
35 | #define REG_MADCTL 0x36 // Memory Data Access Control
36 | #define REG_VSCSAD 0x37 // Vertical Scrolling Start Address
37 | #define REG_IDMOFF 0x38 // Idle Mode OFF
38 | #define REG_IDMON 0x39 // Idle Mode ON
39 | #define REG_COLMOD 0x3A // Interface Pixel Format
40 | #define REG_WRMEMC 0x3C // Memory Write Continue
41 | #define REG_RDMEMC 0x3E // Memory Read Continue
42 | #define REG_STE 0x44 // Set Tear Scanline
43 | #define REG_GSCAN 0x45 // Get Scanline
44 | #define REG_WRDISBV 0x51 // Write Display Brightness
45 | #define REG_RDDISBV 0x52 // Read Display Brightness Value
46 | #define REG_WRCTRLD 0x53 // Write CTRL Display
47 | #define REG_RDCTRLD 0x54 // Read CTRL Display
48 | #define REG_WRCACE 0x55 // Write Content Adaptive Brightness Control and Color Enhancement
49 | #define REG_RDCABC 0x56 // Read Content Adaptive Brightness Control
50 | #define REG_WRCABCMB 0x5E // Write CABC Minimum Brightness
51 | #define REG_RDCABCMB 0x5F // Read CABC Minimum Brightness
52 | #define REG_RDABCSDR 0x68 // Read Automatic Brightness Control Self-Diagnostic Result
53 | #define REG_RDID1 0xDA // Read ID1
54 | #define REG_RDID2 0xDB // Read ID2
55 | #define REG_RDID3 0xDC // Read ID3
56 |
57 | // Table 2
58 | #define REG_RAMCTRL 0xB0 // RAM Control
59 | #define REG_RGBCTRL 0xB1 // RGB Interface Control
60 | #define REG_PORCTRL 0xB2 // Porch Setting
61 | #define REG_FRCTRL1 0xB3 // Frame Rate Control 1
62 | #define REG_PARCTRL 0xB5 // Partial Display Control
63 | #define REG_GCTRL 0xB7 // Gate Control
64 | #define REG_GTADJ 0xB8 // Gate On Timing Adjustment
65 | #define REG_DGMEN 0xBA // Digital Gamma Enable
66 | #define REG_VCOMS 0xBB // VCOM Setting
67 | #define REG_POWSAVE 0xBC // Power Saving Mode
68 | #define REG_DLPOFFSAVE 0xBD // Display off Power Save
69 | #define REG_LCMCTRL 0xC0 // LCM Control
70 | #define REG_IDSET 0xC1 // ID Code Setting
71 | #define REG_VDVVRHEN 0xC2 // VDV and VRH Command Enable
72 | #define REG_VRHS 0xC3 // VRH Set
73 | #define REG_VDVS 0xC4 // VDV Set
74 | #define REG_VCMOFSET 0xC5 // VCOM Offset Set
75 | #define REG_FRCTR2 0xC6 // Frame Rate Control in Normal Mode
76 | #define REG_CABCCTRL 0xC7 // CABC Control
77 | #define REG_REGSEL1 0xC8 // Register Value Section 1
78 | #define REG_REGSEL2 0xCA // Register Value Section 2
79 | #define REG_PWMFRSEL 0xCC // PWM Frequency Selection
80 | #define REG_PWCTRL1 0xD0 // Power Control 1
81 | #define REG_VAPVANEN 0xD2 // Enable VAP/VAN signal output
82 | #define REG_CMD2EN 0xDF // Command 2 Enable
83 | #define REG_PVGAMCTRL 0xE0 // Positive Voltage Gamma Control
84 | #define REG_NVGAMCTRL 0xE1 // Negative Voltage Gamma Control
85 | #define REG_DGMLUTR 0xE2 // Digital Gamma Look-up Table for Red
86 | #define REG_DGMLUTB 0xE3 // Digital Gamma Look-up Table for Blue
87 | #define REG_GATECTRL 0xE4 // Gate Control
88 | #define REG_SPI2EN 0xE7 // SPI2 Enable
89 | #define REG_PWCTRL2 0xE8 // Power Control 2
90 | #define REG_EQCTRL 0xE9 // Equalize time control
91 | #define REG_PROMCTRL 0xEC // Program Mode Control
92 | #define REG_PROMEN 0xFA // Program Mode Enable
93 | #define REG_NVMSET 0xFC // NVM Setting
94 | #define REG_PROMACT 0xFE // Program action
95 |
96 | #define MADCTL_MY 0x80
97 | #define MADCTL_MX 0x40
98 | #define MADCTL_MV 0x20
99 | #define MADCTL_ML 0x10
100 | #define MADCTL_RGB 0x00
101 |
--------------------------------------------------------------------------------
/android-app/app/src/debug/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
10 |
12 |
14 |
16 |
18 |
20 |
22 |
24 |
26 |
28 |
30 |
32 |
34 |
36 |
38 |
40 |
42 |
44 |
46 |
48 |
50 |
52 |
54 |
56 |
58 |
60 |
62 |
64 |
66 |
68 |
70 |
72 |
74 |
75 |
--------------------------------------------------------------------------------
/esp32/lcd.cpp:
--------------------------------------------------------------------------------
1 | #include "lcd.h"
2 | #include "registers.h"
3 | #include
4 |
5 | SimpleSt7789::SimpleSt7789(SPIClass* spi,
6 | const SPISettings& spiSettings,
7 | uint16_t width,
8 | uint16_t height,
9 | uint8_t cs,
10 | uint8_t dc,
11 | uint8_t rst,
12 | uint8_t backlight,
13 | Rotation rotation)
14 | : _spi(spi), _spiSettings(spiSettings), _width(width), _height(height), _pinCs(cs), _pinDc(dc), _pinRst(rst),
15 | _pinBacklight(backlight), _rotation(rotation), _xOffset(0), _yOffset(0) {
16 | }
17 |
18 | void SimpleSt7789::init() {
19 | pinMode(_pinCs, OUTPUT);
20 | pinMode(_pinDc, OUTPUT);
21 |
22 | if (_pinRst != -1) {
23 | pinMode(_pinRst, OUTPUT);
24 | }
25 |
26 | if (_pinBacklight != -1) {
27 | ledcAttach(_pinBacklight, 1000, 10);
28 | ledcWrite(_pinBacklight, 100);
29 | }
30 |
31 | reset();
32 |
33 | sendCommand(REG_SLPOUT);
34 | delay(120);
35 | setRotation(_rotation);
36 |
37 | sendCommandFixed(REG_COLMOD, {0x05});
38 | sendCommandFixed(REG_RAMCTRL, {0x00, 0xE8});
39 | sendCommandFixed(REG_PORCTRL, {0x0C, 0x0C, 0x00, 0x33, 0x33});
40 | sendCommandFixed(REG_GCTRL, {0x35});
41 | sendCommandFixed(REG_VCOMS, {0x35});
42 | sendCommandFixed(REG_LCMCTRL, {0x2C});
43 | sendCommandFixed(REG_VDVVRHEN, {0x01});
44 | sendCommandFixed(REG_VRHS, {0x13});
45 | sendCommandFixed(REG_VDVS, {0x20});
46 | sendCommandFixed(REG_FRCTR2, {0x0F});
47 | sendCommandFixed(REG_PWCTRL1, {0xA4, 0xA1});
48 | sendCommandFixed(0xD6, {0xA1});
49 | sendCommandFixed(REG_PVGAMCTRL, {0xF0, 0x00, 0x04, 0x04, 0x04, 0x05, 0x29, 0x33, 0x3E, 0x38, 0x12, 0x12, 0x28, 0x30});
50 | sendCommandFixed(REG_NVGAMCTRL, {0xF0, 0x07, 0x0A, 0x0D, 0x0B, 0x07, 0x28, 0x33, 0x3E, 0x36, 0x14, 0x14, 0x29, 0x32});
51 | sendCommand(REG_INVON);
52 | sendCommand(REG_SLPOUT);
53 | delay(120);
54 | sendCommand(REG_DISPON);
55 |
56 | setBrightness(100);
57 | }
58 |
59 | void SimpleSt7789::reset() {
60 | if (_pinRst == -1)
61 | return;
62 |
63 | digitalWrite(_pinCs, LOW);
64 | delay(50);
65 | digitalWrite(_pinRst, LOW);
66 | delay(50);
67 | digitalWrite(_pinRst, HIGH);
68 | delay(50);
69 | }
70 |
71 | void SimpleSt7789::setRotation(Rotation rotation) {
72 | uint8_t madctl = 0;
73 | switch (rotation) {
74 | case ROTATION_0: madctl = MADCTL_MX | MADCTL_MY | MADCTL_RGB; break;
75 | case ROTATION_90: madctl = MADCTL_MY | MADCTL_MV | MADCTL_RGB; break;
76 | case ROTATION_180: madctl = MADCTL_RGB; break;
77 | case ROTATION_270: madctl = MADCTL_MX | MADCTL_MV | MADCTL_RGB; break;
78 | }
79 |
80 | sendCommand(REG_MADCTL, &madctl, 1);
81 | }
82 |
83 | void SimpleSt7789::setOffset(uint16_t xOffset, uint16_t yOffset) {
84 | _xOffset = xOffset;
85 | _yOffset = yOffset;
86 | }
87 |
88 | void SimpleSt7789::setBrightness(uint8_t percent) {
89 | if (_pinBacklight == -1)
90 | return;
91 |
92 | percent = constrain(percent, 0, 100);
93 | ledcWrite(_pinBacklight, percent * 10);
94 | }
95 |
96 | void SimpleSt7789::flushWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t* color) {
97 | const auto w = x2 - x1 + 1;
98 | const auto h = y2 - y1 + 1;
99 | const auto numBytes = w * h * sizeof(uint16_t);
100 | setAddrWindow(x1, y1, x2, y2);
101 | sendData((const uint8_t*)color, numBytes);
102 | }
103 |
104 | void SimpleSt7789::invertDisplay(bool invert) {
105 | sendCommand(invert ? REG_INVON : REG_INVOFF);
106 | }
107 |
108 | void SimpleSt7789::setAddrWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) {
109 | const auto ox1 = x1 + _xOffset;
110 | const auto ox2 = x2 + _xOffset;
111 | const auto oy1 = y1 + _yOffset;
112 | const auto oy2 = y2 + _yOffset;
113 |
114 | if (_rotation == ROTATION_180 || _rotation == ROTATION_270) {
115 | sendCommandFixed(REG_CASET, {(uint8_t)((ox1) >> 8), (uint8_t)(ox1), (uint8_t)(ox2 >> 8), (uint8_t)(ox2)});
116 | sendCommandFixed(REG_RASET, {(uint8_t)((oy1) >> 8), (uint8_t)(oy1), (uint8_t)(oy2 >> 8), (uint8_t)(oy2)});
117 | } else {
118 | sendCommandFixed(REG_CASET, {(uint8_t)((oy1) >> 8), (uint8_t)(oy1), (uint8_t)(oy2 >> 8), (uint8_t)(oy2)});
119 | sendCommandFixed(REG_RASET, {(uint8_t)((ox1) >> 8), (uint8_t)(ox1), (uint8_t)(ox2 >> 8), (uint8_t)(ox2)});
120 | }
121 | sendCommand(REG_RAMWR);
122 | }
123 |
124 | void SimpleSt7789::sendCommand(uint8_t command, const uint8_t* data, size_t size) {
125 | _spi->beginTransaction(_spiSettings);
126 | digitalWrite(_pinCs, LOW);
127 | digitalWrite(_pinDc, LOW);
128 | _spi->transfer(command);
129 | if (data && size) {
130 | digitalWrite(_pinDc, HIGH);
131 | _spi->transferBytes(data, nullptr, size);
132 | }
133 | digitalWrite(_pinCs, HIGH);
134 | _spi->endTransaction();
135 | }
136 |
137 | void SimpleSt7789::sendData(const uint8_t* data, size_t size) {
138 | _spi->beginTransaction(_spiSettings);
139 | digitalWrite(_pinCs, LOW);
140 | digitalWrite(_pinDc, HIGH);
141 | _spi->transferBytes(data, nullptr, size);
142 | digitalWrite(_pinCs, HIGH);
143 | _spi->endTransaction();
144 | }
145 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/service/NavigationListener.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.service
2 |
3 | import android.service.notification.NotificationListenerService
4 | import android.service.notification.StatusBarNotification
5 | import com.maisonsmd.catdrive.lib.GMAPS_PACKAGE
6 | import com.maisonsmd.catdrive.lib.GMapsNotification
7 | import com.maisonsmd.catdrive.lib.NavigationData
8 | import com.maisonsmd.catdrive.lib.NavigationNotification
9 | import kotlinx.coroutines.*
10 | import timber.log.Timber
11 |
12 | @OptIn(DelicateCoroutinesApi::class)
13 | open class NavigationListener : NotificationListenerService() {
14 | private var mNotificationParserCoroutine: Job? = null
15 | private lateinit var mLastNotification: StatusBarNotification
16 |
17 | private var mCurrentNotification: NavigationNotification? = null
18 | private var mEnabled = false
19 |
20 | protected var enabled: Boolean
21 | get() = mEnabled
22 | set(value) {
23 | if (value == mEnabled)
24 | return
25 | if (value.also { mEnabled = it })
26 | checkActiveNotifications()
27 | else {
28 | mCurrentNotification = null
29 | }
30 | }
31 |
32 | val lastNavigationData: NavigationData? get() = mCurrentNotification?.navigationData
33 |
34 | override fun onListenerConnected() {
35 | super.onListenerConnected()
36 | checkActiveNotifications()
37 | }
38 |
39 | private fun checkActiveNotifications() {
40 | try {
41 | Timber.d("Checking for active Navigation notifications")
42 | this.activeNotifications.forEach { statusBarNotification ->
43 | // Timber.v(statusBarNotification.toString())
44 | onNotificationPosted(
45 | statusBarNotification
46 | )
47 | }
48 | } catch (e: Throwable) {
49 | Timber.e("Failed to check for active notifications: $e")
50 | }
51 | }
52 |
53 | private fun isGoogleMapsNotification(sbn: StatusBarNotification?): Boolean {
54 | // Timber.v("enabled ${mEnabled}, isOngoing: ${sbn!!.isOngoing}, id: ${sbn.id}")
55 | if (!enabled || sbn == null)
56 | return false
57 |
58 | if (!sbn.isOngoing || GMAPS_PACKAGE !in sbn.packageName)
59 | return false
60 |
61 | return (sbn.id == 1)
62 | }
63 |
64 | protected open fun onNavigationNotificationAdded(navNotification: NavigationNotification) {
65 | }
66 |
67 | protected open fun onNavigationNotificationUpdated(navNotification: NavigationNotification) {
68 | }
69 |
70 | protected open fun onNavigationNotificationRemoved(navNotification: NavigationNotification) {
71 | }
72 |
73 | override fun onNotificationPosted(sbn: StatusBarNotification?) {
74 | // Timber.v("onNotificationPosted ${sbn?.packageName}")
75 |
76 | if (isGoogleMapsNotification(sbn))
77 | handleGoogleNotification(sbn!!)
78 | }
79 |
80 | override fun onNotificationRemoved(sbn: StatusBarNotification?) {
81 | // Timber.v("onNotificationRemoved ${sbn?.packageName}")
82 |
83 | if (isGoogleMapsNotification(sbn)) {
84 | mNotificationParserCoroutine?.cancel()
85 |
86 | onNavigationNotificationRemoved(
87 | if (mCurrentNotification != null) mCurrentNotification!!
88 | else NavigationNotification(applicationContext, sbn!!)
89 | )
90 |
91 | mCurrentNotification = null
92 | }
93 | }
94 |
95 | private fun handleGoogleNotification(statusBarNotification: StatusBarNotification) {
96 | mLastNotification = statusBarNotification
97 | if (mNotificationParserCoroutine != null && mNotificationParserCoroutine!!.isActive)
98 | return
99 |
100 | mNotificationParserCoroutine = GlobalScope.launch(Dispatchers.Main) {
101 | val worker = GlobalScope.async(Dispatchers.Default) {
102 | return@async GMapsNotification(
103 | this@NavigationListener.applicationContext,
104 | mLastNotification
105 | )
106 | }
107 |
108 | try {
109 | val mapNotification = worker.await()
110 | val lastNotification = mCurrentNotification
111 |
112 | val updated: Boolean = if (lastNotification == null) {
113 | onNavigationNotificationAdded(mapNotification)
114 | true
115 | } else {
116 | lastNotification.navigationData != mapNotification.navigationData
117 | // Timber.v("Notification is different than previous: $updated")
118 | }
119 |
120 | if (updated) {
121 | mCurrentNotification = mapNotification
122 | onNavigationNotificationUpdated(mCurrentNotification!!)
123 | }
124 | } catch (error: Exception) {
125 | if (!mNotificationParserCoroutine!!.isCancelled)
126 | Timber.e("Got an error while parsing: $error")
127 | }
128 | }
129 | }
130 |
131 | }
132 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/utils/PermissionCheck.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.utils
2 |
3 | import android.Manifest
4 | import android.annotation.SuppressLint
5 | import android.bluetooth.BluetoothAdapter
6 | import android.bluetooth.BluetoothManager
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.content.pm.PackageManager
10 | import android.provider.Settings
11 | import androidx.appcompat.app.AppCompatActivity
12 | import androidx.core.app.ActivityCompat
13 | import com.maisonsmd.catdrive.GoogleMapNotificationListener
14 | import com.maisonsmd.catdrive.lib.Intents
15 | import timber.log.Timber
16 |
17 |
18 | class PermissionCheck {
19 | companion object {
20 | fun checkNotificationsAccessPermission(context: Context): Boolean {
21 | Settings.Secure.getString(
22 | context.contentResolver, "enabled_notification_listeners"
23 | ).also {
24 | return GoogleMapNotificationListener::class.qualifiedName.toString() in it
25 | }
26 | }
27 |
28 | // TODO: From API 33 we must request permission for notification posting
29 | fun checkNotificationPostingPermission(context: Context): Boolean {
30 | return true
31 | }
32 |
33 | fun checkLocationAccessPermission(context: Context): Boolean {
34 | return ActivityCompat.checkSelfPermission(
35 | context, Manifest.permission.ACCESS_FINE_LOCATION
36 | ) == PackageManager.PERMISSION_GRANTED
37 | && ActivityCompat.checkSelfPermission(
38 | context, Manifest.permission.ACCESS_COARSE_LOCATION
39 | ) == PackageManager.PERMISSION_GRANTED
40 | }
41 |
42 | fun checkBluetoothConnectPermission(context: Context): Boolean {
43 | return ActivityCompat.checkSelfPermission(
44 | context,
45 | Manifest.permission.BLUETOOTH_CONNECT
46 | ) == PackageManager.PERMISSION_GRANTED
47 | }
48 |
49 | fun checkBluetoothPermission(context: Context): Boolean {
50 | return ActivityCompat.checkSelfPermission(
51 | context,
52 | Manifest.permission.BLUETOOTH
53 | ) == PackageManager.PERMISSION_GRANTED
54 | }
55 |
56 | fun checkBluetoothAdminPermission(context: Context): Boolean {
57 | return ActivityCompat.checkSelfPermission(
58 | context,
59 | Manifest.permission.BLUETOOTH_ADMIN
60 | ) == PackageManager.PERMISSION_GRANTED
61 | }
62 |
63 | fun checkBluetoothScanPermission(context: Context): Boolean {
64 | return ActivityCompat.checkSelfPermission(
65 | context,
66 | Manifest.permission.BLUETOOTH_SCAN
67 | ) == PackageManager.PERMISSION_GRANTED
68 | }
69 |
70 | fun checkBluetoothPermissions(context: Context): Boolean {
71 | return checkBluetoothConnectPermission(context)
72 | && checkBluetoothAdminPermission(context)
73 | && checkBluetoothScanPermission(context)
74 | && checkBluetoothPermission(context)
75 | }
76 |
77 | fun allPermissionsGranted(context: Context): Boolean {
78 | return checkNotificationsAccessPermission(context)
79 | && checkNotificationPostingPermission(context)
80 | && checkLocationAccessPermission(context)
81 | && checkBluetoothPermissions(context)
82 | }
83 |
84 | fun requestLocationAccessPermission(activity: AppCompatActivity) {
85 | if (checkLocationAccessPermission(activity)) return
86 | ActivityCompat.requestPermissions(
87 | activity,
88 | arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
89 | 100
90 | )
91 | }
92 |
93 | fun requestNotificationAccessPermission(activity: AppCompatActivity) {
94 | @Suppress("DEPRECATION") activity.startActivityForResult(
95 | Intent(Intents.OPEN_NOTIFICATION_LISTENER_SETTINGS),
96 | 0
97 | )
98 | }
99 |
100 | fun requestBluetoothAccessPermissions(activity: AppCompatActivity) {
101 | if (checkBluetoothPermissions(activity)) return
102 | ActivityCompat.requestPermissions(
103 | activity,
104 | arrayOf(
105 | Manifest.permission.BLUETOOTH_CONNECT,
106 | Manifest.permission.BLUETOOTH,
107 | Manifest.permission.BLUETOOTH_ADMIN,
108 | Manifest.permission.BLUETOOTH_SCAN,
109 | ),
110 | 100
111 | )
112 | }
113 |
114 | fun isBluetoothEnabled(context: Context): Boolean {
115 | val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter!!
116 | return adapter.isEnabled
117 | }
118 |
119 | @SuppressLint("MissingPermission")
120 | fun requestEnableBluetooth(activity: AppCompatActivity) {
121 | if (!checkBluetoothPermissions(activity)) {
122 | Timber.e("No bluetooth permission!!!")
123 | return
124 | }
125 |
126 | if (isBluetoothEnabled(activity.applicationContext))
127 | return
128 |
129 | val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
130 | activity.startActivityForResult(enableBtIntent, 102)
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/android-app/app/src/main/java/com/maisonsmd/catdrive/ui/BleDeviceSelectionActivity.kt:
--------------------------------------------------------------------------------
1 | package com.maisonsmd.catdrive.ui
2 |
3 | import android.annotation.SuppressLint
4 | import android.bluetooth.BluetoothAdapter
5 | import android.bluetooth.BluetoothDevice
6 | import android.bluetooth.BluetoothManager
7 | import android.bluetooth.le.ScanCallback
8 | import android.bluetooth.le.ScanResult
9 | import android.content.Context
10 | import android.content.Intent
11 | import android.os.Bundle
12 | import android.os.Handler
13 | import android.view.LayoutInflater
14 | import android.view.View
15 | import android.view.ViewGroup
16 | import android.widget.Button
17 | import android.widget.FrameLayout
18 | import android.widget.TextView
19 | import androidx.appcompat.app.AppCompatActivity
20 | import androidx.recyclerview.widget.RecyclerView
21 | import com.maisonsmd.catdrive.R
22 | import com.maisonsmd.catdrive.utils.PermissionCheck
23 | import timber.log.Timber
24 |
25 |
26 | class CustomBleAdapter(private val onSelectCallback: (BluetoothDevice) -> Unit) :
27 | RecyclerView.Adapter() {
28 | private var mDataSet: MutableList = mutableListOf()
29 |
30 | fun clear() {
31 | val size = mDataSet.size
32 | mDataSet.clear()
33 | notifyItemRangeRemoved(0, size)
34 | }
35 |
36 | fun addItem(device: BluetoothDevice) {
37 | if (contains(device))
38 | return
39 |
40 | mDataSet.add(device)
41 | notifyItemInserted(mDataSet.size - 1)
42 | }
43 |
44 | fun contains(device: BluetoothDevice): Boolean {
45 | return mDataSet.contains(device)
46 | }
47 |
48 | // Return the size of your dataset (invoked by the layout manager)
49 | override fun getItemCount() = mDataSet.size
50 |
51 | class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
52 | val txtDeviceName: TextView = view.findViewById(R.id.txtItemDeviceName)
53 | val txtDeviceAddress: TextView = view.findViewById(R.id.txtItemMacAddress)
54 | val frame: FrameLayout = view.findViewById(R.id.frame)
55 | }
56 |
57 | // Create new views (invoked by the layout manager)
58 | override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
59 | // Create a new view, which defines the UI of the list item
60 | val view = LayoutInflater.from(viewGroup.context)
61 | .inflate(R.layout.device_row_item, viewGroup, false)
62 |
63 | return ViewHolder(view)
64 | }
65 |
66 | // Replace the contents of a view (invoked by the layout manager)
67 | @SuppressLint("MissingPermission")
68 | override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
69 |
70 | // Get element from your dataset at this position and replace the
71 | // contents of the view with that element
72 | viewHolder.txtDeviceName.text = mDataSet[position].name
73 | viewHolder.txtDeviceAddress.text = mDataSet[position].address
74 |
75 | viewHolder.frame.setOnClickListener { _ -> onSelectCallback(mDataSet[position]) }
76 | }
77 | }
78 |
79 | @SuppressLint("MissingPermission")
80 | class BleDeviceSelectionActivity : AppCompatActivity() {
81 | private lateinit var mViewDeviceAdapter: CustomBleAdapter
82 | private var isScanning = false
83 | private lateinit var scanButton: Button
84 | private lateinit var mAdapter: BluetoothAdapter
85 | private val mCallback: ScanCallback = object : ScanCallback() {
86 | override fun onScanResult(callbackType: Int, result: ScanResult) {
87 | // Timber.d("onScanResult ${result.device.name}(${result.device.address})")
88 | super.onScanResult(callbackType, result)
89 |
90 | val btDevice = result.device
91 | mViewDeviceAdapter.addItem(btDevice)
92 | }
93 | }
94 |
95 | private fun onDeviceSelected(device: BluetoothDevice) {
96 | Timber.d("onDeviceSelected: $device")
97 | mAdapter.bluetoothLeScanner.stopScan(mCallback)
98 | setResult(RESULT_OK, Intent().apply { putExtra("device", device) })
99 | finish()
100 | }
101 |
102 | override fun onCreate(savedInstanceState: Bundle?) {
103 | super.onCreate(savedInstanceState)
104 | setContentView(R.layout.activity_ble_selection)
105 |
106 | supportActionBar?.setDisplayHomeAsUpEnabled(true)
107 |
108 | mViewDeviceAdapter = CustomBleAdapter(this::onDeviceSelected)
109 | findViewById(R.id.viewBleDeviceList).apply { adapter = mViewDeviceAdapter }
110 | scanButton = findViewById