Compare commits

...
21 Commits
Author SHA1 Message Date
darken 576c6db8d0 Release: 3.1.0-rc0 2026-02-23 09:56:40 +01:00
darken 524cac8d30 General: Update app translations from Crowdin 2026-02-23 09:53:52 +01:00
darken 8939f788ad General: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-23 00:38:50 +01:00
darken 9ad3f3c180 refactor(monitor): Remove shared mutable builder from MonitorNotifications
Replace shared NotificationCompat.Builder + Mutex with a stateless
design where each method builds a fresh notification. Introduces a
baseBuilder() helper to deduplicate common setup. Removes builderLock
and suspend modifiers from getNotification/getNotificationConnected.
2026-02-22 06:32:12 +01:00
darken 5b59ddab0f fix(monitor): Prevent crash when OS denies foreground service start
Add promoteToForeground() helper that catches ForegroundServiceStartNotAllowedException
(Android 12+) and SecurityException, allowing graceful service exit instead of crashing.
2026-02-22 06:24:51 +01:00
darken ab24c48024 General: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-21 20:09:52 +01:00
darken a6482c7d76 feat(popup): Move signal quality indicator to top-right corner
Always show signal quality when available instead of gating on debug
mode. Position icon + percentage text in the top-right corner of the
card, aligned with the title. Remove unused DebugSettings dependency.
2026-02-21 19:22:07 +01:00
darken 99aa44b448 chore: Remove obsolete vector XML drawables replaced by PNGs 2026-02-21 19:22:07 +01:00
darken a801f2d701 feat(popup): Add product photo icons for all device models
Replace vector silhouette placeholders with actual product photo PNGs
for 14 device families across 5 DPI buckets. Fix drawable naming prefix
(devic_ -> device_), add per-model icon overrides for popup left/right/case
views, disable image tinting for color PNGs, fix inverted signal visibility
logic, and use monochrome icon for notification small icon.
2026-02-21 19:22:07 +01:00
darken 8405406d22 feat(popup): Redesign connection popup with Apple-inspired layout
Large centered device graphics (64dp), vertically stacked battery info,
adaptive width, rounded card with tonal close button.
2026-02-21 19:10:56 +01:00
darken 9785b5ea7f fix(playpause): Improve test coverage and clarify code comments
Add missing test cases for single-pod one-pod-mode pause direction,
EarDetectionState single-pod properties, and normal-mode steady-state.
Fix stale test comments that incorrectly claimed tests would fail.
Clarify onePodMode flow combine pattern and isWorn field semantics.
2026-02-21 06:21:07 +01:00
darken fd7f9f8ab3 Refactor PlayPause logic and fix behavior for SinglePods (e.g. AirPods Max)
Fix edge case where removing a pod while both were in ears didn't trigger pause in one-pod mode. The aggregate boolean (isEitherPodInEar) stayed true, masking the individual pod removal.

