Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576c6db8d0 | ||
|
|
524cac8d30 | ||
|
|
8939f788ad | ||
|
|
9ad3f3c180 | ||
|
|
5b59ddab0f | ||
|
|
ab24c48024 | ||
|
|
a6482c7d76 | ||
|
|
99aa44b448 | ||
|
|
a801f2d701 | ||
|
|
8405406d22 | ||
|
|
9785b5ea7f | ||
|
|
fd7f9f8ab3 | ||
|
|
46bb8c98de | ||
|
|
b3ead17b12 | ||
|
|
a794d59276 | ||
|
|
584c5c70d0 | ||
|
|
5a55796518 | ||
|
|
bdfdd93735 | ||
|
|
e9a937e4f6 | ||
|
|
0a8bae46a9 | ||
|
|
7efcafbc71 |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Companion App for AirPods (CAPod)
|
||||
|
||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
||||
[](https://shields.rbtlog.dev/eu.darken.capod)
|
||||
[](https://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||
[](https://crowdin.com/project/capod)
|
||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() }
|
||||
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
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 |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
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 |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.2 KiB |