From 22a3e0fe7e36025a6b1d84504e9ce4b10beeb862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Cie=C5=9Blik?= Date: Sat, 13 Jun 2026 21:43:53 +0200 Subject: [PATCH] Initial commit --- .gitattributes | 22 + .gitignore | 24 + OPTIMIZATION_PLAN.md | 833 ++++++++++++++++++ README.md | 36 + ROADMAP.md | 64 ++ TASKS.md | 47 + app/.gitignore | 1 + app/build.gradle.kts | 72 ++ app/proguard-rules.pro | 21 + .../com/example/retroha/AppNavigationTest.kt | 50 ++ .../com/example/retroha/AutomatedClickTest.kt | 23 + .../retroha/ExampleInstrumentedTest.kt | 14 + .../com/example/retroha/MonkeyStressTest.kt | 65 ++ app/src/main/AndroidManifest.xml | 35 + .../java/com/example/retroha/BaseActivity.kt | 10 + .../retroha/ConnectionSettingsActivity.kt | 72 ++ .../retroha/EntitySelectionActivity.kt | 88 ++ .../example/retroha/InstructionsActivity.kt | 8 + .../com/example/retroha/LanguageActivity.kt | 91 ++ .../java/com/example/retroha/MainActivity.kt | 313 +++++++ .../com/example/retroha/SettingsActivity.kt | 38 + .../java/com/example/retroha/data/Prefs.kt | 25 + .../example/retroha/i18n/AndroidStrings.kt | 45 + .../com/example/retroha/i18n/LocaleHelper.kt | 16 + .../example/retroha/network/HaApiService.kt | 28 + .../com/example/retroha/network/HaClient.kt | 42 + .../example/retroha/network/HaStateAdapter.kt | 38 + .../com/example/retroha/ui/BauhausCheckbox.kt | 32 + .../retroha/ui/EntitySelectionAdapter.kt | 56 ++ .../main/java/com/example/retroha/ui/Fonts.kt | 6 + .../java/com/example/retroha/ui/HaIcons.kt | 74 ++ .../retroha/ui/LanguageIconDrawable.kt | 28 + .../example/retroha/ui/LightControlDialog.kt | 75 ++ .../com/example/retroha/ui/WidgetAdapter.kt | 33 + .../com/example/retroha/ui/WidgetCardView.kt | 208 +++++ .../drawable-v24/ic_launcher_foreground.xml | 30 + .../res/drawable/ic_launcher_background.xml | 170 ++++ app/src/main/res/drawable/scrollbar_thumb.xml | 5 + .../layout/activity_connection_settings.xml | 133 +++ .../res/layout/activity_entity_selection.xml | 65 ++ .../main/res/layout/activity_instructions.xml | 90 ++ app/src/main/res/layout/activity_main.xml | 131 +++ app/src/main/res/layout/activity_settings.xml | 134 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/values-en/strings.xml | 60 ++ app/src/main/res/values-night/themes.xml | 11 + app/src/main/res/values/colors.xml | 19 + app/src/main/res/values/strings.xml | 60 ++ app/src/main/res/values/themes.xml | 10 + app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 + .../main/res/xml/network_security_config.xml | 4 + .../com/example/retroha/ExampleUnitTest.kt | 9 + build.gradle.kts | 15 + gradle.properties | 15 + gradle/gradle-daemon-jvm.properties | 12 + gradle/libs.versions.toml | 36 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 251 ++++++ gradlew.bat | 94 ++ settings.gradle.kts | 28 + shared/build.gradle.kts | 8 + .../com/example/retroha/i18n/StringKey.kt | 39 + .../com/example/retroha/i18n/Strings.kt | 4 + .../com/example/retroha/model/EntityState.kt | 2 + .../com/example/retroha/model/WidgetConfig.kt | 11 + .../retroha/model/WidgetInteraction.kt | 15 + .../com/example/retroha/theme/Colors.kt | 28 + 80 files changed, 4175 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 OPTIMIZATION_PLAN.md create mode 100644 README.md create mode 100644 ROADMAP.md create mode 100644 TASKS.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/example/retroha/AppNavigationTest.kt create mode 100644 app/src/androidTest/java/com/example/retroha/AutomatedClickTest.kt create mode 100644 app/src/androidTest/java/com/example/retroha/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/example/retroha/MonkeyStressTest.kt create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/retroha/BaseActivity.kt create mode 100644 app/src/main/java/com/example/retroha/ConnectionSettingsActivity.kt create mode 100644 app/src/main/java/com/example/retroha/EntitySelectionActivity.kt create mode 100644 app/src/main/java/com/example/retroha/InstructionsActivity.kt create mode 100644 app/src/main/java/com/example/retroha/LanguageActivity.kt create mode 100644 app/src/main/java/com/example/retroha/MainActivity.kt create mode 100644 app/src/main/java/com/example/retroha/SettingsActivity.kt create mode 100644 app/src/main/java/com/example/retroha/data/Prefs.kt create mode 100644 app/src/main/java/com/example/retroha/i18n/AndroidStrings.kt create mode 100644 app/src/main/java/com/example/retroha/i18n/LocaleHelper.kt create mode 100644 app/src/main/java/com/example/retroha/network/HaApiService.kt create mode 100644 app/src/main/java/com/example/retroha/network/HaClient.kt create mode 100644 app/src/main/java/com/example/retroha/network/HaStateAdapter.kt create mode 100644 app/src/main/java/com/example/retroha/ui/BauhausCheckbox.kt create mode 100644 app/src/main/java/com/example/retroha/ui/EntitySelectionAdapter.kt create mode 100644 app/src/main/java/com/example/retroha/ui/Fonts.kt create mode 100644 app/src/main/java/com/example/retroha/ui/HaIcons.kt create mode 100644 app/src/main/java/com/example/retroha/ui/LanguageIconDrawable.kt create mode 100644 app/src/main/java/com/example/retroha/ui/LightControlDialog.kt create mode 100644 app/src/main/java/com/example/retroha/ui/WidgetAdapter.kt create mode 100644 app/src/main/java/com/example/retroha/ui/WidgetCardView.kt create mode 100644 app/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/scrollbar_thumb.xml create mode 100644 app/src/main/res/layout/activity_connection_settings.xml create mode 100644 app/src/main/res/layout/activity_entity_selection.xml create mode 100644 app/src/main/res/layout/activity_instructions.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/values-en/strings.xml create mode 100644 app/src/main/res/values-night/themes.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 app/src/test/java/com/example/retroha/ExampleUnitTest.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/gradle-daemon-jvm.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 shared/build.gradle.kts create mode 100644 shared/src/main/kotlin/com/example/retroha/i18n/StringKey.kt create mode 100644 shared/src/main/kotlin/com/example/retroha/i18n/Strings.kt create mode 100644 shared/src/main/kotlin/com/example/retroha/model/EntityState.kt create mode 100644 shared/src/main/kotlin/com/example/retroha/model/WidgetConfig.kt create mode 100644 shared/src/main/kotlin/com/example/retroha/model/WidgetInteraction.kt create mode 100644 shared/src/main/kotlin/com/example/retroha/theme/Colors.kt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..09650ae --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Handle line endings automatically +* text=auto + +# Standard binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.jar binary +*.zip binary +*.aar binary +*.apk binary + +# Android specific +*.flat binary +*.webp binary + +# Ensure gradlew has LF line endings and is executable +gradlew text eol=lf +gradlew.bat text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..665ecde --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +*.iml +.gradle +/local.properties +/.idea/ +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +/.idea/deploymentTargetSelector.xml +/.idea/androidTestResultsUserPreferences.xml +.DS_Store +/build +/app/build +/shared/build +/captures +.externalNativeBuild +.cxx +local.properties +*.bak +*.swp +*.class +.kotlin diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md new file mode 100644 index 0000000..adc54be --- /dev/null +++ b/OPTIMIZATION_PLAN.md @@ -0,0 +1,833 @@ +# Plan optymalizacji — MyApplication (HA Widget Panel) + +Cel: maksymalna wydajność na AllWinner A13 (single-core 1.2GHz, 512MB RAM, Android 4.0, API 14). + +--- + +## Spis treści + +1. [Stack technologiczny](#1-stack-technologiczny) +2. [System designu — Bauhaus / Swiss Grid](#2-system-designu--bauhaus--swiss-grid) +3. [Konfiguracja buildu](#3-konfiguracja-buildu) +4. [Rendering — Custom View](#4-rendering--custom-view) +5. [GridView — lista widgetów](#5-gridview--lista-widgetów) +6. [Ikony — Canvas drawing](#6-ikony--canvas-drawing) +7. [StaticLayout — pre-obliczony tekst](#7-staticlayout--pre-obliczony-tekst) +8. [Animacje — GPU only](#8-animacje--gpu-only) +9. [Sieć — OkHttp + Retrofit](#9-sieć--okhttp--retrofit) +10. [JSON — TypeAdaptery zamiast refleksji](#10-json--typeadaptery-zamiast-refleksji) +11. [Wątki — HandlerThread](#11-wątki--handlerthread) +12. [Pamięć — LruCache + SparseArray](#12-pamięć--lrucache--sparsearray) +13. [Inicjalizacja przy starcie](#13-inicjalizacja-przy-starcie) +14. [Języki](#14-języki) +15. [Priorytety implementacji](#15-priorytety-implementacji) + +--- + +## 1. Stack technologiczny + +| Element | Wybór | Powód | +|---|---|---| +| Język | **Kotlin** | Identyczny bytecode co Java — zero różnicy runtime | +| UI framework | **Natywne Android API 14** | Zero overhead bibliotek, pełna kontrola | +| Tema | **Theme.Holo** | Natywna, zero zależności, dostępna od API 11 | +| Lista widgetów | **android.widget.GridView** | Natywna, wbudowane recycling widoków, brak Jetpack | +| Sieć | **OkHttp 3.12.x + Retrofit 2.6.4** | Ostatnie wersje wspierające API 14 | +| JSON | **Gson + TypeAdaptery** | Streaming parsing bez refleksji | +| **ZABRONIONE** | AppCompat, Material Design, RecyclerView, Jetpack, Compose, CardView | Zbędny overhead / wymaga API 21+ | + +--- + +## 2. System designu — Bauhaus / Swiss Grid + +### Paleta kolorów + +```kotlin +object Colors { + const val WHITE = 0xFFFFFFFF.toInt() // tło, tekst na ciemnym + const val BLACK = 0xFF000000.toInt() // tekst, obramowania, cień + const val RED = 0xFFE23A24.toInt() // błędy, alarmy, akcent krytyczny + const val YELLOW = 0xFFFAD02C.toInt() // aktywny stan, akcent główny + const val BLUE = 0xFF0056B3.toInt() // info, przyciski, akcent drugorzędny + const val GRAY = 0xFF888888.toInt() // wyłączone, disabled +} +``` + +Żadnych innych kolorów. Żadnych gradientów. Żadnego alpha blendingu poza animacjami opacity. + +### Typografia — system monospace + +```kotlin +object Fonts { + // Wbudowany w każde urządzenie z API 14 — zero overhead, zero KB w APK + val REGULAR: Typeface = Typeface.create("monospace", Typeface.NORMAL) + val BOLD: Typeface = Typeface.create("monospace", Typeface.BOLD) +} +``` + +Każdy `TextPaint` używa `Fonts.REGULAR` lub `Fonts.BOLD`. Żadnych innych krojów. + +### Skala typograficzna + +``` +Rola Rozmiar Waga Zastosowanie +──────────────────────────────────────────────────── +value 20sp BOLD główna wartość encji (21.5°C, ON, 58%) +label 12sp NORMAL nazwa encji +secondary 10sp NORMAL jednostka, czas ostatniej aktualizacji +``` + +### Siatka spacingu — tylko wielokrotności 4dp + +``` +Padding wewnętrzny karty: 8dp +Odstęp między kartami: 4dp +Padding ekranu: 8dp +Grubość borderu: 1dp lub 2dp +Szerokość lewego paska: 4dp +``` + +### Geometria — ZERO zaokrągleń + +```kotlin +// ŹLE — zaokrąglone rogi są ZABRONIONE +canvas.drawRoundRect(rect, 8f, 8f, paint) + +// DOBRZE — ostre rogi wszędzie +canvas.drawRect(rect, paint) +``` + +`android:radius` i `corners` w drawable — ZABRONIONE. + +### Hard shadow — technika + +Zamiast `elevation` — przesunięty czarny `View`: + +```xml + + + + + + + + + +``` + +--- + +## 3. Konfiguracja buildu + +### `app/build.gradle.kts` + +```kotlin +android { + defaultConfig { + minSdk = 14 + // Multidex WYŁĄCZONY — bez AppCompat/Material szacowane ~25K metod, poniżej limitu 65K + resourceConfigurations += listOf("pl", "en") + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + // AllWinner A13 = ARM Cortex-A8 = armeabi-v7a + // Wyklucza x86/arm64 → mniejszy APK, szybsza instalacja + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a") + isUniversalApk = false + } + } +} + +dependencies { + // Sieć — OK, nie są to UI wrappery + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.gson) + implementation(libs.mpandroidchart) + + // USUNIĘTE: AppCompat, Material, core-ktx, multidex, RecyclerView +} +``` + +### `res/values/themes.xml` + +```xml + +``` + +### Dlaczego odpada multidex + +``` +AppCompat usunięty: -30 000 metod +Material usunięty: -15 000 metod +core-ktx usunięte: - 5 000 metod +────────────────────────────────────── +Szacowany total: ~25 000 metod ← poniżej limitu 65K +``` + +--- + +## 4. Rendering — Custom View + +**Zasady:** +- `Paint` / `TextPaint` / `Rect` / `RectF` — pola klasy, nigdy zmienne lokalne w `onDraw` +- `Fonts.REGULAR` / `Fonts.BOLD` przypisane raz przy inicjalizacji obiektu Paint +- `drawRect` zamiast `drawRoundRect` — ostre rogi, szybsze +- Stała wysokość karty w `onMeasure` — eliminuje kosztowne obliczenia `wrap_content` +- Settery z `if (field == value) return` — bez zbędnych `invalidate()` + +```kotlin +class WidgetCardView(context: Context) : View(context) { + + private val dp = resources.displayMetrics.density + + private val paintBg = Paint(Paint.ANTI_ALIAS_FLAG) + private val paintBorder = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f * dp + color = Colors.BLACK + } + private val paintLabel = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Fonts.REGULAR + textSize = 12f * dp + color = Colors.WHITE + } + private val paintValue = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Fonts.BOLD + textSize = 20f * dp + color = Colors.WHITE + } + private val paintSecondary = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = Fonts.REGULAR + textSize = 10f * dp + color = Colors.GRAY + } + private val paintStripe = Paint(Paint.ANTI_ALIAS_FLAG) + + private val rectBg = Rect() + private val rectStripe = Rect() + + private var labelLayout: StaticLayout? = null + private var valueLayout: StaticLayout? = null + private var secondaryLayout: StaticLayout? = null + + var label: String = "" + set(v) { if (field == v) return; field = v; rebuildLayouts(); invalidate() } + var value: String = "" + set(v) { if (field == v) return; field = v; rebuildLayouts(); invalidate() } + var secondaryText: String = "" + set(v) { if (field == v) return; field = v; rebuildLayouts(); invalidate() } + var isActive: Boolean = false + set(v) { if (field == v) return; field = v; invalidate() } + var stripeColor: Int = Colors.BLUE + set(v) { if (field == v) return; field = v; invalidate() } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + val stripeW = (4f * dp).toInt() + rectBg.set(0, 0, w, h) + rectStripe.set(0, 0, stripeW, h) + rebuildLayouts() + } + + private fun rebuildLayouts() { + if (width == 0) return + val contentW = width - rectStripe.width() - (8 * dp).toInt() - (4 * dp).toInt() + labelLayout = buildLayout(label, paintLabel, contentW) + valueLayout = buildLayout(value, paintValue, contentW) + secondaryLayout = buildLayout(secondaryText, paintSecondary, contentW) + } + + private fun buildLayout(text: String, paint: TextPaint, w: Int): StaticLayout = + StaticLayout(text, paint, w, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false) + + override fun onDraw(canvas: Canvas) { + val dp8 = 8f * dp + val dp4 = 4f * dp + + // Tło karty + paintBg.color = if (isActive) Colors.YELLOW else Colors.BLACK + canvas.drawRect(rectBg, paintBg) + + // Pionowy pasek koloru (domena encji) + paintStripe.color = stripeColor + canvas.drawRect(rectStripe, paintStripe) + + // Obramowanie + canvas.drawRect(rectBg, paintBorder) + + // Kolor tekstu zależy od tła + val textColor = if (isActive) Colors.BLACK else Colors.WHITE + paintLabel.color = textColor + paintValue.color = textColor + + val textX = rectStripe.width() + dp8 + + canvas.save() + canvas.translate(textX, dp8) + labelLayout?.draw(canvas) + canvas.restore() + + val labelH = labelLayout?.height?.toFloat() ?: 0f + canvas.save() + canvas.translate(textX, dp8 + labelH + dp4) + valueLayout?.draw(canvas) + canvas.restore() + + val valueH = valueLayout?.height?.toFloat() ?: 0f + canvas.save() + canvas.translate(textX, dp8 + labelH + dp4 + valueH + dp4) + secondaryLayout?.draw(canvas) + canvas.restore() + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val w = MeasureSpec.getSize(widthMeasureSpec) + val h = when { + resources.configuration.screenHeightDp < 480 -> (72 * dp).toInt() + else -> (88 * dp).toInt() + } + setMeasuredDimension(w, h) + } +} +``` + +**Czego unikać:** +- `ConstraintLayout` w itemach gridu — skomplikowany algorytm pomiaru +- `CardView` — wymaga AppCompat, ma elevation +- Zagnieżdżenie ViewGroup > 2 poziomów +- `wrap_content` na wysokości itemów gridu + +--- + +## 5. GridView — lista widgetów + +`android.widget.GridView` — natywny od API 1, zero zależności, wbudowane recycling widoków. +Zastępuje `RecyclerView` (Jetpack — zabroniony). + +```kotlin +val gridView = GridView(this).apply { + numColumns = when { + resources.configuration.screenWidthDp >= 840 -> 4 // tablet landscape + resources.configuration.screenWidthDp >= 600 -> 3 // tablet portrait + else -> 2 // telefon + } + val dp4 = (4 * resources.displayMetrics.density).toInt() + val dp8 = (8 * resources.displayMetrics.density).toInt() + horizontalSpacing = dp4 + verticalSpacing = dp4 + setPadding(dp8, dp8, dp8, dp8) + stretchMode = GridView.STRETCH_COLUMN_WIDTH + adapter = widgetAdapter +} +``` + +### BaseAdapter + +```kotlin +class WidgetAdapter( + private val context: Context, + private var items: List +) : BaseAdapter() { + + override fun getCount() = items.size + override fun getItem(pos: Int) = items[pos] + override fun getItemId(pos: Int) = items[pos].entityId.hashCode().toLong() + override fun hasStableIds() = true // GridView może reużywać widoki + + override fun getView(pos: Int, convertView: View?, parent: ViewGroup): View { + // convertView = recycled view — ZAWSZE reużywaj jeśli istnieje + val card = (convertView as? WidgetCardView) ?: WidgetCardView(context) + val item = items[pos] + card.label = item.friendlyName + card.value = item.state + card.secondaryText = item.lastUpdated + card.isActive = item.isOn + card.stripeColor = domainColor(item.domain) + return card + } + + fun update(newItems: List) { + items = newItems + notifyDataSetChanged() + } + + // Bezpośrednia aktualizacja stanu encji bez przebudowy adaptera + fun updateState(entityId: String, newState: String, isOn: Boolean) { + val idx = items.indexOfFirst { it.entityId == entityId } + if (idx == -1) return + items = items.toMutableList().also { + it[idx] = it[idx].copy(state = newState, isOn = isOn) + } + // Aktualizuj tylko widoczną kartę jeśli jest na ekranie + val card = gridView?.getChildAt(idx) as? WidgetCardView + if (card != null) { + card.value = newState + card.isActive = isOn + } else { + notifyDataSetChanged() + } + } + + private fun domainColor(domain: String) = when (domain) { + "light" -> Colors.YELLOW + "switch" -> Colors.BLUE + "sensor" -> Colors.BLUE + "binary_sensor" -> Colors.BLUE + "script", "scene" -> Colors.RED + else -> Colors.GRAY + } +} +``` + +**Uwaga:** `GridView` nie ma `DiffUtil` (Jetpack). Dla małej listy widgetów (< 30 pozycji) +`notifyDataSetChanged()` przy zmianie konfiguracji jest akceptowalne. Przy aktualizacji +samego stanu encji — bezpośrednia modyfikacja `WidgetCardView` bez przebudowy adaptera. + +--- + +## 6. Ikony — Canvas drawing + +Zakaz bitmap (PNG/JPG). Ikony rysowane bezpośrednio na `Canvas` jako kształty geometryczne. +Styl Bauhaus: proste figury, jeden kolor, zero dekoracji. + +```kotlin +object HaIcons { + + // Paint jako pole — zero alokacji przy każdym draw() + private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + } + private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 2f + } + private val tmpRect = RectF() + + fun draw(canvas: Canvas, domain: String, x: Float, y: Float, size: Float, color: Int) { + fillPaint.color = color + strokePaint.color = color + when (domain) { + "light" -> drawLight(canvas, x, y, size) + "switch" -> drawSwitch(canvas, x, y, size) + "sensor" -> drawSensor(canvas, x, y, size) + "binary_sensor" -> drawBinarySensor(canvas, x, y, size) + "script", "automation" -> drawScript(canvas, x, y, size) + else -> drawGeneric(canvas, x, y, size) + } + } + + private fun drawLight(canvas: Canvas, x: Float, y: Float, s: Float) { + // Kwadrat + promienie — geometryczna "lampa" + tmpRect.set(x + s*0.25f, y + s*0.25f, x + s*0.75f, y + s*0.75f) + canvas.drawRect(tmpRect, fillPaint) + canvas.drawLine(x + s*0.5f, y, x + s*0.5f, y + s*0.2f, strokePaint) + canvas.drawLine(x + s*0.5f, y + s*0.8f, x + s*0.5f, y + s, strokePaint) + canvas.drawLine(x, y + s*0.5f, x + s*0.2f, y + s*0.5f, strokePaint) + canvas.drawLine(x + s*0.8f, y + s*0.5f, x + s, y + s*0.5f, strokePaint) + } + + private fun drawSwitch(canvas: Canvas, x: Float, y: Float, s: Float) { + // Prostokąt poziomy + blok po prawej + tmpRect.set(x, y + s*0.35f, x + s, y + s*0.65f) + canvas.drawRect(tmpRect, fillPaint) + tmpRect.set(x + s*0.55f, y + s*0.15f, x + s*0.95f, y + s*0.85f) + canvas.drawRect(tmpRect, fillPaint) + } + + private fun drawSensor(canvas: Canvas, x: Float, y: Float, s: Float) { + // Krzyż — pomiar + tmpRect.set(x + s*0.4f, y, x + s*0.6f, y + s) + canvas.drawRect(tmpRect, fillPaint) + tmpRect.set(x, y + s*0.4f, x + s, y + s*0.6f) + canvas.drawRect(tmpRect, fillPaint) + } + + private fun drawBinarySensor(canvas: Canvas, x: Float, y: Float, s: Float) { + // Kwadrat w kwadracie + tmpRect.set(x + s*0.1f, y + s*0.1f, x + s*0.9f, y + s*0.9f) + canvas.drawRect(tmpRect, strokePaint) + tmpRect.set(x + s*0.3f, y + s*0.3f, x + s*0.7f, y + s*0.7f) + canvas.drawRect(tmpRect, fillPaint) + } + + private fun drawScript(canvas: Canvas, x: Float, y: Float, s: Float) { + // Trzy poziome paski — lista komend + val h = s * 0.12f + val gap = s * 0.25f + for (i in 0..2) { + tmpRect.set(x, y + gap * i + gap * 0.5f, x + s, y + gap * i + gap * 0.5f + h) + canvas.drawRect(tmpRect, fillPaint) + } + } + + private fun drawGeneric(canvas: Canvas, x: Float, y: Float, s: Float) { + tmpRect.set(x + s*0.15f, y + s*0.15f, x + s*0.85f, y + s*0.85f) + canvas.drawRect(tmpRect, fillPaint) + } +} +``` + +Ponieważ ikony są prostymi operacjami Canvas (kilka `drawRect` / `drawLine`), rasteryzacja +jest natychmiastowa — nie potrzeba cache'owania bitmap. + +--- + +## 7. StaticLayout — pre-obliczony tekst + +`canvas.drawText()` mierzy tekst przy każdym `onDraw`. `StaticLayout` robi to raz. + +```kotlin +private fun buildLayout(text: String, paint: TextPaint, widthPx: Int): StaticLayout = + StaticLayout(text, paint, widthPx, Layout.Alignment.ALIGN_NORMAL, 1f, 0f, false) + +// W onDraw — zero mierzenia: +canvas.save() +canvas.translate(x, y) +layout.draw(canvas) +canvas.restore() +``` + +**Reguły:** +- Przebuduj tylko gdy zmienia się tekst **lub** `onSizeChanged` +- `if (field == value) return` w każdym setterze przed `invalidate()` +- Guard `if (width == 0) return` w `rebuildLayouts()` + +--- + +## 8. Animacje — GPU only + +Tylko właściwości animowane przez GPU via `ViewPropertyAnimator`. +Zero animacji zmieniających layout — `width`/`height`/`margin`/`padding` wywołują +`requestLayout()` co zabija wydajność. + +```kotlin +// Wysunięcie panelu konfiguracji z dołu +fun revealPanel(panel: View) { + panel.visibility = View.VISIBLE + panel.translationY = panel.height.toFloat() + panel.animate() + .translationY(0f) + .alpha(1f) + .setDuration(200) + .setInterpolator(DecelerateInterpolator()) + .start() +} + +// Chowanie panelu +fun hidePanel(panel: View) { + panel.animate() + .translationY(panel.height.toFloat()) + .alpha(0f) + .setDuration(150) + .setInterpolator(LinearInterpolator()) + .withEndAction { panel.visibility = View.GONE } + .start() +} + +// Feedback dotknięcia karty +fun tapFeedback(card: View) { + card.animate() + .alpha(0.4f) + .setDuration(80) + .setInterpolator(LinearInterpolator()) + .withEndAction { + card.animate().alpha(1f).setDuration(120).start() + } + .start() +} +``` + +### Tabela dozwolonych właściwości + +``` +Właściwość GPU Zastosowanie +────────────────────────────────────────────────── +translationX/Y ✓ przesuwanie paneli, drawer +scaleX/Y ✓ powiększenie aktywnego widgetu +alpha ✓ fade, feedback dotknięcia +rotation ✓ obrót ikony ładowania + +width/height ✗ requestLayout() — ZABRONIONE +margin/padding ✗ requestLayout() — ZABRONIONE +layout_weight ✗ requestLayout() — ZABRONIONE +backgroundColor ✗ software render — zmień kolor przez Paint w onDraw +``` + +**Interpolatory:** tylko `LinearInterpolator` lub `DecelerateInterpolator`. +**Czas trwania:** 80ms–250ms. Żadnych sprężyn, odbić, elastyczności. + +--- + +## 9. Sieć — OkHttp + Retrofit + +Jeden singleton na całą aplikację — `ConnectionPool` jest drogi w tworzeniu. + +```kotlin +object NetworkModule { + + val okHttpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectionPool(ConnectionPool(3, 30, TimeUnit.SECONDS)) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .cache(Cache(File(context.cacheDir, "ha_http"), 2L * 1024 * 1024)) + .addInterceptor(AuthInterceptor(token)) + .build() + } + + val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(buildGson())) + .build() + } +} +``` + +--- + +## 10. JSON — TypeAdaptery zamiast refleksji + +Gson domyślnie używa refleksji — na A13 odczuwalne przy każdym parsowaniu. +TypeAdapter = ręczne parsowanie strumieniowe, 3-5x szybsze. + +```kotlin +class HaEntityTypeAdapter : TypeAdapter() { + override fun read(reader: JsonReader): HaEntity { + var entityId = ""; var state = ""; var friendlyName = "" + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "entity_id" -> entityId = reader.nextString() + "state" -> state = reader.nextString() + "attributes" -> { + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "friendly_name" -> friendlyName = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + } + else -> reader.skipValue() // nie parsuj niepotrzebnych pól + } + } + reader.endObject() + return HaEntity(entityId, state, friendlyName) + } + override fun write(out: JsonWriter, value: HaEntity?) {} +} +``` + +--- + +## 11. Wątki — HandlerThread + +Na single-core nie twórz wielu wątków — context switching jest kosztowny. +Jeden `HandlerThread` na całą komunikację z HA. + +```kotlin +object HaWorker { + private val thread = HandlerThread("ha-worker").also { it.start() } + val handler = Handler(thread.looper) + val mainThread = Handler(Looper.getMainLooper()) + + fun post(block: () -> Unit) = handler.post(block) + fun postDelayed(ms: Long, block: () -> Unit) = handler.postDelayed(block, ms) + fun cancelAll() = handler.removeCallbacksAndMessages(null) +} +``` + +**Dlaczego `HandlerThread` zamiast `Executors.newSingleThreadExecutor()`:** +- `postDelayed` dla retry z exponential backoff +- `removeCallbacks` do anulowania zakolejkowanych zadań bez dodatkowej synchronizacji +- Wbudowana kolejka z priorytetem + +--- + +## 12. Pamięć — LruCache + SparseArray + +### Cache stanów encji + +```kotlin +object EntityStateCache { + private val cache = LruCache(50) + fun get(id: String) = cache.get(id) + fun put(id: String, s: EntityState) = cache.put(id, s) + fun evictAll() = cache.evictAll() +} +``` + +### SparseArray zamiast HashMap dla kluczy Int + +```kotlin +// ŹLE — autoboxing Int→Integer przy każdym get/put +val viewTypes = HashMap() + +// DOBRZE — zero alokacji, zoptymalizowane dla kluczy int +val viewTypes = SparseArray() +``` + +### onTrimMemory — oddaj pamięć gdy system prosi + +```kotlin +class MainActivity : Activity() { + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) { + EntityStateCache.evictAll() + } + } +} +``` + +### WeakReference w callbackach sieciowych + +```kotlin +// ŹLE — anonimowa klasa trzyma referencję do Activity +client.newCall(req).enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + textView.text = "..." // wyciek Activity po rotacji! + } +}) + +// DOBRZE +class StateCallback(activity: MainActivity) : Callback { + private val ref = WeakReference(activity) + override fun onResponse(call: Call, response: Response) { + ref.get()?.runOnUiThread { /* aktualizuj UI */ } + } + override fun onFailure(call: Call, e: IOException) {} +} +``` + +--- + +## 13. Inicjalizacja przy starcie + +```kotlin +class App : Application() { + + override fun onCreate() { + super.onCreate() + // Pierwsze połączenie w tle — tworzy connection pool przed pierwszym UI request + HaWorker.post { warmupNetwork() } + } + + private fun warmupNetwork() { + try { + NetworkModule.okHttpClient + .newCall(pingRequest()) + .execute() + .close() + } catch (_: IOException) {} + } +} +``` + +### Co kiedy jest przeliczane + +``` +Zdarzenie Co się dzieje +────────────────────────────────────────────────────────────────── +Start aplikacji warmup connection pool w tle +onSizeChanged karty rebuild StaticLayout (1x na widget) +onDraw drawRect + HaIcons.draw + layout.draw + → ZERO alokacji, ZERO parsowania +Zmiana stanu encji bezpośredni setter na WidgetCardView + → tylko ta karta wywołuje invalidate() +onTrimMemory (MODERATE+) EntityStateCache.evictAll() → RAM zwolniony +Obrócenie ekranu stany encji z EntityStateCache (bez sieci) +``` + +--- + +## 14. Języki + +``` +res/values/strings.xml angielski (default) +res/values-pl/strings.xml polski +``` + +`resourceConfigurations += listOf("pl", "en")` wycina pozostałe tłumaczenia z bibliotek +narzędziowych (~200KB oszczędności w APK). + +--- + +## 15. Priorytety implementacji + +``` +Priorytet Optymalizacja Wpływ +────────────────────────────────────────────────────────────── +KRYTYCZNY Brak AppCompat/Material → Theme.Holo RAM / metody + Custom View — drawRect, brak zaokrągleń rendering + Ikony jako Canvas draw (brak bitmap) rendering / RAM + Jeden OkHttpClient singleton RAM + +DUŻY StaticLayout (pre-obliczony tekst) rendering + TypeAdaptery Gson CPU / parsing + HandlerThread (1 wątek tła) CPU + GridView z BaseAdapter + hasStableIds rendering + +ŚREDNI EntityStateCache (LruCache) sieć / RAM + SparseArray zamiast HashMap RAM + onTrimMemory → czyszczenie cache RAM + Animacje tylko GPU (ViewPropertyAnimator) jank + Bezpośredni setter na kartę (bez adapter) rendering + +MAŁY ABI splits (armeabi-v7a only) APK size + isShrinkResources = true APK size + resourceConfigurations (pl + en) APK size + WeakReference w callbackach memory leaks +``` + +--- + +## Zależności po zmianach + +```toml +# gradle/libs.versions.toml +# USUNIĘTO: AppCompat, Material, core-ktx, multidex + +[versions] +okhttp = "3.12.13" +retrofit = "2.6.4" +gson = "2.10.1" +mpandroidchart = "v3.1.0" + +[libraries] +okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } +mpandroidchart = { group = "com.github.PhilJay", name = "MPAndroidChart", version.ref = "mpandroidchart" } +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..1befe49 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# RetroHA + +**RetroHA** is a lightweight and efficient dashboard for Home Assistant, specifically designed for legacy Android tablets intended to serve as wall-mounted home control panels. + +## Key Features + +* **Bauhaus Canvas Engine**: A custom graphics engine. The interface does not use standard Android XML views but is manually rendered on a `Canvas`. This ensures extreme fluidity even on decade-old devices. +* **Low-End Hardware Optimization**: Minimal RAM usage, no unnecessary libraries, and aggressive caching of graphical layouts. +* **Bauhaus Design**: A minimalist aesthetic inspired by the Bauhaus school—high contrast, geometric shapes, and maximum legibility. +* **Kiosk Mode**: Built-in "Wake Lock" support to prevent the screen from turning off while the panel is active. +* **Rapid Control**: + * Tile Click: Toggle or Execute. + * Light Long Press: Brightness menu with "slide and release" gesture (auto-save). + +## Libraries & Acknowledgments + +This project utilizes the following Open Source libraries (compliant with F-Droid requirements): + +* **Retrofit** (Square Inc.) — Apache License 2.0: Type-safe HTTP client for Android and Java. +* **OkHttp** (Square Inc.) — Apache License 2.0: An efficient HTTP client for Android. +* **Gson** (Google) — Apache License 2.0: A Java library that can be used to convert Java Objects into their JSON representation. +* **MPAndroidChart** (PhilJay) — Apache License 2.0: A powerful Android chart view / graph view library. +* **AndroidX Libraries** (Google) — Apache License 2.0: Component libraries for Android. + +## Technical Requirements + +* **Android**: minSdk 14 (Support back to Android 4.0 ICS), optimized for Android 4.4+. +* **Home Assistant**: Requires Server URL and a Long-Lived Access Token. + +## Roadmap + +The current project status and planned features are detailed in [TASKS.md](./TASKS.md). + +## License + +This project is licensed under the **GNU GPL v3**. See the repository for details. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..5a0c2fc --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,64 @@ +# RetroHA — Roadmap + +## Sieć i integracja z HA + +- [x] Ekran konfiguracji: adres serwera HA + Long-Lived Access Token +- [x] Klient REST (Retrofit) — pobieranie stanu encji (`/api/states`) +- [x] Wywoływanie serwisów HA (`/api/services//`) — toggle, execute +- [ ] WebSocket (`/api/websocket`) — subskrypcja zdarzeń `state_changed` dla aktualizacji w czasie rzeczywistym +- [ ] Logika ponownego łączenia po utracie połączenia (exponential backoff) +- [x] Wskaźnik stanu połączenia w górnym pasku (połączono / rozłączono / łączenie) +- [x] Obsługa błędów: timeout, 401 Unauthorized, 404 Not Found + +## Architektura — warstwa sieciowa (moduł `shared`) + +- [x] `HaApiService` — interfejs Retrofit z endpointami REST +- [x] `HaEntity` — DTO mapujący JSON z `/api/states` +- [ ] `HaWebSocketClient` — klient WebSocket z obsługą `auth`, `subscribe_events` +- [x] `EntityStateCache` — mapujący `entity_id → EntityState` (w `Prefs` i `MainActivity`) +- [ ] `HaWorker` — `HandlerThread` jako jeden wątek sieciowy (single-core device) +- [x] `WeakReference` / `runOnUiThread` w callbackach sieciowych — unikanie wycieków `Activity` + +## Konfiguracja widgetów + +- [x] Ekran dodawania widgetu — przeglądarka encji HA (lista z wyszukiwarką) +- [x] Trwałe zapisywanie listy widgetów (JSON w `SharedPreferences`) +- [x] Usuwanie widgetu (przez ekran wyboru encji) +- [ ] Zmiana kolejności widgetów (drag & drop lub przyciski ↑↓) +- [x] Filtrowanie widgetów przez zakładki (Oświetlenie, Gniazdka, Moc, itp.) + +## Obsługa typów encji + +- [x] `light` — sterowanie jasnością (slider, auto-zapis po puszczeniu) +- [ ] `light` — sterowanie kolorem (jeśli obsługuje) +- [ ] `climate` — wyświetlanie temperatury docelowej, trybu HVAC; widok szczegółów +- [x] `sensor` / `binary_sensor` — wyświetlanie wartości i stanów +- [x] `switch` — przełączanie ON/OFF +- [x] `automation` / `script` — wywoływanie akcji (EXECUTE) + +## Wydajność i stabilność + +- [x] **Bauhaus Canvas** — autorski, lekki silnik graficzny (ręczne rysowanie na `Canvas`) +- [x] Optymalizacja `onDraw` — cachowanie `StaticLayout`, unikanie alokacji w pętli rysowania +- [x] Obsługa bardzo starych urządzeń (optymalizacja pod słabe procesory) +- [ ] ProGuard / R8 dla release buildu + +## UI + +- [x] Zakładki (Tabs) do szybkiego filtrowania domen +- [x] Obsługa języków (PL/EN) +- [x] Tryb Kiosk (Wake Lock) — ekran nie gaśnie przy widocznej aplikacji +- [x] Responsywność — automatyczna liczba kolumn (Grid) + +## i18n + +- [x] Pełne wsparcie dla języka polskiego (PL) +- [x] Pełne wsparcie dla języka angielskiego (EN) +- [ ] Tłumaczenia na języki: DE, FR, UK + +## Testy + +- [x] Testy instrumentalne — `AppNavigationTest`, `AutomatedClickTest` +- [x] Monkey Stress Test — testowanie stabilności pod dużym obciążeniem +- [x] Unit testy — `ExampleUnitTest` + diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..8524403 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,47 @@ +# RetroHA — Task Tracker + +Methodology: Incremental Kanban (Simple .md file) + +## [PHASE 1] Foundations (Proof of Concept) +- [x] Project configuration (minSdk 19, Kotlin, Retrofit) +- [x] Login screen (URL + Token) +- [x] Configuration persistence in SharedPreferences +- [x] Basic REST client (fetch `/api/states`) +- [x] Connection verification (Online/Offline status) + +## [PHASE 2] Custom Graphics Engine (Bauhaus Canvas) +- [x] `WidgetCardView` implementation (Custom View) +- [x] Manual tile rendering (background, border, shadow) +- [x] Manual text rendering with `StaticLayout` optimization +- [x] Icon system (`HaIcons.kt`) rendered on Canvas +- [x] Widget grid (GridView) with responsive column count + +## [PHASE 3] Data Integration & Entity Selection +- [x] Mapping Home Assistant states to visual styles +- [x] Entity browser and selection screen (Searchable) +- [x] Persistent storage of selected widget list +- [x] Domain filtering via Tabs (Lighting, Sockets, Power, etc.) +- [x] Automatic background data refresh + +## [PHASE 4] Interactions & Control +- [x] Short click handling (Toggle / Execute) +- [x] Pulsing animation during state transitions (Toggling) +- [x] Brightness control menu (LightControlDialog) +- [x] Button removal from brightness menu (Auto-save on release) +- [x] Dismiss menu on outside touch + +## [PHASE 5] UX Polish & Stability +- [x] Internationalization (PL/EN support) +- [x] Kiosk Mode (Wake Lock - prevent screen sleep) +- [x] `onDraw` performance optimization (zero allocation in draw loop) +- [x] Stability testing (Monkey Stress Test) + +## Future Development +- [ ] WebSocket implementation for real-time updates +- [ ] Support for `climate` and `media_player` domains +- [ ] RGB color support for lighting + +--- +*Legend:* +- [x] - Done +- [ ] - To Do diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..216d82f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.dokka) +} + +android { + namespace = "com.example.retroha" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.example.retroha" + minSdk = 14 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled = false + vectorDrawables.useSupportLibrary = true + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a", "x86", "x86_64") + isUniversalApk = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(project(":shared")) + + // OkHttp 3.12.x — ostatnia wersja wspierająca API 19 + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + + // Retrofit 2 + Gson converter + implementation(libs.retrofit) + implementation(libs.retrofit.gson) + implementation(libs.gson) + + // MPAndroidChart + implementation(libs.mpandroidchart) + + testImplementation(libs.junit) + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.test:runner:1.5.2") + androidTestImplementation("androidx.test:rules:1.5.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/retroha/AppNavigationTest.kt b/app/src/androidTest/java/com/example/retroha/AppNavigationTest.kt new file mode 100644 index 0000000..10057dc --- /dev/null +++ b/app/src/androidTest/java/com/example/retroha/AppNavigationTest.kt @@ -0,0 +1,50 @@ +package com.example.retroha +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) +class AppNavigationTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + @Test + fun testSettingsNavigationAndForms() { + Thread.sleep(1000) + onView(withId(R.id.btnSettings)).perform(click()) + Thread.sleep(500) + onView(withId(R.id.btnEntitySelection)).check(matches(isDisplayed())) + onView(withId(R.id.btnInstructions)).check(matches(isDisplayed())) + onView(withId(R.id.btnGoToConnection)).check(matches(isDisplayed())) + onView(withId(R.id.btnEntitySelection)).perform(click()) + Thread.sleep(500) + onView(withId(R.id.etSearch)).check(matches(isDisplayed())) + onView(withId(R.id.etSearch)).perform(replaceText("light"), closeSoftKeyboard()) + Thread.sleep(500) + pressBack() + Thread.sleep(500) + onView(withId(R.id.btnInstructions)).perform(click()) + Thread.sleep(500) + pressBack() + Thread.sleep(500) + onView(withId(R.id.btnGoToConnection)).perform(click()) + Thread.sleep(500) + onView(withId(R.id.etUrl)).check(matches(isDisplayed())) + onView(withId(R.id.etToken)).check(matches(isDisplayed())) + onView(withId(R.id.etRefreshInterval)).perform(replaceText("60"), closeSoftKeyboard()) + Thread.sleep(500) + pressBack() + Thread.sleep(500) + pressBack() + Thread.sleep(500) + onView(withId(R.id.gridView)).check(matches(isDisplayed())) + } +} diff --git a/app/src/androidTest/java/com/example/retroha/AutomatedClickTest.kt b/app/src/androidTest/java/com/example/retroha/AutomatedClickTest.kt new file mode 100644 index 0000000..fdaf335 --- /dev/null +++ b/app/src/androidTest/java/com/example/retroha/AutomatedClickTest.kt @@ -0,0 +1,23 @@ +package com.example.retroha +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) +class AutomatedClickTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + @Test + fun clickThroughAllTabs() { + Thread.sleep(1000) + val tabs = listOf("OŚWIETLENIE", "GNIAZDKA", "MOC", "POGODA", "WSZYSTKO") + tabs.forEach { tabName -> + onView(withText(tabName)).perform(click()) + Thread.sleep(500) + } + } +} diff --git a/app/src/androidTest/java/com/example/retroha/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/example/retroha/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f8c5e9c --- /dev/null +++ b/app/src/androidTest/java/com/example/retroha/ExampleInstrumentedTest.kt @@ -0,0 +1,14 @@ +package com.example.retroha +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.Assert.* +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.retroha", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/retroha/MonkeyStressTest.kt b/app/src/androidTest/java/com/example/retroha/MonkeyStressTest.kt new file mode 100644 index 0000000..85813d7 --- /dev/null +++ b/app/src/androidTest/java/com/example/retroha/MonkeyStressTest.kt @@ -0,0 +1,65 @@ +package com.example.retroha +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Random +import androidx.test.espresso.ViewInteraction +import android.view.View +import org.hamcrest.Matcher +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import android.view.ViewGroup +import android.widget.GridView +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher +@RunWith(AndroidJUnit4::class) +class MonkeyStressTest { + @get:Rule + val activityRule = ActivityScenarioRule(MainActivity::class.java) + private val random = Random() + @Test + fun runMonkeyTest() { + val iterations = 50 + val tabs = listOf("WSZYSTKO", "OŚWIETLENIE", "GNIAZDKA", "MOC", "POGODA") + for (i in 1..iterations) { + val actionType = random.nextInt(3) + try { + when (actionType) { + 0 -> { + val tab = tabs[random.nextInt(tabs.size)] + onView(withText(tab)).perform(click()) + } + 1 -> { + onView(withId(R.id.gridView)).perform(clickRandomItem()) + } + 2 -> { + onView(withId(R.id.btnSettings)).perform(click()) + Thread.sleep(500) + androidx.test.espresso.Espresso.pressBack() + } + } + } catch (e: Exception) { + } + Thread.sleep(200) + } + } + private fun clickRandomItem(): ViewAction { + return object : ViewAction { + override fun getConstraints(): Matcher = withId(R.id.gridView) + override fun getDescription(): String = "Kliknięcie losowego elementu w GridView" + override fun perform(uiController: UiController, view: View) { + val gridView = view as GridView + if (gridView.childCount > 0) { + val randomIndex = random.nextInt(gridView.childCount) + gridView.getChildAt(randomIndex).performClick() + } + } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..51c4b93 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/retroha/BaseActivity.kt b/app/src/main/java/com/example/retroha/BaseActivity.kt new file mode 100644 index 0000000..b0c143b --- /dev/null +++ b/app/src/main/java/com/example/retroha/BaseActivity.kt @@ -0,0 +1,10 @@ +package com.example.retroha +import android.app.Activity +import android.os.Bundle +import com.example.retroha.i18n.LocaleHelper +abstract class BaseActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + LocaleHelper.setLocale(this) + super.onCreate(savedInstanceState) + } +} diff --git a/app/src/main/java/com/example/retroha/ConnectionSettingsActivity.kt b/app/src/main/java/com/example/retroha/ConnectionSettingsActivity.kt new file mode 100644 index 0000000..adaf308 --- /dev/null +++ b/app/src/main/java/com/example/retroha/ConnectionSettingsActivity.kt @@ -0,0 +1,72 @@ +package com.example.retroha +import android.app.Activity +import android.os.Bundle +import android.widget.* +import com.example.retroha.data.Prefs +import com.example.retroha.network.HaClient +import com.example.retroha.network.HaState +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +class ConnectionSettingsActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_connection_settings) + val etUrl = findViewById(R.id.etUrl) + val etToken = findViewById(R.id.etToken) + val etRefreshInterval = findViewById(R.id.etRefreshInterval) + val btnSave = findViewById