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": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"mcp__ide__getDiagnostics",
|
"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",
|
"WebSearch",
|
||||||
"WebFetch(domain:support.google.com)",
|
"WebFetch(domain:support.google.com)",
|
||||||
"WebFetch(domain:github.com)",
|
"WebFetch(domain:github.com)",
|
||||||
@@ -31,5 +11,10 @@
|
|||||||
"WebFetch(domain:issuetracker.google.com)"
|
"WebFetch(domain:issuetracker.google.com)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"android-translation@claude-code-cafe": true,
|
||||||
|
"debugbadger@claude-code-cafe": true,
|
||||||
|
"jvm-tools@claude-code-cafe": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,48 +1,49 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
CFPropertyList (3.0.7)
|
CFPropertyList (3.0.8)
|
||||||
base64
|
abbrev (0.1.2)
|
||||||
nkf
|
addressable (2.8.8)
|
||||||
rexml
|
public_suffix (>= 2.0.2, < 8.0)
|
||||||
addressable (2.8.7)
|
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
|
||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.3.2)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1109.0)
|
aws-partitions (1.1216.0)
|
||||||
aws-sdk-core (3.224.1)
|
aws-sdk-core (3.242.0)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
base64
|
base64
|
||||||
|
bigdecimal
|
||||||
jmespath (~> 1, >= 1.6.1)
|
jmespath (~> 1, >= 1.6.1)
|
||||||
logger
|
logger
|
||||||
aws-sdk-kms (1.101.0)
|
aws-sdk-kms (1.122.0)
|
||||||
aws-sdk-core (~> 3, >= 3.216.0)
|
aws-sdk-core (~> 3, >= 3.241.4)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.188.0)
|
aws-sdk-s3 (1.213.0)
|
||||||
aws-sdk-core (~> 3, >= 3.224.1)
|
aws-sdk-core (~> 3, >= 3.241.4)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.11.0)
|
aws-sigv4 (1.12.1)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
babosa (1.0.4)
|
babosa (1.0.4)
|
||||||
base64 (0.3.0)
|
base64 (0.2.0)
|
||||||
|
benchmark (0.5.0)
|
||||||
|
bigdecimal (4.0.1)
|
||||||
claide (1.1.0)
|
claide (1.1.0)
|
||||||
colored (1.2)
|
colored (1.2)
|
||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
|
csv (3.3.5)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.5.20190701)
|
domain_name (0.6.20240107)
|
||||||
unf (>= 0.0.5, < 1.0.0)
|
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
emoji_regex (3.2.3)
|
||||||
excon (0.109.0)
|
excon (0.112.0)
|
||||||
faraday (1.10.4)
|
faraday (1.10.5)
|
||||||
faraday-em_http (~> 1.0)
|
faraday-em_http (~> 1.0)
|
||||||
faraday-em_synchrony (~> 1.0)
|
faraday-em_synchrony (~> 1.0)
|
||||||
faraday-excon (~> 1.1)
|
faraday-excon (~> 1.1)
|
||||||
@@ -54,14 +55,14 @@ GEM
|
|||||||
faraday-rack (~> 1.0)
|
faraday-rack (~> 1.0)
|
||||||
faraday-retry (~> 1.0)
|
faraday-retry (~> 1.0)
|
||||||
ruby2_keywords (>= 0.0.4)
|
ruby2_keywords (>= 0.0.4)
|
||||||
faraday-cookie_jar (0.0.7)
|
faraday-cookie_jar (0.0.8)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (~> 1.0.0)
|
http-cookie (>= 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.1)
|
faraday-em_synchrony (1.0.1)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.1.1)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (1.0.2)
|
||||||
faraday-net_http_persistent (1.2.0)
|
faraday-net_http_persistent (1.2.0)
|
||||||
@@ -71,15 +72,19 @@ GEM
|
|||||||
faraday_middleware (1.2.1)
|
faraday_middleware (1.2.1)
|
||||||
faraday (~> 1.0)
|
faraday (~> 1.0)
|
||||||
fastimage (2.4.0)
|
fastimage (2.4.0)
|
||||||
fastlane (2.228.0)
|
fastlane (2.232.1)
|
||||||
CFPropertyList (>= 2.3, < 4.0.0)
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
abbrev (~> 0.1.2)
|
||||||
addressable (>= 2.8, < 3.0.0)
|
addressable (>= 2.8, < 3.0.0)
|
||||||
artifactory (~> 3.0)
|
artifactory (~> 3.0)
|
||||||
aws-sdk-s3 (~> 1.0)
|
aws-sdk-s3 (~> 1.197)
|
||||||
babosa (>= 1.0.3, < 2.0.0)
|
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)
|
colored (~> 1.2)
|
||||||
commander (~> 4.6)
|
commander (~> 4.6)
|
||||||
|
csv (~> 3.3)
|
||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 1.0.0)
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
@@ -91,16 +96,20 @@ GEM
|
|||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
google-apis-androidpublisher_v3 (~> 0.3)
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
google-apis-playcustomapp_v1 (~> 0.1)
|
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)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.1.0, < 3)
|
jwt (>= 2.1.0, < 3)
|
||||||
|
logger (>= 1.6, < 2.0)
|
||||||
mini_magick (>= 4.9.4, < 5.0.0)
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
multipart-post (>= 2.0.0, < 3.0.0)
|
multipart-post (>= 2.0.0, < 3.0.0)
|
||||||
|
mutex_m (~> 0.3.0)
|
||||||
naturally (~> 2.2)
|
naturally (~> 2.2)
|
||||||
|
nkf (~> 0.2.0)
|
||||||
optparse (>= 0.1.1, < 1.0.0)
|
optparse (>= 0.1.1, < 1.0.0)
|
||||||
|
ostruct (>= 0.1.0)
|
||||||
plist (>= 3.1.0, < 4.0.0)
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
rubyzip (>= 2.0.0, < 3.0.0)
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
@@ -116,38 +125,40 @@ GEM
|
|||||||
fastlane-sirp (1.0.0)
|
fastlane-sirp (1.0.0)
|
||||||
sysrandom (~> 1.0)
|
sysrandom (~> 1.0)
|
||||||
gh_inspector (1.1.3)
|
gh_inspector (1.1.3)
|
||||||
google-apis-androidpublisher_v3 (0.54.0)
|
google-apis-androidpublisher_v3 (0.96.0)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (0.11.3)
|
google-apis-core (0.18.0)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
httpclient (>= 2.8.1, < 3.a)
|
httpclient (>= 2.8.3, < 3.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
|
mutex_m
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 2.0, < 4.a)
|
||||||
rexml
|
google-apis-iamcredentials_v1 (0.26.0)
|
||||||
google-apis-iamcredentials_v1 (0.17.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-playcustomapp_v1 (0.17.0)
|
||||||
google-apis-playcustomapp_v1 (0.13.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-apis-storage_v1 (0.60.0)
|
||||||
google-apis-storage_v1 (0.29.0)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-core (>= 0.11.0, < 2.a)
|
google-cloud-core (1.8.0)
|
||||||
google-cloud-core (1.6.1)
|
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (2.1.1)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-errors (1.3.1)
|
google-cloud-errors (1.5.0)
|
||||||
google-cloud-storage (1.45.0)
|
google-cloud-storage (1.58.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
digest-crc (~> 0.4)
|
digest-crc (~> 0.4)
|
||||||
google-apis-iamcredentials_v1 (~> 0.1)
|
google-apis-core (>= 0.18, < 2)
|
||||||
google-apis-storage_v1 (~> 0.29.0)
|
google-apis-iamcredentials_v1 (~> 0.18)
|
||||||
|
google-apis-storage_v1 (>= 0.42)
|
||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
googleauth (1.8.1)
|
googleauth (1.11.2)
|
||||||
faraday (>= 0.17.3, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
|
google-cloud-env (~> 2.1)
|
||||||
jwt (>= 1.4, < 3.0)
|
jwt (>= 1.4, < 3.0)
|
||||||
multi_json (~> 1.11)
|
multi_json (~> 1.11)
|
||||||
os (>= 0.9, < 2.0)
|
os (>= 0.9, < 2.0)
|
||||||
@@ -158,37 +169,38 @@ GEM
|
|||||||
httpclient (2.9.0)
|
httpclient (2.9.0)
|
||||||
mutex_m
|
mutex_m
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.7.6)
|
json (2.18.1)
|
||||||
jwt (2.10.1)
|
jwt (2.10.2)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
mini_magick (4.13.2)
|
mini_magick (4.13.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
multi_json (1.15.0)
|
multi_json (1.19.1)
|
||||||
multipart-post (2.4.1)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
nkf (0.2.0)
|
nkf (0.2.0)
|
||||||
optparse (0.6.0)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
public_suffix (5.1.1)
|
public_suffix (7.0.2)
|
||||||
rake (13.3.0)
|
rake (13.3.1)
|
||||||
representable (3.2.0)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.1.2)
|
retriable (3.2.0)
|
||||||
rexml (3.4.1)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.18.0)
|
signet (0.21.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
faraday (>= 0.17.5, < 3.a)
|
faraday (>= 0.17.5, < 3.a)
|
||||||
jwt (>= 1.5, < 3.0)
|
jwt (>= 1.5, < 4.0)
|
||||||
multi_json (~> 1.10)
|
multi_json (~> 1.10)
|
||||||
simctl (1.6.10)
|
simctl (1.6.10)
|
||||||
CFPropertyList
|
CFPropertyList
|
||||||
@@ -203,7 +215,6 @@ GEM
|
|||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
uber (0.1.0)
|
||||||
unf (0.2.0)
|
|
||||||
unicode-display_width (2.6.0)
|
unicode-display_width (2.6.0)
|
||||||
word_wrap (1.0.0)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.27.0)
|
xcodeproj (1.27.0)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
# Companion App for AirPods (CAPod)
|
# Companion App for AirPods (CAPod)
|
||||||
|
|
||||||
[](https://github.com/d4rken-org/capod/releases/latest)
|
[](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://github.com/d4rken/capod/actions/workflows/code-checks.yml)
|
||||||
[](https://crowdin.com/project/capod)
|
[](https://crowdin.com/project/capod)
|
||||||
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
[](https://github.com/d4rken-org/capod/edit/main/README.md#download)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="foss_upgrade_donate_label">Wsparcie</string>
|
<string name="foss_upgrade_donate_label">Wesprzyj</string>
|
||||||
<string name="foss_upgrade_alreadydonated_label">Już się zrzuciłem</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>
|
<string name="foss_upgrade_no_money_label">Wydałem wszystkie pieniądze na AirPods</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?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_gplay_unavailable_error">Οι υπηρεσίες Google Play δεν είναι διαθέσιμες.</string>
|
||||||
<string name="upgrades_no_purchases_found_check_account">Δε βρέθηκαν αγορές. Χρησιμοποιείτε τον σωστό λογαριασμό;</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_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_label">Σφάλμα χρέωσης Google Play</string>
|
||||||
<string name="upgrades_gplay_billing_result_error_description">Παρουσιάστηκε σφάλμα κατά την αίτηση των στοιχείων αγοράς σας από το Google Play. Εκκαθαρίστε την προσωρινή μνήμη του Google Play και επανεκκινήστε το τηλέφωνό σας.\n\nΣφάλμα %s</string>
|
<string name="upgrades_gplay_billing_result_error_description">Παρουσιάστηκε σφάλμα κατά την αίτηση των στοιχείων αγοράς σας από το Google Play. Εκκαθαρίστε την προσωρινή μνήμη του Google Play και επανεκκινήστε το τηλέφωνό σας.\n\nΣφάλμα %s</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">„Google Play“ paslaugos nepasiekiamos.</string>
|
<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_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_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_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_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_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>
|
</resources>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="upgrades_gplay_unavailable_error">Usługi Google Play nie są dostępne.</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 zakupu. Czy na pewno korzystasz z poprawnego konta?</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_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_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>
|
<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")
|
@Suppress("UNCHECKED_CAST", "LongParameterList")
|
||||||
inline fun <T1, T2, T3, T4, T5, R> combine(
|
inline fun <T1, T2, T3, T4, T5, R> combine(
|
||||||
flow: Flow<T1>,
|
flow: Flow<T1>,
|
||||||
|
|||||||
@@ -3,10 +3,32 @@ package eu.darken.capod.main.ui.widget
|
|||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
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.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.enableEdgeToEdge
|
||||||
import androidx.activity.viewModels
|
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 androidx.core.view.isVisible
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
@@ -30,14 +52,15 @@ class WidgetConfigurationActivity : Activity2() {
|
|||||||
|
|
||||||
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
private var widgetId: Int = AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
|
|
||||||
|
private var isUpdatingHexFromCode = false
|
||||||
|
private val checkerboardDrawable: BitmapDrawable by lazy { createCheckerboardDrawable() }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
// Set result to CANCELED in case user backs out
|
|
||||||
setResult(RESULT_CANCELED)
|
setResult(RESULT_CANCELED)
|
||||||
|
|
||||||
// Get widget ID from intent
|
|
||||||
widgetId = intent.getIntExtra(
|
widgetId = intent.getIntExtra(
|
||||||
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
AppWidgetManager.EXTRA_APPWIDGET_ID,
|
||||||
AppWidgetManager.INVALID_APPWIDGET_ID
|
AppWidgetManager.INVALID_APPWIDGET_ID
|
||||||
@@ -45,7 +68,6 @@ class WidgetConfigurationActivity : Activity2() {
|
|||||||
|
|
||||||
log(TAG) { "onCreate(widgetId=$widgetId)" }
|
log(TAG) { "onCreate(widgetId=$widgetId)" }
|
||||||
|
|
||||||
// If widget ID is invalid, finish
|
|
||||||
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) {
|
||||||
log(TAG) { "Invalid widget ID, finishing" }
|
log(TAG) { "Invalid widget ID, finishing" }
|
||||||
finish()
|
finish()
|
||||||
@@ -61,6 +83,15 @@ class WidgetConfigurationActivity : Activity2() {
|
|||||||
|
|
||||||
ui.profilesRecycler.adapter = profileAdapter
|
ui.profilesRecycler.adapter = profileAdapter
|
||||||
|
|
||||||
|
setupPreviewContainer()
|
||||||
|
setupPresetChips()
|
||||||
|
setupColorSwatches(ui.bgColorGrid, isBg = true)
|
||||||
|
setupColorSwatches(ui.fgColorGrid, isBg = false)
|
||||||
|
setupHexInput()
|
||||||
|
setupTransparencySlider()
|
||||||
|
setupShowDeviceLabelSwitch()
|
||||||
|
setupResetButton()
|
||||||
|
|
||||||
ui.cancelButton.setOnClickListener {
|
ui.cancelButton.setOnClickListener {
|
||||||
log(TAG) { "Cancel clicked" }
|
log(TAG) { "Cancel clicked" }
|
||||||
finish()
|
finish()
|
||||||
@@ -95,6 +126,310 @@ class WidgetConfigurationActivity : Activity2() {
|
|||||||
ui.confirmButton.text = getString(R.string.general_upgrade_action)
|
ui.confirmButton.text = getString(R.string.general_upgrade_action)
|
||||||
ui.confirmButton.isEnabled = true
|
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()
|
finish()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = logTag("Widget", "ConfigurationActivity")
|
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
|
package eu.darken.capod.main.ui.widget
|
||||||
|
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.common.coroutine.DispatcherProvider
|
import eu.darken.capod.common.coroutine.DispatcherProvider
|
||||||
import eu.darken.capod.common.debug.logging.log
|
import eu.darken.capod.common.debug.logging.log
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
@@ -22,8 +24,11 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
private val deviceProfilesRepo: DeviceProfilesRepo,
|
private val deviceProfilesRepo: DeviceProfilesRepo,
|
||||||
private val widgetSettings: WidgetSettings,
|
private val widgetSettings: WidgetSettings,
|
||||||
private val upgradeRepo: UpgradeRepo,
|
private val upgradeRepo: UpgradeRepo,
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
) : ViewModel3(dispatcherProvider) {
|
) : ViewModel3(dispatcherProvider) {
|
||||||
|
|
||||||
|
private val appWidgetManager by lazy { AppWidgetManager.getInstance(context) }
|
||||||
|
|
||||||
val widgetId: Int
|
val widgetId: Int
|
||||||
get() = savedStateHandle.get<Int>(AppWidgetManager.EXTRA_APPWIDGET_ID) ?: AppWidgetManager.INVALID_APPWIDGET_ID
|
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))
|
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,
|
selectedProfile,
|
||||||
|
currentTheme,
|
||||||
|
forceCustomMode,
|
||||||
deviceProfilesRepo.profiles,
|
deviceProfilesRepo.profiles,
|
||||||
upgradeRepo.upgradeInfo,
|
upgradeRepo.upgradeInfo,
|
||||||
) { selected, profiles, upgradeInfo ->
|
) { selected, theme, forceCustom, profiles, upgradeInfo ->
|
||||||
log(TAG) { "loadProfiles()" }
|
log(TAG) { "state update: profile=$selected, theme=$theme, forceCustom=$forceCustom" }
|
||||||
|
|
||||||
|
|
||||||
|
val activePreset = if (forceCustom) null else WidgetTheme.matchPreset(theme)
|
||||||
State(
|
State(
|
||||||
profiles = profiles,
|
profiles = profiles,
|
||||||
isPro = upgradeInfo.isPro,
|
isPro = upgradeInfo.isPro,
|
||||||
selectedProfile = selected,
|
selectedProfile = selected,
|
||||||
|
theme = theme,
|
||||||
|
activePreset = activePreset,
|
||||||
|
isCustomMode = activePreset == null,
|
||||||
)
|
)
|
||||||
}.asLiveData2()
|
}.asLiveData2()
|
||||||
|
|
||||||
@@ -56,13 +75,70 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
val profiles: List<DeviceProfile> = emptyList(),
|
val profiles: List<DeviceProfile> = emptyList(),
|
||||||
val selectedProfile: ProfileId? = null,
|
val selectedProfile: ProfileId? = null,
|
||||||
val isPro: Boolean = false,
|
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
|
val canConfirm: Boolean = selectedProfile != null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectProfile(profileId: ProfileId) {
|
fun selectProfile(profileId: ProfileId) {
|
||||||
log(TAG) { "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() {
|
fun confirmSelection() {
|
||||||
@@ -71,9 +147,16 @@ class WidgetConfigurationViewModel @Inject constructor(
|
|||||||
log(TAG) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
|
log(TAG) { "confirmSelection(widgetId=$widgetId, selectedProfile=$selectedProfile)" }
|
||||||
widgetSettings.saveWidgetProfile(widgetId, 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 {
|
companion object {
|
||||||
private val TAG = logTag("Widget", "ConfigurationVM")
|
private val TAG = logTag("Widget", "ConfigurationVM")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import android.appwidget.AppWidgetManager
|
|||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import androidx.annotation.LayoutRes
|
import androidx.annotation.LayoutRes
|
||||||
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.coroutine.AppScope
|
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.SinglePodDevice
|
||||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||||
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import eu.darken.capod.profiles.core.ProfileId
|
import eu.darken.capod.profiles.core.ProfileId
|
||||||
import finish2
|
import finish2
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -49,6 +54,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
@Inject lateinit var podFactory: PodFactory
|
@Inject lateinit var podFactory: PodFactory
|
||||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||||
@Inject lateinit var widgetSettings: WidgetSettings
|
@Inject lateinit var widgetSettings: WidgetSettings
|
||||||
|
@Inject lateinit var deviceProfilesRepo: DeviceProfilesRepo
|
||||||
@AppScope @Inject lateinit var appScope: CoroutineScope
|
@AppScope @Inject lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
private fun executeAsync(
|
private fun executeAsync(
|
||||||
@@ -136,9 +142,15 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
|
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
|
||||||
|
|
||||||
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
|
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 {
|
val layout = when {
|
||||||
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId)
|
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId, theme)
|
||||||
device is DualPodDevice -> {
|
device is DualPodDevice -> {
|
||||||
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
|
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
|
||||||
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
|
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
|
||||||
@@ -154,19 +166,84 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
else -> R.layout.widget_pod_dual_wide_layout
|
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 SinglePodDevice -> createSinglePodLayout(context, device, widgetId, theme, profileLabel)
|
||||||
device is PodDevice -> createUnknownPodLayout(context, device, widgetId)
|
device is PodDevice -> createUnknownPodLayout(context, device, widgetId, theme)
|
||||||
else -> createNoDeviceLayout(context, profileId != null, widgetId)
|
else -> createNoDeviceLayout(context, profileId != null, widgetId, theme)
|
||||||
}
|
}
|
||||||
widgetManager.updateAppWidget(widgetId, layout)
|
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(
|
private suspend fun createUpgradeRequiredLayout(
|
||||||
context: Context,
|
context: Context,
|
||||||
widgetId: Int
|
widgetId: Int,
|
||||||
|
theme: WidgetTheme,
|
||||||
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||||
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
|
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
|
||||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
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.primary, context.getString(R.string.upgrade_capod_label))
|
||||||
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
|
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
|
||||||
setViewVisibility(R.id.secondary, View.VISIBLE)
|
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(
|
private fun createUnknownPodLayout(
|
||||||
context: Context,
|
context: Context,
|
||||||
podDevice: PodDevice,
|
podDevice: PodDevice,
|
||||||
widgetId: Int
|
widgetId: Int,
|
||||||
|
theme: WidgetTheme,
|
||||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||||
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
|
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
|
||||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||||
@@ -199,12 +286,22 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||||
|
|
||||||
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
|
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(
|
private fun createNoDeviceLayout(
|
||||||
context: Context,
|
context: Context,
|
||||||
hasConfiguredProfile: Boolean = false,
|
hasConfiguredProfile: Boolean = false,
|
||||||
widgetId: Int
|
widgetId: Int,
|
||||||
|
theme: WidgetTheme,
|
||||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||||
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
|
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
|
||||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||||
@@ -222,15 +319,26 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
R.string.overview_nomaindevice_label
|
R.string.overview_nomaindevice_label
|
||||||
}
|
}
|
||||||
setTextViewText(R.id.primary, context.getString(messageRes))
|
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(
|
private fun createDualPodLayout(
|
||||||
context: Context,
|
context: Context,
|
||||||
podDevice: DualPodDevice,
|
podDevice: DualPodDevice,
|
||||||
@LayoutRes layout: Int,
|
@LayoutRes layout: Int,
|
||||||
widgetId: Int
|
widgetId: Int,
|
||||||
|
theme: WidgetTheme,
|
||||||
|
profileLabel: String?,
|
||||||
): RemoteViews = RemoteViews(context.packageName, layout).apply {
|
): 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(
|
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
widgetId,
|
widgetId,
|
||||||
@@ -240,7 +348,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
|
|
||||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||||
|
|
||||||
setTextViewText(R.id.headphones_label, podDevice.getLabel(context))
|
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
|
||||||
|
|
||||||
// Left
|
// Left
|
||||||
val leftPercent = podDevice.batteryLeftPodPercent
|
val leftPercent = podDevice.batteryLeftPodPercent
|
||||||
@@ -276,12 +384,37 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
R.id.pod_right_ear,
|
R.id.pod_right_ear,
|
||||||
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
|
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(
|
private fun createSinglePodLayout(
|
||||||
context: Context,
|
context: Context,
|
||||||
podDevice: SinglePodDevice,
|
podDevice: SinglePodDevice,
|
||||||
widgetId: Int
|
widgetId: Int,
|
||||||
|
theme: WidgetTheme,
|
||||||
|
profileLabel: String?,
|
||||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
|
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
|
||||||
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
|
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
|
||||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||||
@@ -294,7 +427,7 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||||
|
|
||||||
val headsetPercent = podDevice.batteryHeadsetPercent
|
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_icon, podDevice.iconRes)
|
||||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||||
@@ -308,6 +441,23 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
R.id.headphones_charging,
|
R.id.headphones_charging,
|
||||||
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
|
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 {
|
companion object {
|
||||||
@@ -321,4 +471,4 @@ class WidgetProvider : AppWidgetProvider() {
|
|||||||
context.sendBroadcast(intent)
|
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
|
package eu.darken.capod.monitor.core.worker
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.ForegroundServiceStartNotAllowedException
|
||||||
|
import android.app.Notification
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||||
@@ -70,44 +73,63 @@ class MonitorService : Service() {
|
|||||||
private val monitorScope = MonitorCoroutineScope()
|
private val monitorScope = MonitorCoroutineScope()
|
||||||
private var monitoringJob: Job? = null
|
private var monitoringJob: Job? = null
|
||||||
@Volatile private var monitorGeneration = 0
|
@Volatile private var monitorGeneration = 0
|
||||||
|
private var foregroundStartFailed = false
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@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() {
|
override fun onCreate() {
|
||||||
// Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid
|
// Promote to foreground BEFORE Hilt DI (triggered by super.onCreate()) to avoid
|
||||||
// ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts.
|
// ForegroundServiceDidNotStartInTimeException when DI is slow on backgrounded cold starts.
|
||||||
MonitorNotifications.ensureChannel(this)
|
MonitorNotifications.ensureChannel(this)
|
||||||
val earlyNotification = MonitorNotifications.createEarlyNotification(this)
|
if (!promoteToForeground(MonitorNotifications.createEarlyNotification(this))) {
|
||||||
if (hasApiLevel(29)) {
|
foregroundStartFailed = true
|
||||||
startForeground(
|
stopSelf()
|
||||||
MonitorNotifications.NOTIFICATION_ID,
|
super.onCreate()
|
||||||
earlyNotification,
|
return
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(MonitorNotifications.NOTIFICATION_ID, earlyNotification)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
log(TAG, VERBOSE) { "onCreate()" }
|
log(TAG, VERBOSE) { "onCreate()" }
|
||||||
|
|
||||||
// Replace early notification with the full one from injected MonitorNotifications.
|
// Replace early notification with the full one from injected MonitorNotifications.
|
||||||
// Second startForeground() with the same ID updates the notification in place and is
|
// Failure here is non-fatal — the service is already foreground from the early call.
|
||||||
// preferred over notify() for robust foreground state on OEM variants.
|
promoteToForeground(notifications.getStartupNotification())
|
||||||
val notification = notifications.getStartupNotification()
|
|
||||||
if (hasApiLevel(29)) {
|
|
||||||
startForeground(
|
|
||||||
MonitorNotifications.NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(MonitorNotifications.NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
log(TAG, VERBOSE) { "onStartCommand(intent=$intent, flags=$flags, startId=$startId)" }
|
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
|
val forceStart = intent?.getBooleanExtra(EXTRA_FORCE_START, false) ?: false
|
||||||
|
|
||||||
if (monitoringJob?.isActive == true && !forceStart) {
|
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.PodDevice
|
||||||
import eu.darken.capod.pods.core.SinglePodDevice
|
import eu.darken.capod.pods.core.SinglePodDevice
|
||||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ class MonitorNotifications @Inject constructor(
|
|||||||
private val notificationViewFactory: MonitorNotificationViewFactory
|
private val notificationViewFactory: MonitorNotificationViewFactory
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val builderLock = Mutex()
|
private val openPi: PendingIntent
|
||||||
private val builder: NotificationCompat.Builder
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
@@ -44,35 +41,32 @@ class MonitorNotifications @Inject constructor(
|
|||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_LOW
|
||||||
).run { notificationManager.createNotificationChannel(this) }
|
).run { notificationManager.createNotificationChannel(this) }
|
||||||
|
|
||||||
val openIntent = Intent(context, MainActivity::class.java)
|
openPi = PendingIntent.getActivity(
|
||||||
val openPi = PendingIntent.getActivity(
|
|
||||||
context,
|
context,
|
||||||
PENDING_INTENT_REQUEST_CODE,
|
PENDING_INTENT_REQUEST_CODE,
|
||||||
openIntent,
|
Intent(context, MainActivity::class.java),
|
||||||
PendingIntentCompat.FLAG_IMMUTABLE
|
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) {
|
if (device == null) {
|
||||||
return builder.apply {
|
return baseBuilder(channelId).apply {
|
||||||
setCustomContentView(null)
|
|
||||||
setStyle(NotificationCompat.BigTextStyle())
|
setStyle(NotificationCompat.BigTextStyle())
|
||||||
setContentTitle(context.getString(R.string.pods_none_label_short))
|
setContentTitle(context.getString(R.string.pods_none_label_short))
|
||||||
setSubText(context.getString(R.string.app_name))
|
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
|
// 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
|
// Some options are omitted here, as they will conflict with other options
|
||||||
@@ -135,28 +129,20 @@ class MonitorNotifications @Inject constructor(
|
|||||||
|
|
||||||
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
setStyle(NotificationCompat.DecoratedCustomViewStyle())
|
||||||
setCustomBigContentView(notificationViewFactory.createContentView(device))
|
setCustomBigContentView(notificationViewFactory.createContentView(device))
|
||||||
setSmallIcon(device.iconRes)
|
|
||||||
setContentTitle("$batteryText ~ $stateText")
|
setContentTitle("$batteryText ~ $stateText")
|
||||||
setSubText(null)
|
setSubText(null)
|
||||||
log(TAG, VERBOSE) { "updatingNotification(): $device" }
|
log(TAG, VERBOSE) { "updatingNotification(): $device" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getNotification(podDevice: PodDevice?): Notification = builderLock.withLock {
|
fun getNotification(podDevice: PodDevice?): Notification =
|
||||||
getBuilder(podDevice).apply {
|
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID).build()
|
||||||
setChannelId(NOTIFICATION_CHANNEL_ID)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getNotificationConnected(podDevice: PodDevice?): Notification = builderLock.withLock {
|
fun getNotificationConnected(podDevice: PodDevice?): Notification =
|
||||||
getBuilder(podDevice).apply {
|
getBuilder(podDevice, NOTIFICATION_CHANNEL_ID_CONNECTED).build()
|
||||||
setChannelId(NOTIFICATION_CHANNEL_ID_CONNECTED)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getStartupNotification(): Notification = getBuilder(null).apply {
|
fun getStartupNotification(): Notification =
|
||||||
setChannelId(NOTIFICATION_CHANNEL_ID)
|
getBuilder(null, NOTIFICATION_CHANNEL_ID).build()
|
||||||
}.build()
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val TAG = logTag("Monitor", "Notifications")
|
val TAG = logTag("Monitor", "Notifications")
|
||||||
@@ -184,13 +170,13 @@ class MonitorNotifications @Inject constructor(
|
|||||||
Intent(context, MainActivity::class.java),
|
Intent(context, MainActivity::class.java),
|
||||||
PendingIntentCompat.FLAG_IMMUTABLE,
|
PendingIntentCompat.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
|
return NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID).apply {
|
||||||
.setContentIntent(openPi)
|
setContentIntent(openPi)
|
||||||
.setSmallIcon(R.drawable.devic_earbuds_generic_both)
|
setSmallIcon(R.drawable.device_earbuds_generic_both)
|
||||||
.setContentTitle(context.getString(R.string.app_name))
|
setContentTitle(context.getString(R.string.app_name))
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setOngoing(true)
|
setOngoing(true)
|
||||||
.build()
|
}.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ interface DualPodDevice : PodDevice {
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
val leftPodIcon: Int
|
val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_gen1_left
|
get() = R.drawable.device_airpods_gen1_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
val rightPodIcon: Int
|
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
|
@get:DrawableRes
|
||||||
val caseIcon: Int
|
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)
|
@JsonClass(generateAdapter = false)
|
||||||
enum class Model(
|
enum class Model(
|
||||||
val label: String,
|
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(
|
@Json(name = "airpods.gen1") AIRPODS_GEN1(
|
||||||
label = "AirPods (Gen 1)",
|
label = "AirPods (Gen 1)",
|
||||||
iconRes = R.drawable.devic_airpods_gen1_both,
|
iconRes = R.drawable.device_airpods_gen1_both,
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.gen2") AIRPODS_GEN2(
|
@Json(name = "airpods.gen2") AIRPODS_GEN2(
|
||||||
"AirPods (Gen 2)",
|
"AirPods (Gen 2)",
|
||||||
R.drawable.devic_airpods_gen2_both,
|
R.drawable.device_airpods_gen1_both,
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.gen3") AIRPODS_GEN3(
|
@Json(name = "airpods.gen3") AIRPODS_GEN3(
|
||||||
"AirPods (Gen 3)",
|
"AirPods (Gen 3)",
|
||||||
R.drawable.devic_airpods_gen2_both,
|
R.drawable.device_airpods_gen3_both,
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.gen4") AIRPODS_GEN4(
|
@Json(name = "airpods.gen4") AIRPODS_GEN4(
|
||||||
"AirPods (Gen 4)",
|
"AirPods (Gen 4)",
|
||||||
R.drawable.devic_airpods_gen2_both,
|
R.drawable.device_airpods_gen3_both,
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
@Json(name = "airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
||||||
"AirPods (Gen 4 ANC)",
|
"AirPods (Gen 4 ANC)",
|
||||||
R.drawable.devic_airpods_gen2_both,
|
R.drawable.device_airpods_gen4anc_both,
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.pro") AIRPODS_PRO(
|
@Json(name = "airpods.pro") AIRPODS_PRO(
|
||||||
"AirPods Pro",
|
"AirPods Pro",
|
||||||
R.drawable.devic_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.pro2") AIRPODS_PRO2(
|
@Json(name = "airpods.pro2") AIRPODS_PRO2(
|
||||||
"AirPods Pro 2",
|
"AirPods Pro 2",
|
||||||
R.drawable.devic_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
@Json(name = "airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
||||||
"AirPods Pro 2 USB-C",
|
"AirPods Pro 2 USB-C",
|
||||||
R.drawable.devic_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.pro3") AIRPODS_PRO3(
|
@Json(name = "airpods.pro3") AIRPODS_PRO3(
|
||||||
"AirPods Pro 3",
|
"AirPods Pro 3",
|
||||||
R.drawable.devic_airpods_pro2_both
|
R.drawable.device_airpods_pro2_both
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.max") AIRPODS_MAX(
|
@Json(name = "airpods.max") AIRPODS_MAX(
|
||||||
"AirPods Max",
|
"AirPods Max",
|
||||||
R.drawable.devic_headphones_generic
|
R.drawable.device_airpods_max
|
||||||
),
|
),
|
||||||
@Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
|
@Json(name = "airpods.max.usbc") AIRPODS_MAX_USBC(
|
||||||
"AirPods Max USB-C",
|
"AirPods Max USB-C",
|
||||||
R.drawable.devic_headphones_generic
|
R.drawable.device_airpods_max
|
||||||
),
|
),
|
||||||
@Json(name = "beats.flex") BEATS_FLEX(
|
@Json(name = "beats.flex") BEATS_FLEX(
|
||||||
"Beats Flex"
|
"Beats Flex",
|
||||||
|
R.drawable.device_beats_earbuds,
|
||||||
),
|
),
|
||||||
@Json(name = "beats.solo.3") BEATS_SOLO_3(
|
@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(
|
@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(
|
@Json(name = "beats.x") BEATS_X(
|
||||||
"Beats X"
|
"Beats X",
|
||||||
|
R.drawable.device_beats_x,
|
||||||
),
|
),
|
||||||
@Json(name = "beats.powerbeats.3") POWERBEATS_3(
|
@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(
|
@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(
|
@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(
|
@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(
|
@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(
|
@Json(name = "fakes.tws.i99999") FAKE_AIRPODS_GEN1(
|
||||||
"AirPods (Gen 1)? \uD83C\uDFAD",
|
"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(
|
@Json(name = "fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
|
||||||
"AirPods (Gen 2)? \uD83C\uDFAD",
|
"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(
|
@Json(name = "fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
|
||||||
"AirPods (Gen 3)? \uD83C\uDFAD",
|
"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(
|
@Json(name = "fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
|
||||||
"AirPods Pro? \uD83C\uDFAD",
|
"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(
|
@Json(name = "fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
|
||||||
"AirPods Pro2? \uD83C\uDFAD",
|
"AirPods Pro2? \uD83C\uDFAD",
|
||||||
R.drawable.devic_airpods_pro2_both,
|
R.drawable.device_airpods_pro2_both,
|
||||||
),
|
),
|
||||||
@Json(name = "unknown") UNKNOWN(
|
@Json(name = "unknown") UNKNOWN(
|
||||||
"Unknown"
|
"Unknown"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -29,6 +31,18 @@ data class AirPodsGen3(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
|
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?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -29,6 +31,18 @@ data class AirPodsGen4(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4
|
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?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.airpods
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -29,6 +31,18 @@ data class AirPodsGen4Anc(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN4_ANC
|
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?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ data class AirPodsPro(
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val iconRes: Int
|
override val iconRes: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_both
|
get() = R.drawable.device_airpods_pro2_both
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val caseIcon: Int
|
override val caseIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_case
|
get() = R.drawable.device_airpods_pro2_case
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_left
|
get() = R.drawable.device_airpods_pro2_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val rightPodIcon: Int
|
override val rightPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_right
|
get() = R.drawable.device_airpods_pro2_right
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ data class AirPodsPro2(
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val iconRes: Int
|
override val iconRes: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_both
|
get() = R.drawable.device_airpods_pro2_both
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val caseIcon: Int
|
override val caseIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_case
|
get() = R.drawable.device_airpods_pro2_case
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_left
|
get() = R.drawable.device_airpods_pro2_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val rightPodIcon: Int
|
override val rightPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_right
|
get() = R.drawable.device_airpods_pro2_right
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ data class AirPodsPro2Usbc(
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val iconRes: Int
|
override val iconRes: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_both
|
get() = R.drawable.device_airpods_pro2_both
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val caseIcon: Int
|
override val caseIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_case
|
get() = R.drawable.device_airpods_pro2_case
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_left
|
get() = R.drawable.device_airpods_pro2_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val rightPodIcon: Int
|
override val rightPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_right
|
get() = R.drawable.device_airpods_pro2_right
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ data class AirPodsPro3(
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val iconRes: Int
|
override val iconRes: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_both
|
get() = R.drawable.device_airpods_pro2_both
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val caseIcon: Int
|
override val caseIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_case
|
get() = R.drawable.device_airpods_pro2_case
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_left
|
get() = R.drawable.device_airpods_pro2_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val rightPodIcon: Int
|
override val rightPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_right
|
get() = R.drawable.device_airpods_pro2_right
|
||||||
|
|
||||||
override val batteryCasePercent: Float?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.beats
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -29,6 +31,18 @@ data class BeatsFitPro(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.BEATS_FIT_PRO
|
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?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.beats
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -29,6 +31,18 @@ data class PowerBeatsPro(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
|
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?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.beats
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.PodDevice
|
import eu.darken.capod.pods.core.PodDevice
|
||||||
@@ -29,6 +31,18 @@ data class PowerBeatsPro2(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO2
|
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?
|
override val batteryCasePercent: Float?
|
||||||
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
get() = super.batteryCasePercent ?: cachedBatteryPercentage
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.darken.capod.pods.core.apple.misc
|
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.bluetooth.BleScanResult
|
||||||
import eu.darken.capod.common.debug.logging.logTag
|
import eu.darken.capod.common.debug.logging.logTag
|
||||||
import eu.darken.capod.pods.core.HasCase
|
import eu.darken.capod.pods.core.HasCase
|
||||||
@@ -35,6 +37,18 @@ data class FakeAirPodsGen3(
|
|||||||
|
|
||||||
override val model: PodDevice.Model = PodDevice.Model.FAKE_AIRPODS_GEN3
|
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
|
override val rssi: Int
|
||||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||||
|
|
||||||
|
|||||||
@@ -39,19 +39,19 @@ data class FakeAirPodsPro(
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val iconRes: Int
|
override val iconRes: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_both
|
get() = R.drawable.device_airpods_pro2_both
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val caseIcon: Int
|
override val caseIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_case
|
get() = R.drawable.device_airpods_pro2_case
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_left
|
get() = R.drawable.device_airpods_pro2_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val rightPodIcon: Int
|
override val rightPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_right
|
get() = R.drawable.device_airpods_pro2_right
|
||||||
|
|
||||||
override val rssi: Int
|
override val rssi: Int
|
||||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||||
|
|||||||
@@ -41,19 +41,19 @@ data class FakeAirPodsPro2(
|
|||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val iconRes: Int
|
override val iconRes: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_both
|
get() = R.drawable.device_airpods_pro2_both
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val caseIcon: Int
|
override val caseIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_case
|
get() = R.drawable.device_airpods_pro2_case
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val leftPodIcon: Int
|
override val leftPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_left
|
get() = R.drawable.device_airpods_pro2_left
|
||||||
|
|
||||||
@get:DrawableRes
|
@get:DrawableRes
|
||||||
override val rightPodIcon: Int
|
override val rightPodIcon: Int
|
||||||
get() = R.drawable.devic_airpods_pro2_right
|
get() = R.drawable.device_airpods_pro2_right
|
||||||
|
|
||||||
override val rssi: Int
|
override val rssi: Int
|
||||||
get() = rssiAverage ?: super<DualApplePods>.rssi
|
get() = rssiAverage ?: super<DualApplePods>.rssi
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class PlayPause @Inject constructor(
|
|||||||
fun monitor() = combine(
|
fun monitor() = combine(
|
||||||
reactionSettings.autoPlay.flow,
|
reactionSettings.autoPlay.flow,
|
||||||
reactionSettings.autoPause.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 }
|
) { play, pause, _ -> play || pause }
|
||||||
.flatMapLatest { if (it) bluetoothManager.connectedDevices else emptyFlow() }
|
.flatMapLatest { if (it) bluetoothManager.connectedDevices else emptyFlow() }
|
||||||
.flatMapLatest {
|
.flatMapLatest {
|
||||||
@@ -57,46 +57,184 @@ class PlayPause @Inject constructor(
|
|||||||
.onEach { (previous, current) ->
|
.onEach { (previous, current) ->
|
||||||
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
|
log(TAG, VERBOSE) { "Checking\nprevious=$previous\ncurrent=$current" }
|
||||||
|
|
||||||
val previousWorn: Boolean?
|
// Convert to EarDetectionState based on device capabilities
|
||||||
val currentWorn: Boolean?
|
val prevState: EarDetectionState
|
||||||
|
val currState: EarDetectionState
|
||||||
|
|
||||||
when {
|
when {
|
||||||
reactionSettings.onePodMode.value && previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
|
previous is HasEarDetectionDual && current is HasEarDetectionDual -> {
|
||||||
previousWorn = previous.isEitherPodInEar
|
// Dual pod devices (AirPods, AirPods Pro, etc.)
|
||||||
currentWorn = current.isEitherPodInEar
|
log(TAG, VERBOSE) {
|
||||||
log(TAG, VERBOSE) { "previous: left=${previous.isLeftPodInEar}, right=${previous.isRightPodInEar}" }
|
"Dual-pod device: left=${current.isLeftPodInEar}, right=${current.isRightPodInEar}"
|
||||||
log(TAG, VERBOSE) { "current: left${current.isLeftPodInEar}, right=${current.isRightPodInEar}" }
|
}
|
||||||
|
prevState = EarDetectionState.fromDualPod(previous)
|
||||||
|
currState = EarDetectionState.fromDualPod(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
previous is HasEarDetection && current is HasEarDetection -> {
|
previous is HasEarDetection && current is HasEarDetection -> {
|
||||||
previousWorn = previous.isBeingWorn
|
// Single pod devices (AirPods Max, etc.)
|
||||||
currentWorn = current.isBeingWorn
|
log(TAG, VERBOSE) { "Single-pod device: worn=${current.isBeingWorn}" }
|
||||||
log(TAG, VERBOSE) { "prev.isBeingWorn=${previousWorn}, cur.isBeingWorn=${currentWorn}" }
|
prevState = EarDetectionState.fromSinglePod(previous)
|
||||||
|
currState = EarDetectionState.fromSinglePod(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
log(TAG, VERBOSE) { "Current devices don't support ear detection." }
|
log(TAG, VERBOSE) { "Device doesn't support ear detection: $current" }
|
||||||
previousWorn = null
|
return@onEach
|
||||||
currentWorn = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (previousWorn == false && currentWorn == true && !mediaControl.isPlaying) {
|
// Evaluate what action to take
|
||||||
if (reactionSettings.autoPlay.value) {
|
val decision = evaluatePlayPauseAction(
|
||||||
log(TAG) { "autoPlay is triggered, sendPlay()" }
|
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()
|
mediaControl.sendPlay()
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
decision.shouldPlay && !reactionSettings.autoPlay.value -> {
|
||||||
log(TAG, VERBOSE) { "autoPlay is disabled" }
|
log(TAG, VERBOSE) { "autoPlay is disabled" }
|
||||||
}
|
}
|
||||||
} else if (previousWorn == true && currentWorn == false && mediaControl.isPlaying) {
|
|
||||||
if (reactionSettings.autoPause.value) {
|
decision.shouldPause && reactionSettings.autoPause.value -> {
|
||||||
log(TAG) { "autoPause is triggered, sendPause()" }
|
log(TAG) { "autoPause is triggered, sendPause() - ${decision.reason}" }
|
||||||
mediaControl.sendPause()
|
mediaControl.sendPause()
|
||||||
} else {
|
}
|
||||||
log(TAG) { "autoPause is disabled" }
|
|
||||||
|
decision.shouldPause && !reactionSettings.autoPause.value -> {
|
||||||
|
log(TAG, VERBOSE) { "autoPause is disabled" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.setupCommonEventHandlers(TAG) { "monitor" }
|
.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 {
|
companion object {
|
||||||
private val TAG = logTag("Reaction", "PlayPause")
|
private val TAG = logTag("Reaction", "PlayPause")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.appcompat.view.ContextThemeWrapper
|
import androidx.appcompat.view.ContextThemeWrapper
|
||||||
import androidx.core.view.isInvisible
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import eu.darken.capod.R
|
import eu.darken.capod.R
|
||||||
import eu.darken.capod.common.debug.DebugSettings
|
|
||||||
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
|
import eu.darken.capod.databinding.PopupNotificationDualPodsBinding
|
||||||
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
|
import eu.darken.capod.databinding.PopupNotificationSinglePodsBinding
|
||||||
import eu.darken.capod.pods.core.DualPodDevice
|
import eu.darken.capod.pods.core.DualPodDevice
|
||||||
@@ -24,7 +23,6 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
class PopUpPodViewFactory @Inject constructor(
|
class PopUpPodViewFactory @Inject constructor(
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
private val debugSettings: DebugSettings,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
|
private val context = ContextThemeWrapper(appContext, R.style.AppTheme)
|
||||||
@@ -39,14 +37,15 @@ class PopUpPodViewFactory @Inject constructor(
|
|||||||
|
|
||||||
private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View =
|
private fun createDualPods(parent: ViewGroup, device: DualPodDevice): View =
|
||||||
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
|
PopupNotificationDualPodsBinding.inflate(layoutInflater, parent, false).apply {
|
||||||
podIcon.setImageResource(device.iconRes)
|
|
||||||
podLabel.text = device.getLabel(context)
|
podLabel.text = device.getLabel(context)
|
||||||
signal.text = device.getSignalQuality(context)
|
signal.text = device.getSignalQuality(context)
|
||||||
signal.isInvisible = debugSettings.isDebugModeEnabled.value
|
signalIcon.isGone = signal.text.isNullOrBlank()
|
||||||
|
signal.isGone = signal.text.isNullOrBlank()
|
||||||
|
|
||||||
// Left
|
// Left
|
||||||
val leftPercent = device.batteryLeftPodPercent
|
val leftPercent = device.batteryLeftPodPercent
|
||||||
podLeftIcon.setImageResource(device.leftPodIcon)
|
podLeftIcon.setImageResource(device.leftPodIcon)
|
||||||
|
podLeftIcon.imageTintList = null
|
||||||
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
|
podLeftBatteryIcon.setImageResource(getBatteryDrawable(leftPercent))
|
||||||
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
|
podLeftBatteryLabel.text = formatBatteryPercent(context, leftPercent)
|
||||||
|
|
||||||
@@ -55,6 +54,7 @@ class PopUpPodViewFactory @Inject constructor(
|
|||||||
(device as? HasCase)?.let { case ->
|
(device as? HasCase)?.let { case ->
|
||||||
val casePercent = case.batteryCasePercent
|
val casePercent = case.batteryCasePercent
|
||||||
podCaseIcon.setImageResource(case.caseIcon)
|
podCaseIcon.setImageResource(case.caseIcon)
|
||||||
|
podCaseIcon.imageTintList = null
|
||||||
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
|
podCaseBatteryIcon.setImageResource(getBatteryDrawable(casePercent))
|
||||||
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
|
podCaseBatteryLabel.text = formatBatteryPercent(context, casePercent)
|
||||||
}
|
}
|
||||||
@@ -62,6 +62,7 @@ class PopUpPodViewFactory @Inject constructor(
|
|||||||
// Right
|
// Right
|
||||||
val rightPercent = device.batteryRightPodPercent
|
val rightPercent = device.batteryRightPodPercent
|
||||||
podRightIcon.setImageResource(device.rightPodIcon)
|
podRightIcon.setImageResource(device.rightPodIcon)
|
||||||
|
podRightIcon.imageTintList = null
|
||||||
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
|
podRightBatteryIcon.setImageResource(getBatteryDrawable(rightPercent))
|
||||||
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
|
podRightBatteryLabel.text = formatBatteryPercent(context, rightPercent)
|
||||||
}.root
|
}.root
|
||||||
@@ -69,9 +70,11 @@ class PopUpPodViewFactory @Inject constructor(
|
|||||||
private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View =
|
private fun createSinglePod(parent: ViewGroup, device: SinglePodDevice): View =
|
||||||
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
|
PopupNotificationSinglePodsBinding.inflate(layoutInflater, parent, false).apply {
|
||||||
headphonesIcon.setImageResource(device.iconRes)
|
headphonesIcon.setImageResource(device.iconRes)
|
||||||
|
headphonesIcon.imageTintList = null
|
||||||
headphonesLabel.text = device.getLabel(context)
|
headphonesLabel.text = device.getLabel(context)
|
||||||
signal.text = device.getSignalQuality(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
|
val headsetPercent = device.batteryHeadsetPercent
|
||||||
headphonesBatteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
|
headphonesBatteryIcon.setImageResource(getBatteryDrawable(headsetPercent))
|
||||||
|
|||||||
@@ -32,13 +32,15 @@ class PopUpWindow @Inject constructor(
|
|||||||
private val windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
|
private val windowManager = context.getSystemService(WINDOW_SERVICE) as WindowManager
|
||||||
private val layoutParams = WindowManager.LayoutParams(
|
private val layoutParams = WindowManager.LayoutParams(
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.WRAP_CONTENT, // Display it on top of other application windows
|
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Don't let it grab the input focus
|
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, // Make the underlying application window visible
|
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||||
|
|
||||||
PixelFormat.TRANSLUCENT
|
PixelFormat.TRANSLUCENT
|
||||||
).apply {
|
).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 {
|
private val popUpView: View = layoutInflater.inflate(R.layout.popup_window_container_layout, null).apply {
|
||||||
findViewById<View>(R.id.close_action).setOnClickListener { close() }
|
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 |