├── src
├── bluetooth
│ ├── bluetooth.c
│ └── bluetooth.h
├── watchdog
│ ├── watchdog.h
│ └── watchdog.c
├── notifications
│ ├── notifications.h
│ └── notifications.c
├── display
│ ├── display.h
│ └── display.c
├── graphics
│ ├── graphics.h
│ └── graphics.c
└── main.c
├── AndroidApp
├── app
│ ├── .gitignore
│ ├── src
│ │ ├── main
│ │ │ ├── res
│ │ │ │ ├── values
│ │ │ │ │ ├── strings.xml
│ │ │ │ │ ├── themes.xml
│ │ │ │ │ └── colors.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
│ │ │ │ ├── xml
│ │ │ │ │ ├── backup_rules.xml
│ │ │ │ │ └── data_extraction_rules.xml
│ │ │ │ └── drawable
│ │ │ │ │ ├── ic_launcher_foreground.xml
│ │ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── java
│ │ │ │ └── net
│ │ │ │ │ └── yehudae
│ │ │ │ │ └── esp32s3notificationsreceiver
│ │ │ │ │ ├── ui
│ │ │ │ │ └── theme
│ │ │ │ │ │ ├── Color.kt
│ │ │ │ │ │ ├── Type.kt
│ │ │ │ │ │ └── Theme.kt
│ │ │ │ │ ├── NotificationData.kt
│ │ │ │ │ ├── NotificationSettings.kt
│ │ │ │ │ ├── NotificationWorker.kt
│ │ │ │ │ ├── NotificationListener.kt
│ │ │ │ │ ├── DeviceDiscoveryScreen.kt
│ │ │ │ │ ├── BLEService.kt
│ │ │ │ │ └── SettingsScreen.kt
│ │ │ └── AndroidManifest.xml
│ │ └── test
│ │ │ └── java
│ │ │ └── net
│ │ │ └── yehudae
│ │ │ └── esp32s3notificationsreceiver
│ │ │ └── ExampleUnitTest.kt
│ ├── proguard-rules.pro
│ └── build.gradle.kts
├── .idea
│ ├── .name
│ ├── .gitignore
│ ├── codeStyles
│ │ ├── codeStyleConfig.xml
│ │ └── Project.xml
│ ├── compiler.xml
│ ├── vcs.xml
│ ├── AndroidProjectSystem.xml
│ ├── migrations.xml
│ ├── deploymentTargetSelector.xml
│ ├── misc.xml
│ ├── gradle.xml
│ └── runConfigurations.xml
├── gradle
│ ├── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ └── libs.versions.toml
├── build.gradle.kts
├── .gitignore
├── settings.gradle.kts
├── gradle.properties
├── gradlew.bat
└── gradlew
├── README.md
├── boards
└── esp32.overlay
├── CMakeLists.txt
└── prj.conf
/src/bluetooth/bluetooth.c:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/bluetooth/bluetooth.h:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/AndroidApp/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/AndroidApp/.idea/.name:
--------------------------------------------------------------------------------
1 | ESP32S3 Notifications Receiver
--------------------------------------------------------------------------------
/AndroidApp/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ESP32S3-Notifications-Receiver
2 | ESP Notification receiver with BLE. Working on ESP32-S3-Touch-LCD-1.28 with Zephyr RTOS.
3 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | ESP32S3 Notifications Receiver
3 |
4 |
--------------------------------------------------------------------------------
/AndroidApp/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/AndroidApp/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/YehudaEi/ESP32S3-Notifications-Receiver/master/AndroidApp/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/AndroidApp/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/boards/esp32.overlay:
--------------------------------------------------------------------------------
1 | / {
2 | chosen {
3 | nr,rtc = &rtc_timer;
4 | nr,lcd = &gc9a01;
5 | nr,lcd-backlight = &pwm_lcd0;
6 | nr,wdt = &wdt0;
7 | };
8 | };
9 |
10 | &rtc_timer {
11 | status = "okay";
12 | };
13 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/AndroidProjectSystem.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/AndroidApp/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Sep 21 20:06:12 IDT 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/AndroidApp/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.kotlin.android) apply false
5 | alias(libs.plugins.kotlin.compose) apply false
6 | }
--------------------------------------------------------------------------------
/AndroidApp/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/deploymentTargetSelector.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | set(BOARD esp32s3_touch_lcd_1_28/esp32s3/procpu)
2 | set(DTC_OVERLAY_FILE boards/esp32.overlay)
3 |
4 | cmake_minimum_required(VERSION 3.20.0)
5 | find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
6 | project(ESP32S3NotificationsReceiver)
7 |
8 | file(GLOB_RECURSE app_sources src/*.c)
9 |
10 | target_sources(app PRIVATE ${app_sources})
11 | target_include_directories(app PRIVATE src/)
12 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/NotificationData.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import android.os.Parcelable
4 | import kotlinx.parcelize.Parcelize
5 |
6 | @Parcelize
7 | data class NotificationData(
8 | val appName: String,
9 | val title: String,
10 | val text: String,
11 | val timestamp: String,
12 | val isPriority: Boolean = false,
13 | val packageName: String = ""
14 | ) : Parcelable
15 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/test/java/net/yehudae/esp32s3notificationsreceiver/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/AndroidApp/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "ESP32S3 Notifications Receiver"
23 | include(":app")
24 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/AndroidApp/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
--------------------------------------------------------------------------------
/prj.conf:
--------------------------------------------------------------------------------
1 | # Log Configurations
2 | CONFIG_LOG=y
3 | CONFIG_LOG_MODE_IMMEDIATE=y
4 |
5 | # Display Configurations
6 | CONFIG_DISPLAY=y
7 |
8 | # LVGL configuration - Core
9 | CONFIG_LVGL=y
10 | CONFIG_LV_Z_MEM_POOL_SIZE=32768
11 | CONFIG_LV_Z_VDB_SIZE=16
12 |
13 | # LVGL configuration - Features
14 | CONFIG_LV_USE_LOG=y
15 |
16 | # LVGL configuration - Fonts
17 | CONFIG_LV_FONT_MONTSERRAT_46=y
18 | CONFIG_LV_FONT_MONTSERRAT_18=y
19 | CONFIG_LV_FONT_MONTSERRAT_16=y
20 | CONFIG_LV_FONT_MONTSERRAT_14=y
21 | CONFIG_LV_FONT_MONTSERRAT_12=y
22 | CONFIG_LV_FONT_MONTSERRAT_10=y
23 | CONFIG_LV_FONT_DEFAULT_MONTSERRAT_16=y
24 |
25 | # PWM Configurations
26 | CONFIG_PWM=y
27 |
28 | # Settings Subsystem
29 | CONFIG_SETTINGS=y
30 |
31 | # Input Subsystem Configuration
32 | CONFIG_INPUT=y
33 | CONFIG_INPUT_LOG_LEVEL_OFF=y
34 | CONFIG_I2C_LOG_LEVEL_OFF=y
35 |
36 | # Watchdog Timer
37 | CONFIG_WATCHDOG=y
38 | CONFIG_REBOOT=y
39 |
40 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/AndroidApp/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. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-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
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/AndroidApp/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.13.0"
3 | kotlin = "2.0.21"
4 | coreKtx = "1.10.1"
5 | junit = "4.13.2"
6 | junitVersion = "1.1.5"
7 | espressoCore = "3.5.1"
8 | lifecycleRuntimeKtx = "2.6.1"
9 | activityCompose = "1.8.0"
10 | composeBom = "2024.09.00"
11 |
12 | [libraries]
13 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
14 | junit = { group = "junit", name = "junit", version.ref = "junit" }
15 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
16 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
17 | androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
18 | androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
19 | androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
20 | androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
21 | androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
22 | androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
23 | androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
24 | androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
25 | androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
26 | androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
27 |
28 | [plugins]
29 | android-application = { id = "com.android.application", version.ref = "agp" }
30 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
31 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
32 |
33 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.ui.platform.LocalContext
13 |
14 | private val DarkColorScheme = darkColorScheme(
15 | primary = Purple80,
16 | secondary = PurpleGrey80,
17 | tertiary = Pink80
18 | )
19 |
20 | private val LightColorScheme = lightColorScheme(
21 | primary = Purple40,
22 | secondary = PurpleGrey40,
23 | tertiary = Pink40
24 |
25 | /* Other default colors to override
26 | background = Color(0xFFFFFBFE),
27 | surface = Color(0xFFFFFBFE),
28 | onPrimary = Color.White,
29 | onSecondary = Color.White,
30 | onTertiary = Color.White,
31 | onBackground = Color(0xFF1C1B1F),
32 | onSurface = Color(0xFF1C1B1F),
33 | */
34 | )
35 |
36 | @Composable
37 | fun ESP32S3NotificationsReceiverTheme(
38 | darkTheme: Boolean = isSystemInDarkTheme(),
39 | // Dynamic color is available on Android 12+
40 | dynamicColor: Boolean = true,
41 | content: @Composable () -> Unit
42 | ) {
43 | val colorScheme = when {
44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
45 | val context = LocalContext.current
46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
47 | }
48 |
49 | darkTheme -> DarkColorScheme
50 | else -> LightColorScheme
51 | }
52 |
53 | MaterialTheme(
54 | colorScheme = colorScheme,
55 | typography = Typography,
56 | content = content
57 | )
58 | }
--------------------------------------------------------------------------------
/src/watchdog/watchdog.h:
--------------------------------------------------------------------------------
1 | /**
2 | * @file watchdog.h
3 | * @brief Watchdog Timer Management Module Header
4 | *
5 | * This header defines the public interface for the watchdog timer management
6 | * module in Zephyr RTOS applications.
7 | *
8 | * @author Yehuda@YehudaE.net
9 | */
10 |
11 | #ifndef WATCHDOG_H
12 | #define WATCHDOG_H
13 |
14 | #include
15 | #include
16 | #include
17 |
18 | #ifdef __cplusplus
19 | extern "C" {
20 | #endif
21 |
22 | /*==============================================================================
23 | * PUBLIC FUNCTION DECLARATIONS
24 | *============================================================================*/
25 |
26 | /**
27 | * @brief Enables and configures the hardware watchdog timer
28 | *
29 | * Initializes the watchdog timer with predefined configuration parameters.
30 | * Must be called before using kick_watchdog().
31 | *
32 | * @return 0 on success, negative error code on failure
33 | */
34 | int enable_watchdog(void);
35 |
36 | /**
37 | * @brief Disables the hardware watchdog timer
38 | *
39 | * Stops the watchdog timer and disables watchdog functionality.
40 | * Should be called before system shutdown if needed.
41 | *
42 | * @return 0 on success, negative error code on failure
43 | */
44 | int disable_watchdog(void);
45 |
46 | /**
47 | * @brief Feeds (kicks) the watchdog timer to prevent timeout
48 | *
49 | * Must be called periodically to prevent system reset.
50 | * Call frequency should be less than the configured timeout period.
51 | *
52 | * @return 0 on success, negative error code on failure
53 | */
54 | int kick_watchdog(void);
55 |
56 | /**
57 | * @brief Gets the current status of the watchdog timer
58 | *
59 | * @return true if watchdog is enabled and running, false otherwise
60 | */
61 | bool is_watchdog_enabled(void);
62 |
63 | /**
64 | * @brief Gets the configured watchdog timeout in milliseconds
65 | *
66 | * @return Timeout value in milliseconds
67 | */
68 | uint32_t get_watchdog_timeout_ms(void);
69 |
70 | /**
71 | * @brief Gets the watchdog channel ID
72 | *
73 | * @return Channel ID if watchdog is enabled, -1 if disabled
74 | */
75 | int get_watchdog_channel_id(void);
76 |
77 | #ifdef __cplusplus
78 | }
79 | #endif
80 |
81 | #endif /* WATCHDOG_H */
82 |
--------------------------------------------------------------------------------
/src/notifications/notifications.h:
--------------------------------------------------------------------------------
1 | /**
2 | * @file notifications.h
3 | * @brief Notifications Screen Management Header
4 | *
5 | * @author Yehuda@YehudaE.net
6 | */
7 |
8 | #ifndef NOTIFICATIONS_H
9 | #define NOTIFICATIONS_H
10 |
11 | #include
12 | #include
13 |
14 | #ifdef __cplusplus
15 | extern "C" {
16 | #endif
17 |
18 | // Connection status enum
19 | typedef enum {
20 | CONN_CONNECTED, // Green
21 | CONN_WEAK_SIGNAL, // Yellow
22 | CONN_CONNECTING, // Blue
23 | CONN_DISCONNECTED // Red
24 | } connection_status_t;
25 |
26 | /**
27 | * @brief Create the main notification screen
28 | *
29 | * This function creates the complete notification UI including:
30 | * - Top bar with time and connection status
31 | * - App info and notification content
32 | * - Touch gesture handlers
33 | * - Sample notifications for testing
34 | */
35 | void create_notification_screen(void);
36 |
37 | /**
38 | * @brief Update connection status indicator
39 | *
40 | * @param status Connection status to display
41 | */
42 | void notifications_update_connection_status(connection_status_t status);
43 |
44 | /**
45 | * @brief Update time display
46 | *
47 | * @param time_str Time string in HH:MM format
48 | */
49 | void notifications_update_time(const char* time_str);
50 |
51 | /**
52 | * @brief Add a new notification
53 | *
54 | * @param app_name Name of the app (max 31 chars)
55 | * @param sender Sender name (max 63 chars)
56 | * @param content Notification content (max 255 chars)
57 | * @param timestamp Time string (max 15 chars)
58 | */
59 | void notifications_add_notification(const char* app_name, const char* sender,
60 | const char* content, const char* timestamp);
61 |
62 | /**
63 | * @brief Clear all notifications
64 | */
65 | void notifications_clear_all(void);
66 |
67 | /**
68 | * @brief Get count of unread notifications
69 | *
70 | * @return Number of unread notifications
71 | */
72 | int notifications_get_unread_count(void);
73 |
74 | /**
75 | * @brief Handle internal timers (call in main loop)
76 | *
77 | * This handles delete timeout functionality
78 | */
79 | void notifications_handle_timers(void);
80 |
81 | /**
82 | * @brief Demo function for testing status changes
83 | *
84 | * Optional function for testing different UI states
85 | */
86 | void demo_status_changes(void);
87 |
88 | #ifdef __cplusplus
89 | }
90 | #endif
91 |
92 | #endif /* NOTIFICATIONS_H */
93 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
18 |
19 |
20 |
23 |
24 |
34 |
35 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
50 |
51 |
52 |
53 |
54 |
55 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/AndroidApp/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.kotlin.android)
4 | alias(libs.plugins.kotlin.compose)
5 | id("kotlin-parcelize")
6 | }
7 |
8 | android {
9 | namespace = "net.yehudae.esp32s3notificationsreceiver"
10 | compileSdk = 36
11 |
12 | defaultConfig {
13 | applicationId = "net.yehudae.esp32s3notificationsreceiver"
14 | minSdk = 24
15 | targetSdk = 36
16 | versionCode = 1
17 | versionName = "1.0"
18 |
19 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
20 | vectorDrawables {
21 | useSupportLibrary = true
22 | }
23 | }
24 |
25 | buildTypes {
26 | release {
27 | isMinifyEnabled = false
28 | proguardFiles(
29 | getDefaultProguardFile("proguard-android-optimize.txt"),
30 | "proguard-rules.pro"
31 | )
32 | }
33 | }
34 | compileOptions {
35 | sourceCompatibility = JavaVersion.VERSION_11
36 | targetCompatibility = JavaVersion.VERSION_11
37 | }
38 | kotlinOptions {
39 | jvmTarget = "11"
40 | }
41 | buildFeatures {
42 | compose = true
43 | }
44 | lint {
45 | abortOnError = false
46 | checkReleaseBuilds = false
47 | }
48 | packaging {
49 | resources {
50 | excludes += "/META-INF/{AL2.0,LGPL2.1}"
51 | }
52 | }
53 | }
54 |
55 | dependencies {
56 |
57 | implementation(libs.androidx.core.ktx)
58 | implementation(libs.androidx.lifecycle.runtime.ktx)
59 | implementation(libs.androidx.activity.compose)
60 | implementation(platform(libs.androidx.compose.bom))
61 | implementation(libs.androidx.compose.ui)
62 | implementation(libs.androidx.compose.ui.graphics)
63 | implementation(libs.androidx.compose.ui.tooling.preview)
64 | implementation(libs.androidx.compose.material3)
65 |
66 | // Additional Material Icons
67 | implementation("androidx.compose.material:material-icons-core")
68 | implementation("androidx.compose.material:material-icons-extended")
69 |
70 | // Coroutines for Flow
71 | implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
72 |
73 | // WorkManager for background tasks (Garmin-like functionality)
74 | implementation("androidx.work:work-runtime-ktx:2.9.0")
75 |
76 | testImplementation(libs.junit)
77 | androidTestImplementation(libs.androidx.junit)
78 | androidTestImplementation(libs.androidx.espresso.core)
79 | androidTestImplementation(platform(libs.androidx.compose.bom))
80 | androidTestImplementation(libs.androidx.compose.ui.test.junit4)
81 | debugImplementation(libs.androidx.compose.ui.tooling)
82 | debugImplementation(libs.androidx.compose.ui.test.manifest)
83 | }
84 |
--------------------------------------------------------------------------------
/AndroidApp/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 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/src/display/display.h:
--------------------------------------------------------------------------------
1 | /**
2 | * @file display.h
3 | * @brief LCD Display Driver Header
4 | *
5 | * This header provides the interface for LCD display management in Zephyr RTOS.
6 | * The driver supports PWM-controlled backlight and display power management.
7 | *
8 | * @author Yehuda@YehudaE.net
9 | */
10 |
11 | #ifndef DISPLAY_H_
12 | #define DISPLAY_H_
13 |
14 | #include
15 | #include
16 | #include
17 |
18 | #ifdef __cplusplus
19 | extern "C" {
20 | #endif
21 |
22 | /**
23 | * @defgroup display_api Display Driver API
24 | * @brief LCD Display Driver API
25 | * @{
26 | */
27 |
28 | /**
29 | * @brief Initialize and enable the LCD display
30 | *
31 | * This function performs complete display initialization including:
32 | * - Display device validation and setup
33 | * - PWM backlight initialization with default brightness (50%)
34 | * - Display blanking disable (turns display on)
35 | *
36 | * @retval 0 Success
37 | * @retval -ENODEV Display or PWM device not ready
38 | * @retval -EINVAL Invalid device configuration
39 | * @retval Other negative errno codes on other failures
40 | *
41 | * @note This function should be called once during system initialization
42 | */
43 | int enable_display(void);
44 |
45 | /**
46 | * @brief Disable and shutdown the LCD display
47 | *
48 | * This function performs graceful display shutdown:
49 | * - Enables display blanking (turns display off)
50 | * - Sets backlight to minimum brightness
51 | *
52 | * @retval 0 Success
53 | * @retval Negative errno codes on failure
54 | *
55 | * @note This function can be called during system shutdown or power saving
56 | */
57 | int disable_display(void);
58 |
59 | /**
60 | * @brief Change display backlight brightness
61 | *
62 | * Adjusts the PWM duty cycle to control backlight brightness.
63 | * Input values are automatically clamped to the valid range (5-100%).
64 | *
65 | * @param perc Desired brightness percentage (0-100)
66 | * - Values < 5% are automatically set to 5%
67 | * - Values > 100% are automatically set to 100%
68 | *
69 | * @retval 0 Success
70 | * @retval -ENODEV PWM device not ready
71 | * @retval -EINVAL Invalid parameters
72 | * @retval Other negative errno codes on PWM operation failure
73 | *
74 | * @note Minimum brightness is enforced to ensure display remains visible
75 | */
76 | int change_brightness(uint8_t perc);
77 |
78 | /**
79 | * @brief Get current display readiness status
80 | *
81 | * Checks if both display and PWM backlight devices are ready for operation.
82 | *
83 | * @retval true Both display and backlight are ready
84 | * @retval false One or both devices are not ready
85 | *
86 | * @note This function can be used to verify system state before operations
87 | */
88 | bool is_display_ready(void);
89 |
90 | /**
91 | * @brief Control display blanking state
92 | *
93 | * Enables or disables display blanking (screen on/off) without affecting
94 | * the backlight PWM settings.
95 | *
96 | * @param blank true to enable blanking (turn off display),
97 | * false to disable blanking (turn on display)
98 | *
99 | * @retval 0 Success
100 | * @retval -ENODEV Display device not ready
101 | * @retval Other negative errno codes on display operation failure
102 | */
103 | int set_display_blanking(bool blank);
104 |
105 | /**
106 | * @}
107 | */
108 |
109 | #ifdef __cplusplus
110 | }
111 | #endif
112 |
113 | #endif /* DISPLAY_H_ */
114 |
--------------------------------------------------------------------------------
/src/graphics/graphics.h:
--------------------------------------------------------------------------------
1 | /**
2 | * @file graphics.h
3 | * @brief LVGL Initialization Header for Zephyr
4 | *
5 | * Header file for LVGL initialization functions and utilities
6 | * for Zephyr RTOS applications.
7 | *
8 | * @author Yehuda@YehudaE.net
9 | */
10 |
11 | #ifndef __GRAPHICS_H__
12 | #define __GRAPHICS_H__
13 |
14 | #include
15 |
16 | #include
17 | #include
18 |
19 | #ifdef __cplusplus
20 | extern "C" {
21 | #endif
22 |
23 | /**
24 | * @defgroup lvgl_zephyr LVGL Zephyr Integration
25 | * @brief LVGL integration functions for Zephyr RTOS
26 | * @{
27 | */
28 |
29 | /**
30 | * @brief Initialize LVGL graphics library
31 | *
32 | * Performs complete LVGL initialization including:
33 | * - Core LVGL library initialization
34 | * - Display driver setup with Zephyr display API
35 | * - Input device configuration (touchscreen/buttons)
36 | * - Threading and timing setup
37 | * - Initial user interface creation
38 | *
39 | * @retval 0 Success
40 | * @retval -ENODEV Display device not ready
41 | * @retval -ENOMEM Insufficient memory for buffers
42 | * @retval -EFAULT Failed to register drivers with LVGL
43 | *
44 | * @note This function should be called after display hardware is initialized
45 | * @note Requires CONFIG_LVGL=y in prj.conf
46 | */
47 | int init_lvgl_graphics(void);
48 |
49 | /**
50 | * @brief Deinitialize LVGL graphics library
51 | *
52 | * Performs graceful shutdown of LVGL components:
53 | * - Stops LVGL task handler thread
54 | * - Stops LVGL timer
55 | * - Cleans up LVGL core
56 | *
57 | * @retval 0 Success
58 | * @retval Negative errno codes on failure
59 | *
60 | * @note Call during system shutdown or before system reset
61 | */
62 | int deinit_lvgl_graphics(void);
63 |
64 | /**
65 | * @brief Check if LVGL is initialized and ready
66 | *
67 | * @retval true LVGL is initialized and ready for use
68 | * @retval false LVGL is not initialized
69 | *
70 | * @note Use this to verify LVGL state before creating UI elements
71 | */
72 | bool is_lvgl_ready(void);
73 |
74 | /**
75 | * @brief Get LVGL display object
76 | *
77 | * Returns the main LVGL display object for advanced operations.
78 | *
79 | * @return Pointer to LVGL display object, NULL if not initialized
80 | */
81 | lv_display_t* get_lvgl_display(void);
82 |
83 | /**
84 | * @brief Create a simple notification UI
85 | *
86 | * Creates a basic notification display with title and message.
87 | *
88 | * @param title Notification title (max 64 characters)
89 | * @param message Notification message (max 256 characters)
90 | * @param timeout_ms Auto-dismiss timeout in milliseconds (0 = no timeout)
91 | *
92 | * @return Pointer to created notification object, NULL on failure
93 | */
94 | lv_obj_t* create_notification_ui(const char* title, const char* message, uint32_t timeout_ms);
95 |
96 | /**
97 | * @brief Update display brightness via LVGL
98 | *
99 | * Updates display brightness and adjusts LVGL theme accordingly.
100 | *
101 | * @param brightness Brightness level (0-100%)
102 | * @return 0 on success, negative error code on failure
103 | */
104 | int lvgl_set_brightness(uint8_t brightness);
105 |
106 | /**
107 | * @brief Set LVGL theme mode
108 | *
109 | * @param dark_mode true for dark theme, false for light theme
110 | * @return 0 on success, negative error code on failure
111 | */
112 | int lvgl_set_theme_mode(bool dark_mode);
113 |
114 | /**
115 | * @brief Force LVGL display refresh
116 | *
117 | * Forces an immediate refresh of the entire display.
118 | * Use sparingly as it bypasses LVGL's optimization.
119 | */
120 | void lvgl_force_refresh(void);
121 |
122 | /**
123 | * @brief LVGL task handler function (for manual integration)
124 | *
125 | * Call this function periodically if not using the built-in thread.
126 | * Not needed when using init_lvgl_graphics() with automatic threading.
127 | *
128 | * @return Time to sleep before next call (milliseconds)
129 | */
130 | uint32_t lvgl_task_handler_manual(void);
131 |
132 | /**
133 | * @}
134 | */
135 |
136 | #ifdef __cplusplus
137 | }
138 | #endif
139 |
140 | #endif /* __GRAPHICS_H__ */
141 |
--------------------------------------------------------------------------------
/AndroidApp/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | xmlns:android
18 |
19 | ^$
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | xmlns:.*
29 |
30 | ^$
31 |
32 |
33 | BY_NAME
34 |
35 |
36 |
37 |
38 |
39 |
40 | .*:id
41 |
42 | http://schemas.android.com/apk/res/android
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | .*:name
52 |
53 | http://schemas.android.com/apk/res/android
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | name
63 |
64 | ^$
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | style
74 |
75 | ^$
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | .*
85 |
86 | ^$
87 |
88 |
89 | BY_NAME
90 |
91 |
92 |
93 |
94 |
95 |
96 | .*
97 |
98 | http://schemas.android.com/apk/res/android
99 |
100 |
101 | ANDROID_ATTRIBUTE_ORDER
102 |
103 |
104 |
105 |
106 |
107 |
108 | .*
109 |
110 | .*
111 |
112 |
113 | BY_NAME
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/NotificationSettings.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import android.util.Log
6 |
7 | class NotificationSettings(context: Context) {
8 | private val prefs: SharedPreferences = context.getSharedPreferences("notification_settings", Context.MODE_PRIVATE)
9 |
10 | companion object {
11 | private const val TAG = "NotificationSettings"
12 | private const val KEY_ENABLED_APPS = "enabled_apps"
13 | private const val KEY_BLOCKED_APPS = "blocked_apps"
14 | private const val KEY_PRIORITY_APPS = "priority_apps"
15 | private const val KEY_FILTER_KEYWORDS = "filter_keywords"
16 | private const val KEY_QUIET_HOURS_ENABLED = "quiet_hours_enabled"
17 | private const val KEY_QUIET_HOURS_START = "quiet_hours_start"
18 | private const val KEY_QUIET_HOURS_END = "quiet_hours_end"
19 | private const val KEY_MAX_NOTIFICATIONS = "max_notifications"
20 |
21 | // Default blocked apps
22 | private val DEFAULT_BLOCKED_APPS = setOf(
23 | "android",
24 | "com.android.systemui",
25 | "com.android.settings",
26 | "net.yehudae.esp32s3notificationsreceiver"
27 | )
28 | }
29 |
30 | fun isAppEnabled(packageName: String): Boolean {
31 | if (DEFAULT_BLOCKED_APPS.contains(packageName)) return false
32 |
33 | val blockedApps = getBlockedApps()
34 | if (blockedApps.contains(packageName)) return false
35 |
36 | val enabledApps = getEnabledApps()
37 | return enabledApps.isEmpty() || enabledApps.contains(packageName)
38 | }
39 |
40 | fun getEnabledApps(): Set {
41 | return prefs.getStringSet(KEY_ENABLED_APPS, emptySet()) ?: emptySet()
42 | }
43 |
44 | fun setEnabledApps(apps: Set) {
45 | prefs.edit().putStringSet(KEY_ENABLED_APPS, apps).apply()
46 | Log.d(TAG, "Enabled apps updated: ${apps.size} apps")
47 | }
48 |
49 | fun getBlockedApps(): Set {
50 | return prefs.getStringSet(KEY_BLOCKED_APPS, DEFAULT_BLOCKED_APPS) ?: DEFAULT_BLOCKED_APPS
51 | }
52 |
53 | fun setBlockedApps(apps: Set) {
54 | prefs.edit().putStringSet(KEY_BLOCKED_APPS, apps + DEFAULT_BLOCKED_APPS).apply()
55 | Log.d(TAG, "Blocked apps updated: ${apps.size} apps")
56 | }
57 |
58 | fun getPriorityApps(): Set {
59 | return prefs.getStringSet(KEY_PRIORITY_APPS, emptySet()) ?: emptySet()
60 | }
61 |
62 | fun setPriorityApps(apps: Set) {
63 | prefs.edit().putStringSet(KEY_PRIORITY_APPS, apps).apply()
64 | Log.d(TAG, "Priority apps updated: ${apps.size} apps")
65 | }
66 |
67 | fun toggleAppEnabled(packageName: String) {
68 | val blockedApps = getBlockedApps().toMutableSet()
69 | if (blockedApps.contains(packageName)) {
70 | blockedApps.remove(packageName)
71 | setBlockedApps(blockedApps)
72 | } else {
73 | blockedApps.add(packageName)
74 | setBlockedApps(blockedApps)
75 | }
76 | }
77 |
78 | fun toggleAppPriority(packageName: String) {
79 | val priorityApps = getPriorityApps().toMutableSet()
80 | if (priorityApps.contains(packageName)) {
81 | priorityApps.remove(packageName)
82 | } else {
83 | priorityApps.add(packageName)
84 | }
85 | setPriorityApps(priorityApps)
86 | }
87 |
88 | fun isQuietHoursEnabled(): Boolean {
89 | return prefs.getBoolean(KEY_QUIET_HOURS_ENABLED, false)
90 | }
91 |
92 | fun setQuietHoursEnabled(enabled: Boolean) {
93 | prefs.edit().putBoolean(KEY_QUIET_HOURS_ENABLED, enabled).apply()
94 | }
95 |
96 | fun getQuietHoursStart(): Int {
97 | return prefs.getInt(KEY_QUIET_HOURS_START, 22) // 10 PM default
98 | }
99 |
100 | fun setQuietHoursStart(hour: Int) {
101 | prefs.edit().putInt(KEY_QUIET_HOURS_START, hour).apply()
102 | }
103 |
104 | fun getQuietHoursEnd(): Int {
105 | return prefs.getInt(KEY_QUIET_HOURS_END, 7) // 7 AM default
106 | }
107 |
108 | fun setQuietHoursEnd(hour: Int) {
109 | prefs.edit().putInt(KEY_QUIET_HOURS_END, hour).apply()
110 | }
111 |
112 | fun getMaxNotifications(): Int {
113 | return prefs.getInt(KEY_MAX_NOTIFICATIONS, 100)
114 | }
115 |
116 | fun setMaxNotifications(max: Int) {
117 | prefs.edit().putInt(KEY_MAX_NOTIFICATIONS, max).apply()
118 | }
119 |
120 | fun isInQuietHours(): Boolean {
121 | if (!isQuietHoursEnabled()) return false
122 |
123 | val currentHour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY)
124 | val startHour = getQuietHoursStart()
125 | val endHour = getQuietHoursEnd()
126 |
127 | return if (startHour <= endHour) {
128 | currentHour in startHour..endHour
129 | } else {
130 | currentHour >= startHour || currentHour <= endHour
131 | }
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/AndroidApp/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/NotificationWorker.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import android.content.Context
4 | import android.content.Intent
5 | import android.util.Log
6 | import androidx.work.*
7 | import kotlinx.coroutines.delay
8 | import java.util.concurrent.TimeUnit
9 |
10 | class NotificationWorker(
11 | context: Context,
12 | params: WorkerParameters
13 | ) : CoroutineWorker(context, params) {
14 |
15 | companion object {
16 | private const val TAG = "NotificationWorker"
17 | const val WORK_NAME = "notification_worker"
18 |
19 | fun enqueueWork(
20 | context: Context,
21 | notificationData: NotificationData,
22 | delay: Long = 0
23 | ) {
24 | try {
25 | val inputData = Data.Builder()
26 | .putString("appName", notificationData.appName)
27 | .putString("title", notificationData.title)
28 | .putString("text", notificationData.text)
29 | .putString("timestamp", notificationData.timestamp)
30 | .build()
31 |
32 | val workRequest = if (delay > 0) {
33 | OneTimeWorkRequestBuilder()
34 | .setInputData(inputData)
35 | .setInitialDelay(delay, TimeUnit.MILLISECONDS)
36 | .setConstraints(
37 | Constraints.Builder()
38 | .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
39 | .build()
40 | )
41 | .setBackoffCriteria(
42 | BackoffPolicy.EXPONENTIAL,
43 | 10000, // 10 seconds minimum backoff
44 | TimeUnit.MILLISECONDS
45 | )
46 | .build()
47 | } else {
48 | OneTimeWorkRequestBuilder()
49 | .setInputData(inputData)
50 | .setConstraints(
51 | Constraints.Builder()
52 | .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
53 | .build()
54 | )
55 | .setBackoffCriteria(
56 | BackoffPolicy.EXPONENTIAL,
57 | 10000, // 10 seconds minimum backoff
58 | TimeUnit.MILLISECONDS
59 | )
60 | .build()
61 | }
62 |
63 | WorkManager.getInstance(context)
64 | .enqueueUniqueWork(
65 | "${WORK_NAME}_${System.currentTimeMillis()}",
66 | ExistingWorkPolicy.KEEP,
67 | workRequest
68 | )
69 |
70 | Log.d(TAG, "Notification work enqueued: ${notificationData.appName}")
71 | } catch (e: Exception) {
72 | Log.e(TAG, "Error enqueuing work", e)
73 | }
74 | }
75 |
76 | fun startPeriodicSync(context: Context) {
77 | try {
78 | val workRequest = PeriodicWorkRequestBuilder(
79 | 15, TimeUnit.MINUTES // Minimum interval for periodic work
80 | )
81 | .setConstraints(
82 | Constraints.Builder()
83 | .setRequiredNetworkType(NetworkType.NOT_REQUIRED)
84 | .build()
85 | )
86 | .build()
87 |
88 | WorkManager.getInstance(context)
89 | .enqueueUniquePeriodicWork(
90 | "notification_sync_worker",
91 | ExistingPeriodicWorkPolicy.KEEP,
92 | workRequest
93 | )
94 |
95 | Log.d(TAG, "Periodic sync worker started")
96 | } catch (e: Exception) {
97 | Log.e(TAG, "Error starting periodic sync", e)
98 | }
99 | }
100 | }
101 |
102 | override suspend fun doWork(): Result {
103 | return try {
104 | val notificationData = NotificationData(
105 | appName = inputData.getString("appName") ?: "",
106 | title = inputData.getString("title") ?: "",
107 | text = inputData.getString("text") ?: "",
108 | timestamp = inputData.getString("timestamp") ?: ""
109 | )
110 |
111 | Log.d(TAG, "Processing notification: ${notificationData.appName} - ${notificationData.title}")
112 |
113 | // Send notification to BLE Service
114 | val intent = Intent(applicationContext, BLEService::class.java).apply {
115 | action = "SEND_NOTIFICATION"
116 | putExtra("notification_data", notificationData)
117 | }
118 | applicationContext.startService(intent)
119 |
120 | // Add some retry logic with delay
121 | var retryCount = 0
122 | val maxRetries = 3
123 |
124 | while (retryCount < maxRetries) {
125 | delay(1000) // Wait 1 second between retries
126 |
127 | // Here you could check if the notification was successfully sent
128 | // For now, we'll assume it worked after the first try
129 | break
130 | }
131 |
132 | Log.d(TAG, "Notification work completed successfully")
133 | Result.success()
134 | } catch (e: Exception) {
135 | Log.e(TAG, "Error in notification work", e)
136 | if (runAttemptCount < 3) {
137 | Result.retry()
138 | } else {
139 | Result.failure()
140 | }
141 | }
142 | }
143 | }
144 |
145 | class NotificationSyncWorker(
146 | context: Context,
147 | params: WorkerParameters
148 | ) : CoroutineWorker(context, params) {
149 |
150 | companion object {
151 | private const val TAG = "NotificationSyncWorker"
152 | }
153 |
154 | override suspend fun doWork(): Result {
155 | return try {
156 | Log.d(TAG, "Running periodic sync check")
157 |
158 | // Check if BLE service is running and connected
159 | val intent = Intent(applicationContext, BLEService::class.java).apply {
160 | action = "SYNC_CHECK"
161 | }
162 | applicationContext.startService(intent)
163 |
164 | Log.d(TAG, "Sync check completed")
165 | Result.success()
166 | } catch (e: Exception) {
167 | Log.e(TAG, "Error in sync work", e)
168 | Result.retry()
169 | }
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/src/watchdog/watchdog.c:
--------------------------------------------------------------------------------
1 | /**
2 | * @file watchdog.c
3 | * @brief Watchdog Timer Management Module for Zephyr RTOS
4 | *
5 | * This module provides a comprehensive interface for managing hardware watchdog
6 | * timers in Zephyr RTOS applications. It handles initialization, configuration,
7 | * feeding, and deinitialization of the watchdog timer to prevent system resets
8 | * during normal operation and trigger resets when the system becomes unresponsive.
9 | *
10 | * Features:
11 | * - Configurable timeout period (default: 30 seconds)
12 | * - Automatic system reset on timeout
13 | * - Debug mode support (pauses during debugging)
14 | * - Comprehensive error handling and logging
15 | *
16 | * Usage:
17 | * 1. Call enable_watchdog() during system initialization
18 | * 2. Call kick_watchdog() periodically from your main loop
19 | * 3. Call disable_watchdog() before system shutdown (if needed)
20 | *
21 | * @author Yehuda@YehudaE.net
22 | */
23 |
24 | #include
25 | #include
26 | #include
27 | #include
28 | #include
29 |
30 | #include "watchdog/watchdog.h"
31 |
32 | LOG_MODULE_REGISTER(watchdog, LOG_LEVEL_INF);
33 |
34 | /*==============================================================================
35 | * CONSTANTS AND CONFIGURATION
36 | *============================================================================*/
37 |
38 | /** @brief Watchdog timeout in milliseconds (10 seconds) */
39 | #define WATCHDOG_TIMEOUT_MS 10000
40 |
41 | /** @brief Minimum watchdog window in milliseconds */
42 | #define WATCHDOG_MIN_WINDOW_MS 0
43 |
44 | /** @brief Maximum number of watchdog setup retries */
45 | #define WATCHDOG_SETUP_RETRIES 3
46 |
47 | /*==============================================================================
48 | * STATIC VARIABLES
49 | *============================================================================*/
50 |
51 | /** @brief Handle to the watchdog device */
52 | static const struct device* watchdog_device = DEVICE_DT_GET(DT_CHOSEN(nr_wdt));
53 |
54 | /** @brief Channel ID assigned by the watchdog driver */
55 | static int channel_id = -1;
56 |
57 | /** @brief Flag indicating if watchdog is currently enabled */
58 | static bool watchdog_enabled = false;
59 |
60 | /*==============================================================================
61 | * PRIVATE FUNCTIONS
62 | *============================================================================*/
63 |
64 | /**
65 | * @brief Validates watchdog device availability
66 | * @return true if device is ready, false otherwise
67 | */
68 | static bool is_watchdog_device_valid(void)
69 | {
70 | if (!watchdog_device) {
71 | LOG_ERR("Watchdog device not found in devicetree");
72 | return false;
73 | }
74 |
75 | if (!device_is_ready(watchdog_device)) {
76 | LOG_ERR("Watchdog device is not ready");
77 | return false;
78 | }
79 |
80 | return true;
81 | }
82 |
83 | /*==============================================================================
84 | * PUBLIC FUNCTIONS
85 | *============================================================================*/
86 |
87 | /**
88 | * @brief Enables and configures the hardware watchdog timer
89 | *
90 | * This function initializes the watchdog timer with the following configuration:
91 | * - Timeout: WATCHDOG_TIMEOUT_MS milliseconds
92 | * - Window: 0 to WATCHDOG_TIMEOUT_MS (allows feeding at any time)
93 | * - Flags: Reset entire SoC on timeout
94 | * - Debug: Pause during debugging sessions
95 | *
96 | * @return 0 on success
97 | * @return -EIO if watchdog device is not ready
98 | * @return -EINVAL if configuration parameters are invalid
99 | * @return -ENOTSUP if watchdog features are not supported
100 | * @return Negative error code from watchdog driver on other failures
101 | */
102 | int enable_watchdog(void)
103 | {
104 | int ret;
105 |
106 | /* Validate device availability */
107 | if (!is_watchdog_device_valid()) {
108 | return -EIO;
109 | }
110 |
111 | /* Check if already enabled */
112 | if (watchdog_enabled) {
113 | LOG_WRN("Watchdog is already enabled");
114 | return 0;
115 | }
116 |
117 | LOG_INF("Initializing watchdog timer (timeout: %d ms)", WATCHDOG_TIMEOUT_MS);
118 |
119 | /* Configure watchdog timeout parameters */
120 | struct wdt_timeout_cfg watchdog_config = {
121 | .window = {
122 | .min = WATCHDOG_MIN_WINDOW_MS,
123 | .max = WATCHDOG_TIMEOUT_MS,
124 | },
125 | .callback = NULL, /* Use reset instead of callback */
126 | .flags = WDT_FLAG_RESET_SOC, /* Reset entire SoC on timeout */
127 | };
128 |
129 | /* Install timeout configuration */
130 | ret = wdt_install_timeout(watchdog_device, &watchdog_config);
131 | if (ret < 0) {
132 | LOG_ERR("Failed to install watchdog timeout configuration (ret: %d)", ret);
133 | return ret;
134 | }
135 |
136 | channel_id = ret;
137 | LOG_DBG("Watchdog timeout installed successfully, channel ID: %d", channel_id);
138 |
139 | /* Setup watchdog with debug support */
140 | ret = wdt_setup(watchdog_device, WDT_OPT_PAUSE_HALTED_BY_DBG);
141 | if (ret != 0) {
142 | LOG_ERR("Failed to complete watchdog setup (ret: %d)", ret);
143 | return ret;
144 | }
145 |
146 | watchdog_enabled = true;
147 | LOG_INF("Watchdog timer enabled successfully");
148 |
149 | return 0;
150 | }
151 |
152 | /**
153 | * @brief Disables the hardware watchdog timer
154 | *
155 | * Stops all watchdog timers and disables the watchdog functionality.
156 | * This should typically be called before system shutdown or when
157 | * entering a mode where the watchdog is not needed.
158 | *
159 | * @return 0 on success
160 | * @return Negative error code from watchdog driver on failure
161 | */
162 | int disable_watchdog(void)
163 | {
164 | int ret;
165 |
166 | if (!watchdog_enabled) {
167 | LOG_WRN("Watchdog is already disabled");
168 | return 0;
169 | }
170 |
171 | LOG_INF("Disabling watchdog timer");
172 |
173 | ret = wdt_disable(watchdog_device);
174 | if (ret != 0) {
175 | LOG_ERR("Failed to disable watchdog timer (ret: %d)", ret);
176 | return ret;
177 | }
178 |
179 | watchdog_enabled = false;
180 | channel_id = -1;
181 | LOG_INF("Watchdog timer disabled successfully");
182 |
183 | return 0;
184 | }
185 |
186 | /**
187 | * @brief Feeds (kicks) the watchdog timer to prevent timeout
188 | *
189 | * This function must be called periodically (within WATCHDOG_TIMEOUT_MS)
190 | * to prevent the watchdog from resetting the system. It should be called
191 | * from the main application loop or a dedicated watchdog thread.
192 | *
193 | * @note This function should be called frequently but not too frequently
194 | * to avoid unnecessary overhead. A good practice is to call it
195 | * at 1/4 to 1/2 of the timeout interval.
196 | *
197 | * @return 0 on success
198 | * @return -EINVAL if watchdog is not enabled or channel_id is invalid
199 | * @return Negative error code from watchdog driver on other failures
200 | */
201 | int kick_watchdog(void)
202 | {
203 | int ret;
204 |
205 | if (!watchdog_enabled) {
206 | LOG_WRN("Cannot feed watchdog: watchdog is not enabled");
207 | return -EINVAL;
208 | }
209 |
210 | if (channel_id < 0) {
211 | LOG_ERR("Invalid watchdog channel ID: %d", channel_id);
212 | return -EINVAL;
213 | }
214 |
215 | ret = wdt_feed(watchdog_device, channel_id);
216 | if (ret != 0) {
217 | LOG_ERR("Failed to feed watchdog timer (ret: %d)", ret);
218 | return ret;
219 | }
220 |
221 | LOG_DBG("Watchdog timer fed successfully");
222 | return 0;
223 | }
224 |
225 | /**
226 | * @brief Gets the current status of the watchdog timer
227 | *
228 | * @return true if watchdog is enabled, false otherwise
229 | */
230 | bool is_watchdog_enabled(void)
231 | {
232 | return watchdog_enabled;
233 | }
234 |
235 | /**
236 | * @brief Gets the configured watchdog timeout in milliseconds
237 | *
238 | * @return Timeout value in milliseconds
239 | */
240 | uint32_t get_watchdog_timeout_ms(void)
241 | {
242 | return WATCHDOG_TIMEOUT_MS;
243 | }
244 |
245 | /**
246 | * @brief Gets the watchdog channel ID
247 | *
248 | * @return Channel ID if watchdog is enabled, -1 otherwise
249 | */
250 | int get_watchdog_channel_id(void)
251 | {
252 | return channel_id;
253 | }
254 |
--------------------------------------------------------------------------------
/src/display/display.c:
--------------------------------------------------------------------------------
1 | /**
2 | * @file display.c
3 | * @brief LCD Display Driver Implementation
4 | *
5 | * This module provides functionality for managing LCD displays in Zephyr RTOS,
6 | * including initialization, brightness control, and power management.
7 | *
8 | * The driver supports:
9 | * - Display initialization and shutdown
10 | * - PWM-controlled backlight brightness adjustment
11 | * - Display blanking control
12 | *
13 | * @author Yehuda@YehudaE.net
14 | */
15 |
16 | #include "display/display.h"
17 | #include
18 | #include
19 | #include
20 |
21 | LOG_MODULE_REGISTER(display, LOG_LEVEL_INF);
22 |
23 | /** @brief Default PWM period for backlight control in nanoseconds */
24 | #define PWM_PERIOD_NS 50000U
25 |
26 | /** @brief Default PWM duty cycle (50% brightness) */
27 | #define PWM_DEFAULT_DUTY_CYCLE_NS 25000U
28 |
29 | /** @brief Minimum brightness percentage */
30 | #define MIN_BRIGHTNESS_PERCENT 5U
31 |
32 | /** @brief Maximum brightness percentage */
33 | #define MAX_BRIGHTNESS_PERCENT 100U
34 |
35 | /**
36 | * @brief Get and validate PWM backlight device
37 | *
38 | * @param backlight Pointer to PWM device specification structure
39 | * @return 0 on success, negative error code on failure
40 | */
41 | static int get_backlight_device(struct pwm_dt_spec* backlight)
42 | {
43 | if (!backlight) {
44 | LOG_ERR("Invalid backlight parameter");
45 | return -EINVAL;
46 | }
47 |
48 | *backlight = (struct pwm_dt_spec)PWM_DT_SPEC_GET_BY_IDX(DT_CHOSEN(nr_lcd_backlight), 0);
49 |
50 | if (!pwm_is_ready_dt(backlight)) {
51 | LOG_ERR("PWM backlight device is not ready");
52 | return -ENODEV;
53 | }
54 |
55 | return 0;
56 | }
57 |
58 | /**
59 | * @brief Initialize and enable the LCD display
60 | *
61 | * This function performs the following operations:
62 | * 1. Validates display device availability
63 | * 2. Initializes PWM backlight with default settings
64 | * 3. Turns off display blanking (enables display)
65 | *
66 | * @return 0 on success, positive error code on failure
67 | */
68 | int enable_display(void)
69 | {
70 | int ret;
71 | const struct device* display_dev;
72 | struct pwm_dt_spec backlight;
73 |
74 | LOG_INF("Initializing LCD display...");
75 |
76 | /* Get and validate display device */
77 | display_dev = DEVICE_DT_GET(DT_CHOSEN(nr_lcd));
78 | if (!device_is_ready(display_dev)) {
79 | LOG_ERR("Display device is not ready");
80 | return -ENODEV;
81 | }
82 | LOG_DBG("Display device initialized successfully");
83 |
84 | /* Initialize PWM backlight */
85 | ret = get_backlight_device(&backlight);
86 | if (ret < 0) {
87 | LOG_ERR("Failed to initialize backlight device (ret: %d)", ret);
88 | return -ret; /* Convert to positive error code for legacy compatibility */
89 | }
90 | LOG_DBG("PWM backlight device initialized successfully");
91 |
92 | /* Set initial backlight brightness (50%) */
93 | ret = pwm_set_dt(&backlight, PWM_PERIOD_NS, PWM_DEFAULT_DUTY_CYCLE_NS);
94 | if (ret < 0) {
95 | LOG_ERR("Failed to set initial PWM brightness (ret: %d)", ret);
96 | return -ret;
97 | }
98 | LOG_DBG("Initial PWM brightness configured (50%%)");
99 |
100 | /* Enable display by turning off blanking */
101 | ret = display_blanking_off(display_dev);
102 | if (ret < 0) {
103 | LOG_ERR("Failed to disable display blanking (ret: %d)", ret);
104 | return -ret;
105 | }
106 | LOG_DBG("Display blanking disabled - display is now active");
107 |
108 | LOG_INF("LCD display initialization completed successfully");
109 | return 0;
110 | }
111 |
112 | /**
113 | * @brief Disable and shutdown the LCD display
114 | *
115 | * This function performs cleanup operations when shutting down the display:
116 | * 1. Enables display blanking (turns off display)
117 | * 2. Sets backlight brightness to minimum
118 | *
119 | * @return 0 on success, negative error code on failure
120 | */
121 | int disable_display(void)
122 | {
123 | int ret;
124 | const struct device* display_dev;
125 | struct pwm_dt_spec backlight;
126 |
127 | LOG_INF("Shutting down LCD display...");
128 |
129 | /* Get display device */
130 | display_dev = DEVICE_DT_GET(DT_CHOSEN(nr_lcd));
131 | if (!device_is_ready(display_dev)) {
132 | LOG_WRN("Display device not available for shutdown");
133 | /* Continue with backlight shutdown anyway */
134 | } else {
135 | /* Enable blanking to turn off display */
136 | ret = display_blanking_on(display_dev);
137 | if (ret < 0) {
138 | LOG_ERR("Failed to enable display blanking (ret: %d)", ret);
139 | return ret;
140 | }
141 | LOG_DBG("Display blanking enabled");
142 | }
143 |
144 | /* Turn off backlight */
145 | ret = get_backlight_device(&backlight);
146 | if (ret < 0) {
147 | LOG_ERR("Failed to get backlight device for shutdown (ret: %d)", ret);
148 | return ret;
149 | }
150 |
151 | /* Set backlight to minimum (effectively off) */
152 | ret = pwm_set_dt(&backlight, PWM_PERIOD_NS, 0);
153 | if (ret < 0) {
154 | LOG_ERR("Failed to turn off backlight (ret: %d)", ret);
155 | return ret;
156 | }
157 | LOG_DBG("Backlight turned off");
158 |
159 | LOG_INF("LCD display shutdown completed successfully");
160 | return 0;
161 | }
162 |
163 | /**
164 | * @brief Change display backlight brightness
165 | *
166 | * Adjusts the PWM duty cycle to control backlight brightness.
167 | * The brightness percentage is automatically clamped to valid range.
168 | *
169 | * @param perc Brightness percentage (0-100)
170 | * Values below 5% are clamped to 5%
171 | * Values above 100% are clamped to 100%
172 | *
173 | * @return 0 on success, negative error code on failure
174 | */
175 | int change_brightness(uint8_t perc)
176 | {
177 | int ret;
178 | struct pwm_dt_spec backlight;
179 | uint32_t pulse_ns;
180 |
181 | LOG_DBG("Changing brightness to %u%%", perc);
182 |
183 | /* Get and validate backlight device */
184 | ret = get_backlight_device(&backlight);
185 | if (ret < 0) {
186 | return ret;
187 | }
188 |
189 | /* Clamp brightness to valid range */
190 | if (perc > MAX_BRIGHTNESS_PERCENT) {
191 | perc = MAX_BRIGHTNESS_PERCENT;
192 | LOG_WRN("Brightness clamped to maximum (%u%%)", MAX_BRIGHTNESS_PERCENT);
193 | }
194 | if (perc < MIN_BRIGHTNESS_PERCENT) {
195 | perc = MIN_BRIGHTNESS_PERCENT;
196 | LOG_WRN("Brightness clamped to minimum (%u%%)", MIN_BRIGHTNESS_PERCENT);
197 | }
198 |
199 | /* Calculate PWM pulse width based on percentage */
200 | pulse_ns = (PWM_PERIOD_NS * perc) / 100U;
201 |
202 | /* Apply PWM settings */
203 | ret = pwm_set_dt(&backlight, PWM_PERIOD_NS, pulse_ns);
204 | if (ret < 0) {
205 | LOG_ERR("Failed to set PWM brightness (ret: %d)", ret);
206 | return ret;
207 | }
208 |
209 | LOG_INF("Brightness successfully set to %u%% (pulse: %u ns)", perc, pulse_ns);
210 | return 0;
211 | }
212 |
213 | /**
214 | * @brief Get current display status
215 | *
216 | * @return true if display is enabled and ready, false otherwise
217 | */
218 | bool is_display_ready(void)
219 | {
220 | const struct device* display_dev = DEVICE_DT_GET(DT_CHOSEN(nr_lcd));
221 | struct pwm_dt_spec backlight = PWM_DT_SPEC_GET_BY_IDX(DT_CHOSEN(nr_lcd_backlight), 0);
222 |
223 | return device_is_ready(display_dev) && pwm_is_ready_dt(&backlight);
224 | }
225 |
226 | /**
227 | * @brief Set display blanking state
228 | *
229 | * @param blank true to enable blanking (turn off display), false to disable
230 | * @return 0 on success, negative error code on failure
231 | */
232 | int set_display_blanking(bool blank)
233 | {
234 | int ret;
235 | const struct device* display_dev = DEVICE_DT_GET(DT_CHOSEN(nr_lcd));
236 |
237 | if (!device_is_ready(display_dev)) {
238 | LOG_ERR("Display device is not ready");
239 | return -ENODEV;
240 | }
241 |
242 | if (blank) {
243 | ret = display_blanking_on(display_dev);
244 | LOG_DBG("Display blanking enabled");
245 | } else {
246 | ret = display_blanking_off(display_dev);
247 | LOG_DBG("Display blanking disabled");
248 | }
249 |
250 | if (ret < 0) {
251 | LOG_ERR("Failed to set display blanking state (ret: %d)", ret);
252 | }
253 |
254 | return ret;
255 | }
256 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/NotificationListener.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import android.content.Intent
4 | import android.service.notification.NotificationListenerService
5 | import android.service.notification.StatusBarNotification
6 | import android.util.Log
7 | import java.text.SimpleDateFormat
8 | import java.util.*
9 |
10 | class NotificationListener : NotificationListenerService() {
11 |
12 | companion object {
13 | private const val TAG = "NotificationListener"
14 | const val ACTION_READ_EXISTING = "ACTION_READ_EXISTING"
15 | }
16 |
17 | private lateinit var settings: NotificationSettings
18 |
19 | override fun onCreate() {
20 | super.onCreate()
21 | settings = NotificationSettings(this)
22 | }
23 |
24 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
25 | when (intent?.action) {
26 | ACTION_READ_EXISTING -> {
27 | readExistingNotifications()
28 | }
29 | }
30 | return super.onStartCommand(intent, flags, startId)
31 | }
32 |
33 | override fun onNotificationPosted(sbn: StatusBarNotification) {
34 | try {
35 | processNotification(sbn, isExisting = false)
36 | } catch (e: Exception) {
37 | Log.e(TAG, "Error processing new notification", e)
38 | }
39 | }
40 |
41 | /**
42 | * Reads all currently active notifications and sends them to ESP32S3
43 | */
44 | fun readExistingNotifications() {
45 | try {
46 | Log.d(TAG, "Reading existing notifications...")
47 |
48 | val activeNotifications = activeNotifications
49 | if (activeNotifications == null) {
50 | Log.w(TAG, "Cannot access active notifications - permission may be missing")
51 | return
52 | }
53 |
54 | Log.d(TAG, "Found ${activeNotifications.size} active notifications")
55 |
56 | var processedCount = 0
57 | var sentCount = 0
58 |
59 | // Process each active notification
60 | activeNotifications.forEach { sbn ->
61 | try {
62 | val wasProcessed = processNotification(sbn, isExisting = true)
63 | processedCount++
64 | if (wasProcessed) sentCount++
65 | } catch (e: Exception) {
66 | Log.e(TAG, "Error processing existing notification from ${sbn.packageName}", e)
67 | }
68 | }
69 |
70 | Log.d(TAG, "Existing notifications sync completed: $sentCount/$processedCount sent to ESP32S3")
71 |
72 | // Notify BLE Service about sync completion
73 | val intent = Intent(this, BLEService::class.java).apply {
74 | action = "SYNC_COMPLETED"
75 | putExtra("processed_count", processedCount)
76 | putExtra("sent_count", sentCount)
77 | }
78 | startService(intent)
79 |
80 | } catch (e: Exception) {
81 | Log.e(TAG, "Error reading existing notifications", e)
82 | }
83 | }
84 |
85 | /**
86 | * Process a notification (either new or existing)
87 | * @param sbn StatusBarNotification to process
88 | * @param isExisting true if this is an existing notification, false if new
89 | * @return true if notification was sent to ESP32S3, false if filtered out
90 | */
91 | private fun processNotification(sbn: StatusBarNotification, isExisting: Boolean): Boolean {
92 | try {
93 | val packageName = sbn.packageName
94 |
95 | // Use settings to check if app is enabled
96 | if (!settings.isAppEnabled(packageName)) {
97 | Log.d(TAG, "Notification from $packageName blocked by settings")
98 | return false
99 | }
100 |
101 | // Skip ongoing notifications (like music players, navigation, etc.)
102 | if (sbn.isOngoing) {
103 | Log.d(TAG, "Skipping ongoing notification from $packageName")
104 | return false
105 | }
106 |
107 | // Check quiet hours (but allow existing notifications to be synced)
108 | if (!isExisting && settings.isInQuietHours()) {
109 | Log.d(TAG, "Notification from $packageName blocked by quiet hours")
110 | return false
111 | }
112 |
113 | val extras = sbn.notification.extras
114 | val title = extras.getString("android.title") ?: ""
115 | val text = extras.getString("android.text") ?: ""
116 |
117 | // Skip empty notifications
118 | if (title.isEmpty() && text.isEmpty()) {
119 | Log.d(TAG, "Skipping empty notification from $packageName")
120 | return false
121 | }
122 |
123 | val appName = getAppName(packageName)
124 | val isPriority = settings.getPriorityApps().contains(packageName)
125 |
126 | // For existing notifications, use the notification's post time if available
127 | val timestamp = if (isExisting && sbn.postTime > 0) {
128 | SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(sbn.postTime))
129 | } else {
130 | SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
131 | }
132 |
133 | val notificationData = NotificationData(
134 | appName = appName,
135 | title = title,
136 | text = text,
137 | timestamp = timestamp,
138 | isPriority = isPriority,
139 | packageName = packageName
140 | )
141 |
142 | Log.d(TAG, "${if (isExisting) "Existing" else "New"} notification: $appName - $title ${if (isPriority) "(PRIORITY)" else ""}")
143 |
144 | // Send to BLE Service immediately (for real-time when connected)
145 | val intent = Intent(this, BLEService::class.java).apply {
146 | action = "SEND_NOTIFICATION"
147 | putExtra("notification_data", notificationData)
148 | putExtra("is_existing", isExisting)
149 | }
150 | startService(intent)
151 |
152 | // For new notifications, also queue with WorkManager for background reliability
153 | if (!isExisting) {
154 | // Priority notifications get shorter delay
155 | val delay = if (isPriority) 500L else 2000L
156 | NotificationWorker.enqueueWork(
157 | context = this,
158 | notificationData = notificationData,
159 | delay = delay
160 | )
161 |
162 | Log.d(TAG, "New notification queued for background delivery ${if (isPriority) "(priority)" else ""}")
163 | }
164 |
165 | return true
166 |
167 | } catch (e: Exception) {
168 | Log.e(TAG, "Error processing notification", e)
169 | return false
170 | }
171 | }
172 |
173 | override fun onNotificationRemoved(sbn: StatusBarNotification) {
174 | // Handle notification removal if needed
175 | Log.d(TAG, "Notification removed: ${sbn.packageName}")
176 | }
177 |
178 | override fun onListenerConnected() {
179 | super.onListenerConnected()
180 | Log.d(TAG, "Notification listener connected")
181 |
182 | // Start periodic sync worker for background reliability
183 | NotificationWorker.startPeriodicSync(this)
184 | Log.d(TAG, "Background sync worker started")
185 | }
186 |
187 | override fun onListenerDisconnected() {
188 | super.onListenerDisconnected()
189 | Log.d(TAG, "Notification listener disconnected")
190 | }
191 |
192 | private fun getAppName(packageName: String): String {
193 | return try {
194 | val packageManager = packageManager
195 | val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
196 | packageManager.getApplicationLabel(applicationInfo).toString()
197 | } catch (e: Exception) {
198 | // Fallback to extracting from package name
199 | packageName.split(".").lastOrNull()?.replaceFirstChar {
200 | if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
201 | } ?: packageName
202 | }
203 | }
204 | }
205 |
--------------------------------------------------------------------------------
/src/main.c:
--------------------------------------------------------------------------------
1 | /**
2 | * @file main.c
3 | * @brief Notifications Receiver Main Application
4 | *
5 | * This is the main application file for ZephyrWatch - a Zephyr RTOS based
6 | * smartwatch device that receives notifications from Android apps or web browsers
7 | * via Bluetooth Low Energy (BLE).
8 | *
9 | * The application manages:
10 | * - System initialization (watchdog, display, GUI)
11 | * - Main event loop with LVGL graphics processing
12 | * - Watchdog maintenance for system stability
13 | * - BLE communication for notification reception
14 | *
15 | * @author Yehuda@YehudaE.net
16 | */
17 |
18 | #include
19 | #include
20 | #include
21 | #include
22 |
23 | #include "display/display.h"
24 | #include "graphics/graphics.h"
25 | #include "notifications/notifications.h"
26 | #include "watchdog/watchdog.h"
27 |
28 | /* Register logging module */
29 | LOG_MODULE_REGISTER(main, LOG_LEVEL_INF);
30 |
31 | /** @brief Main thread sleep interval in milliseconds */
32 | #define MAIN_THREAD_SLEEP_TIME_MS 100
33 |
34 | /** @brief Application name for logging and identification */
35 | #define APP_DEVICE_NAME "ZephyrWatch"
36 |
37 | /** @brief BLE service UUID for notification service */
38 | #define BLE_SERVICE_UUID "12345678-1234-1234-1234-123456789abc"
39 |
40 | /** @brief BLE characteristic UUID for notification data */
41 | #define BLE_CHARACTERISTIC_UUID "87654321-4321-4321-4321-cba987654321"
42 |
43 | /** @brief Maximum number of initialization retry attempts */
44 | #define MAX_INIT_RETRIES 3
45 |
46 | /**
47 | * @brief Initialize system watchdog
48 | *
49 | * Sets up and enables the hardware watchdog timer to ensure system stability.
50 | * The watchdog will reset the system if not periodically refreshed.
51 | *
52 | * @return 0 on success, negative error code on failure
53 | */
54 | static int init_system_watchdog(void)
55 | {
56 | int ret;
57 | int retry_count = 0;
58 |
59 | LOG_INF("Initializing system watchdog...");
60 |
61 | do {
62 | ret = enable_watchdog();
63 | if (ret == 0) {
64 | LOG_INF("Watchdog enabled successfully");
65 | return 0;
66 | }
67 |
68 | retry_count++;
69 | LOG_WRN("Watchdog initialization failed (attempt %d/%d), ret = %d",
70 | retry_count, MAX_INIT_RETRIES, ret);
71 |
72 | if (retry_count < MAX_INIT_RETRIES) {
73 | k_sleep(K_MSEC(100));
74 | }
75 | } while (retry_count < MAX_INIT_RETRIES);
76 |
77 | LOG_ERR("Failed to initialize watchdog after %d attempts", MAX_INIT_RETRIES);
78 | return ret;
79 | }
80 |
81 | /**
82 | * @brief Initialize display subsystem
83 | *
84 | * Sets up the LCD display and backlight for the user interface.
85 | *
86 | * @return 0 on success, negative error code on failure
87 | */
88 | static int init_display_subsystem(void)
89 | {
90 | int ret;
91 | int retry_count = 0;
92 |
93 | LOG_INF("Initializing display subsystem...");
94 |
95 | do {
96 | ret = enable_display();
97 | if (ret == 0) {
98 | LOG_INF("Display enabled successfully");
99 | return 0;
100 | }
101 |
102 | retry_count++;
103 | LOG_WRN("Display initialization failed (attempt %d/%d), ret = %d",
104 | retry_count, MAX_INIT_RETRIES, ret);
105 |
106 | if (retry_count < MAX_INIT_RETRIES) {
107 | k_sleep(K_MSEC(200));
108 | }
109 | } while (retry_count < MAX_INIT_RETRIES);
110 |
111 | LOG_ERR("Failed to initialize display after %d attempts", MAX_INIT_RETRIES);
112 | return ret;
113 | }
114 |
115 | /**
116 | * @brief Initialize BLE communication
117 | *
118 | * Sets up Bluetooth Low Energy stack for receiving notifications from
119 | * connected devices (Android apps, web browsers, etc.).
120 | *
121 | * @return 0 on success, negative error code on failure
122 | */
123 | static int init_ble_communication(void)
124 | {
125 | LOG_INF("Initializing BLE communication...");
126 |
127 | /* TODO: Add actual BLE initialization code here */
128 | /* This would typically include:
129 | * - bt_enable()
130 | * - Service and characteristic registration
131 | * - Advertising configuration
132 | * - Connection callbacks setup
133 | */
134 |
135 | LOG_INF("BLE communication initialized successfully");
136 | return 0;
137 | }
138 |
139 | /**
140 | * @brief Print system information and status
141 | *
142 | * Displays important system information including device name,
143 | * BLE service details, and operational status.
144 | */
145 | static void print_system_info(void)
146 | {
147 | LOG_INF("=== %s Notifications Receiver Ready! ===", APP_DEVICE_NAME);
148 | LOG_INF("Device name: %s", APP_DEVICE_NAME);
149 | LOG_INF("Service UUID: %s", BLE_SERVICE_UUID);
150 | LOG_INF("Characteristic UUID: %s", BLE_CHARACTERISTIC_UUID);
151 | LOG_INF("Main loop interval: %d ms", MAIN_THREAD_SLEEP_TIME_MS);
152 | LOG_INF("Ready to receive notifications from Android app or web browser!");
153 | }
154 |
155 | /**
156 | * @brief Perform system shutdown sequence
157 | *
158 | * Gracefully shuts down all subsystems before system restart or power off.
159 | */
160 | static void shutdown_system(void)
161 | {
162 | LOG_INF("Initiating system shutdown sequence...");
163 |
164 | /* Disable display to save power and protect screen */
165 | if (disable_display() != 0) {
166 | LOG_WRN("Failed to properly disable display during shutdown");
167 | }
168 |
169 | /* TODO: Add other cleanup tasks:
170 | * - Save configuration to flash
171 | * - Disconnect BLE connections
172 | * - Stop running timers
173 | * - Close file handles
174 | */
175 |
176 | LOG_INF("System shutdown sequence completed");
177 | }
178 |
179 | /**
180 | * @brief Main application entry point
181 | *
182 | * Initializes all system components and runs the main application loop.
183 | * The main loop handles:
184 | * - LVGL graphics processing
185 | * - Notification timers
186 | * - Watchdog maintenance
187 | * - Power management
188 | * - BLE communication processing
189 | *
190 | * @return 0 on normal exit (should not happen), error code on failure
191 | */
192 | int main(void)
193 | {
194 | int ret;
195 |
196 | LOG_INF("Starting %s Notifications Receiver", APP_DEVICE_NAME);
197 |
198 | /* Initialize critical system components */
199 |
200 | /* 1. Initialize watchdog first for system protection */
201 | ret = init_system_watchdog();
202 | if (ret != 0) {
203 | LOG_ERR("Critical: Watchdog initialization failed, ret = %d", ret);
204 | goto error_exit;
205 | }
206 |
207 | /* 2. Initialize display subsystem */
208 | ret = init_display_subsystem();
209 | if (ret != 0) {
210 | LOG_ERR("Critical: Display initialization failed, ret = %d", ret);
211 | goto error_exit;
212 | }
213 |
214 | /* 3. Initialize LVGL graphics library */
215 | ret = init_lvgl_graphics();
216 | if (ret != 0) {
217 | LOG_ERR("Critical: LVGL initialization failed, ret = %d", ret);
218 | goto error_exit;
219 | }
220 |
221 | /* 4. Wait a moment for LVGL to be fully ready */
222 | k_sleep(K_MSEC(100));
223 |
224 | /* 5. Create the notification screen */
225 | LOG_INF("Creating notification screen...");
226 | create_notification_screen();
227 | LOG_INF("Notification screen created successfully");
228 |
229 | /* 6. Initialize BLE communication */
230 | ret = init_ble_communication();
231 | if (ret != 0) {
232 | LOG_ERR("Critical: BLE initialization failed, ret = %d", ret);
233 | goto error_exit;
234 | }
235 |
236 | /* All systems initialized successfully */
237 | print_system_info();
238 |
239 | /* Main application loop */
240 | LOG_INF("Entering main application loop...");
241 |
242 | while (true) {
243 | /* Handle notification timers (delete timeout, etc.) */
244 | notifications_handle_timers();
245 |
246 | /* Maintain watchdog to prevent system reset */
247 | ret = kick_watchdog();
248 | if (ret != 0) {
249 | LOG_ERR("Watchdog kick failed, ret = %d", ret);
250 | /* Continue operation but log the error */
251 | }
252 |
253 | /* Optional: Run demo status changes for testing */
254 | #ifdef CONFIG_DEMO_MODE
255 | demo_status_changes();
256 | #endif
257 |
258 | /* TODO: Add other periodic tasks here:
259 | * - Process BLE notifications
260 | * - Update time display
261 | * - Handle user input
262 | * - Manage power states
263 | * - Check battery status
264 | */
265 |
266 | /* Sleep to allow other threads to run and save power */
267 | k_sleep(K_MSEC(MAIN_THREAD_SLEEP_TIME_MS));
268 | }
269 |
270 | /* This point should never be reached in normal operation */
271 | LOG_WRN("Main loop exited unexpectedly");
272 | return 0;
273 |
274 | error_exit:
275 | LOG_ERR("System initialization failed, initiating shutdown");
276 | shutdown_system();
277 |
278 | /* Optionally restart the system after a delay */
279 | LOG_INF("System will restart in 5 seconds...");
280 | k_sleep(K_SECONDS(5));
281 | sys_reboot(SYS_REBOOT_COLD);
282 |
283 | /* Should never reach here */
284 | return ret;
285 | }
286 |
--------------------------------------------------------------------------------
/src/graphics/graphics.c:
--------------------------------------------------------------------------------
1 | /**
2 | * @file graphics.c
3 | * @brief LVGL Compatible Graphics Implementation for Zephyr
4 | *
5 | * This implementation is compatible with LVGL API changes.
6 | * Major changes from LVGL 8.x:
7 | * - lv_task_handler() -> lv_timer_handler()
8 | * - LV_NO_TASK_READY -> LV_NO_TIMER_READY
9 | * - Display driver API completely changed
10 | * - Input device API simplified
11 | *
12 | * @author Yehuda@YehudaE.net
13 | */
14 |
15 | #include
16 | #include
17 | #include
18 | #include
19 |
20 | #include "graphics/graphics.h"
21 |
22 | LOG_MODULE_REGISTER(graphics, LOG_LEVEL_INF);
23 |
24 | /** @brief LVGL display buffer size */
25 | #define LVGL_BUFFER_SIZE (240 * 320 / 10) // Adjust for your display
26 |
27 | /** @brief LVGL refresh period in milliseconds */
28 | #define LVGL_REFRESH_PERIOD_MS 33 /* ~30 FPS */
29 |
30 | /** @brief LVGL task handler thread priority */
31 | #define LVGL_THREAD_PRIORITY 7
32 |
33 | /** @brief LVGL task handler stack size */
34 | #define LVGL_THREAD_STACK_SIZE 4096
35 |
36 | /* Static display buffer for LVGL */
37 | static lv_color_t lvgl_display_buf[LVGL_BUFFER_SIZE];
38 |
39 | /* Display object pointer */
40 | static lv_display_t* lvgl_display = NULL;
41 |
42 | /* Timer for LVGL task handling */
43 | static struct k_timer lvgl_timer;
44 |
45 | /* Thread for LVGL task handling */
46 | K_THREAD_STACK_DEFINE(lvgl_thread_stack, LVGL_THREAD_STACK_SIZE);
47 | static struct k_thread lvgl_thread_data;
48 | static k_tid_t lvgl_thread_tid;
49 |
50 | /**
51 | * @brief LVGL timer callback function
52 | *
53 | * This callback is called periodically to update LVGL's internal timer.
54 | *
55 | * @param timer Pointer to the timer object
56 | */
57 | static void lvgl_timer_callback(struct k_timer* timer)
58 | {
59 | ARG_UNUSED(timer);
60 | lv_tick_inc(LVGL_REFRESH_PERIOD_MS);
61 | }
62 |
63 | /**
64 | * @brief LVGL task handler thread function
65 | *
66 | * This thread continuously processes LVGL tasks and handles display updates.
67 | */
68 | static void lvgl_task_thread(void* arg1, void* arg2, void* arg3)
69 | {
70 | ARG_UNUSED(arg1);
71 | ARG_UNUSED(arg2);
72 | ARG_UNUSED(arg3);
73 |
74 | LOG_INF("LVGL task handler thread started");
75 |
76 | while (1) {
77 | /* Process LVGL timers and tasks */
78 | uint32_t sleep_time = lv_timer_handler();
79 |
80 | /* Sleep for the time recommended by LVGL or minimum period */
81 | if (sleep_time == LV_NO_TIMER_READY) {
82 | k_sleep(K_MSEC(LVGL_REFRESH_PERIOD_MS));
83 | } else {
84 | /* Ensure minimum sleep time */
85 | sleep_time = MAX(sleep_time, 5);
86 | k_sleep(K_MSEC(sleep_time));
87 | }
88 | }
89 | }
90 |
91 | /**
92 | * @brief Display flush callback for LVGL 9.x
93 | *
94 | * This function is called by LVGL when it needs to flush the display buffer
95 | * to the actual display hardware.
96 | *
97 | * @param disp Pointer to display object
98 | * @param area Area of the display to flush
99 | * @param px_map Pointer to pixel data
100 | */
101 | static void display_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map)
102 | {
103 | const struct device* display_dev = (const struct device*)lv_display_get_user_data(disp);
104 | struct display_buffer_descriptor desc;
105 |
106 | /* Calculate buffer parameters */
107 | uint16_t width = lv_area_get_width(area);
108 | uint16_t height = lv_area_get_height(area);
109 |
110 | desc.buf_size = width * height * sizeof(lv_color_t);
111 | desc.width = width;
112 | desc.height = height;
113 | desc.pitch = width;
114 |
115 | /* Write to display */
116 | int ret = display_write(display_dev, area->x1, area->y1, &desc, (void*)px_map);
117 | if (ret < 0) {
118 | LOG_ERR("Failed to write to display (ret: %d)", ret);
119 | }
120 |
121 | /* Inform LVGL that the flush is complete */
122 | lv_display_flush_ready(disp);
123 | }
124 |
125 | /**
126 | * @brief Input device read callback for LVGL (touchscreen)
127 | *
128 | * This function reads touch input data and provides it to LVGL.
129 | *
130 | * @param indev Pointer to input device object
131 | * @param data Pointer to input data structure
132 | */
133 | static void input_read_cb(lv_indev_t* indev, lv_indev_data_t* data)
134 | {
135 | ARG_UNUSED(indev);
136 |
137 | /* TODO: Implement actual touch input reading */
138 | /* This is a placeholder - replace with actual touch controller code */
139 |
140 | /* For now, just set to released state */
141 | data->state = LV_INDEV_STATE_RELEASED;
142 | data->point.x = 0;
143 | data->point.y = 0;
144 | }
145 |
146 | /**
147 | * @brief Initialize LVGL display driver for version 9.x
148 | *
149 | * Sets up the display driver for LVGL using Zephyr's display API.
150 | *
151 | * @return 0 on success, negative error code on failure
152 | */
153 | static int init_lvgl_display(void)
154 | {
155 | const struct device* display_dev;
156 | struct display_capabilities caps;
157 |
158 | LOG_INF("Initializing LVGL display driver...");
159 |
160 | /* Get display device - adjust this to match your device tree */
161 | display_dev = DEVICE_DT_GET(DT_CHOSEN(nr_lcd));
162 | if (!device_is_ready(display_dev)) {
163 | LOG_ERR("Display device is not ready");
164 | return -ENODEV;
165 | }
166 |
167 | /* Get display capabilities */
168 | display_get_capabilities(display_dev, &caps);
169 | LOG_INF("Display: %dx%d, format: %d",
170 | caps.x_resolution, caps.y_resolution, caps.current_pixel_format);
171 |
172 | /* Create LVGL display object */
173 | lvgl_display = lv_display_create(caps.x_resolution, caps.y_resolution);
174 | if (!lvgl_display) {
175 | LOG_ERR("Failed to create LVGL display object");
176 | return -ENOMEM;
177 | }
178 |
179 | /* Set default theme, primary color - light green, secondary - orange, dark = true. */
180 | lv_theme_t* theme = lv_theme_default_init(lvgl_display, lv_palette_main(LV_PALETTE_LIGHT_GREEN),
181 | lv_palette_main(LV_PALETTE_ORANGE), true, LV_FONT_DEFAULT);
182 | lv_display_set_theme(lvgl_display, theme);
183 |
184 | /* Set display buffer - LVGL way */
185 | lv_display_set_buffers(lvgl_display, lvgl_display_buf, NULL,
186 | sizeof(lvgl_display_buf), LV_DISPLAY_RENDER_MODE_PARTIAL);
187 |
188 | /* Set flush callback and user data */
189 | lv_display_set_flush_cb(lvgl_display, display_flush_cb);
190 | lv_display_set_user_data(lvgl_display, (void*)display_dev);
191 |
192 | LOG_INF("LVGL display driver initialized successfully");
193 | return 0;
194 | }
195 |
196 | /**
197 | * @brief Initialize LVGL input device for version 9.x
198 | *
199 | * Sets up input device handling for LVGL (touchscreen, buttons, etc.).
200 | *
201 | * @return 0 on success, negative error code on failure
202 | */
203 | static int init_lvgl_input(void)
204 | {
205 | lv_indev_t* indev;
206 |
207 | LOG_INF("Initializing LVGL input device...");
208 |
209 | /* Create input device - LVGL way */
210 | indev = lv_indev_create();
211 | if (!indev) {
212 | LOG_ERR("Failed to create input device");
213 | return -ENOMEM;
214 | }
215 |
216 | /* Configure input device */
217 | lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); /* Touchscreen */
218 | lv_indev_set_read_cb(indev, input_read_cb);
219 |
220 | LOG_INF("LVGL input device initialized successfully");
221 | return 0;
222 | }
223 |
224 | /**
225 | * @brief Create initial LVGL UI
226 | *
227 | * Creates a simple initial user interface for testing.
228 | */
229 | static void create_initial_ui(void)
230 | {
231 | // The notification screen will be created after LVGL is fully initialized
232 | // This is now done in main.c after init_lvgl_graphics() completes
233 | LOG_INF("LVGL ready for notification screen creation");
234 | }
235 |
236 | /**
237 | * @brief Initialize LVGL graphics library (LVGL compatible)
238 | *
239 | * Complete initialization of LVGL including:
240 | * - Core LVGL initialization
241 | * - Display driver setup
242 | * - Input device setup
243 | * - Threading and timing setup
244 | * - Initial UI creation
245 | *
246 | * @return 0 on success, negative error code on failure
247 | */
248 | int init_lvgl_graphics(void)
249 | {
250 | int ret;
251 |
252 | LOG_INF("Initializing LVGL graphics library");
253 |
254 | /* Initialize LVGL core */
255 | lv_init();
256 | LOG_DBG("LVGL core initialized");
257 |
258 | /* Initialize display driver */
259 | ret = init_lvgl_display();
260 | if (ret < 0) {
261 | LOG_ERR("Failed to initialize LVGL display (ret: %d)", ret);
262 | return ret;
263 | }
264 |
265 | /* Initialize input device */
266 | ret = init_lvgl_input();
267 | if (ret < 0) {
268 | LOG_ERR("Failed to initialize LVGL input (ret: %d)", ret);
269 | /* Continue without input - display-only mode */
270 | LOG_WRN("Continuing in display-only mode");
271 | }
272 |
273 | /* Initialize timer for LVGL tick */
274 | k_timer_init(&lvgl_timer, lvgl_timer_callback, NULL);
275 | k_timer_start(&lvgl_timer, K_MSEC(LVGL_REFRESH_PERIOD_MS),
276 | K_MSEC(LVGL_REFRESH_PERIOD_MS));
277 | LOG_DBG("LVGL timer initialized");
278 |
279 | /* Create LVGL task handler thread */
280 | lvgl_thread_tid = k_thread_create(&lvgl_thread_data, lvgl_thread_stack,
281 | K_THREAD_STACK_SIZEOF(lvgl_thread_stack),
282 | lvgl_task_thread, NULL, NULL, NULL,
283 | LVGL_THREAD_PRIORITY, 0, K_NO_WAIT);
284 | if (!lvgl_thread_tid) {
285 | LOG_ERR("Failed to create LVGL task thread");
286 | k_timer_stop(&lvgl_timer);
287 | return -EFAULT;
288 | }
289 | k_thread_name_set(lvgl_thread_tid, "lvgl_task");
290 | LOG_DBG("LVGL task thread created");
291 |
292 | /* Create initial UI */
293 | create_initial_ui();
294 |
295 | LOG_INF("LVGL graphics library initialized successfully");
296 | return 0;
297 | }
298 |
299 | /**
300 | * @brief Deinitialize LVGL graphics library
301 | *
302 | * Clean shutdown of LVGL components.
303 | *
304 | * @return 0 on success, negative error code on failure
305 | */
306 | int deinit_lvgl_graphics(void)
307 | {
308 | LOG_INF("Shutting down LVGL graphics library...");
309 |
310 | /* Stop the timer */
311 | k_timer_stop(&lvgl_timer);
312 |
313 | /* Abort the task thread */
314 | if (lvgl_thread_tid) {
315 | k_thread_abort(lvgl_thread_tid);
316 | }
317 |
318 | /* Clean up LVGL - LVGL */
319 | #if LV_VERSION_CHECK(9, 0, 0)
320 | lv_deinit();
321 | #endif
322 |
323 | LOG_INF("LVGL graphics library shut down complete");
324 | return 0;
325 | }
326 |
327 | /**
328 | * @brief Check if LVGL is initialized and ready
329 | *
330 | * @return true if LVGL is ready, false otherwise
331 | */
332 | bool is_lvgl_ready(void)
333 | {
334 | return (lvgl_display != NULL);
335 | }
336 |
337 | /**
338 | * @brief Get LVGL display object
339 | *
340 | * @return Pointer to LVGL display object, NULL if not initialized
341 | */
342 | lv_display_t* get_lvgl_display(void)
343 | {
344 | return lvgl_display;
345 | }
346 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/DeviceDiscoveryScreen.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.lazy.LazyColumn
6 | import androidx.compose.foundation.lazy.items
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
10 | import androidx.compose.material.icons.filled.*
11 | import androidx.compose.material3.*
12 | import androidx.compose.runtime.*
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.graphics.Brush
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.text.font.FontWeight
18 | import androidx.compose.ui.text.style.TextAlign
19 | import androidx.compose.ui.text.style.TextOverflow
20 | import androidx.compose.ui.unit.dp
21 | import androidx.compose.ui.unit.sp
22 |
23 | @OptIn(ExperimentalMaterial3Api::class)
24 | @Composable
25 | fun DeviceDiscoveryScreen(
26 | onBackClick: () -> Unit,
27 | onDeviceClick: (BluetoothDevice) -> Unit,
28 | bleService: BLEService?
29 | ) {
30 | var discoveredDevices by remember { mutableStateOf(listOf()) }
31 | var isScanning by remember { mutableStateOf(false) }
32 | var connectionStatus by remember { mutableStateOf("Disconnected") }
33 |
34 | LaunchedEffect(bleService) {
35 | bleService?.connectionStatus?.collect { status ->
36 | connectionStatus = status
37 | }
38 | }
39 |
40 | LaunchedEffect(bleService) {
41 | bleService?.discoveredDevices?.collect { devices ->
42 | discoveredDevices = devices
43 | }
44 | }
45 |
46 | LaunchedEffect(bleService) {
47 | bleService?.scanningState?.collect { scanning ->
48 | isScanning = scanning
49 | }
50 | }
51 |
52 | Column(
53 | modifier = Modifier
54 | .fillMaxSize()
55 | .background(
56 | Brush.verticalGradient(
57 | colors = listOf(
58 | Color(0xFF667eea),
59 | Color(0xFF764ba2)
60 | )
61 | )
62 | )
63 | ) {
64 | // Top App Bar
65 | CenterAlignedTopAppBar(
66 | title = {
67 | Text(
68 | "Device Discovery",
69 | color = Color.White,
70 | fontSize = 20.sp,
71 | fontWeight = FontWeight.Bold
72 | )
73 | },
74 | navigationIcon = {
75 | IconButton(onClick = onBackClick) {
76 | Icon(
77 | Icons.AutoMirrored.Filled.ArrowBack,
78 | contentDescription = "Back",
79 | tint = Color.White
80 | )
81 | }
82 | },
83 | actions = {
84 | IconButton(
85 | onClick = {
86 | if (isScanning) {
87 | bleService?.stopScanning()
88 | } else {
89 | bleService?.startDeviceDiscovery()
90 | }
91 | }
92 | ) {
93 | Icon(
94 | if (isScanning) Icons.Default.Stop else Icons.Default.Search,
95 | contentDescription = if (isScanning) "Stop Scan" else "Start Scan",
96 | tint = Color.White
97 | )
98 | }
99 | },
100 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
101 | containerColor = Color.Transparent
102 | )
103 | )
104 |
105 | Column(
106 | modifier = Modifier
107 | .fillMaxSize()
108 | .padding(16.dp)
109 | ) {
110 | // Scanning Status Card
111 | Card(
112 | modifier = Modifier
113 | .fillMaxWidth()
114 | .padding(bottom = 16.dp),
115 | shape = RoundedCornerShape(16.dp),
116 | colors = CardDefaults.cardColors(
117 | containerColor = Color.White.copy(alpha = 0.95f)
118 | ),
119 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
120 | ) {
121 | Column(
122 | modifier = Modifier.padding(20.dp),
123 | horizontalAlignment = Alignment.CenterHorizontally
124 | ) {
125 | if (isScanning) {
126 | CircularProgressIndicator(
127 | modifier = Modifier.size(32.dp),
128 | color = Color(0xFF667eea)
129 | )
130 | Spacer(modifier = Modifier.height(8.dp))
131 | Text(
132 | text = "Scanning for devices...",
133 | fontSize = 16.sp,
134 | fontWeight = FontWeight.Medium,
135 | color = Color(0xFF333333)
136 | )
137 | } else {
138 | Icon(
139 | Icons.Default.BluetoothSearching,
140 | contentDescription = null,
141 | modifier = Modifier.size(32.dp),
142 | tint = Color(0xFF667eea)
143 | )
144 | Spacer(modifier = Modifier.height(8.dp))
145 | Text(
146 | text = "Tap search to discover devices",
147 | fontSize = 16.sp,
148 | fontWeight = FontWeight.Medium,
149 | color = Color(0xFF333333)
150 | )
151 | }
152 |
153 | Spacer(modifier = Modifier.height(8.dp))
154 |
155 | Text(
156 | text = "Found ${discoveredDevices.size} devices",
157 | fontSize = 14.sp,
158 | color = Color(0xFF666666)
159 | )
160 | }
161 | }
162 |
163 | // Devices List
164 | Card(
165 | modifier = Modifier.fillMaxSize(),
166 | shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
167 | colors = CardDefaults.cardColors(
168 | containerColor = Color.White.copy(alpha = 0.95f)
169 | ),
170 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
171 | ) {
172 | Column(
173 | modifier = Modifier.padding(20.dp)
174 | ) {
175 | Row(
176 | verticalAlignment = Alignment.CenterVertically,
177 | modifier = Modifier.padding(bottom = 16.dp)
178 | ) {
179 | Icon(
180 | Icons.Default.Devices,
181 | contentDescription = null,
182 | tint = Color(0xFF667eea),
183 | modifier = Modifier.size(24.dp)
184 | )
185 | Spacer(Modifier.width(8.dp))
186 | Text(
187 | "Available Devices",
188 | fontSize = 18.sp,
189 | fontWeight = FontWeight.Bold,
190 | color = Color(0xFF333333)
191 | )
192 | }
193 |
194 | if (discoveredDevices.isEmpty()) {
195 | Column(
196 | modifier = Modifier.fillMaxSize(),
197 | horizontalAlignment = Alignment.CenterHorizontally,
198 | verticalArrangement = Arrangement.Center
199 | ) {
200 | Icon(
201 | Icons.Default.BluetoothDisabled,
202 | contentDescription = null,
203 | modifier = Modifier.size(64.dp),
204 | tint = Color(0xFFBBBBBB)
205 | )
206 | Spacer(Modifier.height(16.dp))
207 | Text(
208 | "No devices found",
209 | fontSize = 16.sp,
210 | color = Color(0xFF888888),
211 | textAlign = TextAlign.Center
212 | )
213 | Text(
214 | "Make sure your ESP32S3 is in pairing mode",
215 | fontSize = 14.sp,
216 | color = Color(0xFFBBBBBB),
217 | textAlign = TextAlign.Center
218 | )
219 | }
220 | } else {
221 | LazyColumn(
222 | verticalArrangement = Arrangement.spacedBy(8.dp)
223 | ) {
224 | items(discoveredDevices) { device ->
225 | DeviceItem(
226 | device = device,
227 | onClick = { onDeviceClick(device) },
228 | isConnected = connectionStatus == "Connected" || connectionStatus == "Ready"
229 | )
230 | }
231 | }
232 | }
233 | }
234 | }
235 | }
236 | }
237 | }
238 |
239 | @Composable
240 | fun DeviceItem(
241 | device: BluetoothDevice,
242 | onClick: () -> Unit,
243 | isConnected: Boolean
244 | ) {
245 | Card(
246 | modifier = Modifier.fillMaxWidth(),
247 | shape = RoundedCornerShape(12.dp),
248 | colors = CardDefaults.cardColors(
249 | containerColor = if (isConnected) Color(0xFFE8F5E8) else Color(0xFFF8F9FA)
250 | ),
251 | elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
252 | onClick = onClick
253 | ) {
254 | Row(
255 | modifier = Modifier
256 | .fillMaxWidth()
257 | .padding(16.dp),
258 | verticalAlignment = Alignment.CenterVertically
259 | ) {
260 | Icon(
261 | Icons.Default.Bluetooth,
262 | contentDescription = null,
263 | modifier = Modifier.size(24.dp),
264 | tint = if (isConnected) Color(0xFF4CAF50) else Color(0xFF667eea)
265 | )
266 |
267 | Spacer(Modifier.width(12.dp))
268 |
269 | Column(
270 | modifier = Modifier.weight(1f)
271 | ) {
272 | Text(
273 | text = device.name ?: "Unknown Device",
274 | fontSize = 16.sp,
275 | fontWeight = FontWeight.SemiBold,
276 | color = Color(0xFF333333),
277 | maxLines = 1,
278 | overflow = TextOverflow.Ellipsis
279 | )
280 |
281 | Text(
282 | text = device.address,
283 | fontSize = 14.sp,
284 | color = Color(0xFF666666),
285 | maxLines = 1,
286 | overflow = TextOverflow.Ellipsis
287 | )
288 |
289 | if (device.rssi != null) {
290 | Text(
291 | text = "Signal: ${device.rssi} dBm",
292 | fontSize = 12.sp,
293 | color = Color(0xFF888888)
294 | )
295 | }
296 | }
297 |
298 | if (isConnected) {
299 | Icon(
300 | Icons.Default.CheckCircle,
301 | contentDescription = "Connected",
302 | tint = Color(0xFF4CAF50),
303 | modifier = Modifier.size(20.dp)
304 | )
305 | } else {
306 | Icon(
307 | Icons.Default.ChevronRight,
308 | contentDescription = "Connect",
309 | tint = Color(0xFF999999),
310 | modifier = Modifier.size(20.dp)
311 | )
312 | }
313 | }
314 | }
315 | }
316 |
--------------------------------------------------------------------------------
/src/notifications/notifications.c:
--------------------------------------------------------------------------------
1 | #include
2 |
3 | #include
4 | #include
5 |
6 | #include "notifications/notifications.h"
7 |
8 | #define SCREEN_WIDTH 240
9 | #define SCREEN_HEIGHT 240
10 | #define SCREEN_RADIUS 120
11 | #define MAX_NOTIFICATIONS 30
12 |
13 | // Notification structure
14 | typedef struct {
15 | char app_name[32];
16 | char sender[64];
17 | char content[256];
18 | char timestamp[16];
19 | bool is_read;
20 | } notification_t;
21 |
22 | // Global UI objects
23 | static lv_obj_t* main_screen;
24 | static lv_obj_t* time_label;
25 | static lv_obj_t* status_circle;
26 | static lv_obj_t* app_icon;
27 | static lv_obj_t* app_name_label;
28 | static lv_obj_t* sender_label;
29 | static lv_obj_t* notification_content;
30 | static lv_obj_t* secondary_info;
31 | static lv_obj_t* counter_label;
32 |
33 | // Status colors - initialized in create_styles()
34 | static lv_color_t status_colors[4];
35 |
36 | // App icon colors - initialized in create_styles()
37 | static lv_color_t app_colors[5];
38 |
39 | // Notification data
40 | static notification_t notifications[MAX_NOTIFICATIONS];
41 | static int notification_count = 0;
42 | static int current_notification = 0;
43 |
44 | // Undo functionality
45 | static bool delete_pending = false;
46 | static int delete_pending_index = -1;
47 | static int delete_timer_counter = 0;
48 | static const int DELETE_TIMEOUT = 30; // 3 seconds (30 * 100ms)
49 | static lv_obj_t* undo_message;
50 |
51 | // Forward declarations
52 | static void update_notification_display(void);
53 | static void next_notification(void);
54 | static void prev_notification(void);
55 | static void mark_current_as_read(void);
56 | static void delete_current_notification(void);
57 | static void undo_deletion(void);
58 | static void complete_deletion(void);
59 | static void handle_delete_timeout(void);
60 |
61 | // Sample notifications for testing
62 | static void init_sample_notifications(void)
63 | {
64 | notification_count = 5;
65 |
66 | // Notification 1: WhatsApp
67 | strcpy(notifications[0].app_name, "WhatsApp");
68 | strcpy(notifications[0].sender, "Mom");
69 | strcpy(notifications[0].content, "Hi honey! How are you today?");
70 | strcpy(notifications[0].timestamp, "14:23");
71 | notifications[0].is_read = false;
72 |
73 | // Notification 2: Email (long content)
74 | strcpy(notifications[1].app_name, "Gmail");
75 | strcpy(notifications[1].sender, "Boss");
76 | strcpy(notifications[1].content, "Meeting tomorrow at 9 AM. Please prepare the quarterly report and bring all necessary documents. This is very important for our Q4 planning.");
77 | strcpy(notifications[1].timestamp, "13:45");
78 | notifications[1].is_read = false;
79 |
80 | // Notification 3: SMS
81 | strcpy(notifications[2].app_name, "Messages");
82 | strcpy(notifications[2].sender, "John");
83 | strcpy(notifications[2].content, "Are we still meeting tonight?");
84 | strcpy(notifications[2].timestamp, "12:30");
85 | notifications[2].is_read = true;
86 |
87 | // Notification 4: Discord
88 | strcpy(notifications[3].app_name, "Discord");
89 | strcpy(notifications[3].sender, "Dev Team");
90 | strcpy(notifications[3].content, "New commit pushed to main branch. Please review the changes in the notification system implementation.");
91 | strcpy(notifications[3].timestamp, "11:15");
92 | notifications[3].is_read = false;
93 |
94 | // Notification 5: Telegram
95 | strcpy(notifications[4].app_name, "Telegram");
96 | strcpy(notifications[4].sender, "Sarah");
97 | strcpy(notifications[4].content, "Check this out! 😄");
98 | strcpy(notifications[4].timestamp, "10:45");
99 | notifications[4].is_read = false;
100 | }
101 |
102 | static void create_styles(void)
103 | {
104 | // Initialize color arrays
105 | status_colors[CONN_CONNECTED] = lv_color_hex(0x00FF00); // Green
106 | status_colors[CONN_WEAK_SIGNAL] = lv_color_hex(0xFFFF00); // Yellow
107 | status_colors[CONN_CONNECTING] = lv_color_hex(0x0096FF); // Blue
108 | status_colors[CONN_DISCONNECTED] = lv_color_hex(0xFF0000); // Red
109 |
110 | app_colors[0] = lv_color_hex(0x25D366); // WhatsApp green
111 | app_colors[1] = lv_color_hex(0x1877F2); // Facebook blue
112 | app_colors[2] = lv_color_hex(0xFF0000); // Gmail red
113 | app_colors[3] = lv_color_hex(0x9146FF); // Discord purple
114 | app_colors[4] = lv_color_hex(0x0088CC); // Telegram blue
115 |
116 | // Main screen style (black background)
117 | static lv_style_t screen_style;
118 | lv_style_init(&screen_style);
119 | lv_style_set_bg_color(&screen_style, lv_color_hex(0x000000));
120 | lv_style_set_text_color(&screen_style, lv_color_hex(0xFFFFFF));
121 |
122 | // Time label style
123 | static lv_style_t time_style;
124 | lv_style_init(&time_style);
125 | lv_style_set_text_font(&time_style, &lv_font_montserrat_16);
126 | lv_style_set_text_color(&time_style, lv_color_hex(0xFFFFFF));
127 |
128 | // App name style
129 | static lv_style_t app_name_style;
130 | lv_style_init(&app_name_style);
131 | lv_style_set_text_font(&app_name_style, &lv_font_montserrat_12);
132 | lv_style_set_text_color(&app_name_style, lv_color_hex(0xC8C8C8));
133 |
134 | // Main content style
135 | static lv_style_t content_style;
136 | lv_style_init(&content_style);
137 | lv_style_set_text_font(&content_style, &lv_font_montserrat_14);
138 | lv_style_set_text_color(&content_style, lv_color_hex(0xFFFFFF));
139 | lv_style_set_text_align(&content_style, LV_TEXT_ALIGN_CENTER);
140 |
141 | // Secondary info style
142 | static lv_style_t secondary_style;
143 | lv_style_init(&secondary_style);
144 | lv_style_set_text_font(&secondary_style, &lv_font_montserrat_10);
145 | lv_style_set_text_color(&secondary_style, lv_color_hex(0x969696));
146 | }
147 |
148 | // Touch event handler
149 | static void screen_event_handler(lv_event_t* e)
150 | {
151 | lv_event_code_t code = lv_event_get_code(e);
152 |
153 | if (code == LV_EVENT_GESTURE) {
154 | lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_get_act());
155 |
156 | switch (dir) {
157 | case LV_DIR_LEFT:
158 | if (!delete_pending) {
159 | mark_current_as_read();
160 | next_notification(); // Next notification
161 | }
162 | break;
163 | case LV_DIR_RIGHT:
164 | if (!delete_pending) {
165 | mark_current_as_read();
166 | prev_notification(); // Previous notification
167 | }
168 | break;
169 | case LV_DIR_TOP:
170 | if (!delete_pending) {
171 | delete_current_notification(); // Delete notification (with undo)
172 | }
173 | break;
174 | case LV_DIR_BOTTOM:
175 | // Optional: could add another action here
176 | break;
177 | default:
178 | break;
179 | }
180 | } else if (code == LV_EVENT_CLICKED) {
181 | if (delete_pending) {
182 | // Undo the deletion
183 | undo_deletion();
184 | }
185 | } else if (code == LV_EVENT_DOUBLE_CLICKED) {
186 | mark_current_as_read(); // Mark as read
187 | }
188 | }
189 |
190 | static void create_top_bar(void)
191 | {
192 | // Create container for top bar
193 | lv_obj_t* top_container = lv_obj_create(main_screen);
194 | lv_obj_set_size(top_container, SCREEN_WIDTH - 20, 30);
195 | lv_obj_align(top_container, LV_ALIGN_TOP_MID, 0, 10);
196 | lv_obj_set_style_bg_opa(top_container, LV_OPA_TRANSP, 0);
197 | lv_obj_set_style_border_opa(top_container, LV_OPA_TRANSP, 0);
198 | lv_obj_set_style_pad_all(top_container, 0, 0);
199 |
200 | // Time label (left-center)
201 | time_label = lv_label_create(top_container);
202 | lv_label_set_text(time_label, "14:23");
203 | lv_obj_align(time_label, LV_ALIGN_CENTER, -15, 0);
204 |
205 | // Status circle (right of time)
206 | status_circle = lv_obj_create(top_container);
207 | lv_obj_set_size(status_circle, 10, 10);
208 | lv_obj_align(status_circle, LV_ALIGN_CENTER, 15, 0);
209 | lv_obj_set_style_radius(status_circle, LV_RADIUS_CIRCLE, 0);
210 | lv_obj_set_style_bg_color(status_circle, status_colors[CONN_CONNECTED], 0);
211 | lv_obj_set_style_border_opa(status_circle, LV_OPA_TRANSP, 0);
212 | }
213 |
214 | static lv_color_t get_app_color(const char* app_name)
215 | {
216 | if (strcmp(app_name, "WhatsApp") == 0)
217 | return app_colors[0];
218 | if (strcmp(app_name, "Gmail") == 0)
219 | return app_colors[2];
220 | if (strcmp(app_name, "Messages") == 0)
221 | return lv_color_hex(0x34C759);
222 | if (strcmp(app_name, "Discord") == 0)
223 | return app_colors[3];
224 | if (strcmp(app_name, "Telegram") == 0)
225 | return app_colors[4];
226 | return lv_color_hex(0x666666); // Default gray
227 | }
228 |
229 | static void create_app_info(void)
230 | {
231 | // Create container for app info (icon + name)
232 | lv_obj_t* app_container = lv_obj_create(main_screen);
233 | lv_obj_set_size(app_container, SCREEN_WIDTH - 40, 30);
234 | lv_obj_align(app_container, LV_ALIGN_TOP_MID, 0, 50);
235 | lv_obj_set_style_bg_opa(app_container, LV_OPA_TRANSP, 0);
236 | lv_obj_set_style_border_opa(app_container, LV_OPA_TRANSP, 0);
237 | lv_obj_set_style_pad_all(app_container, 0, 0);
238 |
239 | // App icon (left side of container)
240 | app_icon = lv_obj_create(app_container);
241 | lv_obj_set_size(app_icon, 20, 20);
242 | lv_obj_align(app_icon, LV_ALIGN_LEFT_MID, 10, 0);
243 | lv_obj_set_style_radius(app_icon, LV_RADIUS_CIRCLE, 0);
244 | lv_obj_set_style_border_opa(app_icon, LV_OPA_TRANSP, 0);
245 |
246 | // App name (centered in container)
247 | app_name_label = lv_label_create(app_container);
248 | lv_obj_align(app_name_label, LV_ALIGN_CENTER, 0, 0);
249 | lv_obj_set_style_text_font(app_name_label, &lv_font_montserrat_12, 0);
250 | lv_obj_set_style_text_color(app_name_label, lv_color_hex(0xC8C8C8), 0);
251 | }
252 |
253 | static void create_notification_content(void)
254 | {
255 | // Main notification content area
256 | lv_obj_t* content_container = lv_obj_create(main_screen);
257 | lv_obj_set_size(content_container, SCREEN_WIDTH - 40, 100);
258 | lv_obj_align(content_container, LV_ALIGN_CENTER, 0, 10);
259 | lv_obj_set_style_bg_opa(content_container, LV_OPA_TRANSP, 0);
260 | lv_obj_set_style_border_opa(content_container, LV_OPA_TRANSP, 0);
261 | lv_obj_set_style_pad_all(content_container, 5, 0);
262 |
263 | // Sender name
264 | sender_label = lv_label_create(content_container);
265 | lv_obj_align(sender_label, LV_ALIGN_TOP_MID, 0, 0);
266 | lv_obj_set_style_text_font(sender_label, &lv_font_montserrat_14, 0);
267 | lv_obj_set_style_text_color(sender_label, lv_color_hex(0xFFFFFF), 0);
268 |
269 | // Message content
270 | notification_content = lv_label_create(content_container);
271 | lv_label_set_long_mode(notification_content, LV_LABEL_LONG_WRAP);
272 | lv_obj_set_width(notification_content, SCREEN_WIDTH - 50);
273 | lv_obj_align_to(notification_content, sender_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 8);
274 | lv_obj_set_style_text_align(notification_content, LV_TEXT_ALIGN_CENTER, 0);
275 | lv_obj_set_style_text_font(notification_content, &lv_font_montserrat_12, 0);
276 | lv_obj_set_style_text_color(notification_content, lv_color_hex(0xE0E0E0), 0);
277 | }
278 |
279 | static void create_bottom_info(void)
280 | {
281 | // Secondary information (timestamp, etc.)
282 | secondary_info = lv_label_create(main_screen);
283 | lv_obj_align(secondary_info, LV_ALIGN_BOTTOM_MID, 0, -35);
284 | lv_obj_set_style_text_font(secondary_info, &lv_font_montserrat_10, 0);
285 | lv_obj_set_style_text_color(secondary_info, lv_color_hex(0x969696), 0);
286 |
287 | // Counter (shows current/total)
288 | counter_label = lv_label_create(main_screen);
289 | lv_obj_align(counter_label, LV_ALIGN_BOTTOM_MID, 0, -20);
290 | lv_obj_set_style_text_font(counter_label, &lv_font_montserrat_10, 0);
291 | lv_obj_set_style_text_color(counter_label, lv_color_hex(0x969696), 0);
292 |
293 | // Undo message (initially hidden)
294 | undo_message = lv_label_create(main_screen);
295 | lv_label_set_text(undo_message, "Deleted. Tap to undo");
296 | lv_obj_align(undo_message, LV_ALIGN_BOTTOM_MID, 0, -5);
297 | lv_obj_set_style_text_font(undo_message, &lv_font_montserrat_12, 0);
298 | lv_obj_set_style_text_color(undo_message, lv_color_hex(0xFFAA00), 0);
299 | lv_obj_add_flag(undo_message, LV_OBJ_FLAG_HIDDEN); // Hidden by default
300 | }
301 |
302 | static void update_connection_status(connection_status_t status)
303 | {
304 | lv_obj_set_style_bg_color(status_circle, status_colors[status], 0);
305 | }
306 |
307 | static void update_time(const char* time_str)
308 | {
309 | lv_label_set_text(time_label, time_str);
310 | }
311 |
312 | static void update_notification_display(void)
313 | {
314 | if (notification_count == 0) {
315 | lv_label_set_text(app_name_label, "No notifications");
316 | lv_label_set_text(sender_label, "");
317 | lv_label_set_text(notification_content, "All clear!");
318 | lv_label_set_text(secondary_info, "");
319 | lv_label_set_text(counter_label, "");
320 | lv_obj_set_style_bg_color(app_icon, lv_color_hex(0x666666), 0);
321 | return;
322 | }
323 |
324 | notification_t* notif = ¬ifications[current_notification];
325 |
326 | // Update app info
327 | lv_label_set_text(app_name_label, notif->app_name);
328 | lv_obj_set_style_bg_color(app_icon, get_app_color(notif->app_name), 0);
329 |
330 | // Update sender (add indicator for unread)
331 | static char sender_text[70];
332 | snprintf(sender_text, sizeof(sender_text), "%s%s",
333 | notif->is_read ? "" : "● ", notif->sender);
334 | lv_label_set_text(sender_label, sender_text);
335 |
336 | // Set sender color based on read status and delete pending
337 | lv_color_t sender_color;
338 | if (delete_pending && delete_pending_index == current_notification) {
339 | sender_color = lv_color_hex(0x666666); // Grayed out for pending deletion
340 | } else {
341 | sender_color = notif->is_read ? lv_color_hex(0xC8C8C8) : lv_color_hex(0xFFFFFF);
342 | }
343 | lv_obj_set_style_text_color(sender_label, sender_color, 0);
344 |
345 | // Update content with dimmed appearance if delete pending
346 | lv_label_set_text(notification_content, notif->content);
347 | if (delete_pending && delete_pending_index == current_notification) {
348 | lv_obj_set_style_text_color(notification_content, lv_color_hex(0x666666), 0);
349 | } else {
350 | lv_obj_set_style_text_color(notification_content, lv_color_hex(0xE0E0E0), 0);
351 | }
352 |
353 | // Update secondary info
354 | lv_label_set_text(secondary_info, notif->timestamp);
355 |
356 | // Update counter
357 | static char counter_text[20];
358 | int display_count = notification_count;
359 | if (delete_pending)
360 | display_count--; // Show count as if item is already deleted
361 |
362 | snprintf(counter_text, sizeof(counter_text), "%d of %d",
363 | current_notification + 1, display_count > 0 ? display_count : notification_count);
364 | lv_label_set_text(counter_label, counter_text);
365 | }
366 |
367 | static void next_notification(void)
368 | {
369 | if (notification_count > 0) {
370 | current_notification = (current_notification + 1) % notification_count;
371 | update_notification_display();
372 | }
373 | }
374 |
375 | static void prev_notification(void)
376 | {
377 | if (notification_count > 0) {
378 | current_notification = (current_notification - 1 + notification_count) % notification_count;
379 | update_notification_display();
380 | }
381 | }
382 |
383 | static void mark_current_as_read(void)
384 | {
385 | if (notification_count > 0) {
386 | notifications[current_notification].is_read = true;
387 | update_notification_display();
388 | }
389 | }
390 |
391 | static void delete_current_notification(void)
392 | {
393 | if (notification_count == 0)
394 | return;
395 |
396 | // Start delete pending process
397 | delete_pending = true;
398 | delete_pending_index = current_notification;
399 | delete_timer_counter = 0;
400 |
401 | // Show undo message
402 | lv_obj_clear_flag(undo_message, LV_OBJ_FLAG_HIDDEN);
403 |
404 | // Move to next notification for preview (but don't actually delete yet)
405 | if (notification_count > 1) {
406 | if (current_notification >= notification_count - 1) {
407 | current_notification = 0;
408 | } else {
409 | current_notification++;
410 | }
411 | }
412 |
413 | update_notification_display();
414 | }
415 |
416 | static void undo_deletion(void)
417 | {
418 | if (delete_pending && delete_pending_index >= 0) {
419 | delete_pending = false;
420 | delete_pending_index = -1;
421 | delete_timer_counter = 0;
422 |
423 | // Hide undo message
424 | lv_obj_add_flag(undo_message, LV_OBJ_FLAG_HIDDEN);
425 |
426 | // Refresh display
427 | update_notification_display();
428 | }
429 | }
430 |
431 | static void complete_deletion(void)
432 | {
433 | if (!delete_pending || delete_pending_index < 0)
434 | return;
435 |
436 | int index_to_delete = delete_pending_index;
437 |
438 | // Shift notifications down
439 | for (int i = index_to_delete; i < notification_count - 1; i++) {
440 | notifications[i] = notifications[i + 1];
441 | }
442 |
443 | notification_count--;
444 |
445 | // Adjust current notification index
446 | if (current_notification >= notification_count && notification_count > 0) {
447 | current_notification = notification_count - 1;
448 | } else if (notification_count == 0) {
449 | current_notification = 0;
450 | } else if (current_notification > index_to_delete) {
451 | current_notification--;
452 | }
453 |
454 | // Clear delete pending state
455 | delete_pending = false;
456 | delete_pending_index = -1;
457 | delete_timer_counter = 0;
458 |
459 | // Hide undo message
460 | lv_obj_add_flag(undo_message, LV_OBJ_FLAG_HIDDEN);
461 |
462 | update_notification_display();
463 | }
464 |
465 | static void handle_delete_timeout(void)
466 | {
467 | if (delete_pending) {
468 | delete_timer_counter++;
469 | if (delete_timer_counter >= DELETE_TIMEOUT) {
470 | complete_deletion();
471 | }
472 | }
473 | }
474 |
475 | // Public API functions for external use
476 | void notifications_update_connection_status(connection_status_t status)
477 | {
478 | update_connection_status(status);
479 | }
480 |
481 | void notifications_update_time(const char* time_str)
482 | {
483 | update_time(time_str);
484 | }
485 |
486 | void notifications_add_notification(const char* app_name, const char* sender,
487 | const char* content, const char* timestamp)
488 | {
489 | if (notification_count >= MAX_NOTIFICATIONS) {
490 | // Remove oldest notification to make room
491 | for (int i = 0; i < notification_count - 1; i++) {
492 | notifications[i] = notifications[i + 1];
493 | }
494 | notification_count--;
495 | if (current_notification > 0) {
496 | current_notification--;
497 | }
498 | }
499 |
500 | // Add new notification
501 | notification_t* new_notif = ¬ifications[notification_count];
502 | strncpy(new_notif->app_name, app_name, sizeof(new_notif->app_name) - 1);
503 | strncpy(new_notif->sender, sender, sizeof(new_notif->sender) - 1);
504 | strncpy(new_notif->content, content, sizeof(new_notif->content) - 1);
505 | strncpy(new_notif->timestamp, timestamp, sizeof(new_notif->timestamp) - 1);
506 | new_notif->is_read = false;
507 |
508 | notification_count++;
509 | current_notification = notification_count - 1; // Show newest notification
510 | update_notification_display();
511 | }
512 |
513 | void notifications_clear_all(void)
514 | {
515 | notification_count = 0;
516 | current_notification = 0;
517 | update_notification_display();
518 | }
519 |
520 | int notifications_get_unread_count(void)
521 | {
522 | int count = 0;
523 | for (int i = 0; i < notification_count; i++) {
524 | if (!notifications[i].is_read) {
525 | count++;
526 | }
527 | }
528 | return count;
529 | }
530 |
531 | // Call this in your main loop to handle delete timeouts
532 | void notifications_handle_timers(void)
533 | {
534 | handle_delete_timeout();
535 | }
536 |
537 | void create_notification_screen(void)
538 | {
539 | // Initialize sample data
540 | init_sample_notifications();
541 |
542 | // Create main screen
543 | main_screen = lv_obj_create(NULL);
544 | lv_obj_set_style_bg_color(main_screen, lv_color_hex(0x000000), 0);
545 |
546 | // Create styles (this initializes color arrays)
547 | create_styles();
548 |
549 | // Create UI components
550 | create_top_bar();
551 | create_app_info();
552 | create_notification_content();
553 | create_bottom_info();
554 |
555 | // Enable gesture detection and add event handler
556 | lv_obj_add_event_cb(main_screen, screen_event_handler, LV_EVENT_ALL, NULL);
557 | lv_obj_clear_flag(main_screen, LV_OBJ_FLAG_GESTURE_BUBBLE);
558 |
559 | // Initial display update
560 | update_connection_status(CONN_CONNECTED);
561 | update_notification_display();
562 |
563 | // Load the screen
564 | lv_scr_load(main_screen);
565 | }
566 |
567 | // Demo function for testing (optional - remove in production)
568 | void demo_status_changes(void)
569 | {
570 | static connection_status_t current_status = CONN_CONNECTED;
571 | static int counter = 0;
572 | static int demo_step = 0;
573 |
574 | counter++;
575 |
576 | // Handle delete timeout every cycle
577 | handle_delete_timeout();
578 |
579 | // Every 5 seconds, do something different
580 | if (counter % 50 == 0) {
581 | switch (demo_step % 6) {
582 | case 0:
583 | current_status = (current_status + 1) % 4;
584 | update_connection_status(current_status);
585 | break;
586 | case 1: {
587 | // Update time
588 | static char time_buf[10];
589 | int hours = 14 + (demo_step / 8) % 10;
590 | int minutes = 23 + (demo_step * 7) % 60;
591 | snprintf(time_buf, sizeof(time_buf), "%02d:%02d", hours, minutes);
592 | update_time(time_buf);
593 | } break;
594 | case 2:
595 | // Add a new notification
596 | notifications_add_notification("Instagram", "Alice", "Liked your photo!", "now");
597 | break;
598 | case 3:
599 | case 4:
600 | case 5:
601 | // Let user interact manually
602 | break;
603 | }
604 | demo_step++;
605 | }
606 | }
607 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/BLEService.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import android.app.Service
4 | import android.bluetooth.*
5 | import android.bluetooth.le.BluetoothLeScanner
6 | import android.bluetooth.le.ScanCallback
7 | import android.bluetooth.le.ScanResult
8 | import android.content.Context
9 | import android.content.Intent
10 | import android.content.pm.PackageManager
11 | import android.os.Binder
12 | import android.os.IBinder
13 | import android.util.Log
14 | import androidx.core.app.ActivityCompat
15 | import kotlinx.coroutines.flow.MutableStateFlow
16 | import kotlinx.coroutines.flow.StateFlow
17 | import java.util.*
18 |
19 | class BLEService : Service() {
20 | private val binder = LocalBinder()
21 | private var bluetoothAdapter: BluetoothAdapter? = null
22 | private var bluetoothLeScanner: BluetoothLeScanner? = null
23 | private var bluetoothGatt: BluetoothGatt? = null
24 | private var notificationCharacteristic: BluetoothGattCharacteristic? = null
25 | private var connectedDevice: android.bluetooth.BluetoothDevice? = null
26 | private var currentMtu = 23 // Default BLE MTU
27 |
28 | // ESP32 Service and Characteristic UUIDs
29 | private val SERVICE_UUID = UUID.fromString("12345678-1234-1234-1234-123456789abc")
30 | private val CHARACTERISTIC_UUID = UUID.fromString("87654321-4321-4321-4321-cba987654321")
31 |
32 | private val _connectionStatus = MutableStateFlow("Disconnected")
33 | val connectionStatus: StateFlow = _connectionStatus
34 |
35 | private val _notifications = MutableStateFlow>(emptyList())
36 | val notifications: StateFlow> = _notifications
37 |
38 | private val _discoveredDevices = MutableStateFlow>(emptyList())
39 | val discoveredDevices: StateFlow> = _discoveredDevices
40 |
41 | private val _scanningState = MutableStateFlow(false)
42 | val scanningState: StateFlow = _scanningState
43 |
44 | private val _connectedDeviceInfo = MutableStateFlow(null)
45 | val connectedDeviceInfo: StateFlow = _connectedDeviceInfo
46 |
47 | private val _syncStatus = MutableStateFlow(null)
48 | val syncStatus: StateFlow = _syncStatus
49 |
50 | private val notificationQueue = mutableListOf()
51 | private var lastConnectionTime: Long = 0
52 | private var totalNotificationsSent: Int = 0
53 |
54 | // Protocol constants
55 | private val CMD_ADD_NOTIFICATION: Byte = 0x01
56 | private val CMD_REMOVE_NOTIFICATION: Byte = 0x02
57 | private val CMD_CLEAR_ALL: Byte = 0x03
58 | private val CMD_ACTION: Byte = 0x04
59 |
60 | companion object {
61 | private const val TAG = "BLEService"
62 | private const val MAX_PACKET_SIZE = 240 // Safe packet size for most devices
63 | }
64 |
65 | inner class LocalBinder : Binder() {
66 | fun getService(): BLEService = this@BLEService
67 | }
68 |
69 | override fun onBind(intent: Intent): IBinder {
70 | return binder
71 | }
72 |
73 | override fun onCreate() {
74 | super.onCreate()
75 | try {
76 | val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
77 | bluetoothAdapter = bluetoothManager.adapter
78 | bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
79 |
80 | NotificationWorker.startPeriodicSync(this)
81 |
82 | Log.d(TAG, "BLE Service created with background worker support")
83 | } catch (e: Exception) {
84 | Log.e(TAG, "Error creating BLE service", e)
85 | }
86 | }
87 |
88 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
89 | try {
90 | when (intent?.action) {
91 | "SEND_NOTIFICATION" -> {
92 | val notificationData = intent.getParcelableExtra("notification_data")
93 | val isExisting = intent.getBooleanExtra("is_existing", false)
94 | notificationData?.let { sendNotificationToESP32(it, isExisting) }
95 | }
96 | "SYNC_CHECK" -> {
97 | checkConnectionAndSync()
98 | }
99 | "READ_EXISTING_NOTIFICATIONS" -> {
100 | readExistingNotifications()
101 | }
102 | "SYNC_COMPLETED" -> {
103 | val processedCount = intent.getIntExtra("processed_count", 0)
104 | val sentCount = intent.getIntExtra("sent_count", 0)
105 | handleSyncCompleted(processedCount, sentCount)
106 | }
107 | }
108 | } catch (e: Exception) {
109 | Log.e(TAG, "Error in onStartCommand", e)
110 | }
111 | return START_STICKY
112 | }
113 |
114 | fun readExistingNotifications() {
115 | try {
116 | Log.d(TAG, "Initiating existing notifications sync...")
117 |
118 | _syncStatus.value = SyncStatus(
119 | isInProgress = true,
120 | processedCount = 0,
121 | sentCount = 0,
122 | startTime = System.currentTimeMillis()
123 | )
124 |
125 | val intent = Intent(this, NotificationListener::class.java).apply {
126 | action = NotificationListener.ACTION_READ_EXISTING
127 | }
128 | startService(intent)
129 |
130 | } catch (e: Exception) {
131 | Log.e(TAG, "Error initiating existing notifications sync", e)
132 | _syncStatus.value = null
133 | }
134 | }
135 |
136 | private fun handleSyncCompleted(processedCount: Int, sentCount: Int) {
137 | try {
138 | val currentSync = _syncStatus.value
139 | if (currentSync != null) {
140 | _syncStatus.value = currentSync.copy(
141 | isInProgress = false,
142 | processedCount = processedCount,
143 | sentCount = sentCount,
144 | endTime = System.currentTimeMillis()
145 | )
146 |
147 | Log.d(TAG, "Sync completed: $sentCount/$processedCount notifications sent")
148 |
149 | android.os.Handler(mainLooper).postDelayed({
150 | _syncStatus.value = null
151 | }, 5000)
152 | }
153 | } catch (e: Exception) {
154 | Log.e(TAG, "Error handling sync completion", e)
155 | }
156 | }
157 |
158 | fun startScanning() {
159 | startDeviceDiscovery()
160 | }
161 |
162 | fun startDeviceDiscovery() {
163 | try {
164 | if (!hasBluetoothPermissions()) {
165 | _connectionStatus.value = "Permission required"
166 | return
167 | }
168 |
169 | _scanningState.value = true
170 | _discoveredDevices.value = emptyList()
171 | _connectionStatus.value = "Scanning..."
172 | bluetoothLeScanner?.startScan(deviceDiscoveryCallback)
173 |
174 | android.os.Handler(mainLooper).postDelayed({
175 | stopScanning()
176 | }, 30000)
177 | } catch (e: Exception) {
178 | Log.e(TAG, "Error starting device discovery", e)
179 | _connectionStatus.value = "Scan failed"
180 | _scanningState.value = false
181 | }
182 | }
183 |
184 | fun stopScanning() {
185 | try {
186 | if (hasBluetoothPermissions()) {
187 | bluetoothLeScanner?.stopScan(deviceDiscoveryCallback)
188 | }
189 | _scanningState.value = false
190 | if (_connectionStatus.value == "Scanning...") {
191 | _connectionStatus.value = "Disconnected"
192 | }
193 | } catch (e: Exception) {
194 | Log.e(TAG, "Error stopping scan", e)
195 | }
196 | }
197 |
198 | fun disconnectDevice() {
199 | try {
200 | bluetoothGatt?.disconnect()
201 | connectedDevice = null
202 | _connectedDeviceInfo.value = null
203 | _connectionStatus.value = "Disconnected"
204 | Log.d(TAG, "Device disconnected manually")
205 | } catch (e: Exception) {
206 | Log.e(TAG, "Error disconnecting device", e)
207 | }
208 | }
209 |
210 | private val deviceDiscoveryCallback = object : ScanCallback() {
211 | override fun onScanResult(callbackType: Int, result: ScanResult) {
212 | try {
213 | val device = result.device
214 | val rssi = result.rssi
215 |
216 | if (!hasBluetoothPermissions()) {
217 | return
218 | }
219 |
220 | val bluetoothDevice = BluetoothDevice(
221 | name = device.name,
222 | address = device.address,
223 | rssi = rssi,
224 | device = device
225 | )
226 |
227 | val currentDevices = _discoveredDevices.value.toMutableList()
228 | val existingDevice = currentDevices.find { it.address == device.address }
229 |
230 | if (existingDevice == null) {
231 | currentDevices.add(bluetoothDevice)
232 | _discoveredDevices.value = currentDevices
233 | } else {
234 | val index = currentDevices.indexOf(existingDevice)
235 | currentDevices[index] = bluetoothDevice
236 | _discoveredDevices.value = currentDevices
237 | }
238 |
239 | val deviceName = device.name
240 | if (deviceName?.contains("ZephyrWatch", ignoreCase = true) == true ||
241 | deviceName?.contains("ESP32", ignoreCase = true) == true) {
242 | Log.d(TAG, "Found target device: $deviceName")
243 | }
244 |
245 | } catch (e: Exception) {
246 | Log.e(TAG, "Error in scan result", e)
247 | }
248 | }
249 |
250 | override fun onScanFailed(errorCode: Int) {
251 | Log.e(TAG, "Scan failed with error: $errorCode")
252 | _connectionStatus.value = "Scan failed: $errorCode"
253 | _scanningState.value = false
254 | }
255 | }
256 |
257 | fun connectToDevice(bluetoothDevice: BluetoothDevice) {
258 | bluetoothDevice.device?.let { device ->
259 | connectToDevice(device)
260 | }
261 | }
262 |
263 | private fun connectToDevice(device: android.bluetooth.BluetoothDevice) {
264 | try {
265 | if (!hasBluetoothPermissions()) {
266 | return
267 | }
268 |
269 | bluetoothGatt?.disconnect()
270 |
271 | _connectionStatus.value = "Connecting..."
272 | bluetoothGatt = device.connectGatt(this, false, gattCallback)
273 | connectedDevice = device
274 |
275 | _connectedDeviceInfo.value = ConnectedDeviceInfo(
276 | name = device.name ?: "Unknown Device",
277 | address = device.address,
278 | connectionTime = System.currentTimeMillis(),
279 | status = "Connecting...",
280 | notificationsSent = 0
281 | )
282 |
283 | } catch (e: Exception) {
284 | Log.e(TAG, "Error connecting to device", e)
285 | _connectionStatus.value = "Connection failed"
286 | }
287 | }
288 |
289 | private val gattCallback = object : BluetoothGattCallback() {
290 | override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
291 | try {
292 | when (newState) {
293 | BluetoothProfile.STATE_CONNECTED -> {
294 | _connectionStatus.value = "Connected"
295 | lastConnectionTime = System.currentTimeMillis()
296 |
297 | connectedDevice?.let { device ->
298 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
299 | status = "Connected",
300 | connectionTime = lastConnectionTime
301 | )
302 | }
303 |
304 | if (hasBluetoothPermissions()) {
305 | // Request larger MTU for better performance
306 | gatt.requestMtu(517) // Max MTU size
307 | }
308 |
309 | processNotificationQueue()
310 | }
311 | BluetoothProfile.STATE_DISCONNECTED -> {
312 | _connectionStatus.value = "Disconnected"
313 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
314 | status = "Disconnected"
315 | )
316 | notificationCharacteristic = null
317 | currentMtu = 23 // Reset to default
318 | }
319 | BluetoothProfile.STATE_CONNECTING -> {
320 | _connectionStatus.value = "Connecting..."
321 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
322 | status = "Connecting..."
323 | )
324 | }
325 | }
326 | } catch (e: Exception) {
327 | Log.e(TAG, "Error in connection state change", e)
328 | }
329 | }
330 |
331 | override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
332 | if (status == BluetoothGatt.GATT_SUCCESS) {
333 | currentMtu = mtu
334 | Log.d(TAG, "MTU changed to: $mtu")
335 |
336 | // Now discover services after MTU is set
337 | if (hasBluetoothPermissions()) {
338 | gatt.discoverServices()
339 | }
340 | } else {
341 | Log.w(TAG, "MTU change failed, using default MTU")
342 | currentMtu = 23
343 | // Still try to discover services
344 | if (hasBluetoothPermissions()) {
345 | gatt.discoverServices()
346 | }
347 | }
348 | }
349 |
350 | override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
351 | try {
352 | if (status == BluetoothGatt.GATT_SUCCESS) {
353 | Log.d(TAG, "Services discovered, looking for notification service...")
354 |
355 | gatt.services.forEach { service ->
356 | Log.d(TAG, "Found service: ${service.uuid}")
357 | service.characteristics.forEach { char ->
358 | Log.d(TAG, " - Characteristic: ${char.uuid}")
359 | }
360 | }
361 |
362 | val service = gatt.getService(SERVICE_UUID)
363 | if (service != null) {
364 | Log.d(TAG, "Found notification service!")
365 | notificationCharacteristic = service.getCharacteristic(CHARACTERISTIC_UUID)
366 | if (notificationCharacteristic != null) {
367 | _connectionStatus.value = "Ready"
368 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
369 | status = "Ready"
370 | )
371 | Log.d(TAG, "Notification characteristic found and ready! MTU: $currentMtu")
372 | processNotificationQueue()
373 | } else {
374 | Log.e(TAG, "Notification characteristic not found!")
375 | _connectionStatus.value = "Characteristic not found"
376 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
377 | status = "Characteristic not found"
378 | )
379 | }
380 | } else {
381 | Log.e(TAG, "Notification service not found!")
382 | _connectionStatus.value = "Service not found"
383 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
384 | status = "Service not found"
385 | )
386 | }
387 | } else {
388 | Log.e(TAG, "Service discovery failed with status: $status")
389 | _connectionStatus.value = "Service discovery failed"
390 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
391 | status = "Service discovery failed"
392 | )
393 | }
394 | } catch (e: Exception) {
395 | Log.e(TAG, "Error in service discovery", e)
396 | }
397 | }
398 |
399 | override fun onCharacteristicWrite(
400 | gatt: BluetoothGatt,
401 | characteristic: BluetoothGattCharacteristic,
402 | status: Int
403 | ) {
404 | if (status == BluetoothGatt.GATT_SUCCESS) {
405 | totalNotificationsSent++
406 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
407 | notificationsSent = totalNotificationsSent
408 | )
409 | Log.d(TAG, "Notification sent successfully. Total sent: $totalNotificationsSent")
410 | } else {
411 | Log.e(TAG, "Failed to send notification: $status")
412 | }
413 | }
414 | }
415 |
416 | private fun sendNotificationToESP32(notificationData: NotificationData, isExisting: Boolean = false) {
417 | try {
418 | if (!hasBluetoothPermissions()) {
419 | return
420 | }
421 |
422 | if (notificationCharacteristic != null && _connectionStatus.value == "Ready") {
423 | // Truncate long text to fit in MTU
424 | val maxDataSize = currentMtu - 3 - 5 // MTU minus ATT overhead minus our header
425 | val truncatedData = truncateNotificationData(notificationData, maxDataSize)
426 |
427 | val packet = createNotificationPacket(truncatedData)
428 |
429 | if (packet.size <= maxDataSize) {
430 | notificationCharacteristic?.value = packet
431 | bluetoothGatt?.writeCharacteristic(notificationCharacteristic)
432 |
433 | if (!isExisting) {
434 | val currentList = _notifications.value.toMutableList()
435 | currentList.add(0, notificationData)
436 | if (currentList.size > 50) {
437 | currentList.removeAt(currentList.size - 1)
438 | }
439 | _notifications.value = currentList
440 | }
441 |
442 | Log.d(TAG, "${if (isExisting) "Existing" else "New"} notification sent: ${truncatedData.appName} - ${truncatedData.title} (${packet.size} bytes)")
443 | } else {
444 | Log.w(TAG, "Packet too large (${packet.size} bytes), skipping notification")
445 | }
446 | } else {
447 | notificationQueue.add(notificationData)
448 | Log.d(TAG, "Notification queued: ${notificationData.appName} - ${notificationData.title}")
449 |
450 | connectedDevice?.let { device ->
451 | if (_connectionStatus.value == "Disconnected") {
452 | Log.d(TAG, "Attempting reconnection for queued notification")
453 | connectToDevice(device)
454 | }
455 | }
456 |
457 | if (!isExisting) {
458 | NotificationWorker.enqueueWork(
459 | context = this,
460 | notificationData = notificationData,
461 | delay = 5000
462 | )
463 | }
464 | }
465 | } catch (e: Exception) {
466 | Log.e(TAG, "Error sending notification", e)
467 | }
468 | }
469 |
470 | private fun truncateNotificationData(data: NotificationData, maxSize: Int): NotificationData {
471 | val appNameBytes = data.appName.toByteArray(Charsets.UTF_8)
472 | val titleBytes = data.title.toByteArray(Charsets.UTF_8)
473 | val textBytes = data.text.toByteArray(Charsets.UTF_8)
474 |
475 | var appLen = minOf(appNameBytes.size, 20) // Max 20 chars for app name
476 | var titleLen = minOf(titleBytes.size, 40) // Max 40 chars for title
477 | var textLen = minOf(textBytes.size, maxSize - 5 - appLen - titleLen) // Remaining for text
478 |
479 | // Ensure we don't go negative
480 | if (textLen < 0) {
481 | titleLen = minOf(titleLen, maxSize - 5 - appLen - 10) // Leave at least 10 for text
482 | textLen = maxSize - 5 - appLen - titleLen
483 | }
484 |
485 | return NotificationData(
486 | appName = String(appNameBytes, 0, appLen, Charsets.UTF_8),
487 | title = String(titleBytes, 0, titleLen, Charsets.UTF_8),
488 | text = String(textBytes, 0, maxOf(0, textLen), Charsets.UTF_8),
489 | timestamp = data.timestamp,
490 | isPriority = data.isPriority,
491 | packageName = data.packageName
492 | )
493 | }
494 |
495 | private fun createNotificationPacket(notificationData: NotificationData): ByteArray {
496 | val appNameBytes = notificationData.appName.toByteArray(Charsets.UTF_8)
497 | val titleBytes = notificationData.title.toByteArray(Charsets.UTF_8)
498 | val textBytes = notificationData.text.toByteArray(Charsets.UTF_8)
499 |
500 | val appLen = appNameBytes.size
501 | val titleLen = titleBytes.size
502 | val textLen = textBytes.size
503 |
504 | val totalLength = 5 + appLen + titleLen + textLen
505 | val packet = ByteArray(totalLength)
506 |
507 | var offset = 0
508 | packet[offset++] = CMD_ADD_NOTIFICATION
509 | packet[offset++] = getNotificationType(notificationData.packageName).toByte()
510 | packet[offset++] = appLen.toByte()
511 | packet[offset++] = titleLen.toByte()
512 | packet[offset++] = textLen.toByte()
513 |
514 | System.arraycopy(appNameBytes, 0, packet, offset, appLen)
515 | offset += appLen
516 | System.arraycopy(titleBytes, 0, packet, offset, titleLen)
517 | offset += titleLen
518 | System.arraycopy(textBytes, 0, packet, offset, textLen)
519 |
520 | Log.d(TAG, "Created packet: CMD=${CMD_ADD_NOTIFICATION}, type=${getNotificationType(notificationData.packageName)}, lengths=[$appLen,$titleLen,$textLen], total=$totalLength bytes, MTU=$currentMtu")
521 |
522 | return packet
523 | }
524 |
525 | private fun getNotificationType(packageName: String): Int {
526 | return when {
527 | packageName.contains("phone", true) || packageName.contains("dialer", true) -> 0
528 | packageName.contains("mms", true) || packageName.contains("message", true) ||
529 | packageName.contains("sms", true) || packageName.contains("whatsapp", true) ||
530 | packageName.contains("telegram", true) -> 1
531 | packageName.contains("gmail", true) || packageName.contains("mail", true) ||
532 | packageName.contains("email", true) -> 2
533 | packageName.contains("facebook", true) || packageName.contains("instagram", true) ||
534 | packageName.contains("twitter", true) || packageName.contains("snapchat", true) ||
535 | packageName.contains("tiktok", true) -> 3
536 | packageName.contains("calendar", true) -> 4
537 | else -> 5
538 | }
539 | }
540 |
541 | private fun processNotificationQueue() {
542 | if (notificationCharacteristic != null && notificationQueue.isNotEmpty()) {
543 | Log.d(TAG, "Processing ${notificationQueue.size} queued notifications")
544 | val queuedNotifications = notificationQueue.toList()
545 | notificationQueue.clear()
546 |
547 | queuedNotifications.forEach { notification ->
548 | sendNotificationToESP32(notification)
549 | Thread.sleep(200) // Longer delay for reliability
550 | }
551 | }
552 | }
553 |
554 | private fun checkConnectionAndSync() {
555 | when (_connectionStatus.value) {
556 | "Disconnected" -> {
557 | connectedDevice?.let { device ->
558 | Log.d(TAG, "Sync check: attempting reconnection")
559 | connectToDevice(device)
560 | }
561 | }
562 | "Ready" -> {
563 | Log.d(TAG, "Sync check: processing queue")
564 | processNotificationQueue()
565 | }
566 | }
567 | }
568 |
569 | private fun hasBluetoothPermissions(): Boolean {
570 | return try {
571 | if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
572 | ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
573 | ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
574 | } else {
575 | ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
576 | ActivityCompat.checkSelfPermission(this, android.Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED
577 | }
578 | } catch (e: Exception) {
579 | Log.e(TAG, "Error checking permissions", e)
580 | false
581 | }
582 | }
583 |
584 | fun clearAllNotifications() {
585 | try {
586 | _notifications.value = emptyList()
587 | notificationQueue.clear()
588 | totalNotificationsSent = 0
589 | _connectedDeviceInfo.value = _connectedDeviceInfo.value?.copy(
590 | notificationsSent = 0
591 | )
592 |
593 | if (notificationCharacteristic != null && _connectionStatus.value == "Ready") {
594 | val packet = ByteArray(5) { 0 }
595 | packet[0] = CMD_CLEAR_ALL
596 | notificationCharacteristic?.value = packet
597 | bluetoothGatt?.writeCharacteristic(notificationCharacteristic)
598 | }
599 |
600 | Log.d(TAG, "All notifications cleared")
601 | } catch (e: Exception) {
602 | Log.e(TAG, "Error clearing notifications", e)
603 | }
604 | }
605 |
606 | override fun onDestroy() {
607 | super.onDestroy()
608 | try {
609 | stopScanning()
610 | bluetoothGatt?.close()
611 | Log.d(TAG, "BLE Service destroyed")
612 | } catch (e: Exception) {
613 | Log.e(TAG, "Error destroying service", e)
614 | }
615 | }
616 | }
617 |
618 | data class BluetoothDevice(
619 | val name: String?,
620 | val address: String,
621 | val rssi: Int? = null,
622 | val device: android.bluetooth.BluetoothDevice? = null
623 | )
624 |
625 | data class ConnectedDeviceInfo(
626 | val name: String,
627 | val address: String,
628 | val connectionTime: Long,
629 | val status: String,
630 | val notificationsSent: Int
631 | )
632 |
633 | data class SyncStatus(
634 | val isInProgress: Boolean,
635 | val processedCount: Int,
636 | val sentCount: Int,
637 | val startTime: Long,
638 | val endTime: Long? = null
639 | )
640 |
--------------------------------------------------------------------------------
/AndroidApp/app/src/main/java/net/yehudae/esp32s3notificationsreceiver/SettingsScreen.kt:
--------------------------------------------------------------------------------
1 | package net.yehudae.esp32s3notificationsreceiver
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.*
5 | import androidx.compose.foundation.lazy.LazyColumn
6 | import androidx.compose.foundation.lazy.items
7 | import androidx.compose.foundation.shape.RoundedCornerShape
8 | import androidx.compose.material.icons.Icons
9 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
10 | import androidx.compose.material.icons.filled.*
11 | import androidx.compose.material3.*
12 | import androidx.compose.runtime.*
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.graphics.Brush
16 | import androidx.compose.ui.graphics.Color
17 | import androidx.compose.ui.platform.LocalContext
18 | import androidx.compose.ui.text.font.FontWeight
19 | import androidx.compose.ui.text.style.TextAlign
20 | import androidx.compose.ui.unit.dp
21 | import androidx.compose.ui.unit.sp
22 | import java.text.SimpleDateFormat
23 | import java.util.*
24 |
25 | @OptIn(ExperimentalMaterial3Api::class)
26 | @Composable
27 | fun SettingsScreen(
28 | onBackClick: () -> Unit,
29 | bleService: BLEService?
30 | ) {
31 | val context = LocalContext.current
32 | val settings = remember { NotificationSettings(context) }
33 |
34 | var notifications by remember { mutableStateOf(listOf()) }
35 | var connectedDeviceInfo by remember { mutableStateOf(null) }
36 | var syncStatus by remember { mutableStateOf(null) }
37 | var connectionStatus by remember { mutableStateOf("Disconnected") }
38 |
39 | LaunchedEffect(bleService) {
40 | try {
41 | bleService?.notifications?.collect { notificationList ->
42 | notifications = notificationList
43 | }
44 | } catch (e: Exception) {
45 | e.printStackTrace()
46 | }
47 | }
48 |
49 | LaunchedEffect(bleService) {
50 | try {
51 | bleService?.connectedDeviceInfo?.collect { deviceInfo ->
52 | connectedDeviceInfo = deviceInfo
53 | }
54 | } catch (e: Exception) {
55 | e.printStackTrace()
56 | }
57 | }
58 |
59 | LaunchedEffect(bleService) {
60 | try {
61 | bleService?.syncStatus?.collect { status ->
62 | syncStatus = status
63 | }
64 | } catch (e: Exception) {
65 | e.printStackTrace()
66 | }
67 | }
68 |
69 | LaunchedEffect(bleService) {
70 | try {
71 | bleService?.connectionStatus?.collect { status ->
72 | connectionStatus = status
73 | }
74 | } catch (e: Exception) {
75 | e.printStackTrace()
76 | }
77 | }
78 |
79 | Column(
80 | modifier = Modifier
81 | .fillMaxSize()
82 | .background(
83 | Brush.verticalGradient(
84 | colors = listOf(
85 | Color(0xFF667eea),
86 | Color(0xFF764ba2)
87 | )
88 | )
89 | )
90 | ) {
91 | // Top App Bar
92 | CenterAlignedTopAppBar(
93 | title = {
94 | Text(
95 | "Settings & Statistics",
96 | color = Color.White,
97 | fontSize = 20.sp,
98 | fontWeight = FontWeight.Bold
99 | )
100 | },
101 | navigationIcon = {
102 | IconButton(onClick = onBackClick) {
103 | Icon(
104 | Icons.AutoMirrored.Filled.ArrowBack,
105 | contentDescription = "Back",
106 | tint = Color.White
107 | )
108 | }
109 | },
110 | actions = {
111 | IconButton(
112 | onClick = {
113 | // Clear all notifications
114 | bleService?.clearAllNotifications()
115 | }
116 | ) {
117 | Icon(
118 | Icons.Default.ClearAll,
119 | contentDescription = "Clear All",
120 | tint = Color.White
121 | )
122 | }
123 | },
124 | colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
125 | containerColor = Color.Transparent
126 | )
127 | )
128 |
129 | LazyColumn(
130 | modifier = Modifier
131 | .fillMaxSize()
132 | .padding(16.dp),
133 | verticalArrangement = Arrangement.spacedBy(16.dp)
134 | ) {
135 | // NEW: Sync Management Card
136 | item {
137 | SyncManagementCard(
138 | syncStatus = syncStatus,
139 | connectionStatus = connectionStatus,
140 | onSyncExisting = { bleService?.readExistingNotifications() }
141 | )
142 | }
143 |
144 | // Statistics Card
145 | item {
146 | StatisticsCard(
147 | notifications = notifications,
148 | connectedDeviceInfo = connectedDeviceInfo
149 | )
150 | }
151 |
152 | // App Management Card
153 | item {
154 | AppManagementCard(
155 | notifications = notifications,
156 | settings = settings
157 | )
158 | }
159 |
160 | // Notification Settings Card
161 | item {
162 | NotificationSettingsCard(settings = settings)
163 | }
164 |
165 | // Recent Notifications by Category
166 | item {
167 | NotificationCategoriesCard(notifications = notifications)
168 | }
169 | }
170 | }
171 | }
172 |
173 | // NEW: Sync Management Card
174 | @Composable
175 | fun SyncManagementCard(
176 | syncStatus: SyncStatus?,
177 | connectionStatus: String,
178 | onSyncExisting: () -> Unit
179 | ) {
180 | Card(
181 | modifier = Modifier.fillMaxWidth(),
182 | shape = RoundedCornerShape(16.dp),
183 | colors = CardDefaults.cardColors(
184 | containerColor = Color.White.copy(alpha = 0.95f)
185 | ),
186 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
187 | ) {
188 | Column(
189 | modifier = Modifier.padding(20.dp)
190 | ) {
191 | Row(
192 | verticalAlignment = Alignment.CenterVertically,
193 | modifier = Modifier.padding(bottom = 16.dp)
194 | ) {
195 | Icon(
196 | Icons.Default.Sync,
197 | contentDescription = null,
198 | tint = Color(0xFF667eea),
199 | modifier = Modifier.size(24.dp)
200 | )
201 | Spacer(Modifier.width(8.dp))
202 | Text(
203 | "Sync Management",
204 | fontSize = 18.sp,
205 | fontWeight = FontWeight.Bold,
206 | color = Color(0xFF333333)
207 | )
208 | }
209 |
210 | // Current sync status
211 | if (syncStatus != null) {
212 | Row(
213 | modifier = Modifier.fillMaxWidth(),
214 | verticalAlignment = Alignment.CenterVertically
215 | ) {
216 | if (syncStatus.isInProgress) {
217 | CircularProgressIndicator(
218 | modifier = Modifier.size(20.dp),
219 | color = Color(0xFFFF9800),
220 | strokeWidth = 2.dp
221 | )
222 | Spacer(Modifier.width(12.dp))
223 | Text(
224 | text = "Syncing existing notifications...",
225 | fontSize = 14.sp,
226 | color = Color(0xFF666666)
227 | )
228 | } else {
229 | Icon(
230 | Icons.Default.CheckCircle,
231 | contentDescription = null,
232 | modifier = Modifier.size(20.dp),
233 | tint = Color(0xFF4CAF50)
234 | )
235 | Spacer(Modifier.width(12.dp))
236 | Column {
237 | Text(
238 | text = "Last sync: ${syncStatus.sentCount}/${syncStatus.processedCount} notifications sent",
239 | fontSize = 14.sp,
240 | color = Color(0xFF666666)
241 | )
242 | val duration = (syncStatus.endTime ?: System.currentTimeMillis()) - syncStatus.startTime
243 | Text(
244 | text = "Completed in ${duration / 1000}s",
245 | fontSize = 12.sp,
246 | color = Color(0xFF888888)
247 | )
248 | }
249 | }
250 | }
251 |
252 | Spacer(modifier = Modifier.height(16.dp))
253 | }
254 |
255 | // Sync button and info
256 | Row(
257 | modifier = Modifier.fillMaxWidth(),
258 | horizontalArrangement = Arrangement.spacedBy(12.dp),
259 | verticalAlignment = Alignment.CenterVertically
260 | ) {
261 | Button(
262 | onClick = onSyncExisting,
263 | enabled = connectionStatus == "Ready" && syncStatus?.isInProgress != true,
264 | colors = ButtonDefaults.buttonColors(
265 | containerColor = Color(0xFF4CAF50)
266 | ),
267 | shape = RoundedCornerShape(12.dp)
268 | ) {
269 | if (syncStatus?.isInProgress == true) {
270 | CircularProgressIndicator(
271 | modifier = Modifier.size(16.dp),
272 | color = Color.White,
273 | strokeWidth = 2.dp
274 | )
275 | } else {
276 | Icon(
277 | Icons.Default.Sync,
278 | contentDescription = null,
279 | modifier = Modifier.size(16.dp)
280 | )
281 | }
282 | Spacer(Modifier.width(8.dp))
283 | Text("Sync Existing")
284 | }
285 |
286 | Text(
287 | text = if (connectionStatus == "Ready")
288 | "Reads all current Android notifications"
289 | else
290 | "Connect to ESP32S3 first",
291 | fontSize = 12.sp,
292 | color = Color(0xFF666666),
293 | modifier = Modifier.weight(1f)
294 | )
295 | }
296 |
297 | Spacer(modifier = Modifier.height(16.dp))
298 |
299 | // Sync info
300 | Column {
301 | Text(
302 | text = "How it works:",
303 | fontSize = 14.sp,
304 | fontWeight = FontWeight.Medium,
305 | color = Color(0xFF333333)
306 | )
307 | Spacer(modifier = Modifier.height(4.dp))
308 | Text(
309 | text = "• Reads all notifications currently in your Android notification panel\n• Filters them using your app settings and preferences\n• Sends them to your connected ESP32S3 device\n• Perfect for initial setup or missed notifications",
310 | fontSize = 12.sp,
311 | color = Color(0xFF666666),
312 | lineHeight = 16.sp
313 | )
314 | }
315 | }
316 | }
317 | }
318 |
319 | @Composable
320 | fun StatisticsCard(
321 | notifications: List,
322 | connectedDeviceInfo: ConnectedDeviceInfo?
323 | ) {
324 | Card(
325 | modifier = Modifier.fillMaxWidth(),
326 | shape = RoundedCornerShape(16.dp),
327 | colors = CardDefaults.cardColors(
328 | containerColor = Color.White.copy(alpha = 0.95f)
329 | ),
330 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
331 | ) {
332 | Column(
333 | modifier = Modifier.padding(20.dp)
334 | ) {
335 | Row(
336 | verticalAlignment = Alignment.CenterVertically,
337 | modifier = Modifier.padding(bottom = 16.dp)
338 | ) {
339 | Icon(
340 | Icons.Default.Analytics,
341 | contentDescription = null,
342 | tint = Color(0xFF667eea),
343 | modifier = Modifier.size(24.dp)
344 | )
345 | Spacer(Modifier.width(8.dp))
346 | Text(
347 | "Statistics",
348 | fontSize = 18.sp,
349 | fontWeight = FontWeight.Bold,
350 | color = Color(0xFF333333)
351 | )
352 | }
353 |
354 | Row(
355 | modifier = Modifier.fillMaxWidth(),
356 | horizontalArrangement = Arrangement.SpaceEvenly
357 | ) {
358 | StatisticItem(
359 | title = "Total Notifications",
360 | value = "${notifications.size}",
361 | icon = Icons.Default.Notifications,
362 | color = Color(0xFF4CAF50)
363 | )
364 |
365 | StatisticItem(
366 | title = "Sent to Device",
367 | value = "${connectedDeviceInfo?.notificationsSent ?: 0}",
368 | icon = Icons.Default.Send,
369 | color = Color(0xFF2196F3)
370 | )
371 |
372 | StatisticItem(
373 | title = "Unique Apps",
374 | value = "${notifications.distinctBy { it.appName }.size}",
375 | icon = Icons.Default.Apps,
376 | color = Color(0xFFFF9800)
377 | )
378 | }
379 |
380 | Spacer(modifier = Modifier.height(16.dp))
381 |
382 | // Today's notifications
383 | val today = SimpleDateFormat("dd/MM", Locale.getDefault()).format(Date())
384 | val todayNotifications = notifications.count {
385 | it.timestamp.startsWith(today) || it.timestamp.contains(SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 2))
386 | }
387 |
388 | Row(
389 | modifier = Modifier.fillMaxWidth(),
390 | horizontalArrangement = Arrangement.SpaceBetween
391 | ) {
392 | Text(
393 | text = "Today's Notifications:",
394 | fontSize = 14.sp,
395 | color = Color(0xFF666666)
396 | )
397 | Text(
398 | text = "$todayNotifications",
399 | fontSize = 14.sp,
400 | fontWeight = FontWeight.Bold,
401 | color = Color(0xFF333333)
402 | )
403 | }
404 |
405 | if (connectedDeviceInfo != null) {
406 | Row(
407 | modifier = Modifier.fillMaxWidth(),
408 | horizontalArrangement = Arrangement.SpaceBetween
409 | ) {
410 | Text(
411 | text = "Connection Duration:",
412 | fontSize = 14.sp,
413 | color = Color(0xFF666666)
414 | )
415 | val duration = (System.currentTimeMillis() - connectedDeviceInfo.connectionTime) / 1000 / 60
416 | Text(
417 | text = "${duration}m",
418 | fontSize = 14.sp,
419 | fontWeight = FontWeight.Bold,
420 | color = Color(0xFF333333)
421 | )
422 | }
423 | }
424 | }
425 | }
426 | }
427 |
428 | @Composable
429 | fun StatisticItem(
430 | title: String,
431 | value: String,
432 | icon: androidx.compose.ui.graphics.vector.ImageVector,
433 | color: Color
434 | ) {
435 | Column(
436 | horizontalAlignment = Alignment.CenterHorizontally
437 | ) {
438 | Icon(
439 | icon,
440 | contentDescription = null,
441 | modifier = Modifier.size(32.dp),
442 | tint = color
443 | )
444 | Spacer(Modifier.height(8.dp))
445 | Text(
446 | text = value,
447 | fontSize = 20.sp,
448 | fontWeight = FontWeight.Bold,
449 | color = color
450 | )
451 | Text(
452 | text = title,
453 | fontSize = 12.sp,
454 | color = Color(0xFF666666),
455 | textAlign = TextAlign.Center
456 | )
457 | }
458 | }
459 |
460 | @Composable
461 | fun AppManagementCard(
462 | notifications: List,
463 | settings: NotificationSettings
464 | ) {
465 | var showAppList by remember { mutableStateOf(false) }
466 |
467 | Card(
468 | modifier = Modifier.fillMaxWidth(),
469 | shape = RoundedCornerShape(16.dp),
470 | colors = CardDefaults.cardColors(
471 | containerColor = Color.White.copy(alpha = 0.95f)
472 | ),
473 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
474 | ) {
475 | Column(
476 | modifier = Modifier.padding(20.dp)
477 | ) {
478 | Row(
479 | verticalAlignment = Alignment.CenterVertically,
480 | modifier = Modifier.padding(bottom = 16.dp)
481 | ) {
482 | Icon(
483 | Icons.Default.Apps,
484 | contentDescription = null,
485 | tint = Color(0xFF667eea),
486 | modifier = Modifier.size(24.dp)
487 | )
488 | Spacer(Modifier.width(8.dp))
489 | Text(
490 | "App Management",
491 | fontSize = 18.sp,
492 | fontWeight = FontWeight.Bold,
493 | color = Color(0xFF333333)
494 | )
495 | Spacer(Modifier.weight(1f))
496 | TextButton(
497 | onClick = { showAppList = !showAppList }
498 | ) {
499 | Text(if (showAppList) "Hide" else "Manage")
500 | }
501 | }
502 |
503 | if (showAppList) {
504 | val appNotifications = notifications.groupBy { it.appName }
505 | .map { (appName, notifications) ->
506 | AppNotificationInfo(
507 | appName = appName,
508 | count = notifications.size,
509 | isEnabled = !settings.getBlockedApps().contains(appName),
510 | isPriority = settings.getPriorityApps().contains(appName)
511 | )
512 | }
513 | .sortedByDescending { it.count }
514 |
515 | appNotifications.forEach { appInfo ->
516 | AppManagementItem(
517 | appInfo = appInfo,
518 | onToggleEnabled = { settings.toggleAppEnabled(appInfo.appName) },
519 | onTogglePriority = { settings.toggleAppPriority(appInfo.appName) }
520 | )
521 | Spacer(Modifier.height(8.dp))
522 | }
523 | } else {
524 | Text(
525 | text = "Tap 'Manage' to control which apps can send notifications to your ESP32S3",
526 | fontSize = 14.sp,
527 | color = Color(0xFF666666)
528 | )
529 | }
530 | }
531 | }
532 | }
533 |
534 | @Composable
535 | fun AppManagementItem(
536 | appInfo: AppNotificationInfo,
537 | onToggleEnabled: () -> Unit,
538 | onTogglePriority: () -> Unit
539 | ) {
540 | Row(
541 | modifier = Modifier.fillMaxWidth(),
542 | verticalAlignment = Alignment.CenterVertically
543 | ) {
544 | Column(
545 | modifier = Modifier.weight(1f)
546 | ) {
547 | Text(
548 | text = appInfo.appName,
549 | fontSize = 16.sp,
550 | fontWeight = FontWeight.Medium,
551 | color = Color(0xFF333333)
552 | )
553 | Text(
554 | text = "${appInfo.count} notifications",
555 | fontSize = 12.sp,
556 | color = Color(0xFF666666)
557 | )
558 | }
559 |
560 | if (appInfo.isPriority) {
561 | Icon(
562 | Icons.Default.Star,
563 | contentDescription = "Priority",
564 | modifier = Modifier.size(16.dp),
565 | tint = Color(0xFFFFD700)
566 | )
567 | Spacer(Modifier.width(8.dp))
568 | }
569 |
570 | Switch(
571 | checked = appInfo.isEnabled,
572 | onCheckedChange = { onToggleEnabled() },
573 | colors = SwitchDefaults.colors(
574 | checkedThumbColor = Color(0xFF4CAF50),
575 | checkedTrackColor = Color(0xFF4CAF50).copy(alpha = 0.5f)
576 | )
577 | )
578 | }
579 | }
580 |
581 | @Composable
582 | fun NotificationSettingsCard(settings: NotificationSettings) {
583 | var quietHoursEnabled by remember { mutableStateOf(settings.isQuietHoursEnabled()) }
584 | var maxNotifications by remember { mutableStateOf(settings.getMaxNotifications()) }
585 |
586 | Card(
587 | modifier = Modifier.fillMaxWidth(),
588 | shape = RoundedCornerShape(16.dp),
589 | colors = CardDefaults.cardColors(
590 | containerColor = Color.White.copy(alpha = 0.95f)
591 | ),
592 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
593 | ) {
594 | Column(
595 | modifier = Modifier.padding(20.dp)
596 | ) {
597 | Row(
598 | verticalAlignment = Alignment.CenterVertically,
599 | modifier = Modifier.padding(bottom = 16.dp)
600 | ) {
601 | Icon(
602 | Icons.Default.Settings,
603 | contentDescription = null,
604 | tint = Color(0xFF667eea),
605 | modifier = Modifier.size(24.dp)
606 | )
607 | Spacer(Modifier.width(8.dp))
608 | Text(
609 | "Notification Settings",
610 | fontSize = 18.sp,
611 | fontWeight = FontWeight.Bold,
612 | color = Color(0xFF333333)
613 | )
614 | }
615 |
616 | // Quiet Hours
617 | Row(
618 | modifier = Modifier.fillMaxWidth(),
619 | verticalAlignment = Alignment.CenterVertically
620 | ) {
621 | Column(
622 | modifier = Modifier.weight(1f)
623 | ) {
624 | Text(
625 | text = "Quiet Hours",
626 | fontSize = 16.sp,
627 | fontWeight = FontWeight.Medium,
628 | color = Color(0xFF333333)
629 | )
630 | Text(
631 | text = "Don't send notifications during ${settings.getQuietHoursStart()}:00-${settings.getQuietHoursEnd()}:00",
632 | fontSize = 12.sp,
633 | color = Color(0xFF666666)
634 | )
635 | }
636 | Switch(
637 | checked = quietHoursEnabled,
638 | onCheckedChange = {
639 | quietHoursEnabled = it
640 | settings.setQuietHoursEnabled(it)
641 | }
642 | )
643 | }
644 |
645 | Spacer(Modifier.height(16.dp))
646 |
647 | // Max Notifications
648 | Row(
649 | modifier = Modifier.fillMaxWidth(),
650 | verticalAlignment = Alignment.CenterVertically
651 | ) {
652 | Column(
653 | modifier = Modifier.weight(1f)
654 | ) {
655 | Text(
656 | text = "Max Stored Notifications",
657 | fontSize = 16.sp,
658 | fontWeight = FontWeight.Medium,
659 | color = Color(0xFF333333)
660 | )
661 | Text(
662 | text = "Currently: $maxNotifications",
663 | fontSize = 12.sp,
664 | color = Color(0xFF666666)
665 | )
666 | }
667 | }
668 | }
669 | }
670 | }
671 |
672 | @Composable
673 | fun NotificationCategoriesCard(notifications: List) {
674 | Card(
675 | modifier = Modifier.fillMaxWidth(),
676 | shape = RoundedCornerShape(16.dp),
677 | colors = CardDefaults.cardColors(
678 | containerColor = Color.White.copy(alpha = 0.95f)
679 | ),
680 | elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
681 | ) {
682 | Column(
683 | modifier = Modifier.padding(20.dp)
684 | ) {
685 | Row(
686 | verticalAlignment = Alignment.CenterVertically,
687 | modifier = Modifier.padding(bottom = 16.dp)
688 | ) {
689 | Icon(
690 | Icons.Default.Category,
691 | contentDescription = null,
692 | tint = Color(0xFF667eea),
693 | modifier = Modifier.size(24.dp)
694 | )
695 | Spacer(Modifier.width(8.dp))
696 | Text(
697 | "Notification Categories",
698 | fontSize = 18.sp,
699 | fontWeight = FontWeight.Bold,
700 | color = Color(0xFF333333)
701 | )
702 | }
703 |
704 | val categories = mapOf(
705 | "Messages" to listOf("whatsapp", "telegram", "signal", "messages", "sms"),
706 | "Social" to listOf("instagram", "facebook", "twitter", "snapchat", "tiktok"),
707 | "Email" to listOf("gmail", "outlook", "mail", "email"),
708 | "Calls" to listOf("phone", "dialer", "call"),
709 | "News" to listOf("news", "reddit", "medium"),
710 | "Other" to emptyList()
711 | )
712 |
713 | categories.forEach { (category, keywords) ->
714 | val categoryNotifications = notifications.filter { notification ->
715 | if (keywords.isEmpty()) {
716 | // "Other" category - notifications that don't match any other category
717 | categories.entries.none { (_, otherKeywords) ->
718 | otherKeywords.isNotEmpty() && otherKeywords.any {
719 | notification.appName.contains(it, ignoreCase = true)
720 | }
721 | }
722 | } else {
723 | keywords.any { notification.appName.contains(it, ignoreCase = true) }
724 | }
725 | }
726 |
727 | if (categoryNotifications.isNotEmpty()) {
728 | Row(
729 | modifier = Modifier.fillMaxWidth(),
730 | horizontalArrangement = Arrangement.SpaceBetween
731 | ) {
732 | Text(
733 | text = category,
734 | fontSize = 14.sp,
735 | color = Color(0xFF666666)
736 | )
737 | Text(
738 | text = "${categoryNotifications.size}",
739 | fontSize = 14.sp,
740 | fontWeight = FontWeight.Bold,
741 | color = Color(0xFF333333)
742 | )
743 | }
744 | Spacer(Modifier.height(4.dp))
745 | }
746 | }
747 | }
748 | }
749 | }
750 |
751 | data class AppNotificationInfo(
752 | val appName: String,
753 | val count: Int,
754 | val isEnabled: Boolean,
755 | val isPriority: Boolean
756 | )
757 |
--------------------------------------------------------------------------------