See thread by `ZV`:
https://discord.com/channels/548521543039189022/1437499888068726814
2026-02-21 06:21:07 +01:00
darken 46bb8c98de feat(widget): Add theme customization to widget configuration
Add color presets, custom color picker, transparency slider, and device
label toggle to the widget configuration screen. Widget preview updates
in real-time. Use profile label as device name in both preview and
actual widget.
2026-02-20 12:22:55 +01:00
darken b3ead17b12 Update Claude settings to remove various Bash command permissions 2026-02-20 10:16:21 +01:00
darken a794d59276 chore: Update Romanian app title in Fastlane metadata 2026-02-19 09:01:03 +01:00
darken 584c5c70d0 chore: Update Ruby dependencies in Gemfile.lock
Updated fastlane (2.232.1), aws-sdk, and google-cloud gems. Added several standard library shims (csv, logger, mutex_m, etc.) to support newer Ruby environments.
2026-02-19 09:01:03 +01:00
darken 5a55796518 General: Update app translations from Crowdin
Pull latest string translations from Crowdin.
Validated and reverted any problematic changes.
2026-02-19 09:01:03 +01:00
darken bdfdd93735 General: Update fastlane translations from Crowdin
Pull latest translations for app store listings from Crowdin.
Validated and reverted any problematic changes.
2026-02-19 09:01:03 +01:00
Matthias Urhahn e9a937e4f6 Remove duplicate RB Status badge
Removed duplicate RB Status badge from README.
2026-02-19 08:42:38 +01:00
Matthias Urhahn 0a8bae46a9 Add RB Status badge to README
Added RB Status badge to README.md
2026-02-19 08:30:01 +01:00
darken 7efcafbc71 chore: Bump Android Gradle Plugin to 9.0.1 2026-02-19 00:20:43 +01:00
433 changed files with 4742 additions and 2197 deletions
+5 -20
View File
@@ -2,26 +2,6 @@
"permissions": {
"allow": [
"mcp__ide__getDiagnostics",
"Bash(./gradlew tasks:*)",
"Bash(./gradlew:*)",
"Bash(find:*)",
"Bash(ls:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(git add:*)",
"Bash(git log:*)",
"Bash(git commit:*)",
"Bash(git show:*)",
"Bash(git checkout:*)",
"Bash(git branch:*)",
"Bash(git switch:*)",
"Bash(git diff:*)",
"Bash(git status:*)",
"Bash(git stash:*)",
"Bash(./crowdin.sh:*)",
"Bash(gh issue list:*)",
"Bash(gh pr list:*)",
"Bash(gh label list:*)",
"WebSearch",
"WebFetch(domain:support.google.com)",
"WebFetch(domain:github.com)",
@@ -31,5 +11,10 @@
"WebFetch(domain:issuetracker.google.com)"
],
"deny": []
},
"enabledPlugins": {
"android-translation@claude-code-cafe": true,
"debugbadger@claude-code-cafe": true,
"jvm-tools@claude-code-cafe": true
}
}
+70 -59
View File
@@ -1,48 +1,49 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.2)
aws-partitions (1.1109.0)
aws-sdk-core (3.224.1)
aws-eventstream (1.4.0)
aws-partitions (1.1216.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.101.0)
aws-sdk-core (~> 3, >= 3.216.0)
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.188.0)
aws-sdk-core (~> 3, >= 3.224.1)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
base64 (0.2.0)
benchmark (0.5.0)
bigdecimal (4.0.1)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.109.0)
faraday (1.10.4)
excon (0.112.0)
faraday (1.10.5)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
@@ -54,14 +55,14 @@ GEM
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
@@ -71,15 +72,19 @@ GEM
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
fastlane (2.232.1)
CFPropertyList (>= 2.3, < 4.0.0)
abbrev (~> 0.1.2)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
base64 (~> 0.2.0)
benchmark (>= 0.1.0)
bundler (>= 1.17.3, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
@@ -91,16 +96,20 @@ GEM
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-env (>= 1.6.0, <= 2.1.1)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3.0)
naturally (~> 2.2)
nkf (~> 0.2.0)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
@@ -116,38 +125,40 @@ GEM
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
google-apis-androidpublisher_v3 (0.96.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.29.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.6.1)
google-apis-iamcredentials_v1 (0.26.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.17.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.60.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-storage (1.45.0)
google-cloud-env (2.1.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.5.0)
google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.29.0)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
googleauth (~> 1.9)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@@ -158,37 +169,38 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.7.6)
jwt (2.10.1)
json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
public_suffix (5.1.1)
rake (13.3.0)
public_suffix (7.0.2)
rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.1)
retriable (3.2.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.18.0)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
@@ -203,7 +215,6 @@ GEM
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unf (0.2.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
+1
View File
@@ -3,6 +3,7 @@
# Companion App for AirPods (CAPod)
[![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/d4rken-org/capod?include_prereleases)](https://github.com/d4rken-org/capod/releases/latest)
[![RB Status](https://shields.rbtlog.dev/simple/eu.darken.capod)](https://shields.rbtlog.dev/eu.darken.capod)
[![Code tests & eval](https://github.com/d4rken-org/capod/actions/workflows/code-checks.yml/badge.svg)](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
[![Crowdin](https://badges.crowdin.net/capod/localized.svg)](https://crowdin.com/project/capod)
[![Github Downloads](https://img.shields.io/github/downloads/d4rken-org/capod/total.svg?label=GitHub%20Downloads&logo=github)](https://github.com/d4rken-org/capod/edit/main/README.md#download)
+1 -1
View File
@@ -1 +1 @@
3.0.5-rc0 30005000
3.1.0-rc0 30100000
+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="foss_upgrade_donate_label">Wsparcie</string>
<string name="foss_upgrade_alreadydonated_label">Już się zrzuciłem</string>
<string name="foss_upgrade_donate_label">Wesprzyj</string>
<string name="foss_upgrade_alreadydonated_label">Już wsparłem</string>
<string name="foss_upgrade_no_money_label">Wydałem wszystkie pieniądze na AirPods</string>
</resources>
+5 -1
View File
@@ -1,2 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>
<resources>
<string name="foss_upgrade_donate_label">Donar</string>
<string name="foss_upgrade_alreadydonated_label">Hai gia donat</string>
<string name="foss_upgrade_no_money_label">Mett tut mes daners en AirPods</string>
</resources>
+1 -1
View File
@@ -3,7 +3,7 @@
<string name="upgrades_gplay_unavailable_error">Οι υπηρεσίες Google Play δεν είναι διαθέσιμες.</string>
<string name="upgrades_no_purchases_found_check_account">Δε βρέθηκαν αγορές. Χρησιμοποιείτε τον σωστό λογαριασμό;</string>
<string name="upgrades_gplay_billing_error_label">Σφάλμα Google Play</string>
<string name="upgrades_gplay_billing_error_description">Παρουσιάστηκε σφάλμα στο Google Play. Δοκιμάστε ξανά αργότερα ή επανεκκινήστε το τηλέφωνό σας.\n\nΣφάλμα: %s</string>
<string name="upgrades_gplay_billing_error_description">Παρουσιάστηκε σφάλμα στο Google Play. Παρακαλώ προσπαθήστε ξανά αργότερα ή επανεκκινήστε το τηλέφωνό σας.\n\nΣφάλμα: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Σφάλμα χρέωσης Google Play</string>
<string name="upgrades_gplay_billing_result_error_description">Παρουσιάστηκε σφάλμα κατά την αίτηση των στοιχείων αγοράς σας από το Google Play. Εκκαθαρίστε την προσωρινή μνήμη του Google Play και επανεκκινήστε το τηλέφωνό σας.\n\nΣφάλμα %s</string>
</resources>
+4 -4
View File
@@ -2,8 +2,8 @@
<resources>
<string name="upgrades_gplay_unavailable_error">„Google Play“ paslaugos nepasiekiamos.</string>
<string name="upgrades_no_purchases_found_check_account">Nerasta jokių pirkinių. Ar naudojate tinkamą paskyrą?</string>
<string name="upgrades_gplay_billing_error_label">„Google Play" klaida</string>
<string name="upgrades_gplay_billing_error_description">„Google Play" įvyko klaida. Bandykite dar kartą vėliau arba paleiskite telefoną iš naujo.\n\nKlaida: %s</string>
<string name="upgrades_gplay_billing_result_error_label">„Google Play" atsiskaitymo klaida</string>
<string name="upgrades_gplay_billing_result_error_description">Įvyko klaida gaunant pirkinių informaciją iš „Google Play". Išvalykite „Google Play" talpyklą ir paleiskite telefoną iš naujo.\n\nKlaida %s</string>
<string name="upgrades_gplay_billing_error_label">„Google Play\" klaida</string>
<string name="upgrades_gplay_billing_error_description">„Google Play\" įvyko klaida. Bandykite dar kartą vėliau arba paleiskite telefoną iš naujo.\n\nKlaida: %s</string>
<string name="upgrades_gplay_billing_result_error_label">„Google Play\" atsiskaitymo klaida</string>
<string name="upgrades_gplay_billing_result_error_description">Įvyko klaida gaunant pirkinių informaciją iš „Google Play\". Išvalykite „Google Play\" talpyklą ir paleiskite telefoną iš naujo.\n\nKlaida %s</string>
</resources>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upgrades_gplay_unavailable_error">Usługi Google Play nie są dostępne.</string>
<string name="upgrades_no_purchases_found_check_account">Nie znaleziono zakupu. Czy na pewno korzystasz z poprawnego konta?</string>
<string name="upgrades_gplay_unavailable_error">Usługi Google Play są niedostępne.</string>
<string name="upgrades_no_purchases_found_check_account">Nie znaleziono zakupów. Czy używasz właściwego konta?</string>
<string name="upgrades_gplay_billing_error_label">Błąd Google Play</string>
<string name="upgrades_gplay_billing_error_description">Wystąpił błąd w Google Play. Spróbuj ponownie później lub uruchom ponownie telefon.\n\nBłąd: %s</string>
<string name="upgrades_gplay_billing_result_error_label">Błąd płatności Google Play</string>
@@ -36,6 +36,27 @@ inline fun <T1, T2, T3, R> combine(
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
crossinline transform: suspend (T1, T2, T3, T4) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow,
flow2,
flow3,
flow4,
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, R> combine(
flow: Flow<T1>,
@@ -3,10 +3,32 @@ package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.text.Editable
import android.text.InputFilter
import android.text.TextWatcher
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.graphics.toColorInt
import androidx.core.view.isVisible
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
@@ -30,14 +52,15 @@ class WidgetConfigurationActivity : Activity2() {
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
private var isUpdatingHexFromCode = false
private val checkerboardDrawable: BitmapDrawable by lazy { createCheckerboardDrawable() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Set result to CANCELED in case user backs out
setResult(RESULT_CANCELED)
// Get widget ID from intent
widgetId = intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID
@@ -45,7 +68,6 @@ class WidgetConfigurationActivity : Activity2() {
log(TAG) { "onCreate(widgetId=$widgetId)" }
// If widget ID is invalid, finish
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
log(TAG) { "Invalid widget ID, finishing" }
finish()
@@ -61,6 +83,15 @@ class WidgetConfigurationActivity : Activity2() {
ui.profilesRecycler.adapter = profileAdapter
setupPreviewContainer()
setupPresetChips()
setupColorSwatches(ui.bgColorGrid, isBg = true)
setupColorSwatches(ui.fgColorGrid, isBg = false)
setupHexInput()
setupTransparencySlider()
setupShowDeviceLabelSwitch()
setupResetButton()
ui.cancelButton.setOnClickListener {
log(TAG) { "Cancel clicked" }
finish()
@@ -95,6 +126,310 @@ class WidgetConfigurationActivity : Activity2() {
ui.confirmButton.text = getString(R.string.general_upgrade_action)
ui.confirmButton.isEnabled = true
}
updatePresetChipSelection(state.activePreset)
updateCustomSectionsVisibility(state.isCustomMode)
updateSwatchSelection(ui.bgColorGrid, state.theme.backgroundColor)
updateSwatchSelection(ui.fgColorGrid, state.theme.foregroundColor)
updateHexInputs(state.theme)
// Disable transparency slider when using Material You (no custom bg to apply alpha to)
val hasCustomBg = state.theme.backgroundColor != null
ui.transparencySlider.isEnabled = hasCustomBg
ui.transparencyLabel.alpha = if (hasCustomBg) 1.0f else 0.5f
val transparencyPercent = ((255 - state.theme.backgroundAlpha) / 255f * 100f)
val displayPercent = (transparencyPercent / 5f).toInt() * 5
if (!ui.transparencySlider.isPressed) {
ui.transparencySlider.value = displayPercent.toFloat()
}
ui.transparencyLabel.text = getString(
R.string.widget_config_transparency_label
) + if (hasCustomBg && displayPercent > 0) " ($displayPercent%)" else ""
ui.showDeviceLabelSwitch.isChecked = state.theme.showDeviceLabel
val selectedProfileLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label
val deviceLabel = ui.previewCard.findViewById<android.widget.TextView>(R.id.preview_device_label)
deviceLabel?.text = selectedProfileLabel ?: ""
updatePreview(state.theme)
}
}
private val widgetThemeContext: Context by lazy {
ContextThemeWrapper(this, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
}
private fun resolveWidgetThemeColor(attr: Int): Int {
val typedArray = widgetThemeContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, Color.BLACK)
typedArray.recycle()
return color
}
private fun setupPreviewContainer() {
// Initial background set from XML, updated dynamically in updatePreview
}
private fun updatePreview(theme: WidgetTheme) {
val previewRoot = ui.previewCard.findViewById<View>(R.id.preview_widget_root)
// Background color applied to the inner view, matching the real widget
val bgColor = theme.backgroundColor
if (bgColor != null) {
previewRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
} else {
previewRoot.setBackgroundColor(resolveWidgetThemeColor(android.R.attr.colorBackground))
}
// Show checkerboard behind preview only when there's actual transparency to visualize
val hasTransparency = bgColor != null && theme.backgroundAlpha < 255
ui.previewContainer.background = if (hasTransparency) {
checkerboardDrawable
} else {
AppCompatResources.getDrawable(this, R.drawable.widget_preview_checkerboard)
}
// Foreground (text + icon colors)
val fgColor = theme.foregroundColor
val defaultTextColor = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
val defaultIconColor = resolveWidgetThemeColor(android.R.attr.colorAccent)
val textViews = listOf(
R.id.preview_left_label,
R.id.preview_right_label,
R.id.preview_case_label,
R.id.preview_device_label,
)
val iconViews = listOf(
R.id.preview_left_icon,
R.id.preview_right_icon,
R.id.preview_case_icon,
)
for (id in textViews) {
val tv = ui.previewCard.findViewById<android.widget.TextView>(id) ?: continue
tv.setTextColor(fgColor ?: defaultTextColor)
}
for (id in iconViews) {
val iv = ui.previewCard.findViewById<ImageView>(id) ?: continue
if (fgColor != null) {
iv.setColorFilter(fgColor, PorterDuff.Mode.SRC_IN)
} else {
iv.setColorFilter(defaultIconColor, PorterDuff.Mode.SRC_IN)
}
}
// Device label visibility
val deviceLabel = ui.previewCard.findViewById<View>(R.id.preview_device_label)
deviceLabel?.isVisible = theme.showDeviceLabel
}
private fun createCheckerboardDrawable(): BitmapDrawable {
val cellSize = (8 * resources.displayMetrics.density).toInt()
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
val canvas = Canvas(bitmap)
val paint = Paint()
// Light squares
paint.color = 0xFFE8E8E8.toInt()
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
// Dark squares
paint.color = 0xFFD0D0D0.toInt()
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
canvas.drawRect(
cellSize.toFloat(),
cellSize.toFloat(),
(cellSize * 2).toFloat(),
(cellSize * 2).toFloat(),
paint
)
return bitmap.toDrawable(resources).apply {
tileModeX = Shader.TileMode.REPEAT
tileModeY = Shader.TileMode.REPEAT
}
}
private fun setupPresetChips() {
val presetNames = mapOf(
WidgetTheme.Preset.MATERIAL_YOU to getString(R.string.widget_config_preset_material_you),
WidgetTheme.Preset.CLASSIC_DARK to getString(R.string.widget_config_preset_dark),
WidgetTheme.Preset.CLASSIC_LIGHT to getString(R.string.widget_config_preset_light),
WidgetTheme.Preset.BLUE to getString(R.string.widget_config_preset_blue),
WidgetTheme.Preset.GREEN to getString(R.string.widget_config_preset_green),
WidgetTheme.Preset.RED to getString(R.string.widget_config_preset_red),
)
for (preset in WidgetTheme.Preset.entries) {
val chip = Chip(this).apply {
text = presetNames[preset] ?: preset.name
isCheckable = true
tag = preset
setOnClickListener { vm.selectPreset(preset) }
}
ui.presetChipGroup.addView(chip)
}
// Custom chip
val customChip = Chip(this).apply {
text = getString(R.string.widget_config_custom_label)
isCheckable = true
tag = CUSTOM_CHIP_TAG
setOnClickListener {
val defaultBg = resolveWidgetThemeColor(android.R.attr.colorBackground)
val defaultFg = resolveWidgetThemeColor(android.R.attr.textColorPrimary)
vm.enterCustomMode(defaultBg, defaultFg)
}
}
ui.presetChipGroup.addView(customChip)
}
private fun updatePresetChipSelection(activePreset: WidgetTheme.Preset?) {
for (i in 0 until ui.presetChipGroup.childCount) {
val chip = ui.presetChipGroup.getChildAt(i) as? Chip ?: continue
chip.isChecked = if (activePreset != null) {
chip.tag == activePreset
} else {
chip.tag == CUSTOM_CHIP_TAG
}
}
}
private fun updateCustomSectionsVisibility(isCustomMode: Boolean) {
val visibility = if (isCustomMode) View.VISIBLE else View.GONE
ui.bgColorLabel.visibility = visibility
ui.bgColorGrid.visibility = visibility
ui.bgHexInputLayout.visibility = visibility
ui.fgColorLabel.visibility = visibility
ui.fgColorGrid.visibility = visibility
ui.fgHexInputLayout.visibility = visibility
}
private fun setupColorSwatches(grid: GridLayout, isBg: Boolean) {
for (color in SWATCH_COLORS) {
val itemView = LayoutInflater.from(this)
.inflate(R.layout.widget_color_swatch_item, grid, false)
val swatchColor = itemView.findViewById<View>(R.id.swatch_color)
val bgDrawable = swatchColor.background?.mutate() as? GradientDrawable ?: continue
bgDrawable.setColor(color)
swatchColor.background = bgDrawable
itemView.setOnClickListener {
ui.bgHexInput.clearFocus()
ui.fgHexInput.clearFocus()
if (isBg) vm.setBackgroundColor(color) else vm.setForegroundColor(color)
}
val params = GridLayout.LayoutParams(
GridLayout.spec(GridLayout.UNDEFINED),
GridLayout.spec(GridLayout.UNDEFINED, 1f),
).apply {
width = GridLayout.LayoutParams.WRAP_CONTENT
height = GridLayout.LayoutParams.WRAP_CONTENT
setGravity(Gravity.CENTER)
}
grid.addView(itemView, params)
}
}
private fun updateSwatchSelection(grid: GridLayout, selectedColor: Int?) {
for (i in 0 until grid.childCount) {
val itemView = grid.getChildAt(i) as? FrameLayout ?: continue
val color = SWATCH_COLORS.getOrNull(i) ?: continue
val isSelected =
selectedColor != null && (selectedColor or 0xFF000000.toInt()) == (color or 0xFF000000.toInt())
itemView.findViewById<View>(R.id.swatch_selected_ring)?.isVisible = isSelected
itemView.findViewById<ImageView>(R.id.swatch_check)?.apply {
isVisible = isSelected
if (isSelected) {
val checkColor = WidgetTheme.bestContrastForeground(color)
setColorFilter(checkColor)
}
}
}
}
private fun setupHexInput() {
val hexFilter = InputFilter { source, _, _, _, _, _ ->
val filtered = source.toString().uppercase().filter { it in "0123456789ABCDEF" }
if (filtered == source.toString()) null else filtered
}
ui.bgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
ui.fgHexInput.filters = arrayOf(hexFilter, InputFilter.LengthFilter(6))
ui.bgHexInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (isUpdatingHexFromCode) return
val hex = s?.toString() ?: return
if (hex.length == 6) {
try {
val color = "#$hex".toColorInt()
vm.setBackgroundColor(color)
} catch (_: IllegalArgumentException) {
}
}
}
})
ui.fgHexInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
if (isUpdatingHexFromCode) return
val hex = s?.toString() ?: return
if (hex.length == 6) {
try {
val color = "#$hex".toColorInt()
vm.setForegroundColor(color)
} catch (_: IllegalArgumentException) {
}
}
}
})
}
private fun updateHexInputs(theme: WidgetTheme) {
isUpdatingHexFromCode = true
try {
val bgHex = theme.backgroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
if (ui.bgHexInput.text.toString() != bgHex && !ui.bgHexInput.hasFocus()) {
ui.bgHexInput.setText(bgHex)
}
val fgHex = theme.foregroundColor?.let { String.format("%06X", 0xFFFFFF and it) } ?: ""
if (ui.fgHexInput.text.toString() != fgHex && !ui.fgHexInput.hasFocus()) {
ui.fgHexInput.setText(fgHex)
}
} finally {
isUpdatingHexFromCode = false
}
}
private fun setupTransparencySlider() {
ui.transparencySlider.addOnChangeListener { _, value, fromUser ->
if (fromUser) {
val alpha = 255 - (value / 100f * 255f).toInt()
vm.setBackgroundAlpha(alpha)
}
}
}
private fun setupShowDeviceLabelSwitch() {
ui.showDeviceLabelSwitch.setOnCheckedChangeListener { _, isChecked ->
vm.setShowDeviceLabel(isChecked)
}
}
private fun setupResetButton() {
ui.resetButton.setOnClickListener {
vm.resetToDefaults()
}
}
@@ -113,10 +448,37 @@ class WidgetConfigurationActivity : Activity2() {
)
finish()
}
companion object {
private val TAG = logTag("Widget", "ConfigurationActivity")
private const val CUSTOM_CHIP_TAG = "custom"
private val SWATCH_COLORS = intArrayOf(
0xFFF44336.toInt(), // Red
0xFFE91E63.toInt(), // Pink
0xFF9C27B0.toInt(), // Purple
0xFF673AB7.toInt(), // Deep Purple
0xFF3F51B5.toInt(), // Indigo
0xFF2196F3.toInt(), // Blue
0xFF03A9F4.toInt(), // Light Blue
0xFF00BCD4.toInt(), // Cyan
0xFF009688.toInt(), // Teal
0xFF4CAF50.toInt(), // Green
0xFF8BC34A.toInt(), // Light Green
0xFFCDDC39.toInt(), // Lime
0xFFFFEB3B.toInt(), // Yellow
0xFFFFC107.toInt(), // Amber
0xFFFF9800.toInt(), // Orange
0xFFFF5722.toInt(), // Deep Orange
0xFF795548.toInt(), // Brown
0xFF9E9E9E.toInt(), // Grey
0xFF607D8B.toInt(), // Blue Grey
0xFFFFFFFF.toInt(), // White
0xFF1E1E1E.toInt(), // Near Black
0xFF37474F.toInt(), // Dark Blue Grey
0xFF1B5E20.toInt(), // Dark Green
0xFF0D47A1.toInt(), // Dark Blue
)
}
}
}
@@ -1,8 +1,10 @@
package eu.darken.capod.main.ui.widget
import android.appwidget.AppWidgetManager
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
@@ -22,8 +24,11 @@ class WidgetConfigurationViewModel @Inject constructor(
private val deviceProfilesRepo: DeviceProfilesRepo,
private val widgetSettings: WidgetSettings,
private val upgradeRepo: UpgradeRepo,
@ApplicationContext private val context: Context,
) : ViewModel3(dispatcherProvider) {
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
val widgetId: Int
get() = savedStateHandle.get<Int>(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID
@@ -37,18 +42,32 @@ class WidgetConfigurationViewModel @Inject constructor(
private val selectedProfile = MutableStateFlow(widgetSettings.getWidgetProfile(widgetId))
val state = combine(
private val initialTheme: WidgetTheme = run {
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return@run WidgetTheme.DEFAULT
WidgetTheme.fromBundle(appWidgetManager.getAppWidgetOptions(widgetId))
}
private val forceCustomMode = MutableStateFlow(WidgetTheme.matchPreset(initialTheme) == null)
private val currentTheme = MutableStateFlow(initialTheme)
val state = eu.darken.capod.common.flow.combine(
selectedProfile,
currentTheme,
forceCustomMode,
deviceProfilesRepo.profiles,
upgradeRepo.upgradeInfo,
) { selected, profiles, upgradeInfo ->
log(TAG) { "loadProfiles()" }
) { selected, theme, forceCustom, profiles, upgradeInfo ->
log(TAG) { "state update: profile=$selected, theme=$theme, forceCustom=$forceCustom" }
val activePreset = if (forceCustom) null else WidgetTheme.matchPreset(theme)
State(
profiles = profiles,
isPro = upgradeInfo.isPro,
selectedProfile = selected,
theme = theme,
activePreset = activePreset,
isCustomMode = activePreset == null,
)
}.asLiveData2()
@@ -56,13 +75,70 @@ class WidgetConfigurationViewModel @Inject constructor(
val profiles: List<DeviceProfile> = emptyList(),
val selectedProfile: ProfileId? = null,
val isPro: Boolean = false,
val theme: WidgetTheme = WidgetTheme.DEFAULT,
val activePreset: WidgetTheme.Preset? = WidgetTheme.Preset.MATERIAL_YOU,
val isCustomMode: Boolean = false,
) {
val canConfirm: Boolean = selectedProfile != null
}
fun selectProfile(profileId: ProfileId) {
log(TAG) { "selectProfile(profileId=$profileId)" }
selectedProfile.value = profileId
selectedProfile.value = profileId
}
fun selectPreset(preset: WidgetTheme.Preset) {
log(TAG) { "selectPreset(preset=$preset)" }
forceCustomMode.value = false
currentTheme.value = WidgetTheme(
backgroundColor = preset.presetBg,
foregroundColor = preset.presetFg,
backgroundAlpha = currentTheme.value.backgroundAlpha,
showDeviceLabel = currentTheme.value.showDeviceLabel,
)
}
fun enterCustomMode(resolvedBg: Int, resolvedFg: Int) {
log(TAG) { "enterCustomMode(resolvedBg=${String.format("#%06X", 0xFFFFFF and resolvedBg)}, resolvedFg=${String.format("#%06X", 0xFFFFFF and resolvedFg)})" }
forceCustomMode.value = true
// Populate null colors with the currently displayed values so the user has a starting point
val theme = currentTheme.value
currentTheme.value = theme.copy(
backgroundColor = theme.backgroundColor ?: (resolvedBg or 0xFF000000.toInt()),
foregroundColor = theme.foregroundColor ?: (resolvedFg or 0xFF000000.toInt()),
)
}
fun setBackgroundColor(color: Int) {
log(TAG) { "setBackgroundColor(color=${String.format("#%06X", 0xFFFFFF and color)})" }
currentTheme.value = currentTheme.value.copy(backgroundColor = color or 0xFF000000.toInt())
}
fun setForegroundColor(color: Int) {
log(TAG) { "setForegroundColor(color=${String.format("#%06X", 0xFFFFFF and color)})" }
currentTheme.value = currentTheme.value.copy(foregroundColor = color or 0xFF000000.toInt())
}
fun setBackgroundAlpha(alpha: Int) {
log(TAG) { "setBackgroundAlpha(alpha=$alpha)" }
currentTheme.value = currentTheme.value.copy(backgroundAlpha = alpha.coerceIn(0, 255))
}
fun toggleDeviceLabel() {
val newValue = !currentTheme.value.showDeviceLabel
log(TAG) { "toggleDeviceLabel(showDeviceLabel=$newValue)" }
currentTheme.value = currentTheme.value.copy(showDeviceLabel = newValue)
}
fun setShowDeviceLabel(show: Boolean) {
log(TAG) { "setShowDeviceLabel(show=$show)" }
currentTheme.value = currentTheme.value.copy(showDeviceLabel = show)
}
fun resetToDefaults() {
log(TAG) { "resetToDefaults()" }
forceCustomMode.value = false
currentTheme.value = WidgetTheme.DEFAULT
}
fun confirmSelection() {
@@ -71,9 +147,16 @@ class WidgetConfigurationViewModel @Inject constructor(
log(TAG) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
widgetSettings.saveWidgetProfile(widgetId, selectedProfile)
}
// Save theme to AppWidgetOptions bundle
val theme = currentTheme.value
log(TAG) { "confirmSelection: saving theme=$theme" }
val options = appWidgetManager.getAppWidgetOptions(widgetId)
theme.toBundle(options)
appWidgetManager.updateAppWidgetOptions(widgetId, options)
}
companion object {
private val TAG = logTag("Widget", "ConfigurationVM")
}
}
}
@@ -5,10 +5,13 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.RemoteViews
import androidx.annotation.LayoutRes
import androidx.appcompat.view.ContextThemeWrapper
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.coroutine.AppScope
@@ -32,8 +35,10 @@ import eu.darken.capod.pods.core.PodFactory
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getBatteryDrawable
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import finish2
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
@@ -49,6 +54,7 @@ class WidgetProvider : AppWidgetProvider() {
@Inject lateinit var podFactory: PodFactory
@Inject lateinit var upgradeRepo: UpgradeRepo
@Inject lateinit var widgetSettings: WidgetSettings
@Inject lateinit var deviceProfilesRepo: DeviceProfilesRepo
@AppScope @Inject lateinit var appScope: CoroutineScope
private fun executeAsync(
@@ -136,9 +142,15 @@ class WidgetProvider : AppWidgetProvider() {
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
val profileLabel: String? = profileId?.let { id ->
deviceProfilesRepo.profiles.first().firstOrNull { it.id == id }?.label
}
val theme = WidgetTheme.fromBundle(widgetManager.getAppWidgetOptions(widgetId))
log(TAG, VERBOSE) { "updateWidget: theme=$theme" }
val layout = when {
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId)
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId, theme)
device is DualPodDevice -> {
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
@@ -154,19 +166,84 @@ class WidgetProvider : AppWidgetProvider() {
else -> R.layout.widget_pod_dual_wide_layout
}
createDualPodLayout(context, device, layout, widgetId)
createDualPodLayout(context, device, layout, widgetId, theme, profileLabel)
}
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId)
device is PodDevice -> createUnknownPodLayout(context, device, widgetId)
else -> createNoDeviceLayout(context, profileId != null, widgetId)
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId, theme, profileLabel)
device is PodDevice -> createUnknownPodLayout(context, device, widgetId, theme)
else -> createNoDeviceLayout(context, profileId != null, widgetId, theme)
}
widgetManager.updateAppWidget(widgetId, layout)
}
private fun applyThemeColors(
context: Context,
views: RemoteViews,
theme: WidgetTheme,
textViewIds: List<Int>,
iconViewIds: List<Int>,
hasDeviceLabel: Boolean,
) {
// Always explicitly set background color to ensure previous custom colors are overwritten
val bgColor = theme.backgroundColor
if (bgColor != null) {
val colorWithAlpha = WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha)
views.setInt(R.id.widget_root, "setBackgroundColor", colorWithAlpha)
} else {
// Reset to theme default — resolve ?android:attr/colorBackground
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
views.setInt(R.id.widget_root, "setBackgroundColor", defaultBg)
}
// Always explicitly set text/icon colors
val fgColor = theme.foregroundColor
if (fgColor != null) {
for (textViewId in textViewIds) {
views.setTextColor(textViewId, fgColor)
}
for (iconViewId in iconViewIds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(fgColor))
} else {
views.setInt(iconViewId, "setColorFilter", fgColor)
}
}
} else {
// Reset to theme defaults
val defaultTextColor = resolveThemeColor(context, android.R.attr.textColorPrimary)
val defaultIconColor = resolveThemeColor(context, android.R.attr.colorAccent)
for (textViewId in textViewIds) {
views.setTextColor(textViewId, defaultTextColor)
}
for (iconViewId in iconViewIds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(defaultIconColor))
} else {
views.setInt(iconViewId, "setColorFilter", defaultIconColor)
}
}
}
if (hasDeviceLabel) {
views.setViewVisibility(
R.id.headphones_label,
if (theme.showDeviceLabel) View.VISIBLE else View.GONE
)
}
}
private fun resolveThemeColor(context: Context, attr: Int): Int {
val themedContext = ContextThemeWrapper(context, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr))
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
typedArray.recycle()
return color
}
private suspend fun createUpgradeRequiredLayout(
context: Context,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -181,12 +258,22 @@ class WidgetProvider : AppWidgetProvider() {
setTextViewText(R.id.primary, context.getString(R.string.upgrade_capod_label))
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
setViewVisibility(R.id.secondary, View.VISIBLE)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary, R.id.secondary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createUnknownPodLayout(
context: Context,
podDevice: PodDevice,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -199,12 +286,22 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createNoDeviceLayout(
context: Context,
hasConfiguredProfile: Boolean = false,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -222,15 +319,26 @@ class WidgetProvider : AppWidgetProvider() {
R.string.overview_nomaindevice_label
}
setTextViewText(R.id.primary, context.getString(messageRes))
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(R.id.primary),
iconViewIds = emptyList(),
hasDeviceLabel = false,
)
}
private fun createDualPodLayout(
context: Context,
podDevice: DualPodDevice,
@LayoutRes layout: Int,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
profileLabel: String?,
): RemoteViews = RemoteViews(context.packageName, layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
log(TAG, VERBOSE) { "createDualPodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
widgetId,
@@ -240,7 +348,7 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
// Left
val leftPercent = podDevice.batteryLeftPodPercent
@@ -276,12 +384,37 @@ class WidgetProvider : AppWidgetProvider() {
R.id.pod_right_ear,
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(
R.id.headphones_label,
R.id.pod_left_label,
R.id.pod_right_label,
R.id.pod_case_label,
),
iconViewIds = listOf(
R.id.pod_left_icon,
R.id.pod_left_charging,
R.id.pod_left_ear,
R.id.pod_case_icon,
R.id.pod_case_charging,
R.id.pod_right_icon,
R.id.pod_right_charging,
R.id.pod_right_ear,
),
hasDeviceLabel = true,
)
}
private fun createSinglePodLayout(
context: Context,
podDevice: SinglePodDevice,
widgetId: Int
widgetId: Int,
theme: WidgetTheme,
profileLabel: String?,
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
val pendingIntent: PendingIntent = PendingIntent.getActivity(
@@ -294,7 +427,7 @@ class WidgetProvider : AppWidgetProvider() {
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
val headsetPercent = podDevice.batteryHeadsetPercent
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
@@ -308,6 +441,23 @@ class WidgetProvider : AppWidgetProvider() {
R.id.headphones_charging,
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
)
applyThemeColors(
context = context,
views = this,
theme = theme,
textViewIds = listOf(
R.id.headphones_label,
R.id.headphones_battery_label,
),
iconViewIds = listOf(
R.id.headphones_icon,
R.id.headphones_battery_icon,
R.id.headphones_charging,
R.id.headphones_worn,
),
hasDeviceLabel = true,
)
}
companion object {
@@ -321,4 +471,4 @@ class WidgetProvider : AppWidgetProvider() {
context.sendBroadcast(intent)
}
}
}
}
@@ -0,0 +1,95 @@
package eu.darken.capod.main.ui.widget
import android.graphics.Color
import android.os.Bundle
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
data class WidgetTheme(
@ColorInt val backgroundColor: Int? = null,
@ColorInt val foregroundColor: Int? = null,
val backgroundAlpha: Int = 255,
val showDeviceLabel: Boolean = true,
) {
enum class Preset(
@ColorInt val presetBg: Int?,
@ColorInt val presetFg: Int?,
) {
MATERIAL_YOU(null, null),
CLASSIC_DARK(0xFF1E1E1E.toInt(), 0xFFFFFFFF.toInt()),
CLASSIC_LIGHT(0xFFFFFFFF.toInt(), 0xFF1E1E1E.toInt()),
BLUE(0xFF1565C0.toInt(), 0xFFFFFFFF.toInt()),
GREEN(0xFF2E7D32.toInt(), 0xFFFFFFFF.toInt()),
RED(0xFFC62828.toInt(), 0xFFFFFFFF.toInt()),
}
companion object {
private const val KEY_THEME_MODE = "capod_theme_mode"
private const val KEY_CUSTOM_BG = "capod_custom_bg"
private const val KEY_CUSTOM_FG = "capod_custom_fg"
private const val KEY_BG_ALPHA = "capod_bg_alpha"
private const val KEY_SHOW_LABEL = "capod_show_label"
private const val MODE_MATERIAL_YOU = "material_you"
private const val MODE_CUSTOM = "custom"
val DEFAULT = WidgetTheme()
fun fromBundle(bundle: Bundle): WidgetTheme {
val mode = bundle.getString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
val showLabel = bundle.getBoolean(KEY_SHOW_LABEL, true)
val alpha = bundle.getInt(KEY_BG_ALPHA, 255).coerceIn(0, 255)
return if (mode == MODE_CUSTOM) {
WidgetTheme(
backgroundColor = if (bundle.containsKey(KEY_CUSTOM_BG)) bundle.getInt(KEY_CUSTOM_BG) else null,
foregroundColor = if (bundle.containsKey(KEY_CUSTOM_FG)) bundle.getInt(KEY_CUSTOM_FG) else null,
backgroundAlpha = alpha,
showDeviceLabel = showLabel,
)
} else {
WidgetTheme(
backgroundAlpha = alpha,
showDeviceLabel = showLabel,
)
}
}
fun applyAlpha(@ColorInt color: Int, alpha: Int): Int {
val clampedAlpha = alpha.coerceIn(0, 255)
return Color.argb(clampedAlpha, Color.red(color), Color.green(color), Color.blue(color))
}
fun bestContrastForeground(@ColorInt bgColor: Int): Int {
val whiteContrast = ColorUtils.calculateContrast(Color.WHITE, bgColor)
val blackContrast = ColorUtils.calculateContrast(Color.BLACK, bgColor)
return if (whiteContrast >= blackContrast) Color.WHITE else Color.BLACK
}
fun matchPreset(theme: WidgetTheme): Preset? = Preset.entries.firstOrNull { preset ->
preset.presetBg == theme.backgroundColor && preset.presetFg == theme.foregroundColor && theme.backgroundAlpha == 255
}
}
fun toBundle(bundle: Bundle) {
if (backgroundColor == null && foregroundColor == null) {
bundle.putString(KEY_THEME_MODE, MODE_MATERIAL_YOU)
bundle.remove(KEY_CUSTOM_BG)
bundle.remove(KEY_CUSTOM_FG)
} else {
bundle.putString(KEY_THEME_MODE, MODE_CUSTOM)
if (backgroundColor != null) {
bundle.putInt(KEY_CUSTOM_BG, backgroundColor)
} else {
bundle.remove(KEY_CUSTOM_BG)
}
if (foregroundColor != null) {
bundle.putInt(KEY_CUSTOM_FG, foregroundColor)
} else {
bundle.remove(KEY_CUSTOM_FG)
}
}
bundle.putInt(KEY_BG_ALPHA, backgroundAlpha.coerceIn(0, 255))
bundle.putBoolean(KEY_SHOW_LABEL, showDeviceLabel)
}
}
@@ -1,11 +1,14 @@
package eu.darken.capod.monitor.core.worker
import android.annotation.SuppressLint
import android.app.ForegroundServiceStartNotAllowedException
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.common.bluetooth.BluetoothDevice2
@@ -70,44 +73,63 @@ class MonitorService : Service() {
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@Volatile private var monitorGeneration = 0
private var foregroundStartFailed = false
@SuppressLint("InlinedApi")
private fun promoteToForeground(notification: Notification): Boolean {
return try {
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
}
true
} catch (e: IllegalStateException) {
@Suppress("NewApi")
if (Build.VERSION.SDK_INT >= 31 && e is ForegroundServiceStartNotAllowedException) {
log(TAG, WARN) { "Foreground service start denied by OS: ${e.message}" }
false
} else {
throw e
}
} catch (e: SecurityException) {
log(TAG, WARN) { "Foreground service start denied (security): ${e.message}" }
false
}
}
override fun onCreate() {
// Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid
// ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts.
MonitorNotifications.ensureChannel(this)
val earlyNotification = MonitorNotifications.createEarlyNotification(this)
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
earlyNotification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, earlyNotification)
if (!promoteToForeground(MonitorNotifications.createEarlyNotification(this))) {
foregroundStartFailed = true
stopSelf()
super.onCreate()
return
}
super.onCreate()
log(TAG, VERBOSE) { "onCreate()" }
// Replace early notification with the full one from injected MonitorNotifications.
// Second startForeground() with the same ID updates the notification in place and is
// preferred over notify() for robust foreground state on OEM variants.
val notification = notifications.getStartupNotification()
if (hasApiLevel(29)) {
startForeground(
MonitorNotifications.NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
)
} else {
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
}
// Failure here is non-fatal — the service is already foreground from the early call.
promoteToForeground(notifications.getStartupNotification())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" }
if (foregroundStartFailed) {
log(TAG, WARN) { "Skipping monitor start, foreground promotion was denied." }
stopSelf(startId)
return START_NOT_STICKY
}
val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false
if (monitoringJob?.isActive == true && !forceStart) {
@@ -22,8 +22,6 @@ import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.SinglePodDevice
import eu.darken.capod.pods.core.formatBatteryPercent
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
@@ -33,8 +31,7 @@ class MonitorNotifications @Inject constructor(
private val notificationViewFactory: MonitorNotificationViewFactory
) {
private val builderLock = Mutex()
private val builder: NotificationCompat.Builder
private val openPi: PendingIntent
init {
ensureChannel(context)
@@ -44,35 +41,32 @@ class MonitorNotifications @Inject constructor(
NotificationManager.IMPORTANCE_LOW
).run { notificationManager.createNotificationChannel(this) }
val openIntent = Intent(context, MainActivity::class.java)
val openPi = PendingIntent.getActivity(
openPi = PendingIntent.getActivity(
context,
PENDING_INTENT_REQUEST_CODE,
openIntent,
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE
)
builder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID).apply {
setContentIntent(openPi)
priority = NotificationCompat.PRIORITY_LOW
setSmallIcon(R.drawable.devic_earbuds_generic_both)
setOngoing(true)
setContentTitle(context.getString(R.string.app_name))
}
}
private fun getBuilder(device: PodDevice?): NotificationCompat.Builder {
private fun baseBuilder(channelId: String): NotificationCompat.Builder =
NotificationCompat.Builder(context, channelId).apply {
setContentIntent(openPi)
priority = NotificationCompat.PRIORITY_LOW
setSmallIcon(R.drawable.device_earbuds_generic_both)
setOngoing(true)
}
private fun getBuilder(device: PodDevice?, channelId: String): NotificationCompat.Builder {
if (device == null) {
return builder.apply {
setCustomContentView(null)
return baseBuilder(channelId).apply {
setStyle(NotificationCompat.BigTextStyle())
setContentTitle(context.getString(R.string.pods_none_label_short))
setSubText(context.getString(R.string.app_name))
setSmallIcon(R.drawable.devic_earbuds_generic_both)
}
}
return builder.apply {
return baseBuilder(channelId).apply {
// Options here should be mutually exclusive, and are prioritized by their order of importance
// Some options are omitted here, as they will conflict with other options
@@ -135,28 +129,20 @@ class MonitorNotifications @Inject constructor(
setStyle(NotificationCompat.DecoratedCustomViewStyle())
setCustomBigContentView(notificationViewFactory.createContentView(device))
setSmallIcon(device.iconRes)
setContentTitle("$batteryText ~ $stateText")
setSubText(null)
log(TAG, VERBOSE) { "updatingNotification(): $device" }
}
}
suspend fun getNotification(podDevice: PodDevice?): Notification = builderLock.withLock {
getBuilder(podDevice).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
}.build()
}
fun getNotification(podDevice: PodDevice?): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID).build()
suspend fun getNotificationConnected(podDevice: PodDevice?): Notification = builderLock.withLock {
getBuilder(podDevice).apply {
setChannelId(NOTIFICATION_CHANNEL_ID_CONNECTED)
}.build()
}
fun getNotificationConnected(podDevice: PodDevice?): Notification =
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
fun getStartupNotification(): Notification = getBuilder(null).apply {
setChannelId(NOTIFICATION_CHANNEL_ID)
}.build()
fun getStartupNotification(): Notification =
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
companion object {
val TAG = logTag("Monitor", "Notifications")
@@ -184,13 +170,13 @@ class MonitorNotifications @Inject constructor(
Intent(context, MainActivity::class.java),
PendingIntentCompat.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
.setContentIntent(openPi)
.setSmallIcon(R.drawable.devic_earbuds_generic_both)
.setContentTitle(context.getString(R.string.app_name))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID).apply {
setContentIntent(openPi)
setSmallIcon(R.drawable.device_earbuds_generic_both)
setContentTitle(context.getString(R.string.app_name))
setPriority(NotificationCompat.PRIORITY_LOW)
setOngoing(true)
}.build()
}
}
}
@@ -16,9 +16,9 @@ interface DualPodDevice : PodDevice {
@get:DrawableRes
val leftPodIcon: Int
get() = R.drawable.devic_airpods_gen1_left
get() = R.drawable.device_airpods_gen1_left
@get:DrawableRes
val rightPodIcon: Int
get() = R.drawable.devic_airpods_gen1_right
get() = R.drawable.device_airpods_gen1_right
}
@@ -11,5 +11,5 @@ interface HasCase {
@get:DrawableRes
val caseIcon: Int
get() = R.drawable.devic_airpods_gen1_case
get() = R.drawable.device_airpods_gen1_case
}
@@ -77,98 +77,107 @@ interface PodDevice {
@JsonClass(generateAdapter = false)
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.devic_earbuds_generic_both,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
) {
@Json(name = "airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.devic_airpods_gen1_both,
iconRes = R.drawable.device_airpods_gen1_both,
),
@Json(name = "airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.devic_airpods_gen2_both,
R.drawable.device_airpods_gen1_both,
),
@Json(name = "airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.devic_airpods_gen2_both,
R.drawable.device_airpods_gen3_both,
),
@Json(name = "airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.devic_airpods_gen2_both,
R.drawable.device_airpods_gen3_both,
),
@Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.devic_airpods_gen2_both,
R.drawable.device_airpods_gen4anc_both,
),
@Json(name = "airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.devic_airpods_pro2_both
R.drawable.device_airpods_pro2_both
),
@Json(name = "airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.devic_airpods_pro2_both
R.drawable.device_airpods_pro2_both
),
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.devic_airpods_pro2_both
R.drawable.device_airpods_pro2_both
),
@Json(name = "airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.devic_airpods_pro2_both
R.drawable.device_airpods_pro2_both
),
@Json(name = "airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.devic_headphones_generic
R.drawable.device_airpods_max
),
@Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.devic_headphones_generic
R.drawable.device_airpods_max
),
@Json(name = "beats.flex") BEATS_FLEX(
"Beats Flex"
"Beats Flex",
R.drawable.device_beats_earbuds,
),
@Json(name = "beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3"
"Beats Solo 3",
R.drawable.device_beats_headphones,
),
@Json(name = "beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3"
"Beats Studio 3",
R.drawable.device_beats_studio3,
),
@Json(name = "beats.x") BEATS_X(
"Beats X"
"Beats X",
R.drawable.device_beats_x,
),
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3"
"Power Beats 3",
R.drawable.device_powerbeats_3,
),
@Json(name = "beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4"
"Power Beats 4",
R.drawable.device_powerbeats_4,
),
@Json(name = "beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro"
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
),
@Json(name = "beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2"
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
),
@Json(name = "beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro"
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
),
@Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen1_both,
R.drawable.device_airpods_gen1_both,
),
@Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
R.drawable.device_airpods_gen1_both,
),
@Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.devic_airpods_gen2_both,
R.drawable.device_airpods_gen3_both,
),
@Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
R.drawable.device_airpods_pro2_both,
),
@Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.devic_airpods_pro2_both,
R.drawable.device_airpods_pro2_both,
),
@Json(name = "unknown") UNKNOWN(
"Unknown"
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
@@ -29,6 +31,18 @@ data class AirPodsGen3(
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_airpods_gen3_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_airpods_gen3_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_airpods_gen3_case
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
@@ -29,6 +31,18 @@ data class AirPodsGen4(
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_airpods_gen3_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_airpods_gen3_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_airpods_gen3_case
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.airpods
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
@@ -29,6 +31,18 @@ data class AirPodsGen4Anc(
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4_ANC
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_airpods_gen4anc_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_airpods_gen4anc_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_airpods_gen4anc_case
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -34,19 +34,19 @@ data class AirPodsPro(
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
get() = R.drawable.device_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
get() = R.drawable.device_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
get() = R.drawable.device_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
get() = R.drawable.device_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -34,19 +34,19 @@ data class AirPodsPro2(
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
get() = R.drawable.device_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
get() = R.drawable.device_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
get() = R.drawable.device_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
get() = R.drawable.device_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -34,19 +34,19 @@ data class AirPodsPro2Usbc(
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
get() = R.drawable.device_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
get() = R.drawable.device_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
get() = R.drawable.device_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
get() = R.drawable.device_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -34,19 +34,19 @@ data class AirPodsPro3(
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
get() = R.drawable.device_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
get() = R.drawable.device_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
get() = R.drawable.device_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
get() = R.drawable.device_airpods_pro2_right
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.beats
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
@@ -29,6 +31,18 @@ data class BeatsFitPro(
override val model: PodDevice.Model = PodDevice.Model.BEATS_FIT_PRO
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_beats_fitpro_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_beats_fitpro_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_beats_fitpro_case
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.beats
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
@@ -29,6 +31,18 @@ data class PowerBeatsPro(
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_powerbeats_pro_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_powerbeats_pro_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_powerbeats_pro_case
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.beats
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
@@ -29,6 +31,18 @@ data class PowerBeatsPro2(
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO2
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_powerbeats_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_powerbeats_pro2_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_powerbeats_pro2_case
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
@@ -1,5 +1,7 @@
package eu.darken.capod.pods.core.apple.misc
import androidx.annotation.DrawableRes
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.HasCase
@@ -35,6 +37,18 @@ data class FakeAirPodsGen3(
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN3
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.device_airpods_gen3_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.device_airpods_gen3_right
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.device_airpods_gen3_case
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
@@ -39,19 +39,19 @@ data class FakeAirPodsPro(
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
get() = R.drawable.device_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
get() = R.drawable.device_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
get() = R.drawable.device_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
get() = R.drawable.device_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
@@ -41,19 +41,19 @@ data class FakeAirPodsPro2(
@get:DrawableRes
override val iconRes: Int
get() = R.drawable.devic_airpods_pro2_both
get() = R.drawable.device_airpods_pro2_both
@get:DrawableRes
override val caseIcon: Int
get() = R.drawable.devic_airpods_pro2_case
get() = R.drawable.device_airpods_pro2_case
@get:DrawableRes
override val leftPodIcon: Int
get() = R.drawable.devic_airpods_pro2_left
get() = R.drawable.device_airpods_pro2_left
@get:DrawableRes
override val rightPodIcon: Int
get() = R.drawable.devic_airpods_pro2_right
get() = R.drawable.device_airpods_pro2_right
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
@@ -33,7 +33,7 @@ class PlayPause @Inject constructor(
fun monitor() = combine(
reactionSettings.autoPlay.flow,
reactionSettings.autoPause.flow,
reactionSettings.onePodMode.flow,
reactionSettings.onePodMode.flow, // Included to restart pipeline when toggled; value read at decision time
) { play, pause, _ -> play || pause }
.flatMapLatest { if (it) bluetoothManager.connectedDevices else emptyFlow() }
.flatMapLatest {
@@ -57,46 +57,184 @@ class PlayPause @Inject constructor(
.onEach { (previous, current) ->
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
val previousWorn: Boolean?
val currentWorn: Boolean?
// Convert to EarDetectionState based on device capabilities
val prevState: EarDetectionState
val currState: EarDetectionState
when {
reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
previousWorn = previous.isEitherPodInEar
currentWorn = current.isEitherPodInEar
log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" }
log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" }
previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
// Dual pod devices (AirPods, AirPods Pro, etc.)
log(TAG, VERBOSE) {
"Dual-pod device: left=${current.isLeftPodInEar}, right=${current.isRightPodInEar}"
}
prevState = EarDetectionState.fromDualPod(previous)
currState = EarDetectionState.fromDualPod(current)
}
previous is HasEarDetection && current is HasEarDetection -> {
previousWorn = previous.isBeingWorn
currentWorn = current.isBeingWorn
log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" }
// Single pod devices (AirPods Max, etc.)
log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" }
prevState = EarDetectionState.fromSinglePod(previous)
currState = EarDetectionState.fromSinglePod(current)
}
else -> {
log(TAG, VERBOSE) { "Current devices don't support ear detection." }
previousWorn = null
currentWorn = null
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
return@onEach
}
}
if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) {
if (reactionSettings.autoPlay.value) {
log(TAG) { "autoPlay is triggered, sendPlay()" }
// Evaluate what action to take
val decision = evaluatePlayPauseAction(
previous = prevState,
current = currState,
onePodMode = reactionSettings.onePodMode.value,
isCurrentlyPlaying = mediaControl.isPlaying
)
log(TAG, VERBOSE) { "Decision: ${decision.reason}" }
// Execute the decision
when {
decision.shouldPlay && reactionSettings.autoPlay.value -> {
log(TAG) { "autoPlay is triggered, sendPlay() - ${decision.reason}" }
mediaControl.sendPlay()
} else {
}
decision.shouldPlay && !reactionSettings.autoPlay.value -> {
log(TAG, VERBOSE) { "autoPlay is disabled" }
}
} else if (previousWorn == true && currentWorn == false && mediaControl.isPlaying) {
if (reactionSettings.autoPause.value) {
log(TAG) { "autoPause is triggered, sendPause()" }
decision.shouldPause && reactionSettings.autoPause.value -> {
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
mediaControl.sendPause()
} else {
log(TAG) { "autoPause is disabled" }
}
decision.shouldPause && !reactionSettings.autoPause.value -> {
log(TAG, VERBOSE) { "autoPause is disabled" }
}
}
}
.setupCommonEventHandlers(TAG) { "monitor" }
internal fun evaluatePlayPauseAction(
previous: EarDetectionState,
current: EarDetectionState,
onePodMode: Boolean,
isCurrentlyPlaying: Boolean
): PlayPauseDecision = if (onePodMode) {
evaluateOnePodMode(previous, current, isCurrentlyPlaying)
} else {
evaluateNormalMode(previous, current, isCurrentlyPlaying)
}
private fun evaluateOnePodMode(
previous: EarDetectionState,
current: EarDetectionState,
isCurrentlyPlaying: Boolean
): PlayPauseDecision {
val netChange = current.podCount - previous.podCount
return when {
// Net decrease: pod(s) removed → pause
netChange < 0 && isCurrentlyPlaying -> PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "One-pod mode: pod(s) removed (net change: $netChange)"
)
// Net increase: pod(s) inserted → play
netChange > 0 && !isCurrentlyPlaying -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "One-pod mode: pod(s) inserted (net change: +$netChange)"
)
// No net change, or action not appropriate for current playing state
else -> PlayPauseDecision(
shouldPlay = false,
shouldPause = false,
reason = "One-pod mode: no action (net change: $netChange, playing: $isCurrentlyPlaying)"
)
}
}
private fun evaluateNormalMode(
previous: EarDetectionState,
current: EarDetectionState,
isCurrentlyPlaying: Boolean
): PlayPauseDecision {
val wasWorn = previous.bothInEar
val isWorn = current.bothInEar
return when {
// Transition: not worn → worn, and not playing → play
!wasWorn && isWorn && !isCurrentlyPlaying -> PlayPauseDecision(
shouldPlay = true,
shouldPause = false,
reason = "Normal mode: both pods in ear"
)
// Transition: worn → not worn, and playing → pause
wasWorn && !isWorn && isCurrentlyPlaying -> PlayPauseDecision(
shouldPlay = false,
shouldPause = true,
reason = "Normal mode: not both pods in ear"
)
// No action needed
else -> PlayPauseDecision(
shouldPlay = false,
shouldPause = false,
reason = "Normal mode: no action (wasWorn: $wasWorn, isWorn: $isWorn, playing: $isCurrentlyPlaying)"
)
}
}
data class EarDetectionState(
val leftInEar: Boolean?, // null for single pod devices
val rightInEar: Boolean?, // null for single pod devices
val isWorn: Boolean // Single-pod: device worn state. Dual-pod: equivalent to bothInEar (left && right).
) {
val isDualPod: Boolean get() = leftInEar != null && rightInEar != null
val isSinglePod: Boolean get() = !isDualPod
val eitherInEar: Boolean get() = if (isDualPod) leftInEar!! || rightInEar!! else isWorn
val bothInEar: Boolean get() = if (isDualPod) leftInEar!! && rightInEar!! else isWorn
val podCount: Int
get() = if (isDualPod) {
(if (leftInEar!!) 1 else 0) + (if (rightInEar!!) 1 else 0)
} else {
if (isWorn) 1 else 0
}
companion object {
fun fromDualPod(device: HasEarDetectionDual) = fromDualPod(
left = device.isLeftPodInEar,
right = device.isRightPodInEar,
)
fun fromDualPod(left: Boolean, right: Boolean) = EarDetectionState(
leftInEar = left,
rightInEar = right,
isWorn = left && right
)
fun fromSinglePod(device: HasEarDetection) = fromSinglePod(worn = device.isBeingWorn)
fun fromSinglePod(worn: Boolean) = EarDetectionState(
leftInEar = null,
rightInEar = null,
isWorn = worn
)
}
}
data class PlayPauseDecision(
val shouldPlay: Boolean,
val shouldPause: Boolean,
val reason: String
)
companion object {
private val TAG = logTag("Reaction", "PlayPause")
}
@@ -5,11 +5,10 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.view.isInvisible
import androidx.core.view.isGone
import androidx.core.view.isVisible
import dagger.hilt.android.qualifiers.ApplicationContext
import eu.darken.capod.R
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
import eu.darken.capod.pods.core.DualPodDevice
@@ -24,7 +23,6 @@ import javax.inject.Inject
class PopUpPodViewFactory @Inject constructor(
@ApplicationContext private val appContext: Context,
private val debugSettings: DebugSettings,
) {
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
@@ -39,14 +37,15 @@ class PopUpPodViewFactory @Inject constructor(
private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View =
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
podIcon.setImageResource(device.iconRes)
podLabel.text = device.getLabel(context)
signal.text = device.getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
signalIcon.isGone = signal.text.isNullOrBlank()
signal.isGone = signal.text.isNullOrBlank()
// Left
val leftPercent = device.batteryLeftPodPercent
podLeftIcon.setImageResource(device.leftPodIcon)
podLeftIcon.imageTintList = null
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
@@ -55,6 +54,7 @@ class PopUpPodViewFactory @Inject constructor(
(device as? HasCase)?.let { case ->
val casePercent = case.batteryCasePercent
podCaseIcon.setImageResource(case.caseIcon)
podCaseIcon.imageTintList = null
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
}
@@ -62,6 +62,7 @@ class PopUpPodViewFactory @Inject constructor(
// Right
val rightPercent = device.batteryRightPodPercent
podRightIcon.setImageResource(device.rightPodIcon)
podRightIcon.imageTintList = null
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
}.root
@@ -69,9 +70,11 @@ class PopUpPodViewFactory @Inject constructor(
private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View =
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
headphonesIcon.setImageResource(device.iconRes)
headphonesIcon.imageTintList = null
headphonesLabel.text = device.getLabel(context)
signal.text = device.getSignalQuality(context)
signal.isInvisible = debugSettings.isDebugModeEnabled.value
signalIcon.isGone = signal.text.isNullOrBlank()
signal.isGone = signal.text.isNullOrBlank()
val headsetPercent = device.batteryHeadsetPercent
headphonesBatteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
@@ -32,13 +32,15 @@ class PopUpWindow @Inject constructor(
private val windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
private val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, // Display it on top of other application windows
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Don't let it grab the input focus
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // Make the underlying application window visible
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.BOTTOM
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
val dm = appContext.resources.displayMetrics
val margin = (24 * dm.density).toInt()
width = minOf(dm.widthPixels - margin * 2, (400 * dm.density).toInt())
}
private val popUpView: View = layoutInflater.inflate(R.layout.popup_window_container_layout, null).apply {
findViewById<View>(R.id.close_action).setOnClickListener { close() }
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Some files were not shown because too many files have changed in this diff Show More