Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac389d2fd | ||
|
|
95fddf5a55 | ||
|
|
206dd67d98 | ||
|
|
817b413eb1 | ||
|
|
03f27c4dda | ||
|
|
42b151034a | ||
|
|
7a774b4091 | ||
|
|
2b8b221d1b | ||
|
|
dce8ae9041 | ||
|
|
7c6f36cdbb | ||
|
|
71d8af5128 | ||
|
|
4172cf1a0a | ||
|
|
bcd9a87174 | ||
|
|
fed735b5a2 | ||
|
|
97555396c0 | ||
|
|
d42bc0d00a | ||
|
|
c33875c8f8 | ||
|
|
a70dea7bd5 | ||
|
|
c07d73cc92 | ||
|
|
962f5ff2c4 | ||
|
|
c7fa32d393 | ||
|
|
3c415fb953 | ||
|
|
75f43c3e3d | ||
|
|
07e398cf79 | ||
|
|
cc78020eda | ||
|
|
1faf418798 | ||
|
|
ef1b811920 | ||
|
|
fa89017521 | ||
|
|
88c85cc871 | ||
|
|
86e61bcc1d | ||
|
|
9ddab16645 | ||
|
|
0a5d0792d6 | ||
|
|
d302caf3a4 | ||
|
|
ea64329f2f | ||
|
|
44d9de2498 | ||
|
|
7991731d4b | ||
|
|
776207eac7 | ||
|
|
296ae063e7 | ||
|
|
4310e7b70d | ||
|
|
0d6b8baf64 | ||
|
|
b1e7138e62 | ||
|
|
16c949b414 | ||
|
|
4a40f782f7 | ||
|
|
e982e75155 | ||
|
|
13139923b2 | ||
|
|
bf2213e4e6 | ||
|
|
bbe734bb14 | ||
|
|
7733a4042e | ||
|
|
5a8bd5a8ac | ||
|
|
b13abee9e0 | ||
|
|
8d61e3245a | ||
|
|
f9eadefc2f | ||
|
|
303958f2b2 | ||
|
|
253cc5cad5 | ||
|
|
3f25122fb6 | ||
|
|
a1a7eca5cb | ||
|
|
588d13c3a8 | ||
|
|
a1dc9a6519 | ||
|
|
3e2e410115 | ||
|
|
08a7af4476 | ||
|
|
dc8544794e | ||
|
|
aadca6019e | ||
|
|
1e3d4f5188 | ||
|
|
83256224d4 | ||
|
|
cae1b06d05 | ||
|
|
db90c72584 | ||
|
|
96821e27e3 | ||
|
|
89ab0f9d63 | ||
|
|
4fb612f602 | ||
|
|
0234cbdc10 | ||
|
|
167933862a | ||
|
|
9bf0911f39 | ||
|
|
4acd1bb569 | ||
|
|
726ca3eddd | ||
|
|
89dd28bd62 | ||
|
|
e9d4203d58 | ||
|
|
327d6fd40a | ||
|
|
37d56be59a | ||
|
|
40313843d1 | ||
|
|
60afbf1dd2 | ||
|
|
8b40f1576d | ||
|
|
14ef6676b9 | ||
|
|
9dca6d302c | ||
|
|
4ea466452b | ||
|
|
d0971bb62d | ||
|
|
fa0530499e | ||
|
|
d5b5fb8533 | ||
|
|
ad0b3807a8 | ||
|
|
b672f820b2 | ||
|
|
c45bfe597d | ||
|
|
a7f6440994 | ||
|
|
f6f72a9e66 | ||
|
|
a5ad7f4384 | ||
|
|
b82a09289c | ||
|
|
6705ade9ae | ||
|
|
df986cae6b | ||
|
|
734dae4836 | ||
|
|
966522cac4 | ||
|
|
cbb0e74cf1 | ||
|
|
406d97e288 | ||
|
|
36d8f987fa | ||
|
|
00d62caa03 | ||
|
|
f4a251d24f | ||
|
|
0a6da250ee | ||
|
|
fea63a092f | ||
|
|
d8694f1b97 | ||
|
|
497a4a1481 | ||
|
|
4cf9b3d9b1 | ||
|
|
0db0c64b6e | ||
|
|
f262b67e83 | ||
|
|
b3cacf0630 | ||
|
|
2e0cee654a | ||
|
|
a0cb34eb87 | ||
|
|
106b0c3987 | ||
|
|
a00f5c4896 | ||
|
|
f6ec5c7a8e | ||
|
|
2f49559600 | ||
|
|
57803988d4 | ||
|
|
bcde51ad56 | ||
|
|
cc522bb0b7 | ||
|
|
e4c2a72fad | ||
|
|
0627a32df7 | ||
|
|
5df2dace77 | ||
|
|
b428c54dd2 | ||
|
|
c0735ed65e | ||
|
|
89dbbee5ef | ||
|
|
fa8c13a6ec | ||
|
|
2912d24c32 | ||
|
|
f3ee10ce0c | ||
|
|
5805ca5471 | ||
|
|
10743b36a4 | ||
|
|
5da51873f6 | ||
|
|
8e41706239 | ||
|
|
8f0c4c4145 | ||
|
|
43193eaa8f | ||
|
|
92bee5e911 | ||
|
|
e13bb2af24 | ||
|
|
4b50be2e73 | ||
|
|
3573999d36 | ||
|
|
a071ee072e | ||
|
|
55c6924eb3 | ||
|
|
8193d97453 | ||
|
|
660b840278 | ||
|
|
614459d9f7 | ||
|
|
aafa6ae593 | ||
|
|
95b12c7225 | ||
|
|
bfbabfe8fc | ||
|
|
d8e4dcf228 | ||
|
|
7feb42211e | ||
|
|
27262719b1 | ||
|
|
a29706f2e7 | ||
|
|
feb104f2eb | ||
|
|
c6f0547943 | ||
|
|
ee1f5d2aa6 | ||
|
|
878b0f64cc | ||
|
|
298ce1cd1f | ||
|
|
2b6ce5c7ce | ||
|
|
04e99562e3 | ||
|
|
9439b2d7d0 | ||
|
|
96b93513dd | ||
|
|
d114108f19 | ||
|
|
67d88ae7b9 | ||
|
|
bfe9723457 | ||
|
|
55b497190f | ||
|
|
65c630585a | ||
|
|
560c6efab0 | ||
|
|
d4d9289034 | ||
|
|
41da785965 | ||
|
|
cb30a3de69 | ||
|
|
4aac815b2e | ||
|
|
d915b0ce45 | ||
|
|
746b0b7325 | ||
|
|
de370751a0 | ||
|
|
e1948a823f | ||
|
|
738b99ff9e | ||
|
|
7959b27d38 | ||
|
|
83c33eeea3 | ||
|
|
b168eb7c04 | ||
|
|
f4b8391fa3 | ||
|
|
d1f67bb64d | ||
|
|
7d02ac8b75 | ||
|
|
6ff59be6ab | ||
|
|
dbeaa9cf5e | ||
|
|
7bfb8c8669 | ||
|
|
3fbabfa0ed | ||
|
|
0fd7568536 | ||
|
|
02618dbcb9 | ||
|
|
dc9514dcfa | ||
|
|
74f557524f | ||
|
|
324112ca8b | ||
|
|
15d6721705 | ||
|
|
1f9ef552c1 | ||
|
|
608d72b856 | ||
|
|
08eef32e59 | ||
|
|
894712c38d | ||
|
|
ef1b2e1be7 | ||
|
|
93a1ff7485 | ||
|
|
8a2dae4173 | ||
|
|
a1a0546b74 | ||
|
|
c616f0203a | ||
|
|
08ac6fe117 | ||
|
|
46c2e92684 | ||
|
|
b2a95adb87 | ||
|
|
9d629c392b | ||
|
|
0eea09542f | ||
|
|
5c72a18f06 | ||
|
|
5cc1f518c7 | ||
|
|
3f393fd56f | ||
|
|
703d8d69e8 | ||
|
|
89e5bbd2bb | ||
|
|
f2344169af | ||
|
|
dc287a7fa2 | ||
|
|
2fcd586e67 | ||
|
|
184e4b70c8 | ||
|
|
30b185e86a | ||
|
|
c888d41d51 | ||
|
|
b0ffcf2b54 | ||
|
|
feab4b4e96 | ||
|
|
88e871c65e | ||
|
|
dc7018f394 | ||
|
|
e920093fa8 | ||
|
|
b71905c8c6 | ||
|
|
0a5acc012e | ||
|
|
8d673d855b | ||
|
|
6751951308 | ||
|
|
0f2582f638 | ||
|
|
bb07b95a9f | ||
|
|
9796320b58 | ||
|
|
3cea17d178 | ||
|
|
795963f178 | ||
|
|
ca33b5d01c | ||
|
|
aaa8babd09 | ||
|
|
636e40d88a | ||
|
|
1481bd9c52 | ||
|
|
b57bfb5324 | ||
|
|
e2e0d3f8bb | ||
|
|
d90c39f50a | ||
|
|
8a6c358257 | ||
|
|
de01448f1b | ||
|
|
f414a3c2c8 | ||
|
|
ea45bf994b | ||
|
|
f8aa0a259a | ||
|
|
0cf28b875c | ||
|
|
1c4b472ce0 | ||
|
|
e54fb08fc9 | ||
|
|
0afe857420 | ||
|
|
4b627bffdd | ||
|
|
fed652f9d3 | ||
|
|
ef006dc695 | ||
|
|
55c25fe967 | ||
|
|
46302b7d60 | ||
|
|
2c1fad6f8c | ||
|
|
75d0df19c3 | ||
|
|
456dda897e | ||
|
|
405cecf2e9 | ||
|
|
8fbd1701b1 | ||
|
|
6945f6755e | ||
|
|
a26605c0ab | ||
|
|
068288a0e7 | ||
|
|
dc61607028 | ||
|
|
ac8488200d | ||
|
|
c7077f2a57 | ||
|
|
6efdad5e53 | ||
|
|
c04c35b5cd | ||
|
|
75c11a92d9 | ||
|
|
99ad0d03d7 | ||
|
|
e42cb767c9 | ||
|
|
19d3afb153 | ||
|
|
32bef88a75 | ||
|
|
18571622d8 | ||
|
|
86a1f968ec | ||
|
|
325e115752 | ||
|
|
08ce09ad61 | ||
|
|
d78585fda1 | ||
|
|
4e71689f8e | ||
|
|
e8f51f4947 | ||
|
|
2281347728 | ||
|
|
6d08fbd871 | ||
|
|
1322cbaf39 | ||
|
|
db6f1b8e82 | ||
|
|
0177975bc8 | ||
|
|
5f30e7b014 | ||
|
|
c42406f353 | ||
|
|
e8d025df47 | ||
|
|
c6d8d8e787 | ||
|
|
77b84212db | ||
|
|
b12e9651c1 | ||
|
|
a5c46967b7 | ||
|
|
30a4b65a08 | ||
|
|
88ff5b0606 | ||
|
|
aa15884897 | ||
|
|
84e1145459 | ||
|
|
53de4a2512 | ||
|
|
ed46868d9b | ||
|
|
2bf4753125 | ||
|
|
879b428d1f | ||
|
|
35bd239c2f | ||
|
|
815a75ad6d | ||
|
|
9b7734bec8 | ||
|
|
7ffa203221 | ||
|
|
9f982875fe | ||
|
|
5c799fed38 | ||
|
|
249e6cefed | ||
|
|
3c9603175e | ||
|
|
10dfb46a96 | ||
|
|
e587ea97e1 | ||
|
|
daa6f24289 | ||
|
|
76955669f5 | ||
|
|
cd7b13c88e | ||
|
|
3d642c17c7 | ||
|
|
cce3b50b52 | ||
|
|
88e1885b2d | ||
|
|
a84d36f17a | ||
|
|
8b269f4734 | ||
|
|
3042e01e75 | ||
|
|
71397a0777 | ||
|
|
cae05ecdc0 | ||
|
|
73be554eb2 | ||
|
|
2812613bca | ||
|
|
014942338b | ||
|
|
3c81980385 | ||
|
|
c029a0894a | ||
|
|
eba8ac1127 | ||
|
|
1086e7c8fa | ||
|
|
470ec2f093 | ||
|
|
341e62d1d8 | ||
|
|
2891a170db | ||
|
|
3855cc4781 | ||
|
|
f1a6901fbc | ||
|
|
b848c5d910 | ||
|
|
4f1e6dcbfa | ||
|
|
10e8568aba | ||
|
|
724dcf25c7 | ||
|
|
dd1fd0242f | ||
|
|
6faad9fdd5 | ||
|
|
3869258e0e | ||
|
|
7d9eb20ff0 | ||
|
|
8fd840bd8d | ||
|
|
5947bc1e63 | ||
|
|
e13b0618db | ||
|
|
9100dac290 | ||
|
|
f1fe94c487 | ||
|
|
5fe59d350c | ||
|
|
909c3549d6 | ||
|
|
1514721498 | ||
|
|
4000e0a9e9 | ||
|
|
02d6cb546e | ||
|
|
59f323ec06 | ||
|
|
0d0bfd3d66 | ||
|
|
b3f2088a1d | ||
|
|
01a41c01c8 | ||
|
|
62b1835444 | ||
|
|
1cd3d6f856 | ||
|
|
8912e88406 | ||
|
|
0bdc51ff6c | ||
|
|
361caf5ea3 | ||
|
|
d53535e56c | ||
|
|
49a064acc0 | ||
|
|
6d4389c7ab | ||
|
|
9fe463c4e7 | ||
|
|
a3761534bb | ||
|
|
ceed61def9 | ||
|
|
fd0ef64a42 | ||
|
|
0d29263f0a | ||
|
|
28d99a3d87 | ||
|
|
a526db6cd1 | ||
|
|
000ca85479 | ||
|
|
e76e9ddcf7 | ||
|
|
8069f54d8f | ||
|
|
df85b0d1f3 | ||
|
|
4ac3d172d3 | ||
|
|
d099193ead | ||
|
|
bc8f2372b1 | ||
|
|
000bd1f3f4 | ||
|
|
5fa9ed17e1 | ||
|
|
e2b9118354 | ||
|
|
e1233cb1e4 | ||
|
|
3ca87d1afc | ||
|
|
8e98851e47 | ||
|
|
af5308a9f9 | ||
|
|
7d6b9cf578 | ||
|
|
b2df84c46b | ||
|
|
30d94bcfb5 | ||
|
|
af9e9a7571 | ||
|
|
7163598352 | ||
|
|
94ed58e13f | ||
|
|
59657c4f80 | ||
|
|
8abc8fe973 | ||
|
|
4fe9421884 | ||
|
|
04b820cb22 | ||
|
|
669bf1f7ce | ||
|
|
83916a6397 | ||
|
|
5a57608591 | ||
|
|
f58ecf303a | ||
|
|
61a7188ad2 | ||
|
|
fff2db63b0 | ||
|
|
fb6bc018d5 | ||
|
|
580a8365eb | ||
|
|
5721b9efb3 | ||
|
|
ed331088cc | ||
|
|
33ed2d0653 | ||
|
|
261e6a719a | ||
|
|
f9e38cbc45 | ||
|
|
cfa01d4195 | ||
|
|
e843f2adfe | ||
|
|
0af16d3348 | ||
|
|
8743e992d7 | ||
|
|
6f9fc061e3 | ||
|
|
239be1256b | ||
|
|
23f12884da | ||
|
|
75dfaa3013 | ||
|
|
179a51ea25 | ||
|
|
006823232d | ||
|
|
0838bbb3cc | ||
|
|
52cb769adc | ||
|
|
9c8b82d66a | ||
|
|
92d7940f56 | ||
|
|
4c2c8c6dda | ||
|
|
4a6305052d | ||
|
|
fd658d4e57 | ||
|
|
ddaf3e5657 | ||
|
|
a549077c13 | ||
|
|
246f571a86 | ||
|
|
a22d7936d4 | ||
|
|
74330db01c | ||
|
|
516f6c7f94 | ||
|
|
40086e061b | ||
|
|
c212c2e2d1 | ||
|
|
fb4b0e2914 | ||
|
|
d0fd6ecb5c | ||
|
|
39e1a5ee09 | ||
|
|
55320c4d98 | ||
|
|
9dc705c8cf | ||
|
|
be981b0899 | ||
|
|
dd3b845c7f | ||
|
|
c57abecc62 | ||
|
|
dfebae93b3 | ||
|
|
1af5eae8e7 | ||
|
|
25b8bbb1a5 | ||
|
|
2d5000e313 | ||
|
|
17534d5784 | ||
|
|
9620dab758 | ||
|
|
e318b651bb | ||
|
|
3ac815eee4 | ||
|
|
10cc17bb66 | ||
|
|
ca09d6adb8 | ||
|
|
af058609dd | ||
|
|
a4ab5b005e | ||
|
|
672a68cd1b | ||
|
|
24d7e4c415 | ||
|
|
1127d4baf7 | ||
|
|
dc550ab735 | ||
|
|
5380cf0660 | ||
|
|
7cc6b64ce2 | ||
|
|
b5ff4ec22b | ||
|
|
302dd6c344 | ||
|
|
62fb8babad | ||
|
|
fe44576b06 | ||
|
|
355eb8bffd | ||
|
|
7d197bf021 | ||
|
|
cf0c324d80 | ||
|
|
5a3bbe82a0 | ||
|
|
b6bd6497b2 | ||
|
|
0eeb486d88 | ||
|
|
1adeb6f7e1 | ||
|
|
bc0d6302d3 | ||
|
|
1341b4a08a | ||
|
|
c6b7f0e178 | ||
|
|
d3592457cf | ||
|
|
024850bf48 | ||
|
|
96d16d048c | ||
|
|
1b44d2d798 | ||
|
|
e4725233dd | ||
|
|
913e4af133 | ||
|
|
07d164ac74 | ||
|
|
01a7ae1930 | ||
|
|
c52a6c32b7 | ||
|
|
f21df41506 | ||
|
|
ff8a6a4cff | ||
|
|
a2d9ed7008 | ||
|
|
3994c9bd2e | ||
|
|
4e4a246a51 | ||
|
|
ebd0ba4add | ||
|
|
42785ce6be | ||
|
|
db205919c8 | ||
|
|
f452099926 | ||
|
|
03b74b1150 | ||
|
|
fbaeb0cc57 | ||
|
|
5f557a5973 | ||
|
|
86edc42b98 | ||
|
|
68f9bf35a1 | ||
|
|
e314aa8546 | ||
|
|
1b2010f939 | ||
|
|
f25eafcc48 | ||
|
|
473c12ad8f | ||
|
|
89b8157eb9 | ||
|
|
304c1de3bc | ||
|
|
6156da4b67 | ||
|
|
ae7c3ec308 | ||
|
|
e7f0aef43d | ||
|
|
97c6a26f61 | ||
|
|
5f9701ccff | ||
|
|
043dc11251 |
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: Seb3thehacker
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: seb3thehacker
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Bug report
|
||||
description: Something in Gearslip broke or behaves wrong.
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting. Check the [open issues](https://github.com/twinksoftware/gearslip/issues) first, in case someone has already reported it.
|
||||
|
||||
- type: input
|
||||
id: car
|
||||
attributes:
|
||||
label: Car
|
||||
description: Make, model, and year.
|
||||
placeholder: 2019 Honda Civic
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: head-unit
|
||||
attributes:
|
||||
label: Head unit
|
||||
options:
|
||||
- The one the car came with
|
||||
- A factory unit from another car
|
||||
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||
- A homemade or open-source unit, such as LIVI
|
||||
- I don't know
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: connection
|
||||
attributes:
|
||||
label: Connection
|
||||
options:
|
||||
- USB cable
|
||||
- Wireless dongle
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What you did, what you expected, and what happened instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: |
|
||||
On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||
The log records your phone, its Android version, the Gearslip version, and why the connection ended.
|
||||
placeholder: Drop the log file here.
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,65 @@
|
||||
name: Car report
|
||||
description: Tell us whether Gearslip works in your car, so others know before they install.
|
||||
labels: ["car report"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Reports from working cars help as much as reports from broken ones.
|
||||
|
||||
- type: input
|
||||
id: car
|
||||
attributes:
|
||||
label: Car
|
||||
description: Make, model, and year.
|
||||
placeholder: 2019 Honda Civic
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: head-unit
|
||||
attributes:
|
||||
label: Head unit
|
||||
options:
|
||||
- The one the car came with
|
||||
- A factory unit from another car
|
||||
- An aftermarket unit (Pioneer, Kenwood, Alpine, and so on)
|
||||
- A homemade or open-source unit, such as LIVI
|
||||
- I don't know
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: connection
|
||||
attributes:
|
||||
label: Connection
|
||||
options:
|
||||
- USB cable
|
||||
- Wireless dongle
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: result
|
||||
attributes:
|
||||
label: Does it work?
|
||||
options:
|
||||
- Yes, everything I tried works
|
||||
- Partly. It connects, but something is wrong
|
||||
- No, it never connects
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Notes
|
||||
description: What you tried, and anything that didn't work.
|
||||
|
||||
- type: textarea
|
||||
id: log
|
||||
attributes:
|
||||
label: Log
|
||||
description: |
|
||||
Needed if the car doesn't work. On the phone, open Logs from Gearslip's home screen and tap Share log. Drag the file here.
|
||||
placeholder: Drop the log file here.
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Feature request
|
||||
description: Suggest something Gearslip should do.
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What do you want to do?
|
||||
description: Describe the problem, not the solution. "I can't see my next turn while music plays" helps more than "add a turn widget."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: idea
|
||||
attributes:
|
||||
label: How should it work?
|
||||
description: Your idea for a fix, if you have one.
|
||||
|
||||
- type: textarea
|
||||
id: apps
|
||||
attributes:
|
||||
label: Apps involved
|
||||
description: Any apps this touches, such as a map or music app.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Weekly pull requests for newer Gradle libraries and GitHub Actions, aimed at experimental,
|
||||
# where unreleased code lands. Security fixes come separately, as soon as an alert opens.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gradle
|
||||
directory: /
|
||||
target-branch: experimental
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: experimental
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -0,0 +1,13 @@
|
||||
Gearslip 0.3.40 is a test build for a few cars that still don't connect.
|
||||
|
||||
If your car works on 0.3.39, you don't need this one. If you're following #18, #39 or #57, please try it and attach a log to your issue: open Gearslip, tap Logs, then Share log.
|
||||
|
||||
## Fixed
|
||||
- A car that holds back its screen is asked for it again with the detail older cars expect. This is for the 2017 Pilot (#57) and other cars on the oldest Android Auto version.
|
||||
- Music no longer carries on from the phone's speaker after you turn the car off. An app that started playing again by itself is paused a second time (#39).
|
||||
- A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
|
||||
|
||||
## Testing
|
||||
- GM cars open two more channels, Bluetooth and Wi-Fi projection, and log what the car sends on them. This is to find out why the 2021 Bolt hangs up about 20 seconds in (#18). If you drive a GM car, type your phone's Bluetooth address under Settings > Connection > Phone's Bluetooth address. You'll find it in Android's Settings under About phone.
|
||||
|
||||
Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||
@@ -0,0 +1,50 @@
|
||||
name: CodeQL
|
||||
|
||||
# Kotlin can only be scanned while it compiles, so this builds the debug app, the same way
|
||||
# release.yml does but without a signing key. Workflow files need no build.
|
||||
on:
|
||||
push:
|
||||
branches: [main, experimental]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
actions: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: java-kotlin
|
||||
build-mode: manual
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- if: matrix.build-mode == 'manual'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- if: matrix.build-mode == 'manual'
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- if: matrix.build-mode == 'manual'
|
||||
run: ./gradlew :gearslip:assembleDebug --no-daemon
|
||||
|
||||
- uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Write signing key and properties
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
|
||||
cat > keystore.properties <<EOF
|
||||
storeFile=gearslip-release.jks
|
||||
storePassword=${{ secrets.KEYSTORE_STORE_PASSWORD }}
|
||||
keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||
keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||
EOF
|
||||
|
||||
- name: Build release APK
|
||||
run: ./gradlew :gearslip:assembleRelease
|
||||
|
||||
- name: Locate APK
|
||||
id: apk
|
||||
run: |
|
||||
# The build moves its APKs to <root>/build/Gearslip-<variant>.apk (see renameApk).
|
||||
apk=build/Gearslip-release.apk
|
||||
test -f "$apk" || { echo "no APK at $apk"; ls -R build | head -50; exit 1; }
|
||||
echo "path=$apk" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ steps.apk.outputs.path }}
|
||||
# The hand-written notes go first; GitHub adds the full changelog link under them.
|
||||
body_path: .github/release-notes.md
|
||||
generate_release_notes: true
|
||||
# Every release starts as a pre-release: only phones on the Alpha update channel get it.
|
||||
# Once it's tested, untick pre-release on GitHub and everyone gets it.
|
||||
prerelease: true
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Translations
|
||||
|
||||
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
|
||||
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
|
||||
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "gearslip/src/main/res/values-*/strings.xml"
|
||||
|
||||
jobs:
|
||||
weblate-only:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Translations go through Weblate
|
||||
run: |
|
||||
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
|
||||
exit 1
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Security scan
|
||||
|
||||
# Trivy looks through the repository for committed secrets and risky workflow or build
|
||||
# settings. Findings show under the Security tab, next to CodeQL's and Dependabot's.
|
||||
on:
|
||||
push:
|
||||
branches: [main, experimental]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
trivy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: aquasecurity/trivy-action@v0.36.0
|
||||
with:
|
||||
scan-type: fs
|
||||
scanners: vuln,secret,misconfig
|
||||
format: sarif
|
||||
output: trivy.sarif
|
||||
severity: MEDIUM,HIGH,CRITICAL
|
||||
|
||||
- uses: github/codeql-action/upload-sarif@v4
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: trivy.sarif
|
||||
category: trivy
|
||||
@@ -6,20 +6,35 @@
|
||||
/build
|
||||
local.properties
|
||||
|
||||
# Reference checkouts only - never referenced by the build; safe to delete entirely.
|
||||
/cloned-repos
|
||||
|
||||
# The head unit certificate. Never committed or shared beyond who Seb sends it to directly.
|
||||
/cert
|
||||
|
||||
# The release signing key and its passwords. Losing these breaks app updates; leaking them
|
||||
# lets anyone sign an APK as Gearslip.
|
||||
# Keys
|
||||
*.jks
|
||||
*.keystore
|
||||
keystore.properties
|
||||
|
||||
# Local investigation script; not meant for the public repo.
|
||||
/docs/cert-sweep.sh
|
||||
archive/
|
||||
# Native build cache
|
||||
.cxx/
|
||||
|
||||
# Testing
|
||||
/cloned-repos
|
||||
docs-internal/
|
||||
|
||||
# Internal
|
||||
*.pdf
|
||||
todo.md
|
||||
logs/
|
||||
|
||||
# For AI code
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
*.pdf
|
||||
|
||||
# Other
|
||||
archive/
|
||||
.wrangler/
|
||||
stats-worker/
|
||||
|
||||
# Certificates and keys never go in the repo; the app downloads or imports them on the phone.
|
||||
*.p12
|
||||
*.pfx
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
@@ -0,0 +1,50 @@
|
||||
# Cars
|
||||
|
||||
Drivers who share usage notes tell us which cars Gearslip works in. This list comes from
|
||||
those notes, and from our own testing. A car shows up here once Gearslip has reached its
|
||||
screen at least once.
|
||||
|
||||
The year is the one the car reports. Some cars report the year of the head unit instead,
|
||||
and some don't say.
|
||||
|
||||
## Works
|
||||
|
||||
| Car | Year | Head unit |
|
||||
|---|---|---|
|
||||
| Audi | | MIB2+ (Alpine) |
|
||||
| Audi | | Harman |
|
||||
| Chevrolet, with myChevrolet | 2019, 2020, 2021 | myChevrolet |
|
||||
| Chevrolet, with MyLink | 2017 | MyLink |
|
||||
| Dacia | | Media Nav (LG ULC 4.1) |
|
||||
| Dacia | | LG ULC 4.5 |
|
||||
| Ford | 2018 | SYNC 3.0 (Panasonic) |
|
||||
| Ford Fiesta | 2019 | SYNC 3.2 (Panasonic) |
|
||||
| Ford Fusion | 2018 | SYNC 3.1 (Panasonic) |
|
||||
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
|
||||
| MG | 2019 | Desay SV |
|
||||
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
|
||||
| Renault Clio V | 2019-2024 | Easy Link |
|
||||
| Škoda | 2018 | MIB Standard2 (TechniSat) |
|
||||
| Subaru Outback | 2020 | Denso |
|
||||
| Toyota RAV4 | 2020 | Pioneer T1 |
|
||||
| Volkswagen | 2018 | MIB Standard2 (Delphi) |
|
||||
| Volkswagen | 2024 | ICAS3 |
|
||||
| Volvo XC90 | 2022 | SPA Plus (Mitsubishi Electric) |
|
||||
| Wuling | 2021 | Desay SV |
|
||||
| [LIVI](https://github.com/f-io/LIVI), the open-source head unit | | |
|
||||
|
||||
## Not working yet
|
||||
|
||||
| Car | Year | What happens |
|
||||
|---|---|---|
|
||||
| Honda | 2016 | The connection drops before Gearslip reaches the screen. |
|
||||
| Honda | 2019 | The connection drops before Gearslip reaches the screen. |
|
||||
| Mitsubishi Attrage | 2020 | The connection drops before Gearslip reaches the screen. |
|
||||
|
||||
Some cars reject Gearslip's certificate before they say what they are, so they can't appear
|
||||
here by name. Most of these are from around 2020 or later.
|
||||
|
||||
## Add your car
|
||||
|
||||
Turn on usage notes in Gearslip's setup or Settings, and drive once. If your car is missing
|
||||
or listed wrongly, open an issue.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Gearslip Contributor License Agreement
|
||||
|
||||
Version 1.0, October 2026
|
||||
|
||||
Thank you for contributing to Gearslip. This agreement lets Twink Software LLC, the company
|
||||
behind Gearslip, use your contribution. You keep the copyright in your work. You sign once,
|
||||
and the agreement covers every contribution you make to Gearslip, past and future.
|
||||
|
||||
## Why we ask
|
||||
|
||||
Gearslip is free software under the GNU Affero General Public License, version 3 (the
|
||||
"AGPL"). Twink Software LLC also sells commercial licenses to companies that cannot meet the
|
||||
AGPL's terms, and that income helps pay for the project. Offering your code on those terms
|
||||
needs your permission, and this agreement gives it.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
- **"We"** and **"us"** mean Twink Software LLC, a California limited liability company
|
||||
(Secretary of State entity number B20260081700), under that name or any other, and its
|
||||
successors and assigns.
|
||||
- **"You"** means the person who signs this agreement.
|
||||
- **"Contribution"** means any work of authorship, such as code, documentation, or images,
|
||||
that you submit to Gearslip, whether before or after you sign. A submission includes a
|
||||
pull request, patch, or issue attachment sent to a repository or channel we manage. It
|
||||
excludes anything you mark in writing as "Not a Contribution".
|
||||
|
||||
## 2. Copyright license
|
||||
|
||||
You grant us, and anyone who receives software from us, a perpetual, worldwide,
|
||||
non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative
|
||||
works of, publicly display, publicly perform, sublicense, and distribute your Contributions
|
||||
and derivative works of them.
|
||||
|
||||
This license lets us offer your Contributions under licenses other than the AGPL, including
|
||||
commercial licenses.
|
||||
|
||||
## 3. Patent license
|
||||
|
||||
You grant us, and anyone who receives software from us, a perpetual, worldwide,
|
||||
non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use,
|
||||
offer to sell, sell, import, and otherwise transfer your Contributions. This license covers
|
||||
only the patent claims you can license that your Contribution, alone or combined with
|
||||
Gearslip, would infringe.
|
||||
|
||||
If anyone sues claiming that Gearslip or a Contribution infringes a patent, the patent
|
||||
licenses granted to that party for Gearslip end on the date the suit is filed.
|
||||
|
||||
## 4. Our promise to you
|
||||
|
||||
As long as we distribute a Contribution, we will also offer it under the AGPL or another
|
||||
license approved by the Open Source Initiative. Your work will always stay available as
|
||||
free software.
|
||||
|
||||
## 5. Your promises
|
||||
|
||||
You promise that:
|
||||
|
||||
1. **The work is yours.** Each Contribution is your original creation, and you have the
|
||||
right to grant these licenses.
|
||||
2. **Your employer agrees.** If your employer has rights in what you create, you have its
|
||||
permission to contribute on its behalf, or it has waived its rights in your
|
||||
Contributions.
|
||||
3. **You name other people's work.** If any part of a Contribution is not your own, you say
|
||||
so when you submit it, and you name its source and its license.
|
||||
4. **You'll tell us of changes.** If any of these promises stops being true, you will tell
|
||||
us.
|
||||
|
||||
## 6. No warranty
|
||||
|
||||
You provide your Contributions "as is", without warranties or conditions of any kind. You do
|
||||
not have to provide support for them.
|
||||
|
||||
## 7. No obligation
|
||||
|
||||
We are not required to use or include any Contribution.
|
||||
|
||||
## 8. General
|
||||
|
||||
- California law governs this agreement.
|
||||
- This agreement does not change the rights you have under the AGPL in Gearslip as
|
||||
released.
|
||||
- We may publish a new version of this agreement. A new version applies only to
|
||||
Contributions you make after you sign it; this version keeps covering the ones you made
|
||||
under it.
|
||||
|
||||
## Signing
|
||||
|
||||
Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
|
||||
and you sign there with one click. Your GitHub account then serves as your signature.
|
||||
|
||||
Translators sign on Weblate: it shows this agreement before your first translation, and
|
||||
accepting it there serves as your signature.
|
||||
|
||||
To sign by hand instead, fill in the following and send it to us on Matrix. The address is
|
||||
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
|
||||
|
||||
Name: ______________________________
|
||||
|
||||
GitHub username: ______________________________
|
||||
|
||||
Email: ______________________________
|
||||
|
||||
Signature: ______________________________
|
||||
|
||||
Date: ______________________________
|
||||
@@ -0,0 +1,73 @@
|
||||
# Contributing to Gearslip
|
||||
|
||||
Thanks for helping. Bug reports from real cars are the most useful thing you can send, so
|
||||
start there if you aren't sure where to begin.
|
||||
|
||||
## Reporting a bug
|
||||
|
||||
Open an issue on [GitHub](https://github.com/twinksoftware/gearslip/issues) and include:
|
||||
|
||||
- your car's make, model, and year, and whether the head unit is the original one; and
|
||||
- the log from the drive. Open Logs on the phone app's home screen and tap Share log.
|
||||
|
||||
The log records your phone, its Android version, the Gearslip version, the head unit, and
|
||||
why the connection ended. Most car bugs can't be fixed without it. Head units often report
|
||||
only a part number, so the log can't name your car for you.
|
||||
|
||||
## Building
|
||||
|
||||
You need Android Studio, or the Android SDK with JDK 21. Gradle downloads the NDK, CMake,
|
||||
and the speech library on the first build.
|
||||
|
||||
```
|
||||
./gradlew :gearslip:assembleDebug
|
||||
```
|
||||
|
||||
The APK lands at `build/Gearslip-debug.apk`. It installs as Gearslip Dev, next to the
|
||||
regular app.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Work happens on the `experimental` branch, which merges into `main` at each release. Base your
|
||||
branch on `experimental`, and open your pull request against it.
|
||||
|
||||
- Keep each pull request to one change, and explain the problem it solves.
|
||||
- Say how you tested it: which car or head unit, which phone, and which apps.
|
||||
- Code written with AI tools is welcome. Read it, test it, and stand behind it as you would
|
||||
your own.
|
||||
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||
permissions and on-device consent dialogs.
|
||||
|
||||
## Translating
|
||||
|
||||
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), never in pull
|
||||
requests. Pick your language, or start a new one, and translate in the browser. Weblate
|
||||
sends the finished work to this repository as a pull request.
|
||||
|
||||
Don't edit the translated `values-*/strings.xml` files by hand. Weblate keeps its own copy of
|
||||
every language, so a change made in git collides with Weblate's next update, and the pull
|
||||
request that made it gets closed. If a code change adds or rewords text, change only the
|
||||
English file, `values/strings.xml`; Weblate picks up the new strings for translators.
|
||||
|
||||
Keep the tone of the English: short, plain, and friendly. Leave placeholders such as `%1$s`
|
||||
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
|
||||
in English, so a partial translation is still useful.
|
||||
|
||||
Before your first translation, Weblate asks you to accept the
|
||||
[Contributor License Agreement](CLA.md). That counts as signing it.
|
||||
|
||||
## Writing
|
||||
|
||||
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||
[*The Elements of Style*](https://www.gutenberg.org/ebooks/37134). In short: use the active
|
||||
voice, state things positively, cut needless words, and use the serial comma.
|
||||
|
||||
## License
|
||||
|
||||
Gearslip is licensed under AGPL-3.0, and your contributions are too. Twink Software LLC, which
|
||||
owns Gearslip, also sells a commercial license, described in [NOTICE.md](NOTICE.md).
|
||||
|
||||
Before we can merge your first pull request, you sign the
|
||||
[Contributor License Agreement](CLA.md). You keep the copyright in your work, and it stays
|
||||
available as free software; the agreement lets us offer it under the commercial license too.
|
||||
A bot comments on your pull request with a link, and signing takes one click.
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (C) 2026 seb
|
||||
Copyright (C) 2026 Twink Software LLC and contributors
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
# License
|
||||
|
||||
Gearslip is licensed under the **GNU Affero General Public License v3.0** (see `LICENSE`).
|
||||
Gearslip is licensed under the GNU Affero General Public License v3.0. The full text is in `LICENSE`.
|
||||
|
||||
In short: you're free to use, run, and modify it. If you modify it — or run a modified
|
||||
version as a service others use over a network — the AGPL requires you to make your source
|
||||
changes available on the same terms. That obligation follows the code even when you don't
|
||||
redistribute a binary, which is what makes the AGPL fit for something that can run as a
|
||||
background service.
|
||||
You may use, run, and modify Gearslip freely. If you distribute a modified version, or let others use one over a network, you must publish your changes under the same license. The obligation follows the code even when you hand out no binary, which suits software that runs as a background service.
|
||||
|
||||
## Commercial licensing
|
||||
|
||||
If the AGPL's terms don't work for you — for example, you want to use Gearslip privately
|
||||
without being required to publish your changes — a separate commercial license is available
|
||||
directly from the author. Terms (including any restrictions on modification) are set on a
|
||||
case-by-case basis. Contact me though Matrix (on my profile) to discuss one.
|
||||
If the AGPL's terms don't work for you, Twink Software LLC, which owns Gearslip, sells a separate commercial license. The usual case is a company that wants to use Gearslip privately without publishing its changes. Terms, including any limits on modification, are set case by case. Contact us on Matrix; the address is on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
|
||||
@@ -2,42 +2,187 @@
|
||||
|
||||
# Gearslip
|
||||
|
||||
Bring any app to your car screen.
|
||||
An Android Auto alternative built for everyone.
|
||||
|
||||
|
||||
[](https://forthebadge.com)
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
|
||||
|
||||
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||
Translations go through Weblate only; pull requests that edit them get closed.
|
||||
|
||||
[](https://hosted.weblate.org/engage/gearslip/)
|
||||
|
||||
Questions, ideas?
|
||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||
|
||||
> [!WARNING]
|
||||
> Gearslip is **alpha software**. It can drop the connection, freeze the car screen, or fail
|
||||
> to start, and a new release can break a car that worked before. Set it up while parked, and
|
||||
> keep another way to navigate.
|
||||
|
||||
> [!WARNING]
|
||||
> On GrapheneOS, make sure **USB-C port control** isn't set to **Charging-only**. That setting
|
||||
> blocks USB data, so the car never sees the phone, even with a data cable. Set it to allow
|
||||
> data transfer.
|
||||
|
||||
## Which cars work
|
||||
|
||||
Most cars work. Starting around 2020, some cars added newer security checks that reject
|
||||
the aasdk certificate, and in those cars Gearslip needs a certificate of your own. [CARS.md](CARS.md) lists the
|
||||
cars drivers have confirmed.
|
||||
|
||||
## What Gearslip is
|
||||
|
||||
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
|
||||
directly to a car head unit over USB, with no Google software in the path. Gearslip acts
|
||||
as a Car App Library host: an app that ships a car interface hands Gearslip a template,
|
||||
and Gearslip renders that template natively on the car screen. Nothing is mirrored from
|
||||
the phone. For apps without a car interface, Gearslip offers a separate screen-sharing
|
||||
mode.
|
||||
directly to a car head unit, over USB or wirelessly, with no Google software in the path.
|
||||
Gearslip acts as a Car App Library host: an app that ships a car interface hands Gearslip
|
||||
a template, and Gearslip renders that template natively on the car screen. Nothing is
|
||||
mirrored from the phone. For apps without a car interface, Gearslip offers a separate
|
||||
screen-sharing mode.
|
||||
|
||||
The author reverse-engineered the protocol for interoperability, so that a client other
|
||||
than Google's own can talk to a car head unit.
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| Your car apps in one place. | Vela Maps navigating, with the player beside it. |
|
||||
|  |  |
|
||||
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
||||
|  |  |
|
||||
| Lyrics light up word by word as the song plays. | Browse the web while parked. |
|
||||
|  |  |
|
||||
| Ask for a place, a song, or an app. | Pick a route to a coffee shop. Each one shows its time and distance. |
|
||||
|
||||
## Features
|
||||
|
||||
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
||||
call goes out. When a call comes in, answer or decline it on the car screen. During a
|
||||
call, the nav bar shows who you're talking to, and you mute or hang up from there.
|
||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
||||
Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
||||
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
|
||||
the map keeps the right side. Turns from Google Maps or Waze running on the phone show in
|
||||
the nav bar, and on the instrument cluster in cars with a screen there.
|
||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
- **The web.** A browser with an address bar, back and forward, and its own on-screen
|
||||
keyboard, for anything that doesn't ship a car interface of its own.
|
||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
||||
the current and coming weather.
|
||||
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
|
||||
nearest gas station", "play Daft Punk", or "call Mom". Say "help" for the full list.
|
||||
Speech turns into text on the phone, and nothing you say leaves it.
|
||||
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
||||
each one aloud, and you reply by voice or with a quick reply. Music pauses while
|
||||
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account.
|
||||
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||
The rest wait on the dashboard.
|
||||
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
||||
messaging, and the rest. Press and hold an app to move it, and hide the ones you don't
|
||||
use from the Apps card on the phone.
|
||||
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
||||
- **Your phone's colors.** The car screen and the phone app take their colors from your
|
||||
phone's Material You palette, and every card and button matches.
|
||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
||||
- **Small screens.** Car display density makes text and buttons bigger in car apps.
|
||||
- **Your language.** Gearslip comes in 19 languages besides English, some only partly
|
||||
translated. Pick one in Settings. Voice commands understand English only for now.
|
||||
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
|
||||
can set things up before you drive. A change on either screen shows on both.
|
||||
|
||||
## Status
|
||||
|
||||
- **Templated apps work.** Gearslip renders all 16 template types that the Car App
|
||||
Library defines, and it draws its own keyboard for search and sign-in. It has been
|
||||
tested against several real navigation and media apps.
|
||||
- **Media playback works.** Gearslip lets you browse and control car-enabled media apps
|
||||
from the car screen, with lyrics and playback state, over the same host connection.
|
||||
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
|
||||
requires a screen-share style consent dialog and indicator for this, even though
|
||||
Gearslip never reads the screen; that grant is the only way a non-privileged app can
|
||||
tap another app's audio. The consent normally repeats every drive; the app's own setup
|
||||
steps include two optional adb commands that reduce it to a one-time grant.
|
||||
- **A real head unit needs a certificate that Google controls.** A head unit accepts only
|
||||
a phone identity that chains to Google's Automotive Link certificate authority, and
|
||||
Google alone issues those certificates. Gearslip does not ship one. Without it,
|
||||
Gearslip runs and renders correctly against a debug harness, but a real car rejects the
|
||||
connection. A leaked certificate exists that older head units accept; newer firmware
|
||||
and Google's reference tooling reject it.
|
||||
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
|
||||
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
|
||||
Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
|
||||
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
|
||||
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
|
||||
then one permission at a time, each with a plain reason and an Allow or Skip. A help
|
||||
screen inside the car answers anything setup didn't cover.
|
||||
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
|
||||
screen, and it draws its own keyboard for search, sign-in, and text fields. Alerts, such
|
||||
as a map app asking whether a hazard is still there, show on the car screen too. It has
|
||||
been tested against several real navigation and media apps.
|
||||
- **Gearslip can be a head unit's home screen.** On a head unit that runs Android, or a
|
||||
spare phone or tablet on the dash, turn on Use as launcher in Settings > Connection. The
|
||||
car screen fills the display, and an All apps page opens everything else.
|
||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||
used in homemade car dashboards.
|
||||
- **Some apps refuse Gearslip.** Spotify and Google Messages use a version of
|
||||
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
||||
Gearslip's own player. TIDAL lets only approved apps browse its library.
|
||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
||||
car to hear calls through its speakers.
|
||||
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
|
||||
Bluetooth is the default: Android never asks for permission, and your map app's voice
|
||||
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
|
||||
or wireless. It sounds a little clearer, with no delay, but each drive Android asks you
|
||||
to share your screen, which lets Gearslip send the sound. Gearslip never reads the
|
||||
screen; capturing playback is the only way an ordinary app can pass another app's audio
|
||||
along.
|
||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||
Android permission or an on-device consent dialog. The two adb commands mentioned
|
||||
above, for audio, are the sole exception: both are optional, and both only remove a
|
||||
dialog that the driver would otherwise see once per drive.
|
||||
Android permission or an on-device consent dialog. For audio through Gearslip, setup offers two
|
||||
optional adb commands: one removes the audio dialog for good, and the other keeps
|
||||
notifications visible while audio plays.
|
||||
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
|
||||
release. When a new version is out, it downloads the APK, checks it against GitHub's
|
||||
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
|
||||
Settings.
|
||||
|
||||
## Verify your download
|
||||
|
||||
Check that your copy of Gearslip is genuine with
|
||||
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
|
||||
with the output of `apksigner verify --print-certs`.
|
||||
|
||||
```
|
||||
app.seb3thehacker.gearslip
|
||||
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
|
||||
```
|
||||
|
||||
## Usage notes
|
||||
|
||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||
turn them on, in setup or in Settings.
|
||||
|
||||
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
|
||||
Nothing runs in the background.
|
||||
- **What they carry.** The Gearslip version and which boxes you ticked, plus whatever
|
||||
those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
|
||||
model, whether a car connected that day, and each car's head unit, model, year, screen
|
||||
size, and connection result, with whether that drive used the cable or wireless and
|
||||
whether music went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far
|
||||
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
|
||||
the server drops the copy it kept.
|
||||
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||
anything you type or play.
|
||||
- **How phones are counted.** A random number counts each phone once. The server keeps
|
||||
only a salted hash of it.
|
||||
- **Where they go.** To Gearslip's server. Like any website, it sees your IP address when a
|
||||
note arrives, and it doesn't store it.
|
||||
|
||||
Turn the notes off, and they stop at once.
|
||||
|
||||
## How Gearslip is made
|
||||
|
||||
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
|
||||
all of the code. I test every change myself before it ships: in a real car, with both a
|
||||
GrapheneOS phone and a stock Android phone, and on [LIVI](https://github.com/f-io/LIVI), the
|
||||
open-source head unit software. I watch the phone's temperature too, so a new feature doesn't
|
||||
cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models,
|
||||
and years as possible. If something breaks, that's on me, so please open an issue.
|
||||
|
||||
## Contributing
|
||||
|
||||
Bug reports from real cars help most, and pull requests are welcome.
|
||||
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
|
||||
on, and the one-time agreement you sign before your first pull request merges.
|
||||
|
||||
To help translate Gearslip into your language, join
|
||||
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed. Please
|
||||
don't send translations as pull requests: Weblate holds its own copy of every language, and
|
||||
changes made in git collide with it.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -57,7 +202,25 @@ keystore. Without them, the debug build still works.
|
||||
[docs/BUILDING_APPS.md](docs/BUILDING_APPS.md) specifies how to write apps that Gearslip
|
||||
can render: templated apps, media apps, and the Gearslip app protocol.
|
||||
|
||||
## How Gearslip talks to cars
|
||||
|
||||
Gearslip is an independent program, written to work with car head units. What it knows of
|
||||
the protocol comes from public open-source projects such as
|
||||
[aasdk](https://github.com/opencardev/aasdk) and open-android-auto, and it contains none
|
||||
of Google's Android Auto code.
|
||||
|
||||
Gearslip ships without a certificate. On first launch you choose where yours comes from:
|
||||
download the one the aasdk project publishes, or import a file of your own. The
|
||||
certificate stays on your phone. Gearslip never uploads, backs up, or logs it.
|
||||
|
||||
## Trademarks
|
||||
|
||||
Android, Android Auto, Google Messages, and Waze are trademarks of Google LLC. Spotify is
|
||||
a trademark of Spotify AB. Gearslip is not affiliated with, endorsed by, or sponsored by
|
||||
any of these companies. All other names belong to their owners.
|
||||
|
||||
## License
|
||||
|
||||
Gearslip is licensed under AGPL-3.0; see [LICENSE](LICENSE). The author offers a separate
|
||||
commercial license for private use outside the AGPL's terms; see [NOTICE.md](NOTICE.md).
|
||||
Gearslip belongs to Twink Software LLC and is licensed under AGPL-3.0; see [LICENSE](LICENSE).
|
||||
The LLC also sells a separate commercial license for private use outside the AGPL's terms; see
|
||||
[NOTICE.md](NOTICE.md).
|
||||
@@ -1,11 +1,12 @@
|
||||
# Building apps for Gearslip
|
||||
|
||||
Gearslip renders three kinds of app on a car screen. This page specifies all three.
|
||||
Gearslip renders four kinds of app on a car screen. This page specifies all four.
|
||||
|
||||
| Kind | What it is | Status |
|
||||
| --- | --- | --- |
|
||||
| [Templated apps](#templated-apps) | An AndroidX Car App Library app. Gearslip hosts it. | Works today |
|
||||
| [Media apps](#media-apps) | A `MediaBrowserService` that opted in to Android Auto. | Works today |
|
||||
| [Messaging apps](#messaging-apps) | An app whose notifications opted in to Android Auto. | Works today |
|
||||
| [Gearslip apps](#gearslip-apps) | A native Gearslip screen, described over binder. | Specified here; not yet implemented |
|
||||
|
||||
Write a templated app when your app fits the Car App Library's templates, because it runs today
|
||||
@@ -29,11 +30,11 @@ any other host.
|
||||
Your app decides which hosts may render it, through the `HostValidator` your `CarAppService`
|
||||
returns.
|
||||
|
||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`, and the library's own
|
||||
`HostValidator` accepts any host holding it. If you build your validator with
|
||||
`HostValidator.Builder` and no allowlist, Gearslip already passes, and you need nothing below.
|
||||
Add an allowlist entry when you wrote a custom validator, or when you want to name Gearslip
|
||||
explicitly:
|
||||
Gearslip declares `android.car.permission.TEMPLATE_RENDERER`. Car App Library 1.8.0-beta01
|
||||
and earlier, and 1.9.0-alpha01, accept any host holding it, so Gearslip passes the default
|
||||
validator. From 1.8.0-rc01 and 1.9.0-alpha02 the library checks that permission only on
|
||||
Android Automotive, so on a phone Gearslip passes only when your allowlist names it. Add this
|
||||
entry:
|
||||
|
||||
```xml
|
||||
<!-- res/values/arrays.xml -->
|
||||
@@ -143,7 +144,14 @@ existing video pipe. Gearslip never reads those pixels back.
|
||||
|
||||
Gearslip then reports which part of that surface the driver can see. Treat the visible area as
|
||||
the region safe for your own controls, and the stable area as the region that never gets
|
||||
covered. Gearslip keeps its chrome to a top bar, so both areas are the surface minus that bar.
|
||||
covered. Gearslip reports the same area for both, and it depends on the template:
|
||||
|
||||
| Template | What Gearslip covers |
|
||||
| --- | --- |
|
||||
| `NavigationTemplate` | A strip along the bottom for the trip estimate |
|
||||
| `MapWithContentTemplate`, `MapTemplate`, and the place list and route preview templates | A content pane on the left |
|
||||
|
||||
The turn card and map buttons float over the visible area, as they do in Android Auto.
|
||||
|
||||
Your app receives four gestures, never raw touches: `onClick`, `onScroll`, `onFling` and
|
||||
`onScale`. Coordinates are surface pixels. Scroll distances follow `GestureDetector`'s
|
||||
@@ -197,6 +205,53 @@ can start it playing on connection.
|
||||
|
||||
---
|
||||
|
||||
## Messaging apps
|
||||
|
||||
Gearslip shows conversations from the notifications your app already posts, as Android Auto
|
||||
does. Your app needs no car screen of its own.
|
||||
|
||||
Gearslip lists a messaging app when its car descriptor declares notifications:
|
||||
|
||||
```xml
|
||||
<!-- res/xml/automotive_app_desc.xml -->
|
||||
<automotiveApp>
|
||||
<uses name="notification" />
|
||||
</automotiveApp>
|
||||
```
|
||||
|
||||
Reference it from the same `com.google.android.gms.car.application` meta-data that a media app
|
||||
uses. Opening your tile on the car screen shows your recent conversations.
|
||||
|
||||
### Posting messages
|
||||
|
||||
Post each conversation as a `MessagingStyle` notification. Gearslip reads the conversation
|
||||
title, each message's sender and text, and whether the chat is a group, and shows the last eight
|
||||
messages. Leave the sender empty on the phone owner's own messages; Gearslip treats a message
|
||||
with no sender as one the driver sent.
|
||||
|
||||
Gearslip reads new messages aloud and lets the driver reply by voice or with a quick reply. For
|
||||
that, attach two actions, either to the notification or to its `WearableExtender`:
|
||||
|
||||
- **Reply:** an action carrying a `RemoteInput` that allows free-form input. Gearslip fills it
|
||||
with the driver's reply and fires the action's intent.
|
||||
- **Mark as read:** an action with `SEMANTIC_ACTION_MARK_AS_READ`. Gearslip shows a Mark read
|
||||
button when you provide it.
|
||||
|
||||
### Which notifications interrupt the driver
|
||||
|
||||
Gearslip pops up a card for a conversation, a notification with a reply action, or a
|
||||
notification in the `msg`, `missed_call`, `alarm`, `reminder` or `event` category. A channel
|
||||
below default importance never pops up. Every other notification waits quietly on the
|
||||
dashboard.
|
||||
|
||||
Gearslip drops these altogether:
|
||||
|
||||
- ongoing notifications and group summaries;
|
||||
- the `transport`, `progress`, `service`, `sys` and `navigation` categories; and
|
||||
- media notifications, because the player already shows them.
|
||||
|
||||
---
|
||||
|
||||
## Gearslip apps
|
||||
|
||||
This section specifies protocol version 1. Gearslip does not yet implement it.
|
||||
@@ -376,7 +431,7 @@ long strings. Send a screen that fits, rather than one Gearslip has to cut.
|
||||
"densityDpi": 160,
|
||||
"batteryPercent": 82,
|
||||
"charging": true,
|
||||
"vehicle": "Uconnect"
|
||||
"vehicle": "Honda"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -416,6 +471,7 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
|
||||
|
||||
## Further reading
|
||||
|
||||
- [README.md](../README.md) - what Gearslip is and how to build it.
|
||||
- [README.md](../README.md) - what Gearslip is.
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) - how to build Gearslip and report bugs.
|
||||
- [Android for Cars App Library](https://developer.android.com/training/cars/apps) - the
|
||||
reference for templated apps.
|
||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,14 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
|
||||
<defs>
|
||||
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
|
||||
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="108" height="108" fill="#0c0c0d"/>
|
||||
<circle cx="54" cy="54" r="52" fill="#131415"/>
|
||||
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
|
||||
<path d="M 54.00,27.00 L 59.99,19.91 Q 61.02,18.69 62.50,19.30 L 72.52,23.45 Q 74.00,24.07 73.87,25.66 L 73.23,33.31 Q 73.09,34.91 73.70,36.39 L 80.39,52.52 Q 81.00,54.00 82.22,55.03 L 88.09,59.99 Q 89.31,61.02 88.70,62.50 L 84.55,72.52 Q 83.93,74.00 82.34,73.87 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.87,82.34 Q 74.00,83.93 72.52,84.55 L 62.50,88.70 Q 61.02,89.31 59.99,88.09 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 48.01,88.09 Q 46.98,89.31 45.50,88.70 L 35.48,84.55 Q 34.00,83.93 34.13,82.34 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 25.66,73.87 Q 24.07,74.00 23.45,72.52 L 19.30,62.50 Q 18.69,61.02 19.91,59.99 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 19.91,48.01 Q 18.69,46.98 19.30,45.50 L 23.45,35.48 Q 24.07,34.00 25.66,34.13 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.13,25.66 Q 34.00,24.07 35.48,23.45 L 45.50,19.30 Q 46.98,18.69 48.01,19.91 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 Z" fill="#8B9096"/>
|
||||
<path d="M 92.78,34.42 L 99.94,37.20 Q 101.43,37.78 101.34,39.37 L 100.67,50.20 Q 100.58,51.80 99.03,52.19 L 91.58,54.08 Q 90.03,54.47 90.13,52.87 L 91.19,35.44 Q 91.29,33.84 92.78,34.42 Z" fill="#8C1F1F"/>
|
||||
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
@@ -5,7 +5,6 @@
|
||||
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="108" height="108" fill="#0c0c0d"/>
|
||||
<circle cx="54" cy="54" r="52" fill="#131415"/>
|
||||
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
|
||||
<path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 449 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 392 KiB |
|
After Width: | Height: | Size: 107 KiB |
@@ -24,16 +24,27 @@ val keystoreProperties = Properties().apply {
|
||||
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
|
||||
}
|
||||
|
||||
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
||||
// <root>/gearslip/build/..., so everything this project produces lands under one
|
||||
// root-level build/ directory. Set before android {}, which works out the path to R8's default
|
||||
// rules from it.
|
||||
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
|
||||
|
||||
android {
|
||||
namespace = "app.seb3thehacker.gearslip"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 35
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 3
|
||||
versionName = "0.0.03-pre"
|
||||
versionCode = 15
|
||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
||||
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-alpha"
|
||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
||||
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||
buildConfigField("String", "STATS_URL", "\"${project.findProperty("gearslipStatsUrl") ?: ""}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -52,19 +63,57 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.findByName("release")
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
// Its own package, so a debug build installs beside the release one instead of over it.
|
||||
// Signed with the release key when it's here: both builds declare the car template
|
||||
// permission, and Android refuses a second app declaring it under a different key.
|
||||
debug {
|
||||
applicationIdSuffix = ".dev"
|
||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
||||
// English (XA) and Arabic (XB) under the phone's languages: stretched, accented and
|
||||
// right-to-left text, which shows what's still hardcoded and what doesn't fit.
|
||||
isPseudoLocalesEnabled = true
|
||||
// Shrunk with R8 like a release, so a debug install behaves like what ships (a keep
|
||||
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
|
||||
if (!project.hasProperty("noR8")) {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
||||
// <root>/gearslip/build/..., so everything this project produces lands under one
|
||||
// root-level build/ directory.
|
||||
layout.buildDirectory.set(rootProject.layout.buildDirectory.dir("gearslip"))
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
// Lists each language that has a translation in the app's locale config, so Android 13+
|
||||
// offers it under the system's per-app languages. English is the unqualified default
|
||||
// (res/resources.properties).
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
lint {
|
||||
// Weblate fills languages in a string at a time; the rest fall back to English.
|
||||
disable += "MissingTranslation"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Speech-to-text (sherpa-onnx, in jniLibs) ships for 64-bit ARM only: every phone
|
||||
// Gearslip runs on, and one copy of the native library instead of four.
|
||||
ndk { abiFilters += "arm64-v8a" }
|
||||
}
|
||||
|
||||
testOptions {
|
||||
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
||||
unitTests.isReturnDefaultValues = true
|
||||
@@ -72,9 +121,10 @@ android {
|
||||
|
||||
}
|
||||
|
||||
// Renames the produced APK and moves it to the root build/ folder (<root>/build/Gearslip-debug.apk)
|
||||
// after the fact rather than fighting AGP's variant-output API across versions - simpler and
|
||||
// doesn't break when that API moves again.
|
||||
// Copies the produced APK to the root build/ folder (<root>/build/Gearslip-debug.apk) after the
|
||||
// fact rather than fighting AGP's variant-output API across versions - simpler and doesn't break
|
||||
// when that API moves again. A copy, not a move: AGP packages incrementally into the APK it left
|
||||
// last time, and with that file gone a build came out holding nothing but classes.dex.
|
||||
tasks.register("renameApk") {
|
||||
doLast {
|
||||
val rootBuildDir = rootProject.layout.buildDirectory.get().asFile
|
||||
@@ -84,7 +134,6 @@ tasks.register("renameApk") {
|
||||
.forEach { apk ->
|
||||
val renamed = File(rootBuildDir, "Gearslip-${apk.parentFile.name}.apk")
|
||||
apk.copyTo(renamed, overwrite = true)
|
||||
apk.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,6 +153,7 @@ dependencies {
|
||||
// their Bundleables deserialize into.
|
||||
implementation(libs.androidx.car.app)
|
||||
implementation(libs.androidx.car.app.projected)
|
||||
implementation(libs.androidx.media)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -112,3 +162,4 @@ tasks.withType<KotlinJvmCompile>().configureEach {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# R8 shrinks the release build; these keep what it can't see being used.
|
||||
|
||||
# Names stay readable, so stack traces in shared logs need no mapping file. Shrinking does
|
||||
# the real work on size.
|
||||
-dontobfuscate
|
||||
|
||||
# Gearslip hosts other apps' car screens: their templates arrive as Bundles, and the car
|
||||
# library rebuilds them by class name and reflection. Any model class an app might send has to
|
||||
# survive, used by Gearslip's code or not.
|
||||
-keep class androidx.car.app.** { *; }
|
||||
|
||||
# Speech-to-text: the native library calls back into these classes by name.
|
||||
-keep class com.k2fsa.sherpa.onnx.** { *; }
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug-only override of the main icon background (src/debug/res wins over src/main/res),
|
||||
so a debug install is never mistaken for a release one on the home screen. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M0,0h108v108h-108z"
|
||||
android:fillColor="#f5c518"/>
|
||||
<path
|
||||
android:pathData="M54,2 A52,52 0 1,1 53.99,2 Z"
|
||||
android:fillColor="#e6b400"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Debug-only override of the main icon foreground, to go with the yellow debug background:
|
||||
a charcoal gear rather than light grey, and a yellow centre hole to match the background
|
||||
instead of the release icon's near-black. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M 66.69,49.48 L 80.91,52.61 L 82.31,42.81 L 66.92,47.91 Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="66.80"
|
||||
android:startY="48.70"
|
||||
android:endX="81.61"
|
||||
android:endY="47.71">
|
||||
<item android:offset="0" android:color="#00E5484D"/>
|
||||
<item android:offset="1" android:color="#E5484D"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z"
|
||||
android:fillColor="#2E3033"/>
|
||||
<path
|
||||
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
|
||||
android:fillColor="#E5484D"/>
|
||||
<path
|
||||
android:pathData="M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
|
||||
android:fillColor="#e6b400"/>
|
||||
</vector>
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<!-- The projected WebView loads real pages; without this every load fails silently. -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!--
|
||||
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
||||
@@ -12,11 +13,33 @@
|
||||
-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
|
||||
<!--
|
||||
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
|
||||
hands over its Wi-Fi details; Gearslip then joins that network to reach the car.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<!-- Wireless auto-connect: Android tells Gearslip when a car it manages connects. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_OBSERVE_COMPANION_DEVICE_PRESENCE" />
|
||||
<uses-feature android:name="android.software.companion_device_setup" android:required="false" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<!-- Android 17 drops an app's traffic to the car's Wi-Fi without this. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
|
||||
<!-- The dashboard opened from the car clock: today's agenda and current weather. -->
|
||||
<uses-permission android:name="android.permission.READ_CALENDAR" />
|
||||
@@ -39,6 +62,21 @@
|
||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<!--
|
||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||
and spam app too - an on-device consent, not a permission declared here.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
|
||||
<!-- Placing a call from the car screen's dial pad or contacts list needs this. -->
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
|
||||
<!--
|
||||
Declared, not just requested. Templated apps decide whether to talk to a host by asking
|
||||
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||
@@ -54,6 +92,14 @@
|
||||
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
||||
|
||||
<queries>
|
||||
<!-- Named directly: current builds have no launcher-category entry point for the MAIN/
|
||||
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||
whether the package is installed at all. -->
|
||||
<package android:name="com.google.android.projection.gearhead" />
|
||||
<!-- The opt-in usage note names the OS: GrapheneOS's setup app and /e/OS's app store give
|
||||
them away (UsageStats.osName). -->
|
||||
<package android:name="app.grapheneos.setupwizard" />
|
||||
<package android:name="foundation.e.apps" />
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -66,14 +112,23 @@
|
||||
<intent>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent>
|
||||
<!-- Speech: without these, Android hides the recognizer and the voice from us and
|
||||
SpeechRecognizer reports none installed even when one is. -->
|
||||
<intent>
|
||||
<action android:name="android.speech.RecognitionService" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.TTS_SERVICE" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:label="Gearslip"
|
||||
android:label="${appLabel}"
|
||||
android:supportsRtl="true"
|
||||
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
|
||||
android:theme="@style/Theme.Gearslip">
|
||||
|
||||
<provider
|
||||
@@ -95,6 +150,7 @@
|
||||
android:name=".GearslipActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||
|
||||
<intent-filter>
|
||||
@@ -102,6 +158,50 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Launcher mode: the car UI as this device's home screen, for a head unit that runs Android
|
||||
or a tablet on the dash. Off until Settings turns it on (LauncherMode), so phones never
|
||||
list Gearslip as a home app.
|
||||
-->
|
||||
<activity
|
||||
android:name=".LauncherActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:screenOrientation="sensorLandscape"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|locale|layoutDirection|fontScale">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!--
|
||||
Answers the car's USB connection on GearslipActivity's behalf. Kept apart from the
|
||||
activity so Settings can switch off just this (AppSettings.setGearslipEnabled) and leave
|
||||
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||
-->
|
||||
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
|
||||
<activity
|
||||
android:name=".ui.ManageSpaceActivity"
|
||||
android:exported="true"
|
||||
android:label="Gearslip storage" />
|
||||
|
||||
<activity
|
||||
android:name=".PermissionActivity"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:theme="@style/Theme.Gearslip.Invisible" />
|
||||
|
||||
<activity-alias
|
||||
android:name=".GearslipUsbAccessory"
|
||||
android:targetActivity=".GearslipActivity"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED" />
|
||||
</intent-filter>
|
||||
@@ -109,7 +209,12 @@
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_ACCESSORY_ATTACHED"
|
||||
android:resource="@xml/accessory_filter" />
|
||||
</activity>
|
||||
</activity-alias>
|
||||
|
||||
<!-- Dev builds' test message: its Reply and Mark as read actions land here. -->
|
||||
<receiver
|
||||
android:name=".notify.TestMessageReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<service
|
||||
android:name=".mirror.MirrorService"
|
||||
@@ -121,11 +226,19 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location" />
|
||||
|
||||
<!-- Holds the capture session that carries a media app's audio to the car. -->
|
||||
<service
|
||||
android:name=".wireless.WirelessKeepAlive"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice" />
|
||||
|
||||
<!--
|
||||
Holds the capture session that carries a media app's audio to the car. It also claims
|
||||
the microphone, so voice replies can hear the driver with the phone locked.
|
||||
-->
|
||||
<service
|
||||
android:name=".audio.AudioCaptureService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
android:foregroundServiceType="mediaProjection|microphone" />
|
||||
|
||||
<!--
|
||||
Touch on the car screen is replayed onto the phone through this service.
|
||||
@@ -158,5 +271,31 @@
|
||||
<action android:name="android.service.notification.NotificationListenerService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!--
|
||||
Sees incoming calls before they ring, so the car screen can show the caller and
|
||||
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
|
||||
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
|
||||
for, not a privileged one.
|
||||
-->
|
||||
<service
|
||||
android:name=".call.GearslipCallScreening"
|
||||
android:exported="true"
|
||||
android:label="Gearslip calls"
|
||||
android:permission="android.permission.BIND_SCREENING_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.telecom.CallScreeningService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- Told when a car approved for wireless auto-connect comes up over Bluetooth. -->
|
||||
<service
|
||||
android:name=".wireless.CarPresenceService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_COMPANION_DEVICE_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.companion.CompanionDeviceService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,14 +1,17 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
|
||||
/** User-facing preferences. Read fresh on every session, so changes apply on the next connect. */
|
||||
object AppSettings {
|
||||
|
||||
private const val PREFS = "gearslip_settings"
|
||||
private const val KEY_STARTUP_URL = "startup_url"
|
||||
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
||||
private const val KEY_DEBUG_MODE = "debug_mode"
|
||||
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
|
||||
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
|
||||
private const val KEY_GEARSLIP_ENABLED = "gearslip_enabled"
|
||||
|
||||
private fun prefs(context: Context) =
|
||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -28,23 +31,58 @@ object AppSettings {
|
||||
prefs(context).edit().putString(KEY_STARTUP_URL, value.trim()).apply()
|
||||
}
|
||||
|
||||
/** Whether the newer-car compatibility warning has already been shown and dismissed once. */
|
||||
fun hasSeenCompatWarning(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SEEN_COMPAT_WARNING, false)
|
||||
/** Whether the certificate setup screen has been deliberately skipped. */
|
||||
fun hasSkippedCertSetup(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
|
||||
|
||||
fun setSeenCompatWarning(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
|
||||
fun setSkippedCertSetup(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
|
||||
}
|
||||
|
||||
/** Whether the one-by-one permissions wizard has already run once. */
|
||||
fun hasSeenPermissionsSetup(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_SEEN_PERMISSIONS_SETUP, false)
|
||||
|
||||
fun setSeenPermissionsSetup(context: Context) {
|
||||
prefs(context).edit().putBoolean(KEY_SEEN_PERMISSIONS_SETUP, true).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
|
||||
* preview) on the home screen. On by default while the project is pre-release.
|
||||
* Whether Gearslip is allowed to claim the USB accessory connection. Off leaves the app
|
||||
* installed but disables the GearslipUsbAccessory alias that answers
|
||||
* USB_ACCESSORY_ATTACHED, so a real Android Auto head unit (or Google's own app) gets the
|
||||
* connection instead - the two fight over it otherwise, since only one app can win the
|
||||
* accessory handoff. The alias is separate from GearslipActivity itself - the activity that
|
||||
* shows this very switch - so turning it off can't disable the screen you're looking at.
|
||||
* On by default.
|
||||
*/
|
||||
fun debugMode(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_DEBUG_MODE, true)
|
||||
fun gearslipEnabled(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_GEARSLIP_ENABLED, true)
|
||||
|
||||
fun setDebugMode(context: Context, value: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_DEBUG_MODE, value).apply()
|
||||
GearslipLog.debug = value
|
||||
fun setGearslipEnabled(context: Context, value: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_GEARSLIP_ENABLED, value).apply()
|
||||
applyGearslipEnabled(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the USB alias match the saved switch. Called at every launch as well, so a switch
|
||||
* saved without the alias following it (an older build crashed right there) heals itself.
|
||||
* Only the alias changes: the driver's "always open with Gearslip" choice for the car is kept.
|
||||
*/
|
||||
fun applyGearslipEnabled(context: Context) {
|
||||
val value = gearslipEnabled(context)
|
||||
val appContext = context.applicationContext
|
||||
// The class name comes from the code's namespace, not the package: a debug build installs
|
||||
// as ".dev", but the alias is still app.seb3thehacker.gearslip.GearslipUsbAccessory.
|
||||
val component = ComponentName(appContext.packageName, "${GearslipActivity::class.java.`package`!!.name}.GearslipUsbAccessory")
|
||||
val state = if (value) {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DEFAULT
|
||||
} else {
|
||||
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
|
||||
}
|
||||
val pm = appContext.packageManager
|
||||
if (pm.getComponentEnabledSetting(component) == state) return
|
||||
runCatching { pm.setComponentEnabledSetting(component, state, PackageManager.DONT_KILL_APP) }
|
||||
.onFailure { GearslipLog.e("could not switch the USB connection ${if (value) "on" else "off"}", it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Who has the car's screen: Gearslip, or the car's own interface. Android Auto's "Exit" hands the
|
||||
* screen back to the car with the session still running, so music keeps playing; the car's own
|
||||
* Android Auto button brings Gearslip back. [GearslipRunner] fills in [request] while connected.
|
||||
*/
|
||||
object CarFocus {
|
||||
|
||||
private val projectedFlow = MutableStateFlow(true)
|
||||
|
||||
/** False while the car shows its own interface. */
|
||||
val projected: StateFlow<Boolean> = projectedFlow
|
||||
|
||||
@Volatile internal var request: (() -> Unit)? = null
|
||||
|
||||
/** Asks the car to show its own interface. Does nothing when no car is connected. */
|
||||
fun exitToCar() {
|
||||
request?.invoke()
|
||||
}
|
||||
|
||||
internal fun set(projected: Boolean) {
|
||||
projectedFlow.value = projected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
|
||||
* repository on GitHub. The app ships no certificates; it downloads them only when the
|
||||
* user chooses to, the same way a browser would.
|
||||
*/
|
||||
object CertDownloader {
|
||||
|
||||
private val log = GearslipLog.tagged("DOWNL")
|
||||
|
||||
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
|
||||
private const val CRT = "$BASE/headunit.crt"
|
||||
private const val KEY = "$BASE/headunit.key"
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 15_000
|
||||
|
||||
class Downloaded(val certificatePem: String, val privateKeyPem: String)
|
||||
|
||||
/** Fetches both files; throws with a readable message on any failure. */
|
||||
fun fetch(): Downloaded {
|
||||
log.i("fetching certificate from $BASE")
|
||||
val crt = fetch(CRT, "certificate")
|
||||
val key = fetch(KEY, "private key")
|
||||
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
|
||||
return Downloaded(crt, key)
|
||||
}
|
||||
|
||||
private fun fetch(url: String, label: String): String {
|
||||
val body = try {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
}
|
||||
conn.inputStream.use { input ->
|
||||
val out = ByteArrayOutputStream()
|
||||
val buf = ByteArray(4096)
|
||||
var n: Int
|
||||
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
|
||||
out.toString(Charsets.UTF_8)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("could not download the $label: ${e.message}", e)
|
||||
}
|
||||
if (body.isBlank()) throw RuntimeException("the $label file is empty")
|
||||
return body
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.X509Certificate
|
||||
@@ -14,7 +15,9 @@ import java.security.cert.X509Certificate
|
||||
*
|
||||
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
||||
* copied into the app's private storage.
|
||||
* 2. **Staged over adb** into the app's external files directory, the way the spike has
|
||||
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
|
||||
* user asks; the app never ships or hosts the certificate.
|
||||
* 3. **Staged over adb** into the app's external files directory, the way the spike has
|
||||
* always done it. Still honoured, so existing setups keep working untouched:
|
||||
*
|
||||
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
||||
@@ -23,7 +26,7 @@ import java.security.cert.X509Certificate
|
||||
*
|
||||
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
|
||||
* regenerating); a `phone.pass` file next to it overrides it.
|
||||
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||
* SPIKE_FINDINGS.md).
|
||||
*/
|
||||
object CertProvider {
|
||||
@@ -34,7 +37,7 @@ object CertProvider {
|
||||
private const val DEFAULT_PASSWORD = "aaspike"
|
||||
private const val IMPORT_DIR = "identity"
|
||||
|
||||
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
|
||||
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
|
||||
|
||||
class Identity(
|
||||
val keyStore: KeyStore,
|
||||
@@ -44,7 +47,10 @@ object CertProvider {
|
||||
val kind: Kind = Kind.SELF_SIGNED,
|
||||
)
|
||||
|
||||
/** Loaded fresh each call, so a newly imported or pushed cert takes effect on the next connect. */
|
||||
/**
|
||||
* The identity for the next connection. A newly imported or pushed cert takes effect on the
|
||||
* next connect: see [loadFromFiles] for how the cache notices.
|
||||
*/
|
||||
fun load(context: Context): Identity = loadFromFiles(context) ?: run {
|
||||
val generated = SelfSignedCert.generate()
|
||||
Identity(
|
||||
@@ -62,7 +68,38 @@ object CertProvider {
|
||||
*/
|
||||
fun loadSupplied(context: Context): Identity? = loadFromFiles(context)
|
||||
|
||||
/**
|
||||
* Unlocking a PKCS#12 file takes about a second on a Pixel 6, which a head unit waiting for
|
||||
* its first reply may not wait out. So the unlocked identity is kept, keyed on the files it
|
||||
* could come from: importing, downloading, removing or pushing a cert changes the key.
|
||||
*/
|
||||
@Volatile private var cached: Pair<String, Identity>? = null
|
||||
|
||||
private fun sourcesKey(context: Context): String {
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
val files = listOf(
|
||||
importedFile(context, P12_NAME),
|
||||
downloadedFile(context, P12_NAME),
|
||||
dir?.let { File(it, P12_NAME) },
|
||||
dir?.let { File(it, PASS_NAME) },
|
||||
)
|
||||
return files.joinToString("|") { f -> f?.let { "${it.path}:${it.lastModified()}:${it.length()}" } ?: "-" }
|
||||
}
|
||||
|
||||
/** Unlocks the certificate ahead of time, so the first connection doesn't wait on it. */
|
||||
fun warm(context: Context) {
|
||||
runCatching { loadFromFiles(context) }
|
||||
}
|
||||
|
||||
private fun loadFromFiles(context: Context): Identity? {
|
||||
val key = sourcesKey(context)
|
||||
cached?.let { (k, identity) -> if (k == key) return identity }
|
||||
val identity = unlockFromFiles(context) ?: return null
|
||||
cached = key to identity
|
||||
return identity
|
||||
}
|
||||
|
||||
private fun unlockFromFiles(context: Context): Identity? {
|
||||
val imported = importedFile(context, P12_NAME)
|
||||
if (imported.isFile) {
|
||||
runCatching {
|
||||
@@ -70,6 +107,13 @@ object CertProvider {
|
||||
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val downloaded = downloadedFile(context, P12_NAME)
|
||||
if (downloaded.isFile) {
|
||||
runCatching {
|
||||
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
|
||||
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
|
||||
}
|
||||
|
||||
val dir = context.getExternalFilesDir(null)
|
||||
val staged = dir?.let { File(it, P12_NAME) }
|
||||
if (dir != null && staged != null && staged.isFile) {
|
||||
@@ -113,17 +157,84 @@ object CertProvider {
|
||||
|
||||
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
||||
|
||||
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
|
||||
/** Whether a downloaded certificate is stored (may or may not also be imported). */
|
||||
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
|
||||
|
||||
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
|
||||
fun removeImported(context: Context) {
|
||||
importedFile(context, P12_NAME).delete()
|
||||
importedFile(context, PASS_NAME).delete()
|
||||
log.i("removed the imported certificate")
|
||||
}
|
||||
|
||||
/** Drops the downloaded identity. */
|
||||
fun removeDownloaded(context: Context) {
|
||||
downloadedFile(context, P12_NAME).delete()
|
||||
downloadedFile(context, PASS_NAME).delete()
|
||||
log.i("removed the downloaded certificate")
|
||||
}
|
||||
|
||||
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
|
||||
fun hasAnyCert(context: Context): Boolean =
|
||||
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
|
||||
|
||||
private fun hasAdbStaged(context: Context): Boolean =
|
||||
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
|
||||
|
||||
/**
|
||||
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
|
||||
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
|
||||
* rather than importing from a file picker.
|
||||
*/
|
||||
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
|
||||
val staging = File(context.cacheDir, "import-$P12_NAME")
|
||||
try {
|
||||
staging.writeBytes(p12Bytes)
|
||||
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
|
||||
|
||||
val target = importedFile(context, P12_NAME)
|
||||
target.parentFile?.mkdirs()
|
||||
staging.copyTo(target, overwrite = true)
|
||||
importedFile(context, PASS_NAME).writeText(password)
|
||||
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
|
||||
return identity
|
||||
} finally {
|
||||
staging.delete()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the publicly available head unit certificate from the opencardev/aasdk
|
||||
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
|
||||
* Runs synchronously (caller is responsible for threading). Throws on any failure.
|
||||
*/
|
||||
fun downloadAndImport(context: Context): Identity {
|
||||
val download = CertDownloader.fetch()
|
||||
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
|
||||
|
||||
val target = downloadedFile(context, P12_NAME)
|
||||
target.parentFile?.mkdirs()
|
||||
val p12Password = DEFAULT_PASSWORD
|
||||
val keyStore = result.keyStore
|
||||
val p12Bytes = ByteArrayOutputStream().use { stream ->
|
||||
keyStore.store(stream, p12Password.toCharArray())
|
||||
stream.toByteArray()
|
||||
}
|
||||
target.writeBytes(p12Bytes)
|
||||
downloadedFile(context, PASS_NAME).writeText(p12Password)
|
||||
|
||||
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
|
||||
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
|
||||
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
|
||||
}
|
||||
|
||||
// The imported files live in the app's private internal storage (not the external files
|
||||
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
|
||||
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
|
||||
|
||||
private fun downloadedFile(context: Context, name: String) =
|
||||
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
|
||||
|
||||
private fun importedPassword(context: Context): String =
|
||||
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
/**
|
||||
* What changed in each release, newest first, shown on the car screen after an update (and on
|
||||
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
|
||||
* a parked car.
|
||||
*/
|
||||
object Changelog {
|
||||
|
||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||
|
||||
val releases = listOf(
|
||||
Release(
|
||||
15, "0.3.40-alpha",
|
||||
listOf(
|
||||
"A liked song lights up the whole like button, as shuffle and repeat do.",
|
||||
"Music no longer carries on from the phone after you turn the car off.",
|
||||
"Cars that hold back their screen are asked for it again in a way older cars understand.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
14, "0.3.39-alpha",
|
||||
listOf(
|
||||
"Turns from Google Maps or Waze on the phone show in the nav bar and on the car's cluster.",
|
||||
"Alerts from car apps show here, such as Waze asking whether a hazard is still there.",
|
||||
"Hide apps you don't use in the car from the Apps card on the phone.",
|
||||
"Make text in car apps bigger with Car display density, under Screen.",
|
||||
"Music playing through Gearslip comes back after a call.",
|
||||
"Maps keep your position while the phone is locked.",
|
||||
"Wireless keeps trying until the car answers.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
9, "0.3.00-alpha",
|
||||
listOf(
|
||||
"Wireless Android Auto: pair the phone with the car once, and leave the cable at home.",
|
||||
"Voice control is built in. Say \"navigate to a gas station\", \"play\" a song or artist, or open an app by name.",
|
||||
"\"Navigate to\" picks the nearest place and stops at the map app's start screen, so you only tap Start.",
|
||||
"Gearslip turns speech into text on the phone, with no Google account. Download the voice models once in setup.",
|
||||
"The car's rotary knob moves between buttons, so cars without a touchscreen work too.",
|
||||
"Choose how music reaches the car: Bluetooth, or USB with no delay. Gearslip offers to pair with the car the first time.",
|
||||
"The car speaks each turn from your map app, and shows the directions on its own display where it can.",
|
||||
"The car shows what's playing, and music apps get their own like, shuffle and repeat buttons.",
|
||||
"Pick how the car's buttons look. Change it any time in the car's settings.",
|
||||
"A new destination mid-route replaces the old route, and switching map apps ends the old app's route.",
|
||||
"The car warns you when the phone's location is off.",
|
||||
"Gearslip finds new versions and installs them for you.",
|
||||
"More cars play music over USB, show a sharper picture, and put taps where you press.",
|
||||
"On GrapheneOS, Gearslip no longer needs exploit protection compatibility mode.",
|
||||
"The app is half the size it was.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
8, "0.2.00-alpha",
|
||||
listOf(
|
||||
"Exit takes you to the car's own screen. Tap Android Auto on the car to come back.",
|
||||
"Gearslip listens through the car's microphone when the car has one, so voice works with the phone in a pocket.",
|
||||
"A voice reply sends when its bar runs out, even over road noise or the radio. Say \"cancel\" or \"change it\" before then.",
|
||||
"Maps turn dark when the car switches to night mode.",
|
||||
"A moving map no longer makes the car screen fall behind your taps.",
|
||||
"Gearslip asks to send sound to the car as soon as you connect, so videos in the web browser play in the car too.",
|
||||
"Cars that offer more than one kind of music audio, such as the 2022 Dacia Jogger, play sound again.",
|
||||
"When the car shows a keyboard, you can type on the phone instead. The car's keyboard is taller too.",
|
||||
"Every car screen has a back button.",
|
||||
"App icons show whether an app works in Gearslip. Metrolist works and shows the green check.",
|
||||
"LIVI connects again, and touch works on cars with a second screen.",
|
||||
"The phone's Settings screen has a new layout, with What's new and the setup guide at the top.",
|
||||
"You can choose to send short usage notes that help the project. They hold no name, account, or location, and they're off unless you turn them on.",
|
||||
"Searching inside a media app moved to Settings > Experimental features.",
|
||||
"Under Settings > Media audio, the choices are now Phone audio and Car audio.",
|
||||
"Logs leave out your location and the car's serial number.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
7, "0.1.10-alpha",
|
||||
listOf(
|
||||
"The launcher groups your apps: Phone, Maps, Music, Messaging, Web, Screen sharing, and Settings.",
|
||||
"To move an app, press and hold it on the launcher, then tap Move left or Move right.",
|
||||
"MapQuest and Flitsmeister work in Gearslip and show the green check.",
|
||||
"Spotify has one tile. If Spotify isn't open on the phone, tap Open Spotify to start it.",
|
||||
"Flick a map and it glides on, as it does in Android Auto.",
|
||||
"The map picks up where it left off when you come back from another screen.",
|
||||
"Search boxes in map apps are larger and easier to read.",
|
||||
"Map apps keep the right icons on their buttons after they update.",
|
||||
"When Gearslip asks \"Do you want to reply?\", a bar shows how long it will listen. The bar stops while you speak.",
|
||||
"Gearslip fits its picture to wide car screens and keeps it clear of the edges the car crops.",
|
||||
"Gearslip answers the car's connection checks correctly. Some cars, Audis among them, hung up after a second without this.",
|
||||
"Google Maps no longer shows up as a messaging app.",
|
||||
),
|
||||
),
|
||||
Release(
|
||||
6, "0.1.0-alpha",
|
||||
listOf(
|
||||
"Messaging apps such as Signal and Molly open to your recent chats.",
|
||||
"Gearslip reads a message aloud, and you reply by voice. Say \"cancel\" or \"change it\" before it sends, or tap a quick reply such as your arrival time.",
|
||||
"Voice works on any phone, GrapheneOS included. Gearslip turns speech into text itself, so it needs no Google account.",
|
||||
"New messages show as a small card at the top right, even while you drive. A bar shows how long until the card closes or the reply sends.",
|
||||
"Only messages, missed calls, alarms, and reminders pop up. Everything else waits on the dashboard.",
|
||||
"The map stays loaded when you switch screens, so it no longer flashes black.",
|
||||
"Map apps draw more reliably and keep their connection when the phone is locked.",
|
||||
"The Home button shows your map app, so it no longer appears twice.",
|
||||
"The next turn shows in the nav bar when you leave the map.",
|
||||
"Steering wheel buttons, control knobs, and D-pads now work. If yours don't, open an issue on GitHub.",
|
||||
"To see the whole song title and artist in the nav bar, choose Settings > Player in the nav bar > Song title.",
|
||||
"Choose miles or kilometres under Settings > Units.",
|
||||
"Try the voice assistant and the vehicle data screen under Settings > Experimental features.",
|
||||
"Logs now say why a car ended the connection.",
|
||||
"Spotify, Waze, and Google Messages updated to a version of Google's car library that only accepts Android Auto itself. Their own car screens won't open in Gearslip. Spotify still plays through the Gearslip player, which has the green check.",
|
||||
"Calls go through the car's Bluetooth, as they do in Android Auto. Pair your phone with the car to hear calls on its speakers.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/** The release that matches this build, if it has notes. */
|
||||
val current: Release? get() = releases.firstOrNull { it.versionCode == BuildConfig.VERSION_CODE }
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.car.ClusterScreen
|
||||
|
||||
/**
|
||||
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
|
||||
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
|
||||
* on its own channel, with its own encoder and its own private display:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
|
||||
* -> VideoFocusRequest <- VideoFocusNotification { projected }
|
||||
* -> Start, CodecConfig, Data... <- Ack
|
||||
*
|
||||
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
|
||||
* untested in a car: none in the notes has a cluster sink.
|
||||
*/
|
||||
internal class ClusterVideo(
|
||||
private val context: Context,
|
||||
private val service: ServiceDiscovery.VideoService,
|
||||
private val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
private val log = GearslipLog.tagged("CLUSTER")
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
private var configIndex = 0
|
||||
@Volatile private var started = false
|
||||
@Volatile private var running = true
|
||||
@Volatile private var source: VideoSource? = null
|
||||
private var projector: ScreenProjector? = null
|
||||
|
||||
private val ackLock = Object()
|
||||
@Volatile private var inFlight = 0
|
||||
@Volatile private var maxUnacked = 1
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var frames = 0
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) return
|
||||
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
|
||||
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_CONFIG -> {
|
||||
val fields = Wire.fields(body)
|
||||
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
|
||||
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
|
||||
// The cluster gets the smallest picture it offers: a turn card needs no more.
|
||||
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
|
||||
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
|
||||
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
|
||||
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
|
||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||
log.i("<- VideoFocusNotification: mode=$mode")
|
||||
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||
if (projected && !started) startStream()
|
||||
}
|
||||
|
||||
GearslipRunner.MSG_MEDIA_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStream() {
|
||||
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
|
||||
val (width, height) = config.pixelSize() ?: run {
|
||||
log.w("unsupported cluster resolution ${config.resolutionName}")
|
||||
return
|
||||
}
|
||||
started = true
|
||||
send(
|
||||
GearslipRunner.MSG_MEDIA_START,
|
||||
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
)
|
||||
log.i("-> Start(config=$configIndex -> $config)")
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
val video = VideoSource(
|
||||
width = width,
|
||||
height = height,
|
||||
frameRate = FRAME_RATE,
|
||||
mode = VideoSource.Mode.SURFACE,
|
||||
wireless = true, // a small share of the link: the main screen comes first
|
||||
onCodecConfig = { csd ->
|
||||
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
|
||||
log.i("-> CodecConfig (${csd.size} bytes)")
|
||||
},
|
||||
onFrame = { data, pts, _ -> sendFrame(data, pts) },
|
||||
)
|
||||
source = video
|
||||
runCatching { video.start() }.onFailure {
|
||||
log.e("cluster encoder failed to start", it)
|
||||
return
|
||||
}
|
||||
val surface = video.inputSurface ?: return
|
||||
val density = config.density.takeIf { it > 0 } ?: 160
|
||||
main.post {
|
||||
if (!running) return@post
|
||||
projector = ScreenProjector(context).also {
|
||||
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
||||
// The margins the car crops off are kept clear, as on the main screen.
|
||||
ClusterScreen(config.widthMargin, config.heightMargin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** As on the main screen: wait for the car's acks rather than drop a frame. */
|
||||
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
|
||||
synchronized(ackLock) {
|
||||
while (running && inFlight >= maxUnacked) {
|
||||
val since = System.currentTimeMillis() - lastAckAt
|
||||
if (since > GearslipRunner.ACK_STALL_MS) {
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
break
|
||||
}
|
||||
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
|
||||
}
|
||||
}
|
||||
if (!running) return
|
||||
val timestamp = ByteArray(8)
|
||||
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
|
||||
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
main.post {
|
||||
projector?.stop()
|
||||
projector = null
|
||||
}
|
||||
source?.stop()
|
||||
source = null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// A turn card changes a few times a minute; the encoder repeats the last frame between.
|
||||
const val FRAME_RATE = 15
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.opengl.EGL14
|
||||
import android.opengl.EGLConfig
|
||||
import android.opengl.EGLContext
|
||||
import android.opengl.EGLDisplay
|
||||
import android.opengl.EGLExt
|
||||
import android.opengl.EGLSurface
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.view.Surface
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Stretches a [width] x [height] picture over the whole of an [outWidth] x [outHeight] encoder
|
||||
* frame.
|
||||
*
|
||||
* For a head unit that squashes the frame onto a screen of another shape: Gearslip draws for the
|
||||
* screen's real shape into [input], this stretches it the other way, and the car's squash brings
|
||||
* it back. It works on the composited picture, so a map app's own surface is stretched with
|
||||
* everything else - scaling Gearslip's UI alone would leave the map squashed.
|
||||
*/
|
||||
class FrameStretcher(
|
||||
private val output: Surface,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
private val outWidth: Int,
|
||||
private val outHeight: Int,
|
||||
) {
|
||||
|
||||
private val thread = HandlerThread("gearslip-stretch").apply { start() }
|
||||
private val handler = Handler(thread.looper)
|
||||
|
||||
private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
|
||||
private var context: EGLContext = EGL14.EGL_NO_CONTEXT
|
||||
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
|
||||
private var program = 0
|
||||
private var texture: SurfaceTexture? = null
|
||||
private val transform = FloatArray(16)
|
||||
|
||||
/** Where to draw. Valid once [start] returns true. */
|
||||
lateinit var input: Surface
|
||||
private set
|
||||
|
||||
/** Sets up on its own thread and waits for it; false if the GPU wouldn't play along. */
|
||||
fun start(): Boolean {
|
||||
val done = CountDownLatch(1)
|
||||
var ok = false
|
||||
handler.post {
|
||||
ok = runCatching { setUp() }
|
||||
.onFailure { GearslipLog.e("stretch: could not set up", it) }
|
||||
.isSuccess
|
||||
done.countDown()
|
||||
}
|
||||
done.await(2, TimeUnit.SECONDS)
|
||||
if (!ok) release()
|
||||
return ok
|
||||
}
|
||||
|
||||
fun release() {
|
||||
handler.post {
|
||||
runCatching { if (::input.isInitialized) input.release() }
|
||||
runCatching { texture?.release() }
|
||||
texture = null
|
||||
if (display != EGL14.EGL_NO_DISPLAY) {
|
||||
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
|
||||
if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface)
|
||||
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
|
||||
EGL14.eglTerminate(display)
|
||||
}
|
||||
display = EGL14.EGL_NO_DISPLAY
|
||||
thread.quitSafely()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setUp() {
|
||||
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
|
||||
val version = IntArray(2)
|
||||
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val count = IntArray(1)
|
||||
val attributes = intArrayOf(
|
||||
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
|
||||
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
|
||||
EGL_RECORDABLE_ANDROID, 1,
|
||||
EGL14.EGL_NONE,
|
||||
)
|
||||
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) {
|
||||
"no EGL config"
|
||||
}
|
||||
context = EGL14.eglCreateContext(
|
||||
display, configs[0], EGL14.EGL_NO_CONTEXT,
|
||||
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
|
||||
)
|
||||
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], output, intArrayOf(EGL14.EGL_NONE), 0)
|
||||
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
|
||||
|
||||
program = link(VERTEX, FRAGMENT)
|
||||
val ids = IntArray(1)
|
||||
GLES20.glGenTextures(1, ids, 0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
|
||||
texture = SurfaceTexture(ids[0]).apply {
|
||||
setDefaultBufferSize(width, height)
|
||||
setOnFrameAvailableListener({ draw() }, handler)
|
||||
}
|
||||
input = Surface(texture)
|
||||
GearslipLog.i("stretch: drawing ${width}x$height, stretched to fill ${outWidth}x$outHeight")
|
||||
}
|
||||
|
||||
private fun draw() {
|
||||
val tex = texture ?: return
|
||||
runCatching {
|
||||
tex.updateTexImage()
|
||||
tex.getTransformMatrix(transform)
|
||||
GLES20.glViewport(0, 0, outWidth, outHeight)
|
||||
GLES20.glUseProgram(program)
|
||||
val position = GLES20.glGetAttribLocation(program, "aPosition")
|
||||
val coord = GLES20.glGetAttribLocation(program, "aCoord")
|
||||
GLES20.glEnableVertexAttribArray(position)
|
||||
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, QUAD)
|
||||
GLES20.glEnableVertexAttribArray(coord)
|
||||
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, COORDS)
|
||||
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||
EGLExt.eglPresentationTimeANDROID(display, eglSurface, tex.timestamp)
|
||||
EGL14.eglSwapBuffers(display, eglSurface)
|
||||
}.onFailure { GearslipLog.e("stretch: draw failed", it) }
|
||||
}
|
||||
|
||||
private fun link(vertex: String, fragment: String): Int {
|
||||
fun compile(type: Int, source: String): Int = GLES20.glCreateShader(type).also {
|
||||
GLES20.glShaderSource(it, source)
|
||||
GLES20.glCompileShader(it)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, status, 0)
|
||||
check(status[0] != 0) { "shader: ${GLES20.glGetShaderInfoLog(it)}" }
|
||||
}
|
||||
return GLES20.glCreateProgram().also {
|
||||
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
|
||||
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
|
||||
GLES20.glLinkProgram(it)
|
||||
val status = IntArray(1)
|
||||
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, status, 0)
|
||||
check(status[0] != 0) { "program: ${GLES20.glGetProgramInfoLog(it)}" }
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** EGL_RECORDABLE_ANDROID: the config must be able to feed a video encoder. */
|
||||
const val EGL_RECORDABLE_ANDROID = 0x3142
|
||||
|
||||
val QUAD = floats(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
|
||||
val COORDS = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
|
||||
|
||||
fun floats(vararg values: Float) = ByteBuffer.allocateDirect(values.size * 4)
|
||||
.order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
|
||||
|
||||
const val VERTEX = """
|
||||
attribute vec4 aPosition;
|
||||
attribute vec4 aCoord;
|
||||
uniform mat4 uTransform;
|
||||
varying vec2 vCoord;
|
||||
void main() {
|
||||
gl_Position = aPosition;
|
||||
vCoord = (uTransform * aCoord).xy;
|
||||
}
|
||||
"""
|
||||
|
||||
const val FRAGMENT = """
|
||||
#extension GL_OES_EGL_image_external : require
|
||||
precision mediump float;
|
||||
varying vec2 vCoord;
|
||||
uniform samplerExternalOES sTexture;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(sTexture, vCoord);
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,10 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Android Auto frame layout, transcribed from aasdk.
|
||||
*
|
||||
* FrameHeader::getData (src/Messenger/FrameHeader.cpp:47-55),
|
||||
* FrameSize::getData (src/Messenger/FrameSize.cpp:49-62) and
|
||||
* MessageOutStream::compoundFrame (src/Messenger/MessageOutStream.cpp:100-120):
|
||||
*
|
||||
* byte 0 channel id
|
||||
* byte 1 frameType | encryption | messageType
|
||||
* bytes 2-3 uint16 BE payload length of THIS frame
|
||||
* bytes 4-7 uint32 BE total message length - present ONLY when frameType == FIRST
|
||||
* payload uint16 BE message id, then the body
|
||||
*/
|
||||
/** Android Auto framing with plaintext fragmentation before per-frame encryption. */
|
||||
object Frames {
|
||||
|
||||
const val CHANNEL_CONTROL = 0
|
||||
@@ -36,8 +26,11 @@ object Frames {
|
||||
const val MESSAGE_SPECIFIC = 0
|
||||
const val MESSAGE_CONTROL = 1 shl 2
|
||||
|
||||
/** aasdk splits at this size (MessageOutStream.hpp:62). Nothing the spike sends is close. */
|
||||
const val MAX_FRAME_PAYLOAD = 0x4000
|
||||
// Android Auto's default fragment budget includes the framing header. TLS adds overhead.
|
||||
private const val FRAGMENT_SIZE = 0x3f00
|
||||
private const val HEADER_SIZE = 4
|
||||
private const val FIRST_HEADER_SIZE = 8
|
||||
const val MAX_FRAME_PAYLOAD = FRAGMENT_SIZE - HEADER_SIZE
|
||||
|
||||
class Frame(
|
||||
val channel: Int,
|
||||
@@ -45,6 +38,7 @@ object Frames {
|
||||
val encrypted: Boolean,
|
||||
val messageType: Int,
|
||||
val payload: ByteArray,
|
||||
val totalSize: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -60,6 +54,11 @@ object Frames {
|
||||
frameType: Int = TYPE_BULK,
|
||||
totalSize: Int? = null,
|
||||
): ByteArray {
|
||||
require(channel in 0..255 && payload.size <= 0xffff)
|
||||
require(frameType in TYPE_MIDDLE..TYPE_BULK)
|
||||
require(messageType == MESSAGE_SPECIFIC || messageType == MESSAGE_CONTROL)
|
||||
require((frameType == TYPE_FIRST) == (totalSize != null))
|
||||
require(totalSize == null || totalSize in 2..MAX_MESSAGE_SIZE)
|
||||
val flags = frameType or messageType or (if (encrypted) ENCRYPTED else 0)
|
||||
val header = byteArrayOf(
|
||||
channel.toByte(),
|
||||
@@ -67,7 +66,7 @@ object Frames {
|
||||
((payload.size shr 8) and 0xFF).toByte(),
|
||||
(payload.size and 0xFF).toByte(),
|
||||
)
|
||||
if (frameType != TYPE_FIRST || totalSize == null) return header + payload
|
||||
if (totalSize == null) return header + payload
|
||||
val extended = byteArrayOf(
|
||||
((totalSize shr 24) and 0xFF).toByte(),
|
||||
((totalSize shr 16) and 0xFF).toByte(),
|
||||
@@ -77,6 +76,43 @@ object Frames {
|
||||
return header + extended + payload
|
||||
}
|
||||
|
||||
/** Splits plaintext before encryption; FIRST carries the complete plaintext length. */
|
||||
fun writeMessage(
|
||||
output: OutputStream,
|
||||
channel: Int,
|
||||
encrypted: Boolean,
|
||||
messageType: Int,
|
||||
message: ByteArray,
|
||||
encrypt: (ByteArray) -> ByteArray,
|
||||
): Int {
|
||||
require(message.size in 2..MAX_MESSAGE_SIZE)
|
||||
val fragmented = message.size > FRAGMENT_SIZE - HEADER_SIZE
|
||||
require(messageType != MESSAGE_CONTROL || !fragmented) { "Channel control messages cannot be fragmented" }
|
||||
var offset = 0
|
||||
var frames = 0
|
||||
while (offset < message.size) {
|
||||
val first = fragmented && offset == 0
|
||||
val headerSize = if (first) FIRST_HEADER_SIZE else HEADER_SIZE
|
||||
val end = minOf(offset + FRAGMENT_SIZE - headerSize, message.size)
|
||||
val type = when {
|
||||
!fragmented -> TYPE_BULK
|
||||
first -> TYPE_FIRST
|
||||
end == message.size -> TYPE_LAST
|
||||
else -> TYPE_MIDDLE
|
||||
}
|
||||
val chunk = message.copyOfRange(offset, end)
|
||||
output.write(build(channel, encrypted, if (encrypted) encrypt(chunk) else chunk,
|
||||
messageType, type, if (type == TYPE_FIRST) message.size else null))
|
||||
offset = end
|
||||
frames++
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
// Local resource limits, not protocol constants. No supported media packet needs more.
|
||||
private const val MAX_MESSAGE_SIZE = 16 * 1024 * 1024
|
||||
private const val MAX_BUFFERED_SIZE = 32 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Pulls whole frames out of an accumulating buffer.
|
||||
*
|
||||
@@ -87,6 +123,8 @@ object Frames {
|
||||
private var pending = ByteArray(0)
|
||||
|
||||
fun append(data: ByteArray, length: Int) {
|
||||
require(length in 0..data.size)
|
||||
if (length > MAX_BUFFERED_SIZE - pending.size) throw IOException("Frame buffer limit exceeded")
|
||||
val grown = ByteArray(pending.size + length)
|
||||
System.arraycopy(pending, 0, grown, 0, pending.size)
|
||||
System.arraycopy(data, 0, grown, pending.size, length)
|
||||
@@ -106,30 +144,53 @@ object Frames {
|
||||
val total = headerLength + frameSize
|
||||
if (pending.size < total) return null
|
||||
|
||||
val messageSize = if (frameType == TYPE_FIRST) {
|
||||
var size = 0L
|
||||
for (i in 4..7) size = (size shl 8) or (pending[i].toLong() and 0xff)
|
||||
if (size !in 2..MAX_MESSAGE_SIZE.toLong()) throw IOException("Invalid fragmented message length: $size")
|
||||
size.toInt()
|
||||
} else null
|
||||
val payload = pending.copyOfRange(headerLength, total)
|
||||
pending = pending.copyOfRange(total, pending.size)
|
||||
return Frame(
|
||||
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload,
|
||||
channel, frameType, (flags and ENCRYPTED) != 0, flags and MESSAGE_CONTROL, payload, messageSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rejoins FIRST/MIDDLE/LAST runs. Payloads arrive already decrypted. */
|
||||
/** Rejoins messages on independent channels, validating their plaintext length and flags. */
|
||||
class Assembler {
|
||||
private val partial = HashMap<Int, ByteArrayOutputStream>()
|
||||
private class Partial(val first: Frame, val data: ByteArrayOutputStream = ByteArrayOutputStream())
|
||||
private val partial = HashMap<Int, Partial>()
|
||||
private var buffered = 0
|
||||
|
||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? = when (frame.frameType) {
|
||||
TYPE_BULK -> payload
|
||||
TYPE_FIRST -> {
|
||||
partial[frame.channel] = ByteArrayOutputStream().apply { write(payload) }
|
||||
null
|
||||
fun offer(frame: Frame, payload: ByteArray): ByteArray? {
|
||||
if (frame.frameType == TYPE_BULK) {
|
||||
if (partial.containsKey(frame.channel)) fail("Complete message interrupted fragments")
|
||||
return payload
|
||||
}
|
||||
TYPE_MIDDLE -> {
|
||||
partial[frame.channel]?.write(payload)
|
||||
null
|
||||
}
|
||||
TYPE_LAST -> partial.remove(frame.channel)?.apply { write(payload) }?.toByteArray()
|
||||
else -> null
|
||||
val message = if (frame.frameType == TYPE_FIRST) {
|
||||
if (frame.totalSize == null || partial.containsKey(frame.channel)) fail("Invalid first fragment")
|
||||
Partial(frame).also { partial[frame.channel] = it }
|
||||
} else partial[frame.channel] ?: fail("Fragment without a first frame")
|
||||
val first = message.first
|
||||
if (first.encrypted != frame.encrypted || first.messageType != frame.messageType) fail("Fragment flags changed")
|
||||
val expected = first.totalSize ?: fail("Missing total size")
|
||||
if (expected !in 2..MAX_MESSAGE_SIZE || payload.size > expected - message.data.size()) fail("Fragment exceeds message length")
|
||||
if (payload.size > MAX_BUFFERED_SIZE - buffered) fail("Fragment buffer limit exceeded")
|
||||
message.data.write(payload)
|
||||
buffered += payload.size
|
||||
if (frame.frameType != TYPE_LAST) return null
|
||||
if (message.data.size() != expected) fail("Truncated fragmented message")
|
||||
partial.remove(frame.channel)
|
||||
buffered -= message.data.size()
|
||||
return message.data.toByteArray()
|
||||
}
|
||||
|
||||
private fun fail(message: String): Nothing {
|
||||
partial.clear()
|
||||
buffered = 0
|
||||
throw IOException(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,16 @@ import android.hardware.usb.UsbAccessory
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.Settings
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
import android.content.res.Configuration
|
||||
@@ -27,6 +30,7 @@ import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import app.seb3thehacker.gearslip.ui.GearslipApp
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import java.io.FileInputStream
|
||||
@@ -40,8 +44,12 @@ import java.io.FileOutputStream
|
||||
* rotation or theme change from recreating it and dropping the connection.
|
||||
*/
|
||||
class GearslipActivity : ComponentActivity(), Projection {
|
||||
private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
|
||||
|
||||
private var descriptor: ParcelFileDescriptor? = null
|
||||
/** The wireless session's socket, when the car came over Wi-Fi instead of USB. */
|
||||
private var wirelessSocket: java.net.Socket? = null
|
||||
private var runner: GearslipRunner? = null
|
||||
private var screenProjector: ScreenProjector? = null
|
||||
private var worker: Thread? = null
|
||||
@@ -69,7 +77,13 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
private val audioPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
if (granted) askAudioCapture() else {
|
||||
if (granted) askAudioCapture()
|
||||
else if (!shouldShowRequestPermissionRationale(android.Manifest.permission.RECORD_AUDIO)) {
|
||||
// Refused twice: Android now says no without showing anything, so the phone must.
|
||||
GearslipLog.w("audio: record permission blocked - Android no longer asks")
|
||||
CarAudio.micBlocked()
|
||||
micBlockedNotice.value = true
|
||||
} else {
|
||||
GearslipLog.w("audio: record permission declined")
|
||||
CarAudio.denied()
|
||||
}
|
||||
@@ -82,51 +96,69 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
} else {
|
||||
GearslipLog.w("audio: capture consent declined")
|
||||
CarAudio.denied()
|
||||
if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Raised when the microphone permission is blocked; see [MicBlockedDialog]. */
|
||||
private val micBlockedNotice = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
|
||||
/** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
|
||||
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
|
||||
|
||||
private val detachReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
||||
GearslipLog.w("accessory detached")
|
||||
closeAccessory()
|
||||
// A wireless session has no accessory to lose; leave it running.
|
||||
if (wirelessSocket == null) closeAccessory()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
|
||||
private val locationPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
||||
private var approvingCar: String? = null
|
||||
|
||||
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
|
||||
private val calendarPermission = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
||||
/** Whether the phone showed Gearslip when the car's intent arrived; see [stepAside]. */
|
||||
private var shownOnPhone = false
|
||||
private var stepAsideOnVideo = false
|
||||
private val carApproval = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
|
||||
) { result ->
|
||||
val address = approvingCar ?: return@registerForActivityResult
|
||||
approvingCar = null
|
||||
if (result.resultCode == RESULT_OK) app.seb3thehacker.gearslip.wireless.WirelessAuto.approved(this, address)
|
||||
else app.seb3thehacker.gearslip.wireless.WirelessAuto.declined(this, address)
|
||||
}
|
||||
|
||||
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
|
||||
// Playback capture needs the microphone even though it never records one. Ask for it here,
|
||||
// at the kerb, rather than the first time a song plays: a permission dialog that appears
|
||||
// mid-drive is one the driver has to read and answer at the wheel, and missing it is
|
||||
// indistinguishable from the car link being broken.
|
||||
if (CarSettings.pipeAudio.value &&
|
||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
|
||||
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
|
||||
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
|
||||
// is both a bad first impression and unreliable: Android does not consistently show more
|
||||
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||
enableEdgeToEdge()
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
GearslipLog.i("Gearslip ready")
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
||||
runOnUiThread { startWireless(socket) }
|
||||
}
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
|
||||
runOnUiThread {
|
||||
approvingCar = address
|
||||
carApproval.launch(androidx.activity.result.IntentSenderRequest.Builder(chooser).build())
|
||||
}
|
||||
}
|
||||
CarSettings.init(this)
|
||||
AppSettings.applyGearslipEnabled(this)
|
||||
// A capture that died without tidying up would leave notifications exposed to every
|
||||
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
||||
if (!app.seb3thehacker.gearslip.audio.AudioCaptureService.running) {
|
||||
@@ -137,7 +169,33 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
GearslipApp(onDisconnect = ::closeAccessory)
|
||||
GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole)
|
||||
val offer by offerBluetooth.collectAsState()
|
||||
if (offer) {
|
||||
fun close() {
|
||||
CarSettings.markAudioChoiceAsked()
|
||||
offerBluetooth.value = false
|
||||
}
|
||||
app.seb3thehacker.gearslip.ui.AudioChoiceDialog(
|
||||
onUseBluetooth = {
|
||||
CarSettings.setPipeAudio(false)
|
||||
close()
|
||||
},
|
||||
onKeepAsking = ::close,
|
||||
)
|
||||
}
|
||||
val micNotice by micBlockedNotice.collectAsState()
|
||||
if (micNotice) {
|
||||
app.seb3thehacker.gearslip.ui.MicBlockedDialog(
|
||||
onOpenSettings = {
|
||||
micBlockedNotice.value = false
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", packageName, null)),
|
||||
)
|
||||
},
|
||||
onDismiss = { micBlockedNotice.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +219,22 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Prefetch.warm(this)
|
||||
UsageStats.onAppOpened(this)
|
||||
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
|
||||
// Back from the app's settings with the microphone turned on: try car audio again.
|
||||
if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
CarAudio.micAllowed()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
shownOnPhone = true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
shownOnPhone = false
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
@@ -170,6 +244,8 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
||||
runCatching { unregisterReceiver(detachReceiver) }
|
||||
PhoneMirror.onStartRequested = null
|
||||
PhoneMirror.onTokenReady = null
|
||||
@@ -181,6 +257,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
|
||||
private fun requestCallScreeningRole() {
|
||||
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
|
||||
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
|
||||
GearslipLog.w("calls: call screening role unavailable on this phone")
|
||||
return
|
||||
}
|
||||
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
|
||||
runCatching {
|
||||
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
|
||||
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
|
||||
}
|
||||
|
||||
// --- Media audio --------------------------------------------------------------------
|
||||
|
||||
private fun requestAudioCapture() {
|
||||
@@ -221,7 +310,8 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
val target = surface ?: return
|
||||
if (screenProjector != null) return
|
||||
val projector = ScreenProjector(this).also { screenProjector = it }
|
||||
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
|
||||
// The frame's density, not frameDensity: that stays the car's own, for the phone mirror.
|
||||
projector.start(target, frameWidth, frameHeight, CarEnvironment.frame.value.densityDpi) { CarUi() }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,7 +335,20 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
// Debug builds only: opens the audio choice for a look, without turning down a real prompt.
|
||||
if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true
|
||||
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
||||
stepAsideOnVideo = !shownOnPhone
|
||||
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
||||
return
|
||||
}
|
||||
if (intent?.action != UsbManager.ACTION_USB_ACCESSORY_ATTACHED) return
|
||||
// Switched off in Settings: the system shouldn't route the car here at all, but if it
|
||||
// does, leave the accessory alone.
|
||||
if (!AppSettings.gearslipEnabled(this)) {
|
||||
GearslipLog.i("accessory attached, but Gearslip is switched off - not connecting")
|
||||
return
|
||||
}
|
||||
val accessory = intent.getParcelableExtra(UsbManager.EXTRA_ACCESSORY, UsbAccessory::class.java)
|
||||
if (accessory == null) {
|
||||
GearslipLog.e("attach intent carried no accessory")
|
||||
@@ -253,14 +356,51 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
return
|
||||
}
|
||||
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
||||
stepAsideOnVideo = !shownOnPhone
|
||||
start(accessory)
|
||||
}
|
||||
|
||||
private fun start(accessory: UsbAccessory) {
|
||||
if (worker?.isAlive == true) {
|
||||
GearslipLog.w("a run is already in progress")
|
||||
/** A known car came up over Bluetooth: connect wirelessly, unless USB or another try is already on it. */
|
||||
private fun connectToNearbyCar(address: String?) {
|
||||
if (address == null) return
|
||||
if (!AppSettings.gearslipEnabled(this)) {
|
||||
GearslipLog.i("auto-connect: Gearslip is switched off - not connecting")
|
||||
return
|
||||
}
|
||||
if (descriptor != null) {
|
||||
GearslipLog.i("auto-connect: the car is on USB already - staying on USB")
|
||||
return
|
||||
}
|
||||
// Only a session that's still running counts. One the car ended (a ByeBye, a dropped
|
||||
// network) leaves its runner behind, since only Gearslip ending a session clears it, and
|
||||
// that used to make every later auto-connect quietly do nothing.
|
||||
if (worker?.isAlive == true || app.seb3thehacker.gearslip.wireless.WirelessLink.busy) {
|
||||
GearslipLog.i("auto-connect: a session is already running")
|
||||
return
|
||||
}
|
||||
if (runner != null) closeAccessory(stopWireless = false)
|
||||
if (checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("auto-connect: needs the Nearby devices permission")
|
||||
return
|
||||
}
|
||||
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
|
||||
val device = adapter?.let { runCatching { it.getRemoteDevice(address) }.getOrNull() }
|
||||
if (device == null) {
|
||||
GearslipLog.w("auto-connect: Bluetooth couldn't hand over the car")
|
||||
return
|
||||
}
|
||||
GearslipLog.i("auto-connect: connecting")
|
||||
app.seb3thehacker.gearslip.wireless.WirelessLink.start(this, device)
|
||||
}
|
||||
|
||||
private fun start(accessory: UsbAccessory) {
|
||||
// A fresh ATTACHED intent means the system wants a new session, whatever state the old
|
||||
// one is in - closing first guarantees openAccessory() below never fights a descriptor
|
||||
// this same process forgot to release. The old worker thread often isn't alive by this
|
||||
// point anyway (its read loop already died when the head unit dropped the transport),
|
||||
// but the accessory it opened was never explicitly closed, which is what actually blocks
|
||||
// a re-open - a dead thread does not release the file descriptor on its own.
|
||||
closeAccessory()
|
||||
describe(accessory)
|
||||
|
||||
val manager = getSystemService(UsbManager::class.java)
|
||||
@@ -278,9 +418,49 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
identityProvider = { CertProvider.load(this) },
|
||||
projection = this,
|
||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||
phoneIcons = { PhoneIcons.pngs(this) },
|
||||
)
|
||||
this.runner = runner
|
||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||
holdLocation()
|
||||
}
|
||||
|
||||
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
|
||||
private fun startWireless(socket: java.net.Socket) {
|
||||
closeAccessory(stopWireless = false)
|
||||
if (!AppSettings.gearslipEnabled(this)) {
|
||||
GearslipLog.i("wireless: Gearslip is switched off - not connecting")
|
||||
runCatching { socket.close() }
|
||||
return
|
||||
}
|
||||
GearslipLog.i("wireless: starting a session over Wi-Fi to ${socket.inetAddress?.hostAddress}:${socket.port}")
|
||||
wirelessSocket = socket
|
||||
val runner = GearslipRunner(
|
||||
input = socket.getInputStream(),
|
||||
output = socket.getOutputStream(),
|
||||
identityProvider = { CertProvider.load(this) },
|
||||
projection = this,
|
||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||
wireless = true,
|
||||
phoneIcons = { PhoneIcons.pngs(this) },
|
||||
)
|
||||
this.runner = runner
|
||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||
holdLocation()
|
||||
}
|
||||
|
||||
/** Whether this session holds [app.seb3thehacker.gearslip.host.LocationKeepAlive]. */
|
||||
private var holdingLocation = false
|
||||
|
||||
/**
|
||||
* Android lets a foreground service started from the background use location only in the few
|
||||
* seconds after one of the app's screens opens. The car just opened this one, so take the
|
||||
* location service now: a map bound later, with the phone already locked, got no position.
|
||||
*/
|
||||
private fun holdLocation() {
|
||||
if (holdingLocation) return
|
||||
holdingLocation = true
|
||||
app.seb3thehacker.gearslip.host.LocationKeepAlive.acquire(this) {}
|
||||
}
|
||||
|
||||
private fun describe(accessory: UsbAccessory) {
|
||||
@@ -288,16 +468,39 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
GearslipLog.i(" version=${accessory.version} description=${accessory.description}")
|
||||
}
|
||||
|
||||
private fun closeAccessory() {
|
||||
private fun closeAccessory() = closeAccessory(stopWireless = true)
|
||||
|
||||
/** Ends the session, wired or wireless. [stopWireless] also drops the car's Bluetooth and Wi-Fi. */
|
||||
private fun closeAccessory(stopWireless: Boolean) {
|
||||
runner?.stop()
|
||||
SessionReport.print()
|
||||
runCatching { descriptor?.close() }
|
||||
descriptor = null
|
||||
runCatching { wirelessSocket?.close() }
|
||||
wirelessSocket = null
|
||||
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
|
||||
runner = null
|
||||
if (holdingLocation) {
|
||||
holdingLocation = false
|
||||
app.seb3thehacker.gearslip.host.LocationKeepAlive.release(this)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Projection ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The car opens Gearslip on the phone, since Android hands a USB car only to an activity and
|
||||
* auto-connect has to start one too. Once the car's screen is up, go back to whatever the
|
||||
* phone showed, as Home would; the session carries on as it does with the phone locked. A
|
||||
* question for the driver keeps Gearslip in front, and so does the phone already showing it.
|
||||
*/
|
||||
private fun stepAside() {
|
||||
stepAsideOnVideo = false
|
||||
if (offerBluetooth.value || micBlockedNotice.value || approvingCar != null) return
|
||||
GearslipLog.i("the car's screen is up - moving Gearslip off the phone's screen")
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
|
||||
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
||||
runOnUiThread {
|
||||
this.surface = surface
|
||||
@@ -307,20 +510,33 @@ class GearslipActivity : ComponentActivity(), Projection {
|
||||
CarEnvironment.setDisplay(width, height, densityDpi)
|
||||
CarEnvironment.start(this)
|
||||
showCarUi()
|
||||
if (stepAsideOnVideo) stepAside()
|
||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
||||
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>) {
|
||||
runOnUiThread {
|
||||
// To the front of the main thread's queue: a touch waiting behind other work is lag the
|
||||
// driver feels, while the work it jumps ahead of can wait a frame.
|
||||
mainHandler.postAtFrontOfQueue {
|
||||
// Mirroring drives one phone touch at a time, so it follows the first finger only.
|
||||
if (PhoneMirror.active.value) points.firstOrNull()?.let { relayTouch(action, it.x, it.y) }
|
||||
else screenProjector?.dispatchTouch(action, actionIndex, points)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStill() {
|
||||
screenProjector?.redraw()
|
||||
}
|
||||
|
||||
override fun onProjectionStopped() {
|
||||
// Before the car UI goes, while the media connection is still up.
|
||||
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
|
||||
runOnUiThread {
|
||||
PhoneMirror.requestStop()
|
||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
|
||||
screenProjector?.stop()
|
||||
screenProjector = null
|
||||
surface = null
|
||||
|
||||
@@ -32,9 +32,9 @@ object GearslipLog {
|
||||
private var file: File? = null
|
||||
|
||||
/**
|
||||
* Set from [AppSettings.debugMode]. Off drops the noisy trace output (hex dumps, and
|
||||
* logcat copies of info lines); warnings, errors and the file copy are always kept, so a
|
||||
* crash report is never empty.
|
||||
* Off drops the noisy trace output (hex dumps, and logcat copies of info lines); warnings,
|
||||
* errors and the file copy are always kept, so a crash report is never empty. Always on for
|
||||
* now, while the project is pre-release.
|
||||
*/
|
||||
@Volatile var debug = true
|
||||
|
||||
@@ -47,13 +47,109 @@ object GearslipLog {
|
||||
val version: StateFlow<Int> = liveVersion
|
||||
|
||||
fun init(context: Context) {
|
||||
debug = AppSettings.debugMode(context)
|
||||
// The phone app and the launcher both start here; one file per process.
|
||||
if (file != null) return
|
||||
val dir = context.getExternalFilesDir(null) ?: return
|
||||
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||
file = File(dir, "gearslip-$name.log")
|
||||
i("log file: ${file?.absolutePath}")
|
||||
SessionReport.init(context)
|
||||
i("debug mode: $debug")
|
||||
// One file per launch adds up; the newest are the ones anyone asks for.
|
||||
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
|
||||
lastExit(context, dir)
|
||||
}
|
||||
|
||||
private const val KEEP_FILES = 30
|
||||
private const val KEY_EXIT_SEEN = "log_exit_seen"
|
||||
|
||||
/**
|
||||
* Why Android ended the last run, when the crash handler can't have seen it: a native crash,
|
||||
* a freeze (ANR), or the memory killer. Written here and onto the end of that run's own log,
|
||||
* which is the one a driver shares after a drive that went wrong.
|
||||
*/
|
||||
private fun lastExit(context: Context, dir: File) {
|
||||
val am = context.getSystemService(android.app.ActivityManager::class.java) ?: return
|
||||
val seen = AppSettings.getString(context, KEY_EXIT_SEEN, "0").toLongOrNull() ?: 0L
|
||||
val exits = runCatching { am.getHistoricalProcessExitReasons(null, 0, 5) }.getOrDefault(emptyList())
|
||||
.filter { it.timestamp > seen }
|
||||
exits.firstOrNull()?.let { AppSettings.putString(context, KEY_EXIT_SEEN, it.timestamp.toString()) }
|
||||
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
for (exit in exits.reversed()) {
|
||||
val why = EXIT_REASONS[exit.reason] ?: continue
|
||||
val at = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(Date(exit.timestamp))
|
||||
val detail = buildString {
|
||||
append("$at, $why")
|
||||
exit.description?.takeIf { it.isNotBlank() }?.let { append(" ($it)") }
|
||||
append(", memory ${exit.pss / 1024} MB")
|
||||
if (exit.reason == android.app.ApplicationExitInfo.REASON_ANR) {
|
||||
anrMainThread(exit)?.let { append("\n").append(it) }
|
||||
}
|
||||
}
|
||||
w("Android ended an earlier run at $detail")
|
||||
// The run that died: the newest log that started before it ended.
|
||||
val run = logFiles(dir).filter { it != file }.firstOrNull { f ->
|
||||
val started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull()
|
||||
started != null && started.time <= exit.timestamp
|
||||
} ?: continue
|
||||
io.execute { runCatching { run.appendText("--- $EXIT_MARK $detail\n") } }
|
||||
}
|
||||
}
|
||||
|
||||
/** The main thread's stack from an ANR trace: what Gearslip was stuck doing. */
|
||||
private fun anrMainThread(exit: android.app.ApplicationExitInfo): String? = runCatching {
|
||||
exit.traceInputStream?.bufferedReader()?.useLines { lines ->
|
||||
lines.dropWhile { !it.startsWith("\"main\"") }.take(ANR_LINES).joinToString("\n")
|
||||
}?.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
|
||||
private const val ANR_LINES = 25
|
||||
private const val EXIT_MARK = "Android ended this run:"
|
||||
|
||||
/** Exit reasons worth a line; leaving, updating and Java crashes (already logged) aren't. */
|
||||
private val EXIT_REASONS = mapOf(
|
||||
android.app.ApplicationExitInfo.REASON_CRASH_NATIVE to "a native crash",
|
||||
android.app.ApplicationExitInfo.REASON_ANR to "the app stopped responding (ANR)",
|
||||
android.app.ApplicationExitInfo.REASON_LOW_MEMORY to "the phone ran low on memory",
|
||||
android.app.ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE to "it used too much of the phone's resources",
|
||||
android.app.ApplicationExitInfo.REASON_INITIALIZATION_FAILURE to "it failed to start",
|
||||
android.app.ApplicationExitInfo.REASON_SIGNALED to "the system killed it",
|
||||
android.app.ApplicationExitInfo.REASON_DEPENDENCY_DIED to "something it depends on died",
|
||||
)
|
||||
|
||||
private fun logFiles(dir: File): List<File> =
|
||||
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
||||
?.sortedByDescending { it.name }.orEmpty()
|
||||
|
||||
/** A log from an earlier launch: when it started, and what a glance at it tells. */
|
||||
class PastLog(val file: File, val started: Date?, val crashed: Boolean, val car: String?)
|
||||
|
||||
/**
|
||||
* Earlier launches' logs, newest first. Each launch starts a fresh file, so after a crash the
|
||||
* one that saw it is here rather than on the live screen.
|
||||
*/
|
||||
fun pastLogs(context: Context): List<PastLog> {
|
||||
val dir = context.getExternalFilesDir(null) ?: return emptyList()
|
||||
val parse = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
return logFiles(dir).filter { it != file }.map { f ->
|
||||
val text = runCatching { f.readText() }.getOrDefault("")
|
||||
PastLog(
|
||||
file = f,
|
||||
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
|
||||
crashed = "FATAL uncaught" in text || EXIT_MARK in text,
|
||||
car = Regex("""car="([^"]+)"""").findAll(text).lastOrNull()?.groupValues?.get(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** An earlier launch's log, copied to the cache dir for sharing like [shareableFile]. */
|
||||
fun shareableFile(context: Context, past: File): android.net.Uri {
|
||||
val dir = File(context.cacheDir, "shared").apply { mkdirs() }
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
val out = File(dir, "gearslip-log-${past.name.removePrefix("gearslip-").removeSuffix(".log")}.txt")
|
||||
past.copyTo(out, overwrite = true)
|
||||
return androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.logshare", out,
|
||||
)
|
||||
}
|
||||
|
||||
fun snapshot(): List<String> = synchronized(live) { live.toList() }
|
||||
@@ -135,7 +231,11 @@ object GearslipLog {
|
||||
* Records an uncaught exception in the log file before the process dies, then lets the
|
||||
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
|
||||
*/
|
||||
@Volatile private var crashHandlerInstalled = false
|
||||
|
||||
fun installCrashHandler() {
|
||||
if (crashHandlerInstalled) return
|
||||
crashHandlerInstalled = true
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
runCatching {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
import app.seb3thehacker.gearslip.car.CarSettings
|
||||
import app.seb3thehacker.gearslip.car.CarUi
|
||||
import app.seb3thehacker.gearslip.car.LauncherMode
|
||||
import app.seb3thehacker.gearslip.car.Prefetch
|
||||
import app.seb3thehacker.gearslip.ui.CarFrame
|
||||
import app.seb3thehacker.gearslip.ui.GearslipTheme
|
||||
import app.seb3thehacker.gearslip.ui.rememberCarProjector
|
||||
|
||||
/**
|
||||
* The home screen in launcher mode (see [LauncherMode]): the car UI, filling this device's own
|
||||
* screen. It runs for as long as the activity lives, not only while it's in front, so a map
|
||||
* keeps guiding and music keeps playing while another app is open over it.
|
||||
*/
|
||||
class LauncherActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
GearslipLog.init(this)
|
||||
GearslipLog.installCrashHandler()
|
||||
CarSettings.init(this)
|
||||
CarKeys.init(this)
|
||||
Prefetch.warm(this)
|
||||
CarEnvironment.setPhoneTheme(resources.configuration)
|
||||
|
||||
// A dash screen is something to glance at: it stays on, with the system bars out of the way.
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
WindowInsetsControllerCompat(window, window.decorView).apply {
|
||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
// Back moves back in the car UI. A home screen has nowhere else to go back to.
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
CarKeys.onKey(KeyEvent.KEYCODE_BACK, down = true, longPress = false)
|
||||
}
|
||||
})
|
||||
|
||||
setContent {
|
||||
GearslipTheme {
|
||||
LauncherScreen(onOpenGearslip = { startActivity(Intent(this, GearslipActivity::class.java)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Home pressed while Gearslip is already the home screen: back to the car UI's Home. */
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
CarKeys.onKey(KeyEvent.KEYCODE_HOME, down = true, longPress = false)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
CarEnvironment.setPhoneTheme(newConfig)
|
||||
}
|
||||
|
||||
/** A head unit's knob, D-pad and hard keys work the car UI the way a car's do. */
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode != KeyEvent.KEYCODE_BACK && event.repeatCount == 0 &&
|
||||
CarKeys.onKey(event.keyCode, event.action == KeyEvent.ACTION_DOWN, event.isLongPress)
|
||||
) return true
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LauncherScreen(onOpenGearslip: () -> Unit) {
|
||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||
// Plugged into a car, this device is the phone and the car UI belongs on the car's screen.
|
||||
val inCar = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
||||
if (inCar) {
|
||||
InCarNotice(onOpenGearslip)
|
||||
return
|
||||
}
|
||||
BoxWithConstraints(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val px = with(LocalDensity.current) { maxWidth.roundToPx() / 2 * 2 to maxHeight.roundToPx() / 2 * 2 }
|
||||
val (width, height) = px
|
||||
if (width <= 0 || height <= 0) return@BoxWithConstraints
|
||||
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses.
|
||||
// This screen stands in for the car's, so hosted apps size to it and the density setting applies.
|
||||
val density = remember(width, height) {
|
||||
CarEnvironment.setDisplay(width, height, (160 * height / 480).coerceIn(120, 640))
|
||||
CarEnvironment.frame.value.densityDpi
|
||||
}
|
||||
// Before the projector starts, so the car UI's first screen already knows what to hide.
|
||||
DisposableEffect(Unit) {
|
||||
LauncherMode.setActive(true)
|
||||
onDispose { LauncherMode.setActive(false) }
|
||||
}
|
||||
val projector = rememberCarProjector(width, height, density, "gearslip-launcher") { CarUi() }
|
||||
DisposableEffect(projector) {
|
||||
CarKeys.focusSink = { code, down -> projector.dispatchKey(code, down) }
|
||||
onDispose { CarKeys.focusSink = null }
|
||||
}
|
||||
CarFrame(projector, width, height, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InCarNotice(onOpenGearslip: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_in_car),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(onClick = onOpenGearslip) { Text(stringResource(R.string.launcher_open_gearslip)) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.security.spec.RSAPrivateCrtKeySpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
|
||||
/**
|
||||
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
|
||||
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
|
||||
*
|
||||
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
|
||||
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
|
||||
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
|
||||
*/
|
||||
object PemKeyParser {
|
||||
|
||||
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
|
||||
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
||||
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
|
||||
|
||||
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
|
||||
|
||||
/**
|
||||
* Parses PEM strings for the certificate and private key, verifies they match, and
|
||||
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
|
||||
*/
|
||||
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
|
||||
val cert = parseCertificate(certificatePem)
|
||||
val keySpec = parsePrivateKey(privateKeyPem)
|
||||
|
||||
val kf = KeyFactory.getInstance("RSA")
|
||||
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
|
||||
require(kf.generatePublic(pubSpec) == cert.publicKey) {
|
||||
"the certificate's public key does not match the private key"
|
||||
}
|
||||
|
||||
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||
load(null, null)
|
||||
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
|
||||
}
|
||||
return Result(keyStore, cert)
|
||||
}
|
||||
|
||||
/** Strips PEM headers/footers and decodes the base64 body. */
|
||||
fun parseCertificate(pem: String): X509Certificate {
|
||||
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
|
||||
return CertificateFactory.getInstance("X.509")
|
||||
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||
}
|
||||
|
||||
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
|
||||
fun parsePrivateKey(pem: String): KeySpec {
|
||||
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
|
||||
val integers = readPkcs1Sequence(der)
|
||||
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
|
||||
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
|
||||
return KeySpec(spec, privateKey)
|
||||
}
|
||||
|
||||
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
|
||||
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
|
||||
fun getPrivate() = privateKey
|
||||
}
|
||||
|
||||
/** Strips headers and decodes base64. */
|
||||
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
|
||||
val start = pem.indexOf(header)
|
||||
val end = pem.indexOf(footer)
|
||||
if (start == -1 || end == -1 || end <= start) {
|
||||
throw IllegalArgumentException("not a valid PEM $header block")
|
||||
}
|
||||
val b64 = pem.substring(start + header.length, end)
|
||||
.replace(Regex("\\s+"), "")
|
||||
return java.util.Base64.getDecoder().decode(b64)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
|
||||
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
|
||||
* silent wrong key.
|
||||
*/
|
||||
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
|
||||
var pos = 0
|
||||
fun readTag(): Int {
|
||||
require(pos < der.size) { "unexpected end of DER at tag" }
|
||||
return der[pos++].toInt() and 0xFF
|
||||
}
|
||||
fun readLength(): Int {
|
||||
require(pos < der.size) { "unexpected end of DER at length" }
|
||||
val b0 = der[pos++].toInt() and 0xFF
|
||||
if (b0 < 0x80) return b0
|
||||
val numBytes = b0 and 0x7F
|
||||
require(pos + numBytes <= der.size) { "truncated long-form length" }
|
||||
var result = 0
|
||||
repeat(numBytes) {
|
||||
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
val outerTag = readTag()
|
||||
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
|
||||
val outerLen = readLength()
|
||||
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
|
||||
|
||||
val integers = mutableListOf<BigInteger>()
|
||||
while (pos < der.size) {
|
||||
val tag = readTag()
|
||||
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
|
||||
val len = readLength()
|
||||
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
|
||||
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
|
||||
pos += len
|
||||
}
|
||||
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||
return integers
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Display
|
||||
|
||||
/**
|
||||
* Asks for runtime permissions on the phone's own screen, for a car screen that can't show
|
||||
* Android's dialog itself. It has no look of its own: the dialog appears, the answer is given,
|
||||
* and it finishes.
|
||||
*/
|
||||
class PermissionActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val wanted = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
|
||||
?.filter { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }
|
||||
.orEmpty()
|
||||
if (wanted.isEmpty() || savedInstanceState != null) {
|
||||
if (wanted.isEmpty()) finish()
|
||||
return
|
||||
}
|
||||
requestPermissions(wanted.toTypedArray(), REQUEST)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
GearslipLog.i("permissions: ${permissions.zip(grantResults.toList()).joinToString { (p, r) -> "${p.substringAfterLast('.')}=${r == 0}" }}")
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_PERMISSIONS = "permissions"
|
||||
private const val REQUEST = 1
|
||||
|
||||
/** Shows the permission dialog on the phone, whichever screen [context] belongs to. */
|
||||
fun ask(context: Context, permissions: Array<String>) {
|
||||
val intent = Intent(context, PermissionActivity::class.java)
|
||||
.putExtra(EXTRA_PERMISSIONS, permissions)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
|
||||
runCatching { context.startActivity(intent, options.toBundle()) }
|
||||
.onFailure { GearslipLog.w("permissions: could not ask on the phone: ${it.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Gearslip's own icon as the PNGs a phone hands the car in its ServiceDiscoveryRequest, at the
|
||||
* 32, 64 and 128 px a real phone sends. Cars draw the phone's tile on their home screen from
|
||||
* them, and older head units may wait for them before answering at all.
|
||||
*/
|
||||
object PhoneIcons {
|
||||
|
||||
private val SIZES = intArrayOf(32, 64, 128)
|
||||
|
||||
@Volatile private var cached: List<ByteArray>? = null
|
||||
|
||||
/** The three PNGs, smallest first; empty if the icon can't be drawn. */
|
||||
fun pngs(context: Context): List<ByteArray> = cached ?: runCatching {
|
||||
val icon = context.packageManager.getApplicationIcon(context.applicationInfo).mutate()
|
||||
SIZES.map { size ->
|
||||
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
icon.setBounds(0, 0, size, size)
|
||||
icon.draw(Canvas(bitmap))
|
||||
val png = ByteArrayOutputStream()
|
||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, png)
|
||||
bitmap.recycle()
|
||||
png.toByteArray()
|
||||
}
|
||||
}.onFailure { GearslipLog.w("phone icons: couldn't draw them (${it.message})") }
|
||||
.getOrDefault(emptyList())
|
||||
.also { if (it.isNotEmpty()) cached = it }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.Socket
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.KeyStore
|
||||
@@ -11,23 +12,10 @@ import javax.net.ssl.SSLEngine
|
||||
import javax.net.ssl.SSLEngineResult
|
||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||
import javax.net.ssl.SSLPeerUnverifiedException
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.X509ExtendedTrustManager
|
||||
|
||||
/**
|
||||
* The phone end of the Android Auto TLS session.
|
||||
*
|
||||
* Two things here are the opposite of what aasdk does, because aasdk implements the head
|
||||
* unit and the head unit is the TLS *client*: it calls TLS_client_method() and
|
||||
* SSL_set_connect_state (src/Transport/SSLWrapper.cpp:97-140, src/Messenger/Cryptor.cpp:87),
|
||||
* and SSL_set_accept_state appears nowhere in that codebase. So the phone is the TLS
|
||||
* **server** and its certificate goes out as the server certificate - sent unconditionally
|
||||
* on every handshake, which is precisely what we are testing.
|
||||
*
|
||||
* TLS here is not layered over the socket: handshake bytes travel inside
|
||||
* MESSAGE_ENCAPSULATED_SSL control messages (id 3). That rules out SSLSocket and makes
|
||||
* SSLEngine - a buffer-to-buffer engine, the JSSE counterpart of aasdk's memory BIO pair -
|
||||
* the right tool.
|
||||
*/
|
||||
/** TLS server for projection. Handshake records travel in control message 3, not a TLS socket. */
|
||||
class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
private val log = GearslipLog.tagged("TLS")
|
||||
|
||||
@@ -36,7 +24,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
private var appBuffer = ByteBuffer.allocate(32 * 1024)
|
||||
private var netBuffer = ByteBuffer.allocate(32 * 1024)
|
||||
|
||||
var handshakeComplete: Boolean = false
|
||||
@Volatile var handshakeComplete: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
@@ -52,6 +40,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
useClientMode = false
|
||||
// want, not need: we ask the head unit for its certificate so the run also tells
|
||||
// us what real units present, but we never make the session depend on getting one.
|
||||
// Homemade head units may have none.
|
||||
wantClientAuth = true
|
||||
enabledProtocols = arrayOf("TLSv1.2") // aasdk pins TLS 1.2
|
||||
}
|
||||
@@ -88,7 +77,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
if (netBuffer.hasRemaining()) {
|
||||
outgoing += ByteArray(netBuffer.remaining()).also { netBuffer.get(it) }
|
||||
}
|
||||
if (result.status == SSLEngineResult.Status.CLOSED) break@loop
|
||||
if (result.status == SSLEngineResult.Status.CLOSED) throw SSLException("TLS closed during handshake")
|
||||
}
|
||||
|
||||
HandshakeStatus.NEED_UNWRAP -> {
|
||||
@@ -100,7 +89,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
appBuffer = ByteBuffer.allocate(appBuffer.capacity() * 2)
|
||||
continue@loop
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break@loop
|
||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS closed during handshake")
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -124,7 +113,7 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
/** Wraps a control-channel payload for an ENCRYPTED frame. */
|
||||
fun encrypt(plain: ByteArray): ByteArray {
|
||||
val source = ByteBuffer.wrap(plain)
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val out = ByteArrayOutputStream()
|
||||
while (source.hasRemaining()) {
|
||||
netBuffer.clear()
|
||||
val result = engine.wrap(source, netBuffer)
|
||||
@@ -132,6 +121,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
netBuffer = ByteBuffer.allocate(netBuffer.capacity() * 2)
|
||||
continue
|
||||
}
|
||||
if (result.status != SSLEngineResult.Status.OK || result.bytesConsumed() == 0) {
|
||||
throw SSLException("TLS could not encrypt application data: ${result.status}")
|
||||
}
|
||||
netBuffer.flip()
|
||||
val chunk = ByteArray(netBuffer.remaining())
|
||||
netBuffer.get(chunk)
|
||||
@@ -140,14 +132,10 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps one ENCRYPTED frame's payload. aasdk decrypts per frame
|
||||
* (MessageOutStream::compoundFrame encrypts per frame), so each frame's payload is
|
||||
* self-contained ciphertext.
|
||||
*/
|
||||
/** Each encrypted projection frame contains complete TLS records. */
|
||||
fun decrypt(cipher: ByteArray): ByteArray {
|
||||
val source = ByteBuffer.wrap(cipher)
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val out = ByteArrayOutputStream()
|
||||
while (source.hasRemaining()) {
|
||||
appBuffer.clear()
|
||||
val result = engine.unwrap(source, appBuffer)
|
||||
@@ -157,10 +145,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
|
||||
continue
|
||||
}
|
||||
SSLEngineResult.Status.BUFFER_UNDERFLOW -> {
|
||||
log.w("decrypt: record split across frames (${source.remaining()} bytes left over)")
|
||||
break
|
||||
throw SSLException("Incomplete TLS record in an encrypted frame")
|
||||
}
|
||||
SSLEngineResult.Status.CLOSED -> break
|
||||
SSLEngineResult.Status.CLOSED -> throw SSLException("TLS peer closed the connection")
|
||||
else -> Unit
|
||||
}
|
||||
appBuffer.flip()
|
||||
|
||||
@@ -24,6 +24,12 @@ interface Projection {
|
||||
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
||||
|
||||
fun onProjectionStopped()
|
||||
|
||||
/**
|
||||
* The screen has been still for a moment and the encoder has sent nothing since. Draw it once
|
||||
* more, so the car gets a fresh frame (see [VideoSource]'s onStill).
|
||||
*/
|
||||
fun onStill() {}
|
||||
}
|
||||
|
||||
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
||||
|
||||
@@ -2,14 +2,7 @@ package app.seb3thehacker.gearslip
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Just enough protobuf to run the spike, hand-rolled so there is no protoc step.
|
||||
*
|
||||
* Only three messages are ever written (AuthResponse is only read):
|
||||
* ServiceDiscoveryRequest { optional string label_text = 4; optional string device_name = 5; }
|
||||
* PingResponse { required int64 timestamp = 1; }
|
||||
* AuthResponse { required int32 status = 1; }
|
||||
*/
|
||||
/** Protobuf field encoding and diagnostic rendering for the projection protocol. */
|
||||
object Protobuf {
|
||||
|
||||
fun varint(value: Long): ByteArray {
|
||||
@@ -31,33 +24,17 @@ object Protobuf {
|
||||
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
|
||||
}
|
||||
|
||||
fun bytesField(field: Int, bytes: ByteArray): ByteArray =
|
||||
varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
|
||||
|
||||
fun varintField(field: Int, value: Long): ByteArray =
|
||||
varint((field shl 3).toLong()) + varint(value)
|
||||
|
||||
/** Reads the single int32 in AuthResponse. Negative values are 10-byte varints. */
|
||||
fun readInt32Field(data: ByteArray, field: Int): Int? {
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
val (tag, afterTag) = readVarint(data, pos) ?: return null
|
||||
pos = afterTag
|
||||
val number = (tag ushr 3).toInt()
|
||||
when ((tag and 7L).toInt()) {
|
||||
0 -> {
|
||||
val (value, afterValue) = readVarint(data, pos) ?: return null
|
||||
pos = afterValue
|
||||
if (number == field) return value.toInt()
|
||||
}
|
||||
2 -> {
|
||||
val (len, afterLen) = readVarint(data, pos) ?: return null
|
||||
pos = afterLen + len.toInt()
|
||||
}
|
||||
5 -> pos += 4
|
||||
1 -> pos += 8
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
fun readInt32Field(data: ByteArray, field: Int): Int? = readInt64Field(data, field)?.toInt()
|
||||
|
||||
/** Reads a varint field whole, for int64s such as a ping's timestamp. */
|
||||
fun readInt64Field(data: ByteArray, field: Int): Long? = Wire.varint(Wire.fields(data), field)
|
||||
|
||||
/**
|
||||
* Shallow field dump. Used on ServiceDiscoveryResponse: its make/model/year strings
|
||||
@@ -65,6 +42,7 @@ object Protobuf {
|
||||
* proof that the session is live and we are decrypting correctly.
|
||||
*/
|
||||
fun describe(data: ByteArray, indent: String = " "): String {
|
||||
if (indent.length > 32) return "$indent(nested message)\n"
|
||||
val sb = StringBuilder()
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
@@ -83,6 +61,7 @@ object Protobuf {
|
||||
}
|
||||
2 -> {
|
||||
val (len, afterLen) = readVarint(data, pos) ?: break
|
||||
if (len < 0 || len > data.size - afterLen) break
|
||||
val end = afterLen + len.toInt()
|
||||
if (end > data.size) break
|
||||
val body = data.copyOfRange(afterLen, end)
|
||||
@@ -111,6 +90,7 @@ object Protobuf {
|
||||
var pos = start
|
||||
while (pos < data.size && shift <= 63) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
if (shift == 63 && b > 1) return null
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) return result to pos
|
||||
@@ -129,45 +109,68 @@ object Wire {
|
||||
val out = mutableListOf<Field>()
|
||||
var pos = 0
|
||||
while (pos < data.size) {
|
||||
val tag = readVarint(data, pos) ?: break
|
||||
val tag = readVarint(data, pos) ?: return emptyList()
|
||||
pos = tag.second
|
||||
val number = (tag.first ushr 3).toInt()
|
||||
if (tag.first ushr 3 !in 1..0x1fff_ffffL) return emptyList()
|
||||
when ((tag.first and 7L).toInt()) {
|
||||
0 -> {
|
||||
val v = readVarint(data, pos) ?: break
|
||||
val v = readVarint(data, pos) ?: return emptyList()
|
||||
pos = v.second
|
||||
out += Field(number, 0, v.first, null)
|
||||
}
|
||||
1 -> { if (pos + 8 > data.size) break; pos += 8 }
|
||||
1 -> { if (data.size - pos < 8) return emptyList(); pos += 8 }
|
||||
2 -> {
|
||||
val len = readVarint(data, pos) ?: break
|
||||
val len = readVarint(data, pos) ?: return emptyList()
|
||||
if (len.first < 0 || len.first > data.size - len.second) return emptyList()
|
||||
val end = len.second + len.first.toInt()
|
||||
if (end > data.size || end < len.second) break
|
||||
if (end > data.size || end < len.second) return emptyList()
|
||||
out += Field(number, 2, 0, data.copyOfRange(len.second, end))
|
||||
pos = end
|
||||
}
|
||||
5 -> { if (pos + 4 > data.size) break; pos += 4 }
|
||||
else -> break
|
||||
5 -> { if (data.size - pos < 4) return emptyList(); pos += 4 }
|
||||
else -> return emptyList()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun varint(fields: List<Field>, number: Int): Long? =
|
||||
fields.firstOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||
fields.lastOrNull { it.number == number && it.wireType == 0 }?.varint
|
||||
|
||||
fun bytes(fields: List<Field>, number: Int): ByteArray? =
|
||||
fields.firstOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||
fields.lastOrNull { it.number == number && it.wireType == 2 }?.bytes
|
||||
|
||||
fun allBytes(fields: List<Field>, number: Int): List<ByteArray> =
|
||||
fields.filter { it.number == number && it.wireType == 2 }.mapNotNull { it.bytes }
|
||||
|
||||
/** Protobuf readers accept both packed and unpacked repeated numeric fields. */
|
||||
fun repeatedInts(fields: List<Field>, number: Int): List<Int> =
|
||||
repeatedLongs(fields, number)?.map(Long::toInt).orEmpty()
|
||||
|
||||
/** Null means a packed field contained an incomplete or overflowing varint. */
|
||||
fun repeatedLongs(fields: List<Field>, number: Int): List<Long>? = buildList {
|
||||
for (field in fields.filter { it.number == number }) {
|
||||
if (field.wireType == 0) add(field.varint)
|
||||
else if (field.wireType == 2) {
|
||||
val bytes = field.bytes ?: continue
|
||||
var position = 0
|
||||
while (position < bytes.size) {
|
||||
val (value, next) = readVarint(bytes, position) ?: return null
|
||||
add(value)
|
||||
position = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readVarint(data: ByteArray, start: Int): Pair<Long, Int>? {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
var pos = start
|
||||
while (pos < data.size && shift <= 63) {
|
||||
val b = data[pos].toInt() and 0xFF
|
||||
if (shift == 63 && b > 1) return null
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
pos++
|
||||
if (b and 0x80 == 0) return result to pos
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
/**
|
||||
* Advertises our supported 1.x version, as Android Auto does for older head units. Head units that
|
||||
* offer a newer major version (LIVI asks for 6.1, a RAV4 and the DHU for 4.2) still accept a 1.x
|
||||
* answer, so only a major version of 0 is refused.
|
||||
*/
|
||||
internal object ProtocolVersion {
|
||||
data class Response(val major: Int, val minor: Int, val supported: Boolean) {
|
||||
val bytes: ByteArray get() = byteArrayOf(
|
||||
(major ushr 8).toByte(), major.toByte(), (minor ushr 8).toByte(), minor.toByte(),
|
||||
if (supported) 0 else -1, if (supported) 0 else -1,
|
||||
)
|
||||
}
|
||||
|
||||
fun respond(major: Int, minor: Int): Response = Response(1, 7, major >= 1 && minor in 0..0xffff)
|
||||
}
|
||||
@@ -45,7 +45,19 @@ class ScreenProjector(private val context: Context) {
|
||||
/** Tracks the synthetic gesture so injected MotionEvents have a coherent downTime. */
|
||||
private var downTime = 0L
|
||||
|
||||
fun start(surface: Surface, width: Int, height: Int, densityDpi: Int, content: @Composable () -> Unit) {
|
||||
/**
|
||||
* [name] names the virtual display so the two callers are told apart from outside the app
|
||||
* (`dumpsys display`): the real car session keeps "gearslip-projection", the on-phone bench
|
||||
* preview passes "gearslip-preview".
|
||||
*/
|
||||
fun start(
|
||||
surface: Surface?,
|
||||
width: Int,
|
||||
height: Int,
|
||||
densityDpi: Int,
|
||||
name: String = "gearslip-projection",
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
stop()
|
||||
|
||||
val displayManager = context.getSystemService(DisplayManager::class.java)
|
||||
@@ -57,7 +69,7 @@ class ScreenProjector(private val context: Context) {
|
||||
// driver on every connect.
|
||||
val display = try {
|
||||
displayManager.createVirtualDisplay(
|
||||
"gearslip-projection", width, height, densityDpi, surface,
|
||||
name, width, height, densityDpi, surface,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_PRESENTATION or
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_OWN_CONTENT_ONLY,
|
||||
)
|
||||
@@ -179,6 +191,47 @@ class ScreenProjector(private val context: Context) {
|
||||
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) downTime = 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* A key from the car's D-pad or knob, as a hardware key event on the car display. Compose
|
||||
* moves focus between buttons on arrows and Tab, and clicks the focused one on OK/Enter.
|
||||
* [keyCode] of [CarKeys.KEYCODE_SHIFT_TAB] is Tab with Shift held.
|
||||
*/
|
||||
fun dispatchKey(keyCode: Int, down: Boolean) {
|
||||
val target = root ?: return
|
||||
// Keys handed straight to the view skip the step where Android leaves touch mode, and in
|
||||
// touch mode Compose's buttons refuse focus, so the knob had nothing to move between.
|
||||
if (target.isInTouchMode) {
|
||||
target.requestFocusFromTouch()
|
||||
GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})")
|
||||
}
|
||||
// Nothing on the display takes keys until something in it holds focus.
|
||||
if (target.findFocus() == null) target.requestFocus()
|
||||
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
|
||||
val code = if (shift) android.view.KeyEvent.KEYCODE_TAB else keyCode
|
||||
val now = SystemClock.uptimeMillis()
|
||||
val event = android.view.KeyEvent(
|
||||
now, now,
|
||||
if (down) android.view.KeyEvent.ACTION_DOWN else android.view.KeyEvent.ACTION_UP,
|
||||
code, 0,
|
||||
if (shift) android.view.KeyEvent.META_SHIFT_ON or android.view.KeyEvent.META_SHIFT_LEFT_ON else 0,
|
||||
android.view.KeyCharacterMap.VIRTUAL_KEYBOARD, 0, 0, android.view.InputDevice.SOURCE_DPAD,
|
||||
)
|
||||
target.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the display at another surface, or none, without restarting what it shows. The
|
||||
* preview's SurfaceView comes and goes as the phone turns; the car UI on it carries on.
|
||||
*/
|
||||
/** Draws the screen again as it is, which hands the display's consumer a new frame. */
|
||||
fun redraw() {
|
||||
root?.let { it.post { it.invalidate() } }
|
||||
}
|
||||
|
||||
fun setSurface(surface: Surface?) {
|
||||
virtualDisplay?.surface = surface
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
runCatching { presentation?.dismiss() }
|
||||
runCatching { owner?.destroy() }
|
||||
|
||||
@@ -20,8 +20,8 @@ object ServiceDiscovery {
|
||||
|
||||
/**
|
||||
* [widthMargin]/[heightMargin] are the head unit's own numbers (VideoConfiguration fields 3
|
||||
* and 4): pixels of the frame it does not treat as usable UI. Logged, and available to a
|
||||
* vehicle profile, but not applied automatically - what they mean varies by unit.
|
||||
* and 4): pixels of the frame it crops away, split evenly between the two edges, to fit the
|
||||
* frame to a screen of a different shape. A matched vehicle profile's insets override them.
|
||||
*/
|
||||
class VideoConfig(
|
||||
val resolution: Int,
|
||||
@@ -38,11 +38,30 @@ object ServiceDiscovery {
|
||||
|
||||
fun pixelCount(): Int = pixelSize()?.let { it.first * it.second } ?: Int.MAX_VALUE
|
||||
|
||||
/** What's left on the car's screen once the margins are cropped off. */
|
||||
fun visibleSize(): Pair<Int, Int>? = pixelSize()?.let { (w, h) -> (w - widthMargin) to (h - heightMargin) }
|
||||
|
||||
/** The margins as the strips of the frame the car UI keeps clear. */
|
||||
fun marginInsets(): Insets = Insets(
|
||||
top = heightMargin / 2, bottom = heightMargin - heightMargin / 2,
|
||||
left = widthMargin / 2, right = widthMargin - widthMargin / 2,
|
||||
)
|
||||
|
||||
override fun toString() =
|
||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
||||
}
|
||||
|
||||
class VideoService(val serviceId: Int, val codecType: Int, val configs: List<VideoConfig>) {
|
||||
/**
|
||||
* [displayId] is MediaSinkService field 6: 0, the default, is the main screen. [displayType]
|
||||
* is field 7 (0 main, 1 instrument cluster, 2 auxiliary), or null when the car leaves it out.
|
||||
*/
|
||||
class VideoService(
|
||||
val serviceId: Int,
|
||||
val codecType: Int,
|
||||
val configs: List<VideoConfig>,
|
||||
val displayId: Int = 0,
|
||||
val displayType: Int? = null,
|
||||
) {
|
||||
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||
}
|
||||
|
||||
@@ -66,7 +85,7 @@ object ServiceDiscovery {
|
||||
fun findAudioServices(response: ByteArray): List<AudioService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val sink = Wire.bytes(service, 3) ?: continue
|
||||
val sinkFields = Wire.fields(sink)
|
||||
val audioConfigs = Wire.allBytes(sinkFields, 3)
|
||||
@@ -89,6 +108,124 @@ object ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The media sink to stream to. Gearslip sends raw PCM, so a PCM sink wins: the 2022 Dacia
|
||||
* Jogger (LGE ULC 4.5) lists an AAC media sink on channel 3 before a PCM one on channel 5,
|
||||
* and PCM pushed into the AAC sink was acked but played as silence. With no PCM sink, the
|
||||
* first media sink is the best there is.
|
||||
*/
|
||||
fun pickMediaSink(sinks: List<AudioService>): AudioService? {
|
||||
val media = sinks.filter { it.streamType == STREAM_MEDIA }
|
||||
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9),
|
||||
* which feeds its cluster and media screens. It carries no settings. Null when the car has none.
|
||||
*/
|
||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
||||
|
||||
/**
|
||||
* The channel the car takes call status on (PhoneStatusService, Service field 10), for its
|
||||
* cluster and phone screens. It carries no settings. Null when the car has none.
|
||||
*/
|
||||
fun findPhoneStatusChannel(response: ByteArray): Int? = findChannelWith(response, 10)
|
||||
|
||||
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||
|
||||
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||
fun findNavStatusService(response: ByteArray): NavStatusService? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
val id = serviceId(service) ?: return@firstNotNullOfOrNull null
|
||||
val navigation = Wire.bytes(service, 8) ?: return@firstNotNullOfOrNull null
|
||||
val interval = Wire.varint(Wire.fields(navigation), 1) ?: return@firstNotNullOfOrNull null
|
||||
if (interval !in 0L..Int.MAX_VALUE.toLong()) return@firstNotNullOfOrNull null
|
||||
NavStatusService(id, interval.toInt())
|
||||
}
|
||||
|
||||
private fun serviceId(fields: List<Wire.Field>): Int? =
|
||||
Wire.varint(fields, 1)?.takeIf { it in 1L..255L }?.toInt()
|
||||
|
||||
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
if (Wire.bytes(service, serviceField) != null) serviceId(service) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's microphone, from MediaSourceService (Service field 5):
|
||||
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
|
||||
* bool available_while_in_call = 3; }
|
||||
* Null when the head unit has none.
|
||||
*/
|
||||
fun findMicService(response: ByteArray): AudioService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = serviceId(service) ?: continue
|
||||
val source = Wire.fields(Wire.bytes(service, 5) ?: continue)
|
||||
val f = Wire.fields(Wire.bytes(source, 2) ?: continue)
|
||||
return AudioService(
|
||||
serviceId = id,
|
||||
streamType = 0,
|
||||
codecType = Wire.varint(source, 1)?.toInt() ?: CODEC_PCM,
|
||||
configs = listOf(
|
||||
AudioConfig(
|
||||
sampleRate = Wire.varint(f, 1)?.toInt() ?: 16_000,
|
||||
bits = Wire.varint(f, 2)?.toInt() ?: 16,
|
||||
channels = Wire.varint(f, 3)?.toInt() ?: 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The car's Bluetooth, from BluetoothService (Service field 6):
|
||||
* BluetoothService { string car_address = 1; repeated BluetoothPairingMethod methods = 2 [packed]; }
|
||||
* Null when the car offers no pairing over the link.
|
||||
*/
|
||||
class BluetoothService(val serviceId: Int, val carAddress: String, val pairingMethods: List<Int>)
|
||||
|
||||
/** A carmaker's own channel (service field 12), for its apps on the phone: GM's is "com.gm.vip". */
|
||||
class VendorService(val serviceId: Int, val name: String)
|
||||
|
||||
fun findVendorServices(response: ByteArray): List<VendorService> =
|
||||
Wire.allBytes(Wire.fields(response), 1).mapNotNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: return@mapNotNull null
|
||||
val fields = Wire.fields(Wire.bytes(service, 12) ?: return@mapNotNull null)
|
||||
val name = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: return@mapNotNull null
|
||||
VendorService(id, name)
|
||||
}
|
||||
|
||||
/** The channel of the car's Wi-Fi projection service (Service field 14), or null. */
|
||||
fun findWifiProjectionService(response: ByteArray): Int? =
|
||||
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
|
||||
val service = Wire.fields(channel)
|
||||
if (Wire.bytes(service, 14) == null) null else Wire.varint(service, 1)?.toInt()
|
||||
}
|
||||
|
||||
fun findBluetoothService(response: ByteArray): BluetoothService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val fields = Wire.fields(Wire.bytes(service, 6) ?: continue)
|
||||
val address = Wire.bytes(fields, 1)?.let { String(it, Charsets.UTF_8) } ?: continue
|
||||
// Packed as declared, though a car may send each method as its own varint.
|
||||
val methods = Wire.repeatedInts(fields, 2)
|
||||
return BluetoothService(id, address, methods)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const val STREAM_GUIDANCE = 1
|
||||
const val STREAM_MEDIA = 3
|
||||
const val CODEC_PCM = 1
|
||||
const val CODEC_AAC_LC = 2
|
||||
const val CODEC_AAC_LC_ADTS = 4
|
||||
|
||||
private val STREAMS = mapOf(0 to "NONE", 1 to "GUIDANCE", 2 to "SYSTEM_AUDIO", 3 to "MEDIA", 4 to "TELEPHONY")
|
||||
|
||||
private val RESOLUTIONS = mapOf(
|
||||
@@ -106,33 +243,79 @@ object ServiceDiscovery {
|
||||
)
|
||||
|
||||
/**
|
||||
* The head unit's touchscreen, from InputSourceService (Service field 4):
|
||||
* InputSourceService { keycodes = 1; repeated TouchScreen touchscreen = 2; ... }
|
||||
* The head unit's touchscreen and button set, from InputSourceService (Service field 4):
|
||||
* InputSourceService { repeated int32 keycodes_supported = 1 [packed=true];
|
||||
* repeated TouchScreen touchscreen = 2; ... }
|
||||
* TouchScreen { required int32 width = 1; required int32 height = 2; type = 3; }
|
||||
*
|
||||
* Touch coordinates arrive in this space (1258x708 on the 2018 Uconnect) and must be
|
||||
* scaled to the projected video size before injection.
|
||||
* Touch reports use projected video coordinates; these dimensions describe the input
|
||||
* hardware and help select a video configuration. [keycodesSupported] is what the
|
||||
* head unit says it will actually send over the Input channel as key events (steering-wheel
|
||||
* buttons, etc.) - not every unit reports the same set, so it's kept for diagnostics and to
|
||||
* know ahead of time whether a button like voice/PTT is even wired up on this hardware.
|
||||
*/
|
||||
class InputService(val serviceId: Int, val touchWidth: Int, val touchHeight: Int)
|
||||
class InputService(
|
||||
val serviceId: Int,
|
||||
val touchWidth: Int,
|
||||
val touchHeight: Int,
|
||||
val keycodesSupported: List<Int> = emptyList(),
|
||||
/** InputSourceService field 5: which screen this input belongs to, 0 for the main one. */
|
||||
val displayId: Int = 0,
|
||||
)
|
||||
|
||||
fun findInputService(response: ByteArray): InputService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val input = Wire.bytes(service, 4) ?: continue
|
||||
val touch = Wire.bytes(Wire.fields(input), 2) ?: continue
|
||||
val touchFields = Wire.fields(touch)
|
||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: continue
|
||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: continue
|
||||
return InputService(id, width, height)
|
||||
}
|
||||
return null
|
||||
/**
|
||||
* The input for the screen Gearslip projects to ([displayId]). A head unit with two screens
|
||||
* offers an input service for each: LIVI lists an empty one for its second screen first,
|
||||
* and taking that one left every touch on the real touchscreen ignored.
|
||||
*/
|
||||
fun findInputService(response: ByteArray, displayId: Int = 0): InputService? {
|
||||
val inputs = inputServices(response)
|
||||
return inputs.firstOrNull { it.displayId == displayId }
|
||||
?: inputs.firstOrNull { it.touchWidth > 0 && it.touchHeight > 0 }
|
||||
?: inputs.firstOrNull()
|
||||
}
|
||||
|
||||
fun findVideoService(response: ByteArray): VideoService? {
|
||||
private fun inputServices(response: ByteArray): List<InputService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = Wire.varint(service, 1)?.toInt() ?: continue
|
||||
val id = serviceId(service) ?: continue
|
||||
val input = Wire.bytes(service, 4) ?: continue
|
||||
val inputFields = Wire.fields(input)
|
||||
// A unit with only a knob and buttons has no touchscreen entry; it still has keys.
|
||||
val touchFields = Wire.bytes(inputFields, 2)?.let { Wire.fields(it) }.orEmpty()
|
||||
val width = Wire.varint(touchFields, 1)?.toInt() ?: 0
|
||||
val height = Wire.varint(touchFields, 2)?.toInt() ?: 0
|
||||
val keycodes = Wire.repeatedInts(inputFields, 1)
|
||||
add(InputService(id, width, height, keycodes, Wire.varint(inputFields, 5)?.toInt() ?: 0))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main screen's video sink. A head unit with a second screen, such as LIVI with a
|
||||
* cluster display, offers a sink for each; the one without a display id (0) is the main one.
|
||||
*/
|
||||
fun findVideoService(response: ByteArray): VideoService? {
|
||||
val sinks = videoServices(response)
|
||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The instrument cluster's video sink: one marked as a cluster, or, from a car that marks
|
||||
* none, a second sink beside the main screen's. Null when the car has no cluster screen.
|
||||
*/
|
||||
fun findClusterVideoService(response: ByteArray): VideoService? {
|
||||
val sinks = videoServices(response)
|
||||
val main = findVideoService(response) ?: return null
|
||||
return sinks.firstOrNull { it.displayType == DISPLAY_TYPE_CLUSTER && it.serviceId != main.serviceId }
|
||||
?: sinks.firstOrNull { it.displayType == null && it.serviceId != main.serviceId && it.displayId != main.displayId }
|
||||
}
|
||||
|
||||
private const val DISPLAY_TYPE_CLUSTER = 1
|
||||
|
||||
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = serviceId(service) ?: continue
|
||||
val sink = Wire.bytes(service, 3) ?: continue
|
||||
|
||||
val sinkFields = Wire.fields(sink)
|
||||
@@ -150,7 +333,30 @@ object ServiceDiscovery {
|
||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
return VideoService(id, codecType, configs)
|
||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0, Wire.varint(sinkFields, 7)?.toInt()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The head unit's sensor feed, from SensorSourceService (Service field 2; field 5 is the car's mic):
|
||||
* SensorSourceService { repeated Sensor sensors = 1; }
|
||||
* Sensor { required SensorType sensor_type = 1; }
|
||||
*
|
||||
* SensorBatch contains repeated messages in fields 1 through 22 matching these type ids.
|
||||
* The vehicle data screen keeps their raw contents for diagnostics.
|
||||
*/
|
||||
class SensorService(val serviceId: Int, val types: List<Int>)
|
||||
|
||||
fun findSensorService(response: ByteArray): SensorService? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
val service = Wire.fields(channel)
|
||||
val id = serviceId(service) ?: continue
|
||||
val sensorSource = Wire.bytes(service, 2) ?: continue
|
||||
val types = Wire.allBytes(Wire.fields(sensorSource), 1).mapNotNull {
|
||||
Wire.varint(Wire.fields(it), 1)?.toInt()
|
||||
}
|
||||
if (types.isEmpty()) continue
|
||||
return SensorService(id, types)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app.seb3thehacker.gearslip
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
@@ -17,22 +18,42 @@ object SessionReport {
|
||||
enum class Category(val label: String) {
|
||||
NONE("none"),
|
||||
USB("USB transport"),
|
||||
WIFI("Wi-Fi transport"),
|
||||
NO_HANDSHAKE("head unit never started talking"),
|
||||
TLS("TLS handshake failed"),
|
||||
CERTIFICATE("certificate rejected by the head unit"),
|
||||
AUTH("authentication failed"),
|
||||
SERVICE_DISCOVERY("service discovery failed"),
|
||||
VIDEO("video stream failed"),
|
||||
BYEBYE("head unit ended the session"),
|
||||
CRASH("app crash"),
|
||||
UNKNOWN("unclassified"),
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a session got, in the order a connection is built. The usage note's "Where
|
||||
* connections fail" share sends the furthest one reached, so a failure says where it broke.
|
||||
*/
|
||||
enum class Stage(val key: String) {
|
||||
USB("usb"), // the link opened; the car may have said nothing
|
||||
VERSIONS("versions"), // the car said which protocol it speaks
|
||||
TLS("tls"), // the secure link is up
|
||||
ACCEPTED("accepted"), // the car accepted the certificate
|
||||
CHANNELS("channels"), // the car listed its channels and said who it is
|
||||
VIDEO("video"), // the car's screen is set up for Gearslip
|
||||
STREAMING("streaming"), // video frames are reaching the car
|
||||
}
|
||||
|
||||
private var appVersion = "?"
|
||||
private var appCode = 0L
|
||||
|
||||
@Volatile private var startedAt = 0L
|
||||
@Volatile var category = Category.NONE; private set
|
||||
@Volatile private var failure = ""
|
||||
@Volatile var stage = Stage.USB; private set
|
||||
/** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */
|
||||
@Volatile private var failCode = ""
|
||||
@Volatile private var failedAfter = 0L
|
||||
@Volatile private var protocolVersion = ""
|
||||
@Volatile private var certSource = ""
|
||||
@Volatile private var certIssuer = ""
|
||||
@@ -47,41 +68,114 @@ object SessionReport {
|
||||
@Volatile private var lastState = ""
|
||||
private var printed = false
|
||||
|
||||
private var appContext: Context? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||
appVersion = info?.versionName ?: "?"
|
||||
appCode = info?.longVersionCode ?: 0
|
||||
}
|
||||
|
||||
fun begin() {
|
||||
/** How this drive reached the car, and how its music did: "usb" or "wireless", "usb" or "bluetooth". */
|
||||
@Volatile var link = ""; private set
|
||||
@Volatile var audioMode = ""; private set
|
||||
|
||||
fun begin(wireless: Boolean) {
|
||||
startedAt = System.currentTimeMillis()
|
||||
link = if (wireless) "wireless" else "usb"
|
||||
audioMode = if (app.seb3thehacker.gearslip.car.CarSettings.pipeAudio.value) "usb" else "bluetooth"
|
||||
category = Category.NONE
|
||||
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
|
||||
stage = Stage.USB; failCode = ""; failedAfter = 0
|
||||
tlsProtocol = ""; tlsCipher = ""; authStatus = null
|
||||
headUnitInfo = null
|
||||
carScreen = ""; carDpi = 0
|
||||
headUnit = ""; profile = ""; video = ""; lastStream = ""; lastState = ""
|
||||
printed = false
|
||||
}
|
||||
|
||||
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" }
|
||||
/** Moves the session's furthest stage on; never back. */
|
||||
private fun reached(next: Stage) { if (next > stage) stage = next }
|
||||
|
||||
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) }
|
||||
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher }
|
||||
fun auth(status: Int) { authStatus = status }
|
||||
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) }
|
||||
fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) }
|
||||
fun headUnit(info: String) { headUnit = info }
|
||||
|
||||
/** Who the car said it is, for the opt-in usage note; null until service discovery. */
|
||||
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
|
||||
set(value) { field = value; if (value != null) reached(Stage.CHANNELS) }
|
||||
|
||||
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
|
||||
@Volatile var carScreen = ""; private set
|
||||
@Volatile var carDpi = 0; private set
|
||||
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) }
|
||||
fun profile(name: String?) { profile = name ?: "none matched" }
|
||||
fun video(summary: String) { video = summary }
|
||||
fun audio(summary: String) { audio = summary }
|
||||
fun stream(summary: String) { lastStream = summary }
|
||||
fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) }
|
||||
|
||||
/** Records the first failure only: later ones are usually fallout from it. */
|
||||
fun fail(category: Category, detail: String, state: String = "") {
|
||||
/**
|
||||
* Records the first failure only: later ones are usually fallout from it. [code] is the short
|
||||
* thing the car or the phone said, like "auth -3" or "bye 1", for the usage note.
|
||||
*/
|
||||
fun fail(category: Category, detail: String, state: String = "", code: String = "") {
|
||||
if (this.category != Category.NONE) return
|
||||
this.category = category
|
||||
failure = detail
|
||||
lastState = state
|
||||
failCode = code
|
||||
failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
|
||||
}
|
||||
|
||||
fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
|
||||
fail(category, "$detail (${GearslipLog.rootCause(t)})", state)
|
||||
fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t))
|
||||
|
||||
/**
|
||||
* A short code from an exception, never its whole message: the TLS alert's name, the errno
|
||||
* of a USB failure, or else the exception's class.
|
||||
*/
|
||||
private fun codeOf(t: Throwable): String {
|
||||
var root = t
|
||||
while (root.cause != null && root.cause !== root) root = root.cause!!
|
||||
val text = "${t.message} ${root.message}"
|
||||
Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let {
|
||||
return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}"
|
||||
}
|
||||
Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] }
|
||||
return root.javaClass.simpleName.take(40)
|
||||
}
|
||||
|
||||
/**
|
||||
* One session for the usage note's "Where connections fail" share: how far it got, how and
|
||||
* when it ended, and with which kind of certificate. Counts and codes only, nothing personal.
|
||||
*/
|
||||
fun sessionNote(): org.json.JSONObject {
|
||||
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
|
||||
val source = certSource.lowercase()
|
||||
return org.json.JSONObject()
|
||||
.put("stage", stage.key)
|
||||
.put("ended", category.name.lowercase())
|
||||
.put("code", failCode)
|
||||
.put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter))
|
||||
.put("seconds", rounded(seconds))
|
||||
.put("cert", when {
|
||||
"imported" in source -> "imported"
|
||||
"downloaded" in source -> "downloaded"
|
||||
"self" in source -> "self-signed"
|
||||
source.isEmpty() -> ""
|
||||
else -> "other"
|
||||
})
|
||||
}
|
||||
|
||||
/** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */
|
||||
private fun rounded(s: Long): Long = when {
|
||||
s < 60 -> (s + 2) / 5 * 5
|
||||
s < 3600 -> (s + 30) / 60 * 60
|
||||
else -> (s + 150) / 300 * 300
|
||||
}
|
||||
|
||||
/** The report as plain text, for the log file and for anything that gets sent. */
|
||||
fun render(): String {
|
||||
@@ -120,6 +214,25 @@ object SessionReport {
|
||||
if (printed || startedAt == 0L) return
|
||||
printed = true
|
||||
render().lines().forEach { GearslipLog.i(it) }
|
||||
appContext?.let { context ->
|
||||
// Video started means the car showed Gearslip: that's a working car, and unplugging or
|
||||
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||
val reached = carScreen.isNotEmpty()
|
||||
val outcome = when {
|
||||
reached && category in setOf(Category.NONE, Category.USB, Category.WIFI, Category.BYEBYE) -> "connected"
|
||||
reached -> "connected, then ${category.label}"
|
||||
category == Category.NONE -> "ended before video"
|
||||
else -> category.label
|
||||
}
|
||||
runCatching {
|
||||
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote(), link, audioMode)
|
||||
}
|
||||
if (outcome == "connected") runCatching {
|
||||
app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
|
||||
app.seb3thehacker.gearslip.ui.DonatePrompt.onDriveWorked(context)
|
||||
}
|
||||
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
|
||||
}
|
||||
GearslipLog.flush()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,13 +14,16 @@ object SessionStatus {
|
||||
|
||||
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
|
||||
|
||||
data class Snapshot(val phase: Phase, val headline: String, val detail: String)
|
||||
/** [stuckOn] is set when connecting has gone on too long: the cable or Wi-Fi, for what to try. */
|
||||
data class Snapshot(val phase: Phase, val headline: String, val detail: String, val stuckOn: Link? = null)
|
||||
|
||||
enum class Link { CABLE, WIRELESS }
|
||||
|
||||
private val flow = MutableStateFlow(
|
||||
Snapshot(
|
||||
Phase.IDLE,
|
||||
"Not connected",
|
||||
"Plug the phone into the car's USB port. Gearslip opens by itself when the head unit connects.",
|
||||
"Plug into the car's USB port, or connect wirelessly. Gearslip opens by itself when the car answers.",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,6 +33,12 @@ object SessionStatus {
|
||||
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
||||
}
|
||||
|
||||
/** Still connecting after [GearslipRunner]'s patience ran out: Home says what to try. */
|
||||
fun stillConnecting(link: Link) {
|
||||
val now = flow.value
|
||||
if (now.phase == Phase.CONNECTING) flow.value = now.copy(stuckOn = link)
|
||||
}
|
||||
|
||||
fun projecting() {
|
||||
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
||||
}
|
||||
|
||||
@@ -23,8 +23,16 @@ class VideoSource(
|
||||
private val height: Int,
|
||||
private val frameRate: Int,
|
||||
private val mode: Mode = Mode.TEST_CARD,
|
||||
/** Over Wi-Fi: a fraction of the cable bitrate, so the link keeps up and sound isn't starved. */
|
||||
private val wireless: Boolean = false,
|
||||
private val onCodecConfig: (ByteArray) -> Unit,
|
||||
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
||||
/**
|
||||
* SURFACE mode, nothing encoded for [STILL_MS]. The repeat setting below should cover a still
|
||||
* screen, but on a Pixel 10 it didn't: a LuChi box got 11 frames, then none, and stayed black
|
||||
* (issue #58). Whoever draws the screen can draw it again, and that is a frame.
|
||||
*/
|
||||
private val onStill: () -> Unit = {},
|
||||
) {
|
||||
private val log = GearslipLog.tagged("VIDEO")
|
||||
|
||||
@@ -40,12 +48,26 @@ class VideoSource(
|
||||
private set
|
||||
|
||||
private var codec: MediaCodec? = null
|
||||
|
||||
/** The bitrate the encoder started at, the most [setBitRate] ever goes back up to. */
|
||||
var fullBitRate = 0
|
||||
private set
|
||||
|
||||
/** What the encoder is asked for right now. */
|
||||
@Volatile var bitRate = 0
|
||||
private set
|
||||
private var worker: Thread? = null
|
||||
@Volatile private var running = false
|
||||
private var frameIndex = 0L
|
||||
|
||||
fun start() {
|
||||
val format = MediaFormat.createVideoFormat(MIME, width, height).apply {
|
||||
val level = levelFor(width, height, frameRate)
|
||||
val bitRate = defaultBitRate(width, height, frameRate, level).let {
|
||||
if (wireless) (it * WIRELESS_SHARE).toInt().coerceAtMost(WIRELESS_MAX_BIT_RATE) else it
|
||||
}
|
||||
fullBitRate = bitRate
|
||||
this.bitRate = bitRate
|
||||
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
|
||||
setInteger(
|
||||
MediaFormat.KEY_COLOR_FORMAT,
|
||||
if (mode == Mode.SURFACE) {
|
||||
@@ -54,21 +76,50 @@ class VideoSource(
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
|
||||
},
|
||||
)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
|
||||
// Drops already ask for a fresh keyframe, so a long interval costs nothing in recovery
|
||||
// and spares the link a big frame every second.
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, I_FRAME_INTERVAL_S)
|
||||
if (mode == Mode.SURFACE) {
|
||||
// A surface encoder only makes a frame when the screen changes, and many decoders
|
||||
// show a frame only once the next one arrives. So the last change before the
|
||||
// screen goes still (a menu closing) sat in the car's decoder until something
|
||||
// else moved. Repeating the last frame after a short pause pushes it through;
|
||||
// a repeat of an unchanged screen costs a few bytes. scrcpy does the same.
|
||||
setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, REPEAT_AFTER_US)
|
||||
// An app that draws nonstop (a moving map) renders at the phone's refresh rate,
|
||||
// faster than the car acks. Every extra frame queued inside the encoder, and the
|
||||
// car's screen fell 25 seconds behind. Capping the input drops the extras before
|
||||
// they're encoded, so the car always gets the newest frame.
|
||||
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
|
||||
}
|
||||
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31)
|
||||
setInteger(MediaFormat.KEY_LEVEL, level.codecLevel)
|
||||
if (tuned) {
|
||||
// Hints only: a car screen wants each frame now, not the best frame a moment later.
|
||||
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
|
||||
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||
setInteger(MediaFormat.KEY_LATENCY, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val encoder = MediaCodec.createEncoderByType(MIME)
|
||||
encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
var encoder = MediaCodec.createEncoderByType(MIME)
|
||||
try {
|
||||
encoder.configure(format(tuned = true), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
} catch (e: Exception) {
|
||||
// Some encoders refuse a hint they don't know. The plain format is what always worked.
|
||||
log.w("encoder refused the tuned format (${e.message}) - using the plain one")
|
||||
encoder.release()
|
||||
encoder = MediaCodec.createEncoderByType(MIME)
|
||||
encoder.configure(format(tuned = false), null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
}
|
||||
if (mode == Mode.SURFACE) inputSurface = encoder.createInputSurface()
|
||||
encoder.start()
|
||||
codec = encoder
|
||||
running = true
|
||||
|
||||
log.i("encoder started: ${width}x$height @ ${frameRate}fps, H.264 Baseline, mode=$mode")
|
||||
log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps${if (wireless) " (wireless)" else ""}, H.264 Baseline level ${level.label}, mode=$mode")
|
||||
|
||||
worker = Thread {
|
||||
runCatching { pump(encoder) }
|
||||
@@ -93,9 +144,28 @@ class VideoSource(
|
||||
}.onFailure { log.w("requestSyncFrame failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the bitrate while streaming, without a new keyframe or a restart. Used to back off
|
||||
* when the car (or the Wi-Fi to it) can't keep up, and to climb back once it can.
|
||||
*/
|
||||
fun setBitRate(bps: Int) {
|
||||
val target = bps.coerceIn(fullBitRate / 4, fullBitRate)
|
||||
if (target == bitRate) return
|
||||
runCatching {
|
||||
codec?.setParameters(Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target) })
|
||||
bitRate = target
|
||||
}.onFailure { log.w("setBitRate failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
worker?.join(1_000)
|
||||
// Stop is called from threads that are themselves being shut down. An interrupt here
|
||||
// used to escape as an uncaught exception and take the whole app down with the session.
|
||||
try {
|
||||
worker?.join(1_000)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
runCatching { inputSurface?.release() }
|
||||
inputSurface = null
|
||||
runCatching { codec?.stop() }
|
||||
@@ -106,6 +176,8 @@ class VideoSource(
|
||||
private fun pump(encoder: MediaCodec) {
|
||||
val info = MediaCodec.BufferInfo()
|
||||
val frameIntervalUs = 1_000_000L / frameRate
|
||||
var lastOutputAt = 0L
|
||||
var lastNudgeAt = 0L
|
||||
|
||||
while (running) {
|
||||
// SURFACE mode has no input buffers to fill - frames arrive from the display.
|
||||
@@ -123,7 +195,10 @@ class VideoSource(
|
||||
frameIndex++
|
||||
}
|
||||
|
||||
var outIndex = encoder.dequeueOutputBuffer(info, TIMEOUT_US)
|
||||
// A surface-fed encoder has nothing to do between frames, so block until one is ready
|
||||
// instead of sleeping a fixed time: less delay on screen, fewer wakeups for the CPU.
|
||||
val wait = if (mode == Mode.SURFACE) SURFACE_TIMEOUT_US else TIMEOUT_US
|
||||
var outIndex = encoder.dequeueOutputBuffer(info, wait)
|
||||
while (outIndex >= 0) {
|
||||
val buffer = encoder.getOutputBuffer(outIndex)
|
||||
if (buffer != null && info.size > 0) {
|
||||
@@ -141,10 +216,18 @@ class VideoSource(
|
||||
}
|
||||
}
|
||||
encoder.releaseOutputBuffer(outIndex, false)
|
||||
lastOutputAt = System.currentTimeMillis()
|
||||
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
||||
}
|
||||
if (mode == Mode.SURFACE && lastOutputAt > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastOutputAt > STILL_MS && now - lastNudgeAt > STILL_MS) {
|
||||
lastNudgeAt = now
|
||||
onStill()
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
||||
if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +271,53 @@ class VideoSource(
|
||||
private companion object {
|
||||
const val MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
const val TIMEOUT_US = 10_000L
|
||||
const val SURFACE_TIMEOUT_US = 100_000L
|
||||
private const val STILL_MS = 1_000L
|
||||
|
||||
/**
|
||||
* 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
|
||||
* video in the browser stays sharp. Bigger screens get more, but less than in proportion,
|
||||
* since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x,
|
||||
* because frames that close together are mostly alike.
|
||||
*/
|
||||
const val BASE_BIT_RATE = 8_000_000.0
|
||||
const val BASE_PIXELS = 800.0 * 480
|
||||
/** No car's decoder is asked for more than this, whatever its level allows. */
|
||||
const val MAX_BIT_RATE = 25_000_000
|
||||
|
||||
/**
|
||||
* The H.264 levels a car screen can need, smallest first: macroblocks a second each
|
||||
* decodes, and the Baseline bitrate it must handle. The encoder declares the smallest
|
||||
* that fits, and the bitrate stays within it, so a car decoder isn't asked for more than
|
||||
* the level it was told about.
|
||||
*/
|
||||
enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) {
|
||||
L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000),
|
||||
L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000),
|
||||
L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000),
|
||||
L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000),
|
||||
}
|
||||
|
||||
fun levelFor(width: Int, height: Int, frameRate: Int): Level {
|
||||
val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate
|
||||
return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42
|
||||
}
|
||||
|
||||
fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int {
|
||||
val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75)
|
||||
val rate = if (frameRate > 30) 1.5 else 1.0
|
||||
return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE))
|
||||
}
|
||||
/**
|
||||
* A car's Wi-Fi is shared, noisy and far slower than its nominal rate, and the sound rides
|
||||
* the same link behind the video. Real Android Auto sends a few Mbps wirelessly; at the
|
||||
* cable rate (15-20 Mbps for 720p) a laptop hotspot fell seconds behind and the music
|
||||
* cut in and out. 720p30 comes out near 5.4 Mbps.
|
||||
*/
|
||||
const val WIRELESS_SHARE = 0.35
|
||||
const val WIRELESS_MAX_BIT_RATE = 8_000_000
|
||||
const val I_FRAME_INTERVAL_S = 5
|
||||
private const val REPEAT_AFTER_US = 100_000L
|
||||
|
||||
/** (Y, Cb, Cr) - white, yellow, cyan, green, magenta, red, blue. */
|
||||
val BAR_COLOURS = listOf(
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package app.seb3thehacker.gearslip.audio
|
||||
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
|
||||
/**
|
||||
* Turns captured PCM into AAC-LC for a car whose music channel takes nothing else. Cars that
|
||||
* offer PCM never get here: AAC costs a little delay and quality, and every car that works today
|
||||
* is a PCM car.
|
||||
*
|
||||
* Android's encoder hands back raw access units, which is what AUDIO_AAC_LC expects. For
|
||||
* AUDIO_AAC_LC_ADTS each unit gets the 7-byte ADTS header in front, so the car can find the
|
||||
* frame edges itself. The encoder's first output is its configuration, not audio, and is skipped.
|
||||
*/
|
||||
internal class AacEncoder(private val sampleRate: Int, private val channels: Int, private val adts: Boolean) {
|
||||
|
||||
private val codec: MediaCodec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_AAC).apply {
|
||||
val format = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, sampleRate, channels).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, if (channels == 1) 96_000 else 192_000)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16 * 1024)
|
||||
}
|
||||
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
start()
|
||||
}
|
||||
private val info = MediaCodec.BufferInfo()
|
||||
private var samplesIn = 0L
|
||||
|
||||
/** Encodes [size] bytes of 16-bit PCM and returns the AAC frames that came out, oldest first. */
|
||||
fun encode(pcm: ByteArray, size: Int): List<ByteArray> {
|
||||
var offset = 0
|
||||
val out = ArrayList<ByteArray>()
|
||||
while (offset < size) {
|
||||
val index = codec.dequeueInputBuffer(INPUT_WAIT_US)
|
||||
if (index < 0) {
|
||||
drain(out)
|
||||
continue
|
||||
}
|
||||
val buffer = codec.getInputBuffer(index) ?: continue
|
||||
val n = minOf(buffer.remaining(), size - offset)
|
||||
buffer.put(pcm, offset, n)
|
||||
val presentationUs = samplesIn * 1_000_000L / sampleRate
|
||||
codec.queueInputBuffer(index, 0, n, presentationUs, 0)
|
||||
samplesIn += n / (2 * channels)
|
||||
offset += n
|
||||
drain(out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun drain(out: MutableList<ByteArray>) {
|
||||
while (true) {
|
||||
val index = codec.dequeueOutputBuffer(info, 0)
|
||||
if (index < 0) return
|
||||
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 && info.size > 0) {
|
||||
val buffer = codec.getOutputBuffer(index)
|
||||
if (buffer != null) {
|
||||
val frame = ByteArray(info.size)
|
||||
buffer.position(info.offset)
|
||||
buffer.get(frame)
|
||||
out.add(if (adts) adtsHeader(frame.size) + frame else frame)
|
||||
}
|
||||
}
|
||||
codec.releaseOutputBuffer(index, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** ADTS for one AAC-LC frame: sync word, no CRC, and the frame's length including this header. */
|
||||
private fun adtsHeader(payload: Int): ByteArray {
|
||||
val length = payload + 7
|
||||
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
|
||||
val profile = 1 // AAC-LC is object type 2, written as type minus one
|
||||
return byteArrayOf(
|
||||
0xFF.toByte(),
|
||||
0xF1.toByte(),
|
||||
((profile shl 6) or (freq shl 2) or (channels shr 2)).toByte(),
|
||||
(((channels and 3) shl 6) or (length shr 11)).toByte(),
|
||||
((length shr 3) and 0xFF).toByte(),
|
||||
(((length and 7) shl 5) or 0x1F).toByte(),
|
||||
0xFC.toByte(),
|
||||
)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
runCatching { codec.stop() }
|
||||
runCatching { codec.release() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val INPUT_WAIT_US = 10_000L
|
||||
private val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
||||
|
||||
/** The two-byte AudioSpecificConfig for AAC-LC: object type 2, rate index, channel count. */
|
||||
fun audioSpecificConfig(sampleRate: Int, channels: Int): ByteArray {
|
||||
val freq = FREQUENCIES.indexOf(sampleRate).let { if (it < 0) 3 else it }
|
||||
return byteArrayOf(
|
||||
((2 shl 3) or (freq shr 1)).toByte(),
|
||||
(((freq and 1) shl 7) or (channels shl 3)).toByte(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
@@ -15,6 +16,7 @@ import android.media.AudioRecord
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -83,7 +85,13 @@ class AudioCaptureService : Service() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||
// Android silences the microphone for apps in the background unless a running service
|
||||
// claims it. Claiming it here lets voice replies hear the driver with the phone locked.
|
||||
// Without the permission, claiming it would crash, so it's left off.
|
||||
val micGranted = checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION or
|
||||
(if (micGranted) ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE else 0)
|
||||
startForeground(NOTIFICATION_ID, notification(), type)
|
||||
ScreenShareGuard.lift(this)
|
||||
projection = try {
|
||||
getSystemService(MediaProjectionManager::class.java).getMediaProjection(resultCode, data)
|
||||
@@ -129,7 +137,7 @@ class AudioCaptureService : Service() {
|
||||
AudioRecord.Builder()
|
||||
.setAudioPlaybackCaptureConfig(config)
|
||||
.setAudioFormat(format)
|
||||
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * 4))
|
||||
.setBufferSizeInBytes(maxOf(minimum, CHUNK_BYTES * BUFFER_CHUNKS))
|
||||
.build()
|
||||
} catch (t: Throwable) {
|
||||
GearslipLog.e("audio: could not set up playback capture", t)
|
||||
@@ -140,6 +148,9 @@ class AudioCaptureService : Service() {
|
||||
|
||||
reading = true
|
||||
thread = Thread({
|
||||
// A busy phone (a map app drawing, the video encoder) would otherwise starve this
|
||||
// loop, and the car hears every late read as a skip.
|
||||
Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)
|
||||
val buffer = ByteArray(CHUNK_BYTES)
|
||||
var peak = 0
|
||||
var heardAt = System.currentTimeMillis()
|
||||
@@ -233,6 +244,8 @@ class AudioCaptureService : Service() {
|
||||
|
||||
/** About 21 ms of 48 kHz stereo: small enough for lip-sync, big enough not to flood the link. */
|
||||
private const val CHUNK_BYTES = 4096
|
||||
/** About half a second: room to wait out a slow ack from the car without losing sound. */
|
||||
private const val BUFFER_CHUNKS = 24
|
||||
private const val LEVEL_LOG_MS = 3_000L
|
||||
private const val SILENCE_MS = 5_000L
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The phone end of the head unit's media audio sink, the audio twin of the video channel:
|
||||
@@ -22,6 +24,21 @@ class AudioLink(
|
||||
private val service: ServiceDiscovery.AudioService,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
private val sendControl: (messageId: Int, body: ByteArray) -> Unit,
|
||||
/** Log name: "audio" for the media sink, "guidance" for the directions sink. */
|
||||
private val tag: String = "audio",
|
||||
private val sessionId: Int = SESSION_ID,
|
||||
/**
|
||||
* Media asks the car for focus when it starts. Directions ask only around each announcement,
|
||||
* through [askForSpeakers], and only when the music isn't already ours.
|
||||
*/
|
||||
private val asksFocus: Boolean = true,
|
||||
private val onReady: (AudioLink) -> Unit = { CarAudio.attach(it) },
|
||||
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
||||
/** The car took the sound back after giving it, as when the driver switches to the radio. */
|
||||
private val onTakenBack: () -> Unit = {},
|
||||
/** The car wants the speakers for a moment (a call, its own voice), and then [onGivenBack]. */
|
||||
private val onPausedForNow: () -> Unit = {},
|
||||
private val onGivenBack: () -> Unit = {},
|
||||
) : CarAudio.Link {
|
||||
|
||||
private var configIndex = 0
|
||||
@@ -29,14 +46,25 @@ class AudioLink(
|
||||
@Volatile private var started = false
|
||||
@Volatile private var focusGranted = false
|
||||
@Volatile private var focusRequested = false
|
||||
/** The car took the speakers for a moment, and we paused the phone's music until it's done. */
|
||||
@Volatile private var pausedForNow = false
|
||||
@Volatile private var inFlight = 0
|
||||
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
||||
@Volatile private var maxUnacked = 4
|
||||
@Volatile private var lastAckAt = 0L
|
||||
private var stalls = 0
|
||||
private var chunks = 0
|
||||
private var bytes = 0L
|
||||
private var dropped = 0
|
||||
@Volatile private var configTimer: Thread? = null
|
||||
/** Underflows the car reported this drive, and the silence still to slip in ahead of the next chunk. */
|
||||
private var underflows = 0
|
||||
@Volatile private var padMs = 0
|
||||
@Volatile private var pendingPadMs = 0
|
||||
private val ackLock = Object()
|
||||
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
|
||||
@Volatile private var aac: AacEncoder? = null
|
||||
private val aacSink get() = service.codecType == ServiceDiscovery.CODEC_AAC_LC || service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS
|
||||
|
||||
private val config get() = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull()
|
||||
override val sampleRate get() = config?.sampleRate ?: 48_000
|
||||
@@ -44,29 +72,89 @@ class AudioLink(
|
||||
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
/** Sending audio now, which for media means the car has handed us the speakers. */
|
||||
val streaming get() = started
|
||||
|
||||
/** The car has set the sink up, so a stream can start. */
|
||||
val isReady get() = ready
|
||||
|
||||
@Volatile private var holdsSpeakers = false
|
||||
@Volatile private var focusAnswer: CountDownLatch? = null
|
||||
@Volatile private var answerDueBy = 0L
|
||||
|
||||
/** True just after [askForSpeakers] or [handBackSpeakers], when the car's next focus answer is ours. */
|
||||
fun expectsFocusAnswer() = System.currentTimeMillis() < answerDueBy
|
||||
|
||||
/**
|
||||
* For directions when nothing of ours is playing, so the car's speakers belong to the radio
|
||||
* or a Bluetooth phone. Android Auto asks for them before each announcement, letting the
|
||||
* other source duck, and hands them back afterwards. Speaking without asking left a 2025
|
||||
* Nissan's Bosch head unit rebooting mid-drive (issue #35).
|
||||
*
|
||||
* Waits briefly for the car's answer and speaks anyway without one, as some cars never answer.
|
||||
*/
|
||||
fun askForSpeakers() {
|
||||
if (holdsSpeakers) return
|
||||
val answer = CountDownLatch(1)
|
||||
focusAnswer = answer
|
||||
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
|
||||
holdsSpeakers = true
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN_TRANSIENT_MAY_DUCK.toLong()))
|
||||
GearslipLog.i("$tag: -> AudioFocusRequest(GAIN_TRANSIENT_MAY_DUCK)")
|
||||
if (!answer.await(FOCUS_WAIT_MS, TimeUnit.MILLISECONDS)) {
|
||||
GearslipLog.i("$tag: no focus answer after ${FOCUS_WAIT_MS}ms - speaking anyway")
|
||||
}
|
||||
focusAnswer = null
|
||||
}
|
||||
|
||||
/** Gives back what [askForSpeakers] took, so the other source comes back to full volume. */
|
||||
fun handBackSpeakers() {
|
||||
if (!holdsSpeakers) return
|
||||
holdsSpeakers = false
|
||||
answerDueBy = System.currentTimeMillis() + FOCUS_WAIT_MS
|
||||
runCatching { sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong())) }
|
||||
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
|
||||
}
|
||||
|
||||
/**
|
||||
* A spoken turn on the music channel while no music plays (see DirectionsVoice): the stream
|
||||
* runs for the turn only, under ducking focus like the directions channel's, so the radio
|
||||
* comes back after it rather than giving way to Android Auto for good.
|
||||
*/
|
||||
fun startForTurn() {
|
||||
askForSpeakers()
|
||||
startStream()
|
||||
}
|
||||
|
||||
fun stopForTurn() {
|
||||
synchronized(startLock) { stopLocked(release = false) }
|
||||
handBackSpeakers()
|
||||
}
|
||||
|
||||
fun opened(status: Int) {
|
||||
GearslipLog.i("audio: <- ChannelOpenResponse (media audio): status=$status")
|
||||
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
GearslipLog.w("audio: the head unit refused the media audio channel (status $status)")
|
||||
GearslipLog.w("$tag: the head unit refused the $tag channel (status $status)")
|
||||
return
|
||||
}
|
||||
// AVChannelSetupRequest { required uint32 config_index = 1 }. This field is an index into
|
||||
// the sink's own audio_configs, not a codec id - sending the codec type asks for a config
|
||||
// the sink does not have, and it answers with a status that never reaches READY.
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, 0L))
|
||||
GearslipLog.i("audio: -> Setup(config_index=0) on channel ${service.serviceId}, codec ${service.codecName}")
|
||||
// Setup { required MediaCodecType type = 1 }: the codec the car advertised for this sink
|
||||
// (aasdk's current protos, and LIVI reads it as the codec). Gearslip used to send 0 here,
|
||||
// read from an older proto as a config index; 0 is no codec at all, and the 2024 VW
|
||||
// answered UNEXPECTED_MESSAGE (id 255), never acked a chunk and played nothing.
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
|
||||
GearslipLog.i("$tag: -> Setup(codec=${service.codecName}) on channel ${service.serviceId}")
|
||||
armConfigFallback()
|
||||
}
|
||||
|
||||
/**
|
||||
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no
|
||||
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never
|
||||
* reaches READY and no audio is ever sent.
|
||||
* The 2018 Uconnect, a Dacia ULC 4.5 and the 2024 VW answered the old Setup (codec 0) with
|
||||
* UNEXPECTED_MESSAGE (id 255, no body) rather than a Config, so the sink never reached READY.
|
||||
* [MSG_SETUP_ANSWER] still takes that as the go-ahead, in case a car rejects the real codec too.
|
||||
*
|
||||
* Service discovery already told us everything that message would have: the sink is PCM at
|
||||
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a
|
||||
* message that is not coming, assume the advertised configuration and carry on. If that guess
|
||||
* is wrong the head unit simply will not ack, which the write path already reports.
|
||||
* Service discovery already told us everything a Config would have: the sink's rate, channels
|
||||
* and its one configuration. So for a car that answers nothing at all, wait a moment and then
|
||||
* assume the advertised configuration. If that guess is wrong the head unit simply will not
|
||||
* ack, which the write path already reports.
|
||||
*/
|
||||
private fun armConfigFallback() {
|
||||
val timer = Thread({
|
||||
@@ -75,19 +163,25 @@ class AudioLink(
|
||||
} catch (e: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (ready) return@Thread
|
||||
GearslipLog.w(
|
||||
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
|
||||
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
|
||||
)
|
||||
configIndex = 0
|
||||
ready = true
|
||||
CarAudio.attach(this)
|
||||
useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms")
|
||||
}, "gearslip-audio-config").apply { isDaemon = true }
|
||||
configTimer = timer
|
||||
timer.start()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun useAdvertisedSink(why: String) {
|
||||
if (ready) return
|
||||
configTimer?.interrupt()
|
||||
GearslipLog.i(
|
||||
"$tag: $why - using the sink the head unit advertised " +
|
||||
"(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
|
||||
)
|
||||
configIndex = 0
|
||||
ready = true
|
||||
onReady(this)
|
||||
}
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0)
|
||||
@@ -97,31 +191,38 @@ class AudioLink(
|
||||
val status = Wire.varint(fields, 1)?.toInt()
|
||||
val window = Wire.varint(fields, 2)?.toInt()
|
||||
val indices = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
|
||||
GearslipLog.i("audio: <- Config status=$status max_unacked=$window indices=$indices")
|
||||
GearslipLog.i("$tag: <- Config status=$status max_unacked=$window indices=$indices")
|
||||
if (window != null) maxUnacked = maxOf(1, window)
|
||||
configIndex = indices.firstOrNull() ?: 0
|
||||
if (status == STATUS_READY) {
|
||||
configTimer?.interrupt()
|
||||
ready = true
|
||||
GearslipLog.i("audio: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||
CarAudio.attach(this)
|
||||
GearslipLog.i("$tag: sink ready - ${sampleRate}Hz x$channels ${config?.bits}bit")
|
||||
onReady(this)
|
||||
} else {
|
||||
GearslipLog.w(
|
||||
"audio: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
|
||||
"$tag: the sink is not ready (status=$status, 1=WAIT 2=READY) - " +
|
||||
"nothing will be sent until it is",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MSG_UNDERFLOW -> onUnderflow()
|
||||
|
||||
MSG_ACK -> {
|
||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
synchronized(ackLock) {
|
||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
ackLock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the head unit answers Setup with, we need its bytes to identify it.
|
||||
else -> {
|
||||
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
// Whatever else the head unit answers Setup with, we need its bytes to identify it.
|
||||
else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) {
|
||||
useAdvertisedSink("<- empty Setup answer (id $messageId)")
|
||||
} else {
|
||||
GearslipLog.i("$tag: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
GearslipLog.hex(" audio msg", body, limit = 64)
|
||||
}
|
||||
}
|
||||
@@ -134,79 +235,208 @@ class AudioLink(
|
||||
*/
|
||||
fun onFocus(state: Int) {
|
||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||
GearslipLog.i("audio: <- AudioFocusResponse state=$name")
|
||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT
|
||||
if (focusGranted) startStream() else stop()
|
||||
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
||||
if (!asksFocus || expectsFocusAnswer()) {
|
||||
// Directions speak whatever the answer: a lost turn is worse than a loud one.
|
||||
answerDueBy = 0L
|
||||
focusAnswer?.countDown()
|
||||
return
|
||||
}
|
||||
val had = focusGranted
|
||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
||||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
||||
when {
|
||||
focusGranted -> {
|
||||
startStream()
|
||||
if (pausedForNow) {
|
||||
pausedForNow = false
|
||||
onGivenBack()
|
||||
}
|
||||
}
|
||||
// A call or the car's own voice. Releasing focus here meant the car never handed it
|
||||
// back, so the music stayed paused after the call: keep the claim and wait for GAIN.
|
||||
state == FOCUS_STATE_LOSS_TRANSIENT -> {
|
||||
synchronized(startLock) { stopLocked(release = false) }
|
||||
if (had) {
|
||||
pausedForNow = true
|
||||
onPausedForNow()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
stop()
|
||||
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
||||
if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
|
||||
pausedForNow = false
|
||||
onTakenBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
|
||||
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
|
||||
* the car's buffer only ever holds what the window lets through. A little silence slipped in
|
||||
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
|
||||
* than heard. Capped, as each step adds a little delay to the music.
|
||||
*/
|
||||
private fun onUnderflow() {
|
||||
underflows++
|
||||
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
|
||||
padMs += step
|
||||
pendingPadMs += step
|
||||
if (underflows <= 3 || underflows % 20 == 0) {
|
||||
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence for [ms], whole frames of this sink's format. */
|
||||
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
|
||||
|
||||
override fun start() {
|
||||
if (!ready || started) return
|
||||
if (!focusRequested) {
|
||||
if (asksFocus && !focusRequested) {
|
||||
focusRequested = true
|
||||
sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_GAIN.toLong()))
|
||||
GearslipLog.i("audio: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
|
||||
GearslipLog.i("$tag: -> AudioFocusRequest(GAIN); waiting for the head unit to hand the sink over")
|
||||
}
|
||||
// Some head units grant the sink silently and never answer. Do not let that be the end of
|
||||
// it: start anyway, and let the acks say whether anything is being taken.
|
||||
startStream()
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards starting and stopping. [start] starts the stream at once for head units that never
|
||||
* answer the focus request, and the car's answer arrives on another thread and starts it
|
||||
* too: without this both got through, so the car got Start twice and two AAC encoders, and a
|
||||
* 2014 head unit dropped the connection on the first audio.
|
||||
*/
|
||||
private val startLock = Any()
|
||||
|
||||
private fun startStream() {
|
||||
synchronized(startLock) { startStreamLocked() }
|
||||
}
|
||||
|
||||
private fun startStreamLocked() {
|
||||
if (!ready || started) return
|
||||
sendOnChannel(
|
||||
MSG_START,
|
||||
Protobuf.varintField(1, SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||
)
|
||||
// Raw AAC-LC has no header of its own: the car needs the codec config before the first frame.
|
||||
if (service.codecType == ServiceDiscovery.CODEC_AAC_LC) {
|
||||
val asc = AacEncoder.audioSpecificConfig(sampleRate, channels)
|
||||
sendOnChannel(MSG_CODEC_CONFIG, asc)
|
||||
GearslipLog.hex("$tag: -> CodecConfig (AudioSpecificConfig)", asc)
|
||||
}
|
||||
inFlight = 0
|
||||
lastAckAt = System.currentTimeMillis()
|
||||
if (aacSink && aac == null) {
|
||||
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
|
||||
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
|
||||
.onFailure { GearslipLog.e("$tag: couldn't start the AAC encoder", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
started = true
|
||||
GearslipLog.i("audio: -> Start(session=$SESSION_ID, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
|
||||
GearslipLog.i("$tag: -> Start(session=$sessionId, config=$configIndex) - streaming ${sampleRate}Hz x$channels")
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
synchronized(startLock) { stopLocked() }
|
||||
}
|
||||
|
||||
/** [release] false keeps our claim on the speakers, so the car hands them back on its own. */
|
||||
private fun stopLocked(release: Boolean = true) {
|
||||
if (!started) return
|
||||
started = false
|
||||
synchronized(ackLock) { ackLock.notifyAll() }
|
||||
runCatching {
|
||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, SESSION_ID.toLong()))
|
||||
if (focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
|
||||
if (release && focusRequested) sendControl(MSG_FOCUS_REQUEST, Protobuf.varintField(1, FOCUS_RELEASE.toLong()))
|
||||
}
|
||||
focusRequested = false
|
||||
if (release) focusRequested = false
|
||||
focusGranted = false
|
||||
GearslipLog.i("audio: -> Stop, AudioFocusRequest(RELEASE) after $chunks chunks / $bytes bytes")
|
||||
aac?.release()
|
||||
aac = null
|
||||
GearslipLog.i("$tag: -> Stop${if (release) ", AudioFocusRequest(RELEASE)" else ""} after $chunks chunks / $bytes bytes")
|
||||
chunks = 0
|
||||
bytes = 0
|
||||
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
|
||||
padMs = 0
|
||||
pendingPadMs = 0
|
||||
}
|
||||
|
||||
override fun write(pcm: ByteArray, size: Int) {
|
||||
if (!started) return
|
||||
if (inFlight >= maxUnacked) {
|
||||
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
|
||||
if (System.currentTimeMillis() - lastAckAt > STALL_MS) {
|
||||
GearslipLog.w("audio: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window")
|
||||
inFlight = 0
|
||||
} else {
|
||||
dropped++
|
||||
return
|
||||
val pad = pendingPadMs
|
||||
if (pad > 0) {
|
||||
pendingPadMs = 0
|
||||
val quiet = silence(pad)
|
||||
writeNow(quiet, quiet.size)
|
||||
}
|
||||
writeNow(pcm, size)
|
||||
}
|
||||
|
||||
private fun writeNow(pcm: ByteArray, size: Int) {
|
||||
val encoder = aac
|
||||
if (encoder == null) {
|
||||
send(pcm.copyOf(size))
|
||||
return
|
||||
}
|
||||
val frames = try {
|
||||
encoder.encode(pcm, size)
|
||||
} catch (e: Exception) {
|
||||
// A codec that dies mid-drive leaves the car silent, as it was before AAC; log it once.
|
||||
GearslipLog.e("$tag: AAC encoding failed - no more audio this drive", e)
|
||||
aac = null
|
||||
started = false
|
||||
return
|
||||
}
|
||||
frames.forEach(::send)
|
||||
}
|
||||
|
||||
/** One Data message, once the car has room for it. */
|
||||
private fun send(payload: ByteArray) {
|
||||
// Wait for the car to catch up rather than throw the chunk away: a dropped chunk is a gap
|
||||
// the driver hears, while a short wait is soaked up by the capture buffer behind us.
|
||||
synchronized(ackLock) {
|
||||
while (started && inFlight >= maxUnacked) {
|
||||
val waited = System.currentTimeMillis() - lastAckAt
|
||||
if (waited > STALL_MS) {
|
||||
// A lost ack must not silence the stream for good; after a stall assume the window cleared.
|
||||
// A car that never acks (the 2024 VW, the Bolt) hits this every chunk: say so
|
||||
// once, then now and then, so the rest of the log stays readable.
|
||||
if (stalls++ % 500 == 0) {
|
||||
GearslipLog.w("$tag: no ack for ${STALL_MS}ms with $inFlight in flight - reopening the window ($stalls times)")
|
||||
}
|
||||
inFlight = 0
|
||||
break
|
||||
}
|
||||
ackLock.wait(STALL_MS - waited + 1)
|
||||
}
|
||||
}
|
||||
if (!started) {
|
||||
dropped++
|
||||
return
|
||||
}
|
||||
val timestamp = ByteArray(8)
|
||||
val micros = System.nanoTime() / 1_000
|
||||
for (i in 0 until 8) timestamp[i] = ((micros shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||
try {
|
||||
sendOnChannel(MSG_DATA, timestamp + pcm.copyOf(size))
|
||||
sendOnChannel(MSG_DATA, timestamp + payload)
|
||||
} catch (e: java.io.IOException) {
|
||||
GearslipLog.e("audio: the link died mid-stream", e)
|
||||
GearslipLog.e("$tag: the link died mid-stream", e)
|
||||
started = false
|
||||
return
|
||||
}
|
||||
inFlight++
|
||||
synchronized(ackLock) { inFlight++ }
|
||||
chunks++
|
||||
bytes += size
|
||||
bytes += payload.size
|
||||
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||
// the car is still taking audio and to catch a window that has quietly closed.
|
||||
if (chunks == 1 || chunks % CHUNK_LOG_EVERY == 0) {
|
||||
GearslipLog.i(
|
||||
"audio: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
|
||||
"$tag: -> $chunks chunks / $bytes bytes, inFlight=$inFlight/$maxUnacked, " +
|
||||
"dropped=$dropped, focus=${if (focusGranted) "granted" else "not granted"}",
|
||||
)
|
||||
}
|
||||
@@ -216,8 +446,9 @@ class AudioLink(
|
||||
configTimer?.interrupt()
|
||||
configTimer = null
|
||||
stop()
|
||||
handBackSpeakers()
|
||||
ready = false
|
||||
CarAudio.detach(this)
|
||||
onClose(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -225,23 +456,35 @@ class AudioLink(
|
||||
const val MSG_FOCUS_RESPONSE = 19
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_DATA = 0
|
||||
private const val MSG_CODEC_CONFIG = 1
|
||||
private const val MSG_SETUP = 32768
|
||||
private const val MSG_START = 32769
|
||||
private const val MSG_STOP = 32770
|
||||
private const val MSG_CONFIG = 32771
|
||||
private const val MSG_ACK = 32772
|
||||
private const val MSG_SETUP_ANSWER = 255
|
||||
private const val MSG_UNDERFLOW = 32779 // 0x800B, car to phone, no body
|
||||
private const val PAD_STEP_MS = 60
|
||||
private const val MAX_PAD_MS = 240
|
||||
private const val STATUS_READY = 2
|
||||
private const val FOCUS_GAIN = 1
|
||||
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
|
||||
private const val FOCUS_RELEASE = 4
|
||||
private const val FOCUS_WAIT_MS = 500L
|
||||
private const val FOCUS_STATE_GAIN = 1
|
||||
private const val FOCUS_STATE_GAIN_TRANSIENT = 2
|
||||
private const val FOCUS_STATE_GAIN_MEDIA_ONLY = 6
|
||||
private const val FOCUS_STATE_LOSS = 3
|
||||
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
|
||||
private const val SESSION_ID = 2
|
||||
private const val STALL_MS = 2_000L
|
||||
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
||||
private const val CONFIG_WAIT_MS = 2_500L
|
||||
private const val CONFIG_WAIT_MS = 1_000L
|
||||
|
||||
// AudioFocusStateNotification.AudioFocusState in aasdk.
|
||||
private val FOCUS_STATES = mapOf(
|
||||
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
|
||||
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT_CAN_DUCK",
|
||||
5 to "LOSS_TRANSIENT", 6 to "GAIN_MEDIA_ONLY", 7 to "GAIN_TRANSIENT_GUIDANCE_ONLY",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ object CarAudio {
|
||||
|
||||
@Volatile private var link: Link? = null
|
||||
|
||||
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
|
||||
@Volatile var openSink: (() -> Unit)? = null
|
||||
|
||||
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
||||
val sampleRate get() = link?.sampleRate ?: 48_000
|
||||
val channels get() = link?.channels ?: 2
|
||||
@@ -71,6 +74,7 @@ object CarAudio {
|
||||
when (_capture.value) {
|
||||
Capture.ON, Capture.ASKING, Capture.DENIED -> return
|
||||
Capture.OFF -> if (link == null) {
|
||||
openSink?.invoke()
|
||||
GearslipLog.i("audio: waiting for the car to offer an audio sink")
|
||||
} else {
|
||||
ask()
|
||||
@@ -100,6 +104,26 @@ object CarAudio {
|
||||
_capture.value = Capture.DENIED
|
||||
}
|
||||
|
||||
private val _micBlocked = MutableStateFlow(false)
|
||||
|
||||
/** The microphone permission is off for good: Android refuses without asking, so say why. */
|
||||
val micBlocked: StateFlow<Boolean> = _micBlocked.asStateFlow()
|
||||
|
||||
fun micBlocked() {
|
||||
_micBlocked.value = true
|
||||
denied()
|
||||
}
|
||||
|
||||
/** The driver turned the permission back on mid-drive: ask for capture again. */
|
||||
fun micAllowed() {
|
||||
if (!_micBlocked.value) return
|
||||
_micBlocked.value = false
|
||||
if (_capture.value == Capture.DENIED) {
|
||||
_capture.value = Capture.OFF
|
||||
if (wanted) request()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun captureStarted() {
|
||||
_capture.value = Capture.ON
|
||||
link?.start()
|
||||
@@ -113,6 +137,59 @@ object CarAudio {
|
||||
|
||||
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
||||
_level.value = peak
|
||||
link?.write(pcm, size)
|
||||
val voice = synchronized(voiceLock) { spoken }
|
||||
if (voice == null) link?.write(pcm, size) else link?.write(mixVoice(pcm, size), size)
|
||||
}
|
||||
|
||||
private val voiceLock = Any()
|
||||
private var spoken: ByteArray? = null
|
||||
private var spokenAt = 0
|
||||
private var spokenDone: java.util.concurrent.CountDownLatch? = null
|
||||
|
||||
/**
|
||||
* Mixes a spoken turn into the music from the next chunk on, with the music turned down
|
||||
* under it, and waits until it has all gone out. [pcm] is 16-bit, in the sink's own format.
|
||||
* False when no music is flowing to carry it, so the caller sends it some other way.
|
||||
*/
|
||||
fun speakOverMusic(pcm: ByteArray): Boolean {
|
||||
if (_capture.value != Capture.ON || (link as? AudioLink)?.streaming != true) return false
|
||||
val done = java.util.concurrent.CountDownLatch(1)
|
||||
synchronized(voiceLock) {
|
||||
spokenDone?.countDown()
|
||||
spoken = pcm
|
||||
spokenAt = 0
|
||||
spokenDone = done
|
||||
}
|
||||
val ms = pcm.size * 1000L / (2 * channels * sampleRate)
|
||||
if (!done.await(ms + VOICE_SLACK_MS, java.util.concurrent.TimeUnit.MILLISECONDS)) {
|
||||
// Capture stopped mid-turn; the rest of it is dropped rather than played late.
|
||||
synchronized(voiceLock) { if (spokenDone === done) spoken = null }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun mixVoice(pcm: ByteArray, size: Int): ByteArray = synchronized(voiceLock) {
|
||||
val voice = spoken ?: return pcm
|
||||
val out = pcm.copyOf(size)
|
||||
var i = 0
|
||||
while (i + 1 < size) {
|
||||
val music = (out[i].toInt() and 0xFF) or (out[i + 1].toInt() shl 8)
|
||||
val word = if (spokenAt + 1 < voice.size) (voice[spokenAt].toInt() and 0xFF) or (voice[spokenAt + 1].toInt() shl 8) else 0
|
||||
val mixed = (music * DUCK_PERCENT / 100 + word).coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
|
||||
out[i] = mixed.toByte()
|
||||
out[i + 1] = (mixed shr 8).toByte()
|
||||
i += 2
|
||||
spokenAt += 2
|
||||
}
|
||||
if (spokenAt >= voice.size) {
|
||||
spoken = null
|
||||
spokenDone?.countDown()
|
||||
spokenDone = null
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/** How loud the music stays under a spoken turn. */
|
||||
private const val DUCK_PERCENT = 30
|
||||
private const val VOICE_SLACK_MS = 2_000L
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package app.seb3thehacker.gearslip.audio
|
||||
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The car's own microphone, which most head units offer over the same link as the screen:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> Setup { config_index } <- Config
|
||||
* -> VoiceSessionNotification { START } (control channel)
|
||||
* -> MicrophoneRequest { open } <- MicrophoneResponse { status, session_id }
|
||||
* <- Data { 8-byte timestamp, PCM }... -> Ack { session_id, ack }
|
||||
* -> MicrophoneRequest { open = false }
|
||||
* -> VoiceSessionNotification { END }
|
||||
*
|
||||
* The channel opens with the session, but the car only records between [start] and [stop], and
|
||||
* only inside a voice session: the 2024 VW answered a bare MicrophoneRequest with status -7.
|
||||
* Speech wants 16 kHz mono; a car that records at a multiple of that is averaged down to it, and
|
||||
* one that doesn't isn't used, so voice falls back to the phone's microphone.
|
||||
*/
|
||||
class CarMic(
|
||||
private val service: ServiceDiscovery.AudioService,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
private val voiceSession: (start: Boolean) -> Unit,
|
||||
) {
|
||||
val channelId get() = service.serviceId
|
||||
|
||||
private val config = service.configs.first()
|
||||
private val step = config.sampleRate / RATE
|
||||
private val usable = service.codecType == ServiceDiscovery.CODEC_PCM && config.bits == 16 &&
|
||||
config.sampleRate % RATE == 0 && step >= 1 && config.channels in 1..2
|
||||
|
||||
@Volatile private var ready = false
|
||||
@Volatile private var listening = false
|
||||
@Volatile private var session = 0
|
||||
private var packets = 0
|
||||
private val chunks = LinkedBlockingQueue<ShortArray>()
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
GearslipLog.i("mic: <- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) return
|
||||
// Setup { type = the codec }, as for audio: 0 got UNEXPECTED_MESSAGE from the VW.
|
||||
sendOnChannel(MSG_SETUP, Protobuf.varintField(1, service.codecType.toLong()))
|
||||
ready = usable
|
||||
if (!usable) {
|
||||
GearslipLog.w(
|
||||
"mic: the car records ${service.codecName} ${config.sampleRate}Hz/${config.bits}bit/x${config.channels}, " +
|
||||
"which speech can't use - voice stays on the phone's microphone",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
MSG_CONFIG -> GearslipLog.i("mic: <- Config status=${Wire.varint(Wire.fields(body), 1)}")
|
||||
|
||||
MSG_MIC_RESPONSE -> {
|
||||
val fields = Wire.fields(body)
|
||||
session = Wire.varint(fields, 2)?.toInt() ?: 0
|
||||
GearslipLog.i("mic: <- MicrophoneResponse status=${Wire.varint(fields, 1)} session=$session")
|
||||
}
|
||||
|
||||
MSG_DATA -> {
|
||||
sendOnChannel(MSG_ACK, Protobuf.varintField(1, session.toLong()) + Protobuf.varintField(2, 1L))
|
||||
if (!listening || body.size <= TIMESTAMP_BYTES) return
|
||||
if (packets++ == 0) GearslipLog.i("mic: first audio from the car (${body.size - TIMESTAMP_BYTES} bytes)")
|
||||
chunks.offer(toSpeechRate(body))
|
||||
}
|
||||
|
||||
else -> GearslipLog.i("mic: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
/** Little-endian PCM after the timestamp, mixed to mono and averaged down to [RATE]. */
|
||||
private fun toSpeechRate(body: ByteArray): ShortArray {
|
||||
val frameBytes = 2 * config.channels
|
||||
val frames = (body.size - TIMESTAMP_BYTES) / frameBytes
|
||||
val out = ShortArray(frames / step)
|
||||
for (i in out.indices) {
|
||||
var sum = 0
|
||||
for (s in 0 until step) {
|
||||
val at = TIMESTAMP_BYTES + (i * step + s) * frameBytes
|
||||
for (c in 0 until config.channels) {
|
||||
val o = at + 2 * c
|
||||
sum += (body[o].toInt() and 0xFF) or (body[o + 1].toInt() shl 8)
|
||||
}
|
||||
}
|
||||
out[i] = (sum / (step * config.channels)).toShort()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Asks the car to start recording. False when it can't, so the phone's microphone is used. */
|
||||
fun start(): Boolean {
|
||||
if (!ready) return false
|
||||
chunks.clear()
|
||||
packets = 0
|
||||
listening = true
|
||||
voiceSession(true)
|
||||
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 1L) + Protobuf.varintField(4, 1L))
|
||||
GearslipLog.i("mic: -> MicrophoneRequest(open)")
|
||||
return true
|
||||
}
|
||||
|
||||
/** The next piece of audio, or null if the car sent nothing for [timeoutMs]. */
|
||||
fun read(timeoutMs: Long): ShortArray? = chunks.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
fun stop() {
|
||||
if (!listening) return
|
||||
listening = false
|
||||
sendOnChannel(MSG_MIC_REQUEST, Protobuf.varintField(1, 0L))
|
||||
voiceSession(false)
|
||||
GearslipLog.i("mic: -> MicrophoneRequest(close) after $packets packets, voice session ended")
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The connected car's microphone, or null with no car or one that has none. */
|
||||
@Volatile var current: CarMic? = null
|
||||
|
||||
const val RATE = 16_000
|
||||
private const val TIMESTAMP_BYTES = 8
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_DATA = 0
|
||||
private const val MSG_SETUP = 32768
|
||||
private const val MSG_CONFIG = 32771
|
||||
private const val MSG_ACK = 32772
|
||||
private const val MSG_MIC_REQUEST = 32773
|
||||
private const val MSG_MIC_RESPONSE = 32774
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.ContactsContract
|
||||
import android.telecom.TelecomManager
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
|
||||
data class Caller(val number: String, val name: String?) {
|
||||
val label: String get() = name ?: number.ifBlank { "Unknown number" }
|
||||
}
|
||||
|
||||
sealed interface CallUiState {
|
||||
data object None : CallUiState
|
||||
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||
/** [since] is the uptime the call went off-hook, for the timer. */
|
||||
data class Active(val caller: Caller?, val since: Long) : CallUiState
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks the phone's call state for the car screen, and answers or declines through the
|
||||
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||
* [GearslipCallScreening]'s response to decline one that is still ringing, and
|
||||
* [android.telecom.TelecomManager.endCall] to hang up, which needs only ANSWER_PHONE_CALLS for an
|
||||
* ordinary carrier call. Mute is the phone's microphone mute, put back when the call ends.
|
||||
*
|
||||
* Call audio stays on the phone's normal voice path; nothing here sends it over the USB link,
|
||||
* which only ever carries media playback.
|
||||
*/
|
||||
object CarCalls {
|
||||
|
||||
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
|
||||
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||
|
||||
private var telephony: TelephonyManager? = null
|
||||
private var watcher: TelephonyCallback? = null
|
||||
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||
private var ringingCaller: Caller? = null
|
||||
|
||||
private val _muted = MutableStateFlow(false)
|
||||
val muted: StateFlow<Boolean> = _muted.asStateFlow()
|
||||
private var audio: android.media.AudioManager? = null
|
||||
|
||||
fun start(context: Context) {
|
||||
if (watcher != null) return
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||
audio = context.getSystemService(android.media.AudioManager::class.java)
|
||||
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||
}
|
||||
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
|
||||
.onSuccess { telephony = manager; watcher = callback }
|
||||
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val callback = watcher ?: return
|
||||
runCatching { telephony?.unregisterTelephonyCallback(callback) }
|
||||
telephony = null
|
||||
watcher = null
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
unmute()
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
|
||||
private fun onPhoneState(state: Int) {
|
||||
when (state) {
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
pendingRespond = null
|
||||
ringingCaller = null
|
||||
// A mute left on would silence the microphone for everything after the call.
|
||||
unmute()
|
||||
_state.value = CallUiState.None
|
||||
}
|
||||
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||
pendingRespond = null
|
||||
if (_state.value !is CallUiState.Active) {
|
||||
_state.value = CallUiState.Active(ringingCaller, android.os.SystemClock.uptimeMillis())
|
||||
}
|
||||
}
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
// The number itself only ever arrives through the screening service; if this
|
||||
// fires first, the ringing card appears once incomingCall() catches up.
|
||||
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
|
||||
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
|
||||
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
|
||||
ringingCaller = caller
|
||||
pendingRespond = respond
|
||||
_state.value = CallUiState.Ringing(caller, canDecline = true)
|
||||
}
|
||||
|
||||
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
|
||||
fun screeningExpired() {
|
||||
pendingRespond = null
|
||||
val caller = ringingCaller ?: return
|
||||
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
|
||||
}
|
||||
|
||||
fun decline() {
|
||||
val respond = pendingRespond ?: return
|
||||
pendingRespond = null
|
||||
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||
}
|
||||
|
||||
/** A call Gearslip itself is placing, so the call pill can say who it's to. */
|
||||
fun outgoingCall(context: Context, number: String) {
|
||||
ringingCaller = Caller(number, lookUpName(context, number))
|
||||
}
|
||||
|
||||
fun end(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: ending a call needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
@Suppress("DEPRECATION")
|
||||
runCatching { telecom.endCall() }
|
||||
.onSuccess { if (!it) GearslipLog.w("calls: the phone wouldn't end the call") }
|
||||
.onFailure { GearslipLog.w("calls: end failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
val manager = audio ?: return
|
||||
val want = !_muted.value
|
||||
runCatching { manager.isMicrophoneMute = want }
|
||||
.onFailure { GearslipLog.w("calls: mute failed: ${it.message}") }
|
||||
// Read back rather than trusted: some phones let the call app overrule it.
|
||||
_muted.value = manager.isMicrophoneMute
|
||||
if (_muted.value != want) GearslipLog.w("calls: the phone didn't take the mute change")
|
||||
}
|
||||
|
||||
private fun unmute() {
|
||||
if (!_muted.value) return
|
||||
runCatching { audio?.isMicrophoneMute = false }
|
||||
_muted.value = false
|
||||
}
|
||||
|
||||
fun answer(context: Context) {
|
||||
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||
return
|
||||
}
|
||||
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||
runCatching { telecom.acceptRingingCall() }
|
||||
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun lookUpName(context: Context, number: String): String? {
|
||||
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
|
||||
return runCatching {
|
||||
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
|
||||
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
|
||||
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
|
||||
Handler(Looper.getMainLooper()).postDelayed(action, ms)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.view.KeyEvent
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import app.seb3thehacker.gearslip.car.CarKeys
|
||||
|
||||
/**
|
||||
* Tells the car about calls, so its own screens show them: the instrument cluster, the steering
|
||||
* wheel display, the car's phone page. Android Auto does the same over the car's
|
||||
* PhoneStatusService (Service field 10):
|
||||
*
|
||||
* -> PhoneStatusUpdate { repeated PhoneCall calls = 1 }
|
||||
* PhoneCall { state = 1 (1 in call, 4 incoming, 6 muted); duration_seconds = 2;
|
||||
* number = 3; name = 4 }
|
||||
* <- PhoneStatusInput { input = 1 { action = 1 (7 call) }; caller = 2; name = 3 }
|
||||
*
|
||||
* Read once a second from [CarCalls]. An update goes when the call changes, and every second
|
||||
* while one is going, so the car's timer counts along; no calls goes once, as an empty list.
|
||||
* The car's own call button answers through [CarKeys], like the steering wheel's.
|
||||
*/
|
||||
class CarPhoneStatus(
|
||||
val channelId: Int,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
@Volatile private var running = false
|
||||
private var sent: String? = null
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("phone status: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
MSG_INPUT -> onInput(body)
|
||||
else -> GearslipLog.i("phone status: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
Thread({
|
||||
while (running) {
|
||||
runCatching { tick() }.onFailure { GearslipLog.w("phone status: ${it.message}") }
|
||||
Thread.sleep(TICK_MS)
|
||||
}
|
||||
}, "gearslip-phone-status").start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
running = false
|
||||
}
|
||||
|
||||
private fun tick() {
|
||||
val call = when (val state = CarCalls.state.value) {
|
||||
CallUiState.None -> null
|
||||
is CallUiState.Ringing -> Call(STATE_INCOMING, 0, state.caller)
|
||||
is CallUiState.Active -> Call(
|
||||
if (CarCalls.muted.value) STATE_MUTED else STATE_IN_CALL,
|
||||
((SystemClock.uptimeMillis() - state.since) / 1000).coerceAtLeast(0),
|
||||
state.caller,
|
||||
)
|
||||
}
|
||||
// Only a call that's going changes from one second to the next.
|
||||
val key = call?.let { "${it.state}|${it.caller?.number}|${it.caller?.name}|${if (it.state == STATE_INCOMING) 0 else it.seconds}" } ?: ""
|
||||
if (key == sent) return
|
||||
val changed = call?.state != lastState
|
||||
sent = key
|
||||
lastState = call?.state
|
||||
val body = call?.let { Protobuf.bytesField(1, it.encode()) } ?: ByteArray(0)
|
||||
sendOnChannel(MSG_STATUS, body)
|
||||
if (changed) GearslipLog.i("phone status: -> ${call?.let { STATE_NAMES[it.state] } ?: "no calls"}")
|
||||
}
|
||||
|
||||
private var lastState: Int? = null
|
||||
|
||||
private class Call(val state: Int, val seconds: Long, val caller: Caller?) {
|
||||
fun encode(): ByteArray {
|
||||
var body = Protobuf.varintField(1, state.toLong()) + Protobuf.varintField(2, seconds)
|
||||
caller?.number?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(3, it) }
|
||||
caller?.name?.takeIf { it.isNotBlank() }?.let { body += Protobuf.stringField(4, it) }
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
/** The car's own phone buttons. Only "call" does anything: it answers, as the wheel's does. */
|
||||
private fun onInput(body: ByteArray) {
|
||||
val action = Wire.bytes(Wire.fields(body), 1)?.let { Wire.varint(Wire.fields(it), 1)?.toInt() }
|
||||
GearslipLog.i("phone status: <- input action=${action ?: "?"}")
|
||||
if (action == ACTION_CALL) CarKeys.onKey(KeyEvent.KEYCODE_CALL, down = true, longPress = false)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TICK_MS = 1_000L
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_STATUS = 32769 // 0x8001
|
||||
private const val MSG_INPUT = 32770 // 0x8002
|
||||
private const val STATE_IN_CALL = 1
|
||||
private const val STATE_INCOMING = 4
|
||||
private const val STATE_MUTED = 6
|
||||
private const val ACTION_CALL = 7
|
||||
private val STATE_NAMES = mapOf(STATE_IN_CALL to "in a call", STATE_INCOMING to "ringing", STATE_MUTED to "in a call, muted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package app.seb3thehacker.gearslip.call
|
||||
|
||||
import android.telecom.Call
|
||||
import android.telecom.CallScreeningService
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
|
||||
/**
|
||||
* Sees every incoming call before it rings, so the car screen can show who is calling and
|
||||
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
|
||||
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
|
||||
* for, not a privileged permission.
|
||||
*
|
||||
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
|
||||
* response is sent either when the driver taps a button or when that window is about to close,
|
||||
* whichever comes first. Answering a call that has already passed this point still works, since
|
||||
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
|
||||
*/
|
||||
class GearslipCallScreening : CallScreeningService() {
|
||||
|
||||
override fun onScreenCall(callDetails: Call.Details) {
|
||||
val number = callDetails.handle?.schemeSpecificPart
|
||||
var responded = false
|
||||
|
||||
val respond: (Boolean) -> Unit = { allow ->
|
||||
if (!responded) {
|
||||
responded = true
|
||||
runCatching {
|
||||
respondToCall(
|
||||
callDetails,
|
||||
CallResponse.Builder()
|
||||
.setDisallowCall(!allow)
|
||||
.setRejectCall(!allow)
|
||||
.setSkipNotification(false)
|
||||
.build(),
|
||||
)
|
||||
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
CarCalls.incomingCall(applicationContext, number, respond)
|
||||
|
||||
// Answer for the system if nobody has by the time its own patience runs out, so a call
|
||||
// Gearslip is slow to show never gets silently blocked.
|
||||
mainThreadDelay(SCREENING_WINDOW_MS) {
|
||||
respond(true)
|
||||
CarCalls.screeningExpired()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SCREENING_WINDOW_MS = 3_500L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApp
|
||||
import app.seb3thehacker.gearslip.mirror.PhoneApps
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Launcher mode's app drawer: every app on the device, A to Z, each opened full screen. Gearslip
|
||||
* comes first, for its own settings, which the car UI only partly covers.
|
||||
*/
|
||||
@Composable
|
||||
fun AllAppsScreen() {
|
||||
val context = LocalContext.current
|
||||
val apps by produceState(initialValue = PhoneApps.cached() ?: emptyList(), context) {
|
||||
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||
}
|
||||
val gearslip = remember(context) {
|
||||
val label = context.packageManager.getApplicationLabel(context.applicationInfo).toString()
|
||||
val icon = runCatching { context.packageManager.getApplicationIcon(context.packageName).toBitmap(ICON_PX, ICON_PX).asImageBitmap() }
|
||||
PhoneApp(context.packageName, label, icon.getOrNull())
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
|
||||
ScreenHeader(stringResource(R.string.app_all_apps))
|
||||
Spacer(Modifier.height(12.dp))
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
PhoneAppTile(gearslip) { LauncherMode.launch(context, context.packageName) }
|
||||
}
|
||||
items(apps.sortedBy { it.label.lowercase() }) { app ->
|
||||
PhoneAppTile(app) { LauncherMode.launch(context, app.packageName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ICON_PX = 128
|
||||
@@ -0,0 +1,405 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.speech.VoiceFocus
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Turns what [CarAssistant] heard into an action plus something to say back.
|
||||
*
|
||||
* Deliberately small and keyword-matched rather than run through any NLU or bundled model: a
|
||||
* fixed set of things a driver actually wants hands-free (media, calling, opening an app,
|
||||
* getting back Home), covering the same ground the rest of the car UI already does rather than
|
||||
* trying to be a general assistant.
|
||||
*/
|
||||
object AssistantCommands {
|
||||
|
||||
suspend fun handle(context: Context, navigator: CarNavigator, frame: CarEnvironment.Frame, heardRaw: String): String {
|
||||
val heard = natural(heardRaw)
|
||||
VoiceHelp.hide()
|
||||
if (heard in HELP_WORDS || heard.startsWith("what can i say") || heard.startsWith("what can you do")) {
|
||||
VoiceHelp.show()
|
||||
return "Here's what you can say."
|
||||
}
|
||||
if (heard.matches(Regex("(fuck|f\\*+) (you|off)( too)?"))) return "Fuck you too."
|
||||
|
||||
navigateCommand(context, navigator, frame, heard)?.let { return it }
|
||||
playSearchCommand(context, navigator, heard)?.let { return it }
|
||||
mediaCommand(heard)?.let { return it }
|
||||
navCommand(navigator, heard)?.let { return it }
|
||||
infoCommand(heard)?.let { return it }
|
||||
callCommand(context, heard)?.let { return it }
|
||||
openAppCommand(context, navigator, frame, heard)?.let { return it }
|
||||
|
||||
return "I don't know how to do that yet."
|
||||
}
|
||||
|
||||
/**
|
||||
* What was said, minus the padding people wrap a command in: "hey", "can you", "I want to",
|
||||
* "please", "thanks". Each command below then only has to know its own words.
|
||||
*/
|
||||
private fun natural(raw: String): String {
|
||||
var t = raw.lowercase().replace(Regex("[.,!?]"), " ").replace(Regex("\\s+"), " ").trim()
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (lead in LEAD_FILLERS) if (t.startsWith("$lead ")) { t = t.removePrefix("$lead ").trim(); changed = true }
|
||||
for (tail in TAIL_FILLERS) if (t.endsWith(" $tail")) { t = t.removeSuffix(" $tail").trim(); changed = true }
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
/** The part after the first of [triggers] that [text] starts with, or null. */
|
||||
private fun after(text: String, triggers: List<String>): String? =
|
||||
triggers.firstOrNull { text.startsWith(it) }?.let { text.removePrefix(it).trim() }
|
||||
|
||||
private suspend fun allEntries(context: Context): List<Entry> {
|
||||
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
|
||||
val builtIn = BuiltInApps.entries(LauncherMode.active.value, LauncherMode.canCall(context)).filter { it.builtIn !== BuiltInApps.VehicleData || vehicleData }
|
||||
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Best label match: exact, then a label starting with [name], then one containing it. Spaces
|
||||
* and punctuation don't count, since the recognizer may hear "MapQuest" as "map quest".
|
||||
*/
|
||||
private fun <T> bestMatch(items: List<T>, name: String, label: (T) -> String): T? {
|
||||
val n = squash(name.removePrefix("the ").removePrefix("my ").removeSuffix(" app"))
|
||||
if (n.isEmpty()) return null
|
||||
items.firstOrNull { squash(label(it)) == n }?.let { return it }
|
||||
// Loose matches need a real word: "pizza in a" mustn't pick "Organic Maps" for its "a".
|
||||
if (n.length < 3) return null
|
||||
return items.firstOrNull { squash(label(it)).startsWith(n) }
|
||||
?: items.firstOrNull { squash(label(it)).contains(n) }
|
||||
}
|
||||
|
||||
private fun squash(s: String) = s.lowercase().filter { it.isLetterOrDigit() }
|
||||
|
||||
/**
|
||||
* "navigate to X [with|using|on|via Y]" - Y picks the nav app by (partial) label; without it,
|
||||
* whichever nav app was used last, or the only one installed, or the first one found. Starts
|
||||
* it straight into navigating there via [CarAppConnection]'s `ACTION_NAVIGATE` deep link -
|
||||
* the same one Android Auto's own Assistant integration uses - rather than opening the app
|
||||
* and leaving the driver to type the address in on the car keyboard.
|
||||
*/
|
||||
private suspend fun navigateCommand(
|
||||
context: Context,
|
||||
navigator: CarNavigator,
|
||||
frame: CarEnvironment.Frame,
|
||||
heard: String,
|
||||
): String? {
|
||||
// "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
|
||||
// "take me home", "go home": no "to", so no trigger would catch them.
|
||||
var text = if (Regex("^(?:(?:take|bring|get|drive) me|go|drive|head|get|directions|go back|head back) home$").matches(heard)) {
|
||||
"navigate to home"
|
||||
} else {
|
||||
heard
|
||||
}
|
||||
var leadingApp: String? = null
|
||||
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
|
||||
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
|
||||
leadingApp = m.groupValues[1]
|
||||
text = m.groupValues[2]
|
||||
}
|
||||
}
|
||||
var destination = after(text, NAVIGATE_TRIGGERS) ?: return null
|
||||
if (destination.isBlank()) return "Where to?"
|
||||
|
||||
val entries = allEntries(context)
|
||||
// "go to Spotify" means the app, and "go to settings" the screen; "go to Target" is a place.
|
||||
val asApp = destination.removePrefix("the ").removeSuffix(" app")
|
||||
if (text.startsWith("go to ") &&
|
||||
(asApp in SCREEN_WORDS || asApp in APP_NICKNAMES || entries.any { it.label.lowercase() == asApp })
|
||||
) {
|
||||
return null
|
||||
}
|
||||
val navApps = entries.filter { it.template?.isNavigation == true }
|
||||
if (navApps.isEmpty()) return "You don't have any navigation apps installed."
|
||||
|
||||
// "X with Organic Maps", "X on OsmAnd": split only when the end names a nav app, so a
|
||||
// place like "Starbucks on Main Street" stays whole.
|
||||
var chosenApp = leadingApp?.let { name -> bestMatch(navApps, name) { it.label } ?: return "I couldn't find a navigation app called $name." }
|
||||
if (chosenApp == null) {
|
||||
for (sep in listOf(" with ", " using ", " on ", " via ", " in ", " through ")) {
|
||||
val at = destination.lastIndexOf(sep)
|
||||
if (at <= 0) continue
|
||||
val app = bestMatch(navApps, destination.substring(at + sep.length)) { it.label } ?: continue
|
||||
chosenApp = app
|
||||
destination = destination.substring(0, at).trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
// "the nearest gas station" -> "gas station": the map app already searches around the car
|
||||
// and AutoRoute picks the nearest result, and Organic Maps took "nearest" as part of the name.
|
||||
val (place, nearby) = plainPlace(destination)
|
||||
destination = place
|
||||
if (destination.isBlank()) return "Where to?"
|
||||
// "home", "work", or a name from Saved places: its address is the search.
|
||||
var spoken: String? = null
|
||||
SavedPlaces.match(context, destination)?.let { (slot, saved) ->
|
||||
val name = when (slot) {
|
||||
SavedPlaces.HOME -> "home"
|
||||
SavedPlaces.WORK -> "work"
|
||||
else -> saved.label
|
||||
}
|
||||
if (saved.address.isBlank()) {
|
||||
return "You haven't saved a $name address yet. Add it under Saved places in Gearslip's settings on your phone."
|
||||
}
|
||||
destination = saved.address
|
||||
spoken = if (slot == SavedPlaces.HOME) "home" else "to $name"
|
||||
}
|
||||
|
||||
val chosen = chosenApp ?: when {
|
||||
navApps.size == 1 -> navApps.first()
|
||||
else -> navApps.firstOrNull { it.componentId == CarSettings.lastNav.value } ?: navApps.first()
|
||||
}
|
||||
val template = chosen.template ?: return "That's not a navigation app."
|
||||
|
||||
CarServices.connectNav(template, frame, destination)
|
||||
navigator.home()
|
||||
spoken?.let { return "Navigating $it with ${chosen.label}." }
|
||||
return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
|
||||
}
|
||||
|
||||
/** [destination] without "a", "the nearest", "near me" and the like, and whether any of that said "close by". */
|
||||
private fun plainPlace(destination: String): Pair<String, Boolean> {
|
||||
var t = destination
|
||||
var nearby = false
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (lead in PLACE_LEADS) if (t.startsWith(lead)) {
|
||||
t = t.removePrefix(lead).trim(); changed = true
|
||||
if (lead.trim() !in ARTICLES) nearby = true
|
||||
}
|
||||
for (tail in PLACE_TAILS) if (t.endsWith(tail)) { t = t.removeSuffix(tail).trim(); changed = true; nearby = true }
|
||||
}
|
||||
return t to nearby
|
||||
}
|
||||
|
||||
/**
|
||||
* "play X" with something after it: a song, artist, album or playlist, asked of the music app
|
||||
* that's open through its own search ([CarMedia.search]). A bare "play", or "play music",
|
||||
* falls through to resume.
|
||||
*/
|
||||
private suspend fun playSearchCommand(context: Context, navigator: CarNavigator, heard: String): String? {
|
||||
var query = after(heard, PLAY_TRIGGERS) ?: return null
|
||||
query = query.removePrefix("some ").trim()
|
||||
if (query.isEmpty() || query in RESUME_WORDS) return null
|
||||
val media = CarServices.media
|
||||
|
||||
// "play Daft Punk on Metrolist": switch to that app first, if it's one we know.
|
||||
for (sep in listOf(" on ", " in ", " from ", " with ", " using ")) {
|
||||
val at = query.lastIndexOf(sep)
|
||||
if (at <= 0) continue
|
||||
val apps = withContext(Dispatchers.IO) { app.seb3thehacker.gearslip.media.MediaCatalog.installed(context) }
|
||||
val wanted = bestMatch(apps, query.substring(at + sep.length)) { it.label } ?: continue
|
||||
query = query.substring(0, at).trim()
|
||||
if (query.isEmpty() || query in RESUME_WORDS) {
|
||||
CarServices.openMedia(wanted, autoplay = true)
|
||||
navigator.media()
|
||||
return "Opening ${wanted.label}."
|
||||
}
|
||||
if (CarServices.mediaApp.value?.component != wanted.component) {
|
||||
CarServices.openMedia(wanted)
|
||||
navigator.media()
|
||||
// Give it a moment to connect before asking it to search.
|
||||
withTimeoutOrNull(APP_CONNECT_MS) {
|
||||
while (media.appLabel == null || (!media.canSearchLibrary && !media.now.value.canSearch)) delay(200)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// No music app open, or its connection is gone (the app was closed): use the one played
|
||||
// last, the way "play X" works on Android Auto.
|
||||
val phase = media.phase.value
|
||||
if (media.appLabel == null || phase == CarMedia.Phase.IDLE || phase == CarMedia.Phase.REJECTED) {
|
||||
if (!CarServices.reopenLastMedia()) return "Open a music app first, then ask again."
|
||||
navigator.media()
|
||||
}
|
||||
withTimeoutOrNull(APP_CONNECT_MS) {
|
||||
while (media.phase.value != CarMedia.Phase.READY || media.appLabel == null ||
|
||||
(!media.canSearchLibrary && !media.now.value.canSearch)
|
||||
) delay(200)
|
||||
}
|
||||
val app = media.appLabel ?: return "Your music app didn't open in time. Try again."
|
||||
if (!media.canSearchLibrary && !media.now.value.canSearch) {
|
||||
return "$app ignores voice searches. Pick something on its screen instead."
|
||||
}
|
||||
media.playBestMatch(query)
|
||||
return "Looking for $query on $app."
|
||||
}
|
||||
|
||||
private fun mediaCommand(heard: String): String? {
|
||||
val media = CarServices.media
|
||||
return when {
|
||||
"pause" in heard || heard.startsWith("stop") || heard == "quiet" || heard == "shut up" -> {
|
||||
media.pause()
|
||||
VoiceFocus.afterRelease { media.pause() }
|
||||
"Paused."
|
||||
}
|
||||
"resume" in heard || "unpause" in heard || heard in RESUME_PHRASES ||
|
||||
(heard.startsWith("play") && "playing" !in heard) -> {
|
||||
if (!media.now.value.playing) media.togglePlay()
|
||||
"Playing."
|
||||
}
|
||||
"next" in heard || "skip" in heard || heard.startsWith("change the song") -> {
|
||||
media.next()
|
||||
"Skipping."
|
||||
}
|
||||
"previous" in heard || "last song" in heard || "back song" in heard || "go back a song" in heard ||
|
||||
"play that again" in heard || "replay" in heard -> {
|
||||
media.previous()
|
||||
"Playing the previous track."
|
||||
}
|
||||
("what" in heard && "playing" in heard) || "what song" in heard || "who sings" in heard ||
|
||||
"who's singing" in heard || "who is singing" in heard || "what's this song" in heard ||
|
||||
"name of this song" in heard -> {
|
||||
val now = media.now.value
|
||||
if (now.isActive) listOf(now.title, now.artist).filter { it.isNotBlank() }.joinToString(" by ")
|
||||
else "Nothing's playing."
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun navCommand(navigator: CarNavigator, heard: String): String? = when {
|
||||
heard == "go back" || heard == "back" -> {
|
||||
navigator.back()
|
||||
"Going back."
|
||||
}
|
||||
heard in EXIT_WORDS -> {
|
||||
BuiltInApps.Exit.open(navigator)
|
||||
"Back to the car's screen."
|
||||
}
|
||||
"go home" in heard || heard == "home" || heard == "home screen" || "go to the home screen" in heard -> {
|
||||
navigator.home()
|
||||
"Heading home."
|
||||
}
|
||||
"open apps" in heard || heard == "apps" || "app list" in heard || "all apps" in heard || "my apps" in heard || heard == "go to apps" -> {
|
||||
navigator.apps()
|
||||
"Here are your apps."
|
||||
}
|
||||
"settings" in heard -> {
|
||||
navigator.settings()
|
||||
"Opening settings."
|
||||
}
|
||||
"vehicle data" in heard || "car data" in heard -> {
|
||||
if (CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value) {
|
||||
navigator.vehicleData()
|
||||
"Opening vehicle data."
|
||||
} else {
|
||||
"Vehicle data is off. Turn it on under experimental features in settings."
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun infoCommand(heard: String): String? = when {
|
||||
"what time" in heard || "what's the time" in heard || heard == "time" || "the time" in heard ->
|
||||
"It's ${DateFormat.getTimeInstance(DateFormat.SHORT).format(Date())}."
|
||||
"weather" in heard || "going to rain" in heard || "gonna rain" in heard || "how hot" in heard ||
|
||||
"how cold" in heard || "temperature outside" in heard -> {
|
||||
val data = (Weather.state.value as? WeatherState.Ready)?.data
|
||||
if (data != null) "${data.temp}${data.tempUnit} and ${Weather.describe(data.code)} in ${data.place}."
|
||||
else "I don't have the weather yet."
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun callCommand(context: Context, heard: String): String? {
|
||||
val name = (
|
||||
after(heard, CALL_TRIGGERS)
|
||||
?: Regex("^give (.+) a (?:call|ring)$").find(heard)?.groupValues?.get(1)
|
||||
?: return null
|
||||
).replace(Regex(" (?:on|at) (?:their |his |her )?(?:mobile|cell|phone|home|work)$"), "").trim()
|
||||
if (name.isBlank()) return "Who should I call?"
|
||||
if (!hasCallPermissions(context)) return "Gearslip needs the phone and contacts permissions first, from the phone screen."
|
||||
|
||||
val match = withContext(Dispatchers.IO) {
|
||||
loadContacts(context).firstOrNull { it.name.lowercase().contains(name) }
|
||||
} ?: return "I couldn't find $name in your contacts."
|
||||
|
||||
placeCall(context, match.number)
|
||||
return "Calling ${match.name}."
|
||||
}
|
||||
|
||||
private suspend fun openAppCommand(
|
||||
context: Context,
|
||||
navigator: CarNavigator,
|
||||
frame: CarEnvironment.Frame,
|
||||
heard: String,
|
||||
): String? {
|
||||
val name = (after(heard, OPEN_TRIGGERS) ?: return null)
|
||||
.removePrefix("the ").removePrefix("my ").removeSuffix(" app").trim()
|
||||
if (name.isBlank()) return null
|
||||
val wanted = APP_NICKNAMES[name] ?: name
|
||||
|
||||
// Gearslip's own apps come first in the list, so "phone" opens its dialer, not the phone's.
|
||||
val match = bestMatch(allEntries(context), wanted) { it.label }
|
||||
?: return "I couldn't find an app called $name."
|
||||
|
||||
launchEntry(match, navigator, frame)
|
||||
return "Opening ${match.label}."
|
||||
}
|
||||
|
||||
private val EXIT_WORDS = setOf("exit", "exit gearslip", "close gearslip", "quit", "quit gearslip", "open exit")
|
||||
|
||||
/** Other names people use for Gearslip's own apps. */
|
||||
private val APP_NICKNAMES = mapOf(
|
||||
"web" to "browser", "internet" to "browser", "the web" to "browser", "web browser" to "browser",
|
||||
"dialer" to "phone", "the dialer" to "phone", "keypad" to "phone",
|
||||
"screen share" to "screen sharing", "screen mirroring" to "screen sharing", "mirroring" to "screen sharing",
|
||||
)
|
||||
|
||||
private val NAVIGATE_TRIGGERS = listOf(
|
||||
"navigate to ", "navigate me to ", "directions to ", "get directions to ", "give me directions to ",
|
||||
"take me to ", "drive to ", "drive me to ", "get me to ", "go to ", "head to ", "route to ",
|
||||
"how do i get to ", "show me the way to ", "find a route to ", "navigation to ", "get to ",
|
||||
// Last, so "navigate to" wins: the recognizer sometimes drops the "to".
|
||||
"navigate me ", "navigate ", "find me the nearest ", "find the nearest ", "find the closest ",
|
||||
)
|
||||
|
||||
private val ARTICLES = setOf("a", "an", "the", "some")
|
||||
// Longest first, so "the nearest " goes before "the ".
|
||||
private val PLACE_LEADS = listOf(
|
||||
"the nearest ", "the closest ", "a nearby ", "the nearby ", "nearest ", "closest ", "nearby ",
|
||||
"a ", "an ", "some ",
|
||||
)
|
||||
private val PLACE_TAILS = listOf(" near me", " nearby", " near here", " around here", " close by", " closest to me")
|
||||
|
||||
private val PLAY_TRIGGERS = listOf("play me ", "play ", "put on ", "listen to ", "i want to hear ", "shuffle ")
|
||||
|
||||
private val CALL_TRIGGERS = listOf("call ", "phone ", "dial ", "ring ", "make a call to ", "place a call to ", "start a call with ")
|
||||
|
||||
private val OPEN_TRIGGERS = listOf("open ", "launch ", "start ", "go to ", "switch to ", "show me ", "bring up ", "pull up ")
|
||||
|
||||
/** Screens "go to X" means rather than a place. */
|
||||
private val SCREEN_WORDS = setOf("settings", "home", "the home screen", "home screen", "apps", "my apps", "all apps")
|
||||
|
||||
/** Padding stripped from the front of a command, longest first where they overlap. */
|
||||
private val LEAD_FILLERS = listOf(
|
||||
"hey gearslip", "ok gearslip", "okay gearslip", "gearslip", "hey", "ok", "okay", "um", "uh", "so",
|
||||
"please", "can you please", "can you", "could you", "would you", "will you", "can i", "could i",
|
||||
"i want you to", "i want to", "i'd like to", "i would like to", "i wanna", "i need to", "i need you to",
|
||||
"let's", "lets", "go ahead and", "just", "now", "quickly",
|
||||
)
|
||||
|
||||
/** Longest first, so "right now" goes whole rather than leaving "right". */
|
||||
private val TAIL_FILLERS = listOf("thank you very much", "thank you", "right now", "for me", "please", "thanks", "now")
|
||||
|
||||
private const val APP_CONNECT_MS = 5_000L
|
||||
|
||||
private val HELP_WORDS = setOf("help", "commands", "voice commands", "list commands", "show commands")
|
||||
|
||||
private val RESUME_WORDS = setOf("music", "the music", "some music", "something", "it", "again", "that", "my music")
|
||||
|
||||
private val RESUME_PHRASES = setOf("keep playing", "continue", "keep going", "start the music", "music on", "turn the music back on")
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.scale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* What the assistant hears and says, laid over the bottom of whatever screen is up: a dark shade
|
||||
* so white words read over a bright map, and a glow along the bottom edge in the phone's own
|
||||
* colours that breathes while listening. The shade passes touches through; only the buttons take them.
|
||||
*/
|
||||
@Composable
|
||||
fun AssistantOverlay(modifier: Modifier = Modifier) {
|
||||
val state by CarAssistant.state.collectAsState()
|
||||
if (state.phase == CarAssistant.Phase.IDLE) return
|
||||
val context = LocalContext.current
|
||||
val listening = state.phase == CarAssistant.Phase.LISTENING && state.ready
|
||||
val glow = MaterialTheme.colorScheme.primary
|
||||
val glow2 = MaterialTheme.colorScheme.tertiary
|
||||
val breathe by rememberInfiniteTransition(label = "glow").animateFloat(
|
||||
0.55f, 1f, infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), label = "glow",
|
||||
)
|
||||
val strength = when (state.phase) {
|
||||
CarAssistant.Phase.LISTENING -> if (state.ready) breathe else 0.3f
|
||||
CarAssistant.Phase.ERROR -> 0.3f
|
||||
else -> 0.6f
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.drawBehind {
|
||||
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
|
||||
// A wide, flat oval of light rising from the bottom edge.
|
||||
scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
|
||||
drawCircle(
|
||||
Brush.radialGradient(
|
||||
0f to glow.copy(alpha = 0.9f * strength), 0.5f to glow2.copy(alpha = 0.35f * strength), 1f to Color.Transparent,
|
||||
center = Offset(size.width / 2, size.height), radius = size.width * 0.55f,
|
||||
),
|
||||
radius = size.width * 0.55f, center = Offset(size.width / 2, size.height),
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(start = 16.dp, end = 16.dp, top = 56.dp, bottom = 20.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
PulsingMic(listening)
|
||||
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val (small, big) = when (state.phase) {
|
||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
|
||||
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
|
||||
else -> state.heard to state.reply
|
||||
}
|
||||
if (small.isNotBlank()) {
|
||||
Text(small, color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(
|
||||
big,
|
||||
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (state.missingPermission) {
|
||||
GsButton(tone = GsTone.Tonal, onClick = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||
)
|
||||
}) { Text("Grant") }
|
||||
}
|
||||
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A filled circle with a mic glyph, pulsing gently while actually listening. */
|
||||
@Composable
|
||||
private fun PulsingMic(listening: Boolean, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = modifier.size(36.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
MicGlyph(Modifier.padding(8.dp).size(20.dp))
|
||||
}
|
||||
}
|
||||
// `listening` only changes what's drawn elsewhere in the row (the "Listening…" label);
|
||||
// the glyph itself doesn't need to animate for this to read clearly on a glance.
|
||||
}
|
||||
|
||||
/** The assistant's mic, from `R.drawable.mic_24`, tinted with the current content colour. */
|
||||
@Composable
|
||||
fun MicGlyph(modifier: Modifier = Modifier) {
|
||||
Icon(ImageVector.vectorResource(R.drawable.mic_24), contentDescription = "Assistant", modifier = modifier)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothA2dp
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* With Bluetooth audio chosen, music only reaches the car if the phone is connected to it over
|
||||
* Bluetooth. Twenty seconds after the car shows Gearslip, long enough for a car to connect on its
|
||||
* own, this checks. If the phone isn't connected, the car and the phone's Home screen both say so,
|
||||
* until it connects or the drive ends.
|
||||
*
|
||||
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
|
||||
* connection does, since there's no telling which device is the car.
|
||||
*/
|
||||
object BluetoothAudioCheck {
|
||||
|
||||
private val _missing = MutableStateFlow(false)
|
||||
/** True while Bluetooth audio is chosen and the phone isn't connected to the car for it. */
|
||||
val missing: StateFlow<Boolean> = _missing
|
||||
|
||||
private val _carDismissed = MutableStateFlow(false)
|
||||
/** The driver tapped OK on the car's card; it stays away for the rest of this drive. */
|
||||
val carDismissed: StateFlow<Boolean> = _carDismissed
|
||||
|
||||
private val log = GearslipLog.tagged("bluetooth")
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
private var armed = false
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/** The car shows Gearslip. Called again whenever the video restarts, so it arms once per drive. */
|
||||
fun onProjecting() = main.post {
|
||||
if (armed) return@post
|
||||
armed = true
|
||||
main.postDelayed(::start, WAIT_MS)
|
||||
}
|
||||
|
||||
fun onCarDisconnected() = main.post {
|
||||
armed = false
|
||||
main.removeCallbacksAndMessages(null)
|
||||
stopWatching()
|
||||
_missing.value = false
|
||||
_carDismissed.value = false
|
||||
}
|
||||
|
||||
fun dismissOnCar() {
|
||||
_carDismissed.value = true
|
||||
}
|
||||
|
||||
/** "Don't show again" on the car: turns the warning off in Settings, for every car. */
|
||||
fun neverAgain() {
|
||||
CarSettings.setBluetoothWarning(false)
|
||||
log.i("Bluetooth audio warning turned off")
|
||||
_carDismissed.value = true
|
||||
_missing.value = false
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (!armed) return
|
||||
check()
|
||||
// Connecting later clears the warning, and losing the connection brings it back.
|
||||
val context = appContext ?: return
|
||||
val r = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
main.postDelayed(::check, SETTLE_MS)
|
||||
}
|
||||
}
|
||||
context.registerReceiver(
|
||||
r,
|
||||
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED).apply {
|
||||
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
},
|
||||
Context.RECEIVER_EXPORTED,
|
||||
)
|
||||
receiver = r
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun check() {
|
||||
if (!armed) return
|
||||
val context = appContext ?: return
|
||||
if (CarSettings.pipeAudio.value) {
|
||||
set(false, "audio goes through Gearslip")
|
||||
return
|
||||
}
|
||||
if (!CarSettings.bluetoothWarning.value) {
|
||||
set(false, "the warning is turned off")
|
||||
return
|
||||
}
|
||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
|
||||
return
|
||||
}
|
||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
|
||||
if (!adapter.isEnabled) {
|
||||
set(true, "Bluetooth is off")
|
||||
return
|
||||
}
|
||||
val car = CarBluetooth.carAddress?.uppercase()
|
||||
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
|
||||
adapter.closeProfileProxy(profile, proxy)
|
||||
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
|
||||
main.post {
|
||||
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
}
|
||||
|
||||
private fun set(missing: Boolean, why: String) {
|
||||
if (!armed || _missing.value == missing) return
|
||||
_missing.value = missing
|
||||
log.i("Bluetooth audio: $why")
|
||||
}
|
||||
|
||||
private fun stopWatching() {
|
||||
val r = receiver ?: return
|
||||
receiver = null
|
||||
runCatching { appContext?.unregisterReceiver(r) }
|
||||
}
|
||||
|
||||
private const val WAIT_MS = 20_000L
|
||||
private const val SETTLE_MS = 1_000L
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
|
||||
* pairing dialog. See [CarBluetooth]. With no pairing to do, says when Bluetooth audio isn't
|
||||
* connected (see [BluetoothAudioCheck]), or else when the phone's location is off (see
|
||||
* [LocationCheck]). Unlike the call prompt, a tap outside the card does nothing:
|
||||
* the driver picks Pair or Not now.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BluetoothPairOverlay() {
|
||||
val prompt by CarBluetooth.prompt.collectAsState()
|
||||
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
|
||||
when (prompt) {
|
||||
CarBluetooth.Prompt.NONE -> {
|
||||
val missing by BluetoothAudioCheck.missing.collectAsState()
|
||||
val dismissed by BluetoothAudioCheck.carDismissed.collectAsState()
|
||||
val warn by CarSettings.bluetoothWarning.collectAsState()
|
||||
val locationOff by LocationCheck.off.collectAsState()
|
||||
val locationDismissed by LocationCheck.carDismissed.collectAsState()
|
||||
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
|
||||
title = stringResource(R.string.home_bt_title),
|
||||
body = stringResource(R.string.overlay_bt_body),
|
||||
secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
|
||||
primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
|
||||
) else if (locationOff && !locationDismissed) PairCard(
|
||||
title = stringResource(R.string.overlay_location_off),
|
||||
body = stringResource(R.string.overlay_location_body),
|
||||
secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
|
||||
primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
|
||||
)
|
||||
}
|
||||
CarBluetooth.Prompt.ASK -> PairCard(
|
||||
title = stringResource(R.string.overlay_pair_title),
|
||||
body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
|
||||
secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
|
||||
primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
|
||||
)
|
||||
CarBluetooth.Prompt.PAIRING -> PairCard(
|
||||
title = stringResource(R.string.overlay_confirm_title),
|
||||
body = stringResource(R.string.overlay_confirm_body),
|
||||
secondary = null,
|
||||
primary = stringResource(R.string.carset_hide) to CarBluetooth::hide,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PairCard(title: String, body: String, secondary: Pair<String, () -> Unit>?, primary: Pair<String, () -> Unit>) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = {},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
body,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
secondary?.let { (label, onClick) ->
|
||||
GsButton(onClick = onClick, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
GsButton(onClick = primary.second, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||
Text(primary.first, style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
|
||||
*
|
||||
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
|
||||
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
|
||||
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
|
||||
* with your finger is the touch offset. Ignores vehicle insets on purpose.
|
||||
*/
|
||||
@Composable
|
||||
fun CalibrationApp() {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val measurer = rememberTextMeasurer()
|
||||
var touch by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
touch = down.position
|
||||
}
|
||||
},
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val label = TextStyle(color = Color.White, fontSize = 11.sp)
|
||||
val faint = Color(0x55FFFFFF)
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
|
||||
var y = 0
|
||||
while (y <= h) {
|
||||
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
|
||||
if (y % 20 == 0) {
|
||||
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
|
||||
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
|
||||
}
|
||||
y += 20
|
||||
}
|
||||
var x = 0
|
||||
while (x <= w) {
|
||||
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
|
||||
if (x % 50 == 0 && x > 0) {
|
||||
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
|
||||
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
|
||||
}
|
||||
x += 20
|
||||
}
|
||||
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
|
||||
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
|
||||
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
|
||||
|
||||
touch?.let { t ->
|
||||
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
|
||||
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
|
||||
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
|
||||
}
|
||||
}
|
||||
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
|
||||
Text("Exit calibration")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
|
||||
/**
|
||||
* Takes the whole car screen while a call is ringing, because nothing else matters more right
|
||||
* now. A call under way moves into the nav bar instead (see NavCallPill), so it covers nothing.
|
||||
*/
|
||||
@Composable
|
||||
fun CallOverlay() {
|
||||
val state by CarCalls.state.collectAsState()
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
when (val s = state) {
|
||||
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Call,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.call_incoming),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
s.caller.label,
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(56.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||
CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||
CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||
}
|
||||
if (!s.canDecline) {
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
stringResource(R.string.call_ringing),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
GsIconButton(
|
||||
icon, label, onClick,
|
||||
colors = GsColors(color, Color.White),
|
||||
enabled = enabled,
|
||||
size = 72.dp,
|
||||
iconSize = 30.dp,
|
||||
shape = CircleShape,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.car.app.model.Action
|
||||
import androidx.car.app.model.Alert
|
||||
import androidx.car.app.model.AlertCallback
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.host.cancel
|
||||
import app.seb3thehacker.gearslip.host.click
|
||||
import app.seb3thehacker.gearslip.host.color
|
||||
import app.seb3thehacker.gearslip.host.dismissed
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* The one alert on the car screen, from whichever hosted app sent it: Waze asking whether a
|
||||
* hazard is still there, say. As in Android Auto, a newer alert replaces the one showing, and it
|
||||
* shows over any screen, since a question about the road matters while music is up too.
|
||||
*/
|
||||
@MainThread
|
||||
internal object CarAlerts {
|
||||
class Shown(val owner: Any, val alert: Alert)
|
||||
|
||||
private val current = MutableStateFlow<Shown?>(null)
|
||||
val shown: StateFlow<Shown?> = current.asStateFlow()
|
||||
|
||||
fun show(owner: Any, alert: Alert) {
|
||||
val old = current.value
|
||||
current.value = Shown(owner, alert)
|
||||
GearslipLog.i("host: alert ${alert.id} \"${alert.title.text()}\" with ${alert.actions.size} buttons")
|
||||
if (old != null && !(old.owner === owner && old.alert.id == alert.id)) old.alert.callbackDelegate.dismissed()
|
||||
}
|
||||
|
||||
/** The app took its alert down; a no-op unless that alert is the one showing. */
|
||||
fun dismiss(owner: Any, id: Int) {
|
||||
val shown = current.value ?: return
|
||||
if (shown.owner !== owner || shown.alert.id != id) return
|
||||
current.value = null
|
||||
shown.alert.callbackDelegate.dismissed()
|
||||
}
|
||||
|
||||
/** The driver tapped one of its buttons: the app gets the tap, and the alert goes. */
|
||||
fun tapped(shown: Shown, action: Action) {
|
||||
action.onClickDelegate.click("alert button")
|
||||
if (current.value !== shown) return
|
||||
current.value = null
|
||||
shown.alert.callbackDelegate.dismissed()
|
||||
}
|
||||
|
||||
fun cancelled(shown: Shown, reason: Int) {
|
||||
if (current.value !== shown) return
|
||||
current.value = null
|
||||
shown.alert.callbackDelegate.cancel(reason)
|
||||
}
|
||||
|
||||
/** The app disconnected: its alert goes with it, and there is no one left to tell. */
|
||||
fun clear(owner: Any) {
|
||||
if (current.value?.owner === owner) current.value = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CarAlertOverlay(modifier: Modifier = Modifier) {
|
||||
val current by CarAlerts.shown.collectAsState()
|
||||
val shown = current ?: return
|
||||
val alert = shown.alert
|
||||
val timed = alert.durationMillis in 1 until Alert.DURATION_SHOW_INDEFINITELY
|
||||
// What's left of the alert's time, drawn as the bar along its foot.
|
||||
val left = remember(shown) { Animatable(1f) }
|
||||
LaunchedEffect(shown) {
|
||||
if (!timed) return@LaunchedEffect
|
||||
left.animateTo(0f, tween(alert.durationMillis.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), easing = LinearEasing))
|
||||
CarAlerts.cancelled(shown, AlertCallback.REASON_TIMEOUT)
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val dark = scheme.surface.luminanceIsDark()
|
||||
Surface(
|
||||
modifier
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.widthIn(max = 640.dp)
|
||||
.fillMaxWidth()
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = scheme.surfaceContainerHigh,
|
||||
shadowElevation = 8.dp,
|
||||
) {
|
||||
Column {
|
||||
Row(Modifier.padding(start = 20.dp, end = 12.dp, top = 16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (alert.icon != null) {
|
||||
CarGlyph(alert.icon, Modifier.size(40.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
alert.title.text(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
alert.subtitle?.text()?.takeIf { it.isNotEmpty() }?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
// With no timer and no buttons, nothing else would ever take it away.
|
||||
if (!timed || alert.actions.isEmpty()) {
|
||||
GsIconBox(
|
||||
onClick = { CarAlerts.cancelled(shown, AlertCallback.REASON_USER_ACTION) },
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = GsColors(Color.Transparent, scheme.onSurface),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.close))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (alert.actions.isNotEmpty()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
alert.actions.take(2).forEach { action ->
|
||||
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||
GsIconBox(
|
||||
onClick = { CarAlerts.tapped(shown, action) },
|
||||
modifier = Modifier.weight(1f).height(64.dp),
|
||||
colors = GsColors(
|
||||
action.backgroundColor.color(dark, scheme.surfaceContainerHighest),
|
||||
if (custom) Color.White else scheme.onSurface,
|
||||
),
|
||||
enabled = action.isEnabled,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (action.icon != null) {
|
||||
CarGlyph(action.icon, Modifier.size(28.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(action.title.text(), style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
if (timed) {
|
||||
Box(Modifier.fillMaxWidth().height(4.dp).background(scheme.surfaceContainerHighest)) {
|
||||
Box(Modifier.fillMaxWidth(left.value).height(4.dp).clip(RoundedCornerShape(2.dp)).background(scheme.primary))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
import kotlin.math.hypot
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -28,8 +39,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
@@ -53,14 +67,40 @@ fun CarAppStage(
|
||||
) {
|
||||
val template by connection.template.collectAsStateWithLifecycle()
|
||||
|
||||
val cover = remember { ChromeCover() }
|
||||
val navBar = LocalNavBarInset.current
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||
// the template it described, laid over the top.
|
||||
if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
||||
if (status.surfaceRequested) {
|
||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
||||
}
|
||||
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
||||
// and the app hears that side is covered, so its map centres in the rest.
|
||||
val panel = LocalSidePanelInset.current
|
||||
val panelAtStart = LocalCardsAtEnd.current
|
||||
TemplateChrome(
|
||||
template,
|
||||
Modifier.fillMaxSize().padding(
|
||||
top = if (embedded) 0.dp else CHROME_BAR,
|
||||
bottom = navBar,
|
||||
start = if (panelAtStart) panel else 0.dp,
|
||||
end = if (panelAtStart) 0.dp else panel,
|
||||
),
|
||||
)
|
||||
if (panel > 0.dp) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(if (panelAtStart) Alignment.CenterStart else Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(panel)
|
||||
.covers("side-panel", if (panelAtStart) CoverEdge.START else CoverEdge.END),
|
||||
)
|
||||
}
|
||||
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
||||
}
|
||||
TemplateChrome(template, Modifier.fillMaxSize().padding(top = if (embedded) 0.dp else CHROME_BAR))
|
||||
if (embedded) CornerMask(EMBEDDED_RADIUS, MaterialTheme.colorScheme.background)
|
||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
@@ -79,17 +119,57 @@ private fun AppSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var size by remember { mutableStateOf(0 to 0) }
|
||||
var bounds by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
|
||||
val cover = LocalChromeCover.current
|
||||
|
||||
// The app lays its map out around whatever the host covers, so a template change moves the
|
||||
// visible area even though the surface itself hasn't changed.
|
||||
LaunchedEffect(template, size) {
|
||||
val (width, height) = size
|
||||
// The app lays its map out around whatever the host covers, so a template change or a card
|
||||
// changing size moves the visible area even though the surface itself hasn't changed.
|
||||
val (width, height) = size
|
||||
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
|
||||
// The card column moves the map's centre but isn't taken out of the stable area: apps size
|
||||
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
|
||||
val cardsAtEnd = LocalCardsAtEnd.current
|
||||
val cardEdge = cardEdge
|
||||
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
|
||||
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
|
||||
if (width > 0 && height > 0) {
|
||||
val visible = visibleAreaFor(template, width, height)
|
||||
connection.reportAreas(visible = visible, stable = visible)
|
||||
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
|
||||
// Waze seems to centre its car in the stable area rather than the visible one, so it
|
||||
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
|
||||
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
|
||||
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
|
||||
} else {
|
||||
visible
|
||||
}
|
||||
connection.reportAreas(visible = visible, stable = stable)
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh SurfaceView - and so a fresh Surface - for every size, the way Android Auto hands an
|
||||
// app a new surface rather than resizing the old one. A resized SurfaceView keeps the same
|
||||
// BufferQueue, and apps that draw through their own VirtualDisplay build the
|
||||
// new one on it before releasing the old: the old display's disconnect then takes the new one's
|
||||
// connection with it and the map freezes for good. The nav bar hiding for the search keyboard
|
||||
// and the lyrics pane opening both resize this area.
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
modifier.onGloballyPositioned { bounds = it.boundsInWindow() },
|
||||
) {
|
||||
val epoch by connection.surfaceEpoch.collectAsState()
|
||||
androidx.compose.runtime.key(constraints.maxWidth, constraints.maxHeight, epoch) {
|
||||
SurfaceHost(connection, frame, onSize = { size = it }, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SurfaceHost(
|
||||
connection: CarAppConnection,
|
||||
frame: CarEnvironment.Frame,
|
||||
onSize: (Pair<Int, Int>) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
AndroidView(
|
||||
modifier = modifier
|
||||
.pointerInput(connection) {
|
||||
@@ -102,6 +182,30 @@ private fun AppSurface(
|
||||
if (pan != Offset.Zero) connection.surfaceScroll(-pan.x, -pan.y)
|
||||
if (zoom != 1f) connection.surfaceScale(centroid.x, centroid.y, zoom)
|
||||
}
|
||||
}
|
||||
.pointerInput(connection) {
|
||||
// detectTransformGestures has no velocity, so a fling is measured here alongside
|
||||
// it: one finger lifting while still moving lets the map glide on, as on a car.
|
||||
// Velocity is the finger's own, which is GestureDetector's onFling convention.
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val tracker = VelocityTracker()
|
||||
tracker.addPosition(down.uptimeMillis, down.position)
|
||||
var pinched = false
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (event.changes.size > 1) pinched = true
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
tracker.addPosition(change.uptimeMillis, change.position)
|
||||
if (!change.pressed) {
|
||||
val v = tracker.calculateVelocity()
|
||||
if (!pinched && hypot(v.x, v.y) >= FLING_MIN_PX_S) {
|
||||
connection.surfaceFling(v.x, v.y)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
factory = { context ->
|
||||
SurfaceView(context).apply {
|
||||
@@ -115,20 +219,20 @@ private fun AppSurface(
|
||||
height: Int,
|
||||
) {
|
||||
GearslipLog.i("stage: surface ready at ${width}x$height")
|
||||
size = width to height
|
||||
onSize(width to height)
|
||||
connection.attachSurface(holder.surface, width, height, frame.densityDpi)
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
GearslipLog.i("stage: surface destroyed")
|
||||
connection.detachSurface()
|
||||
// Only this view's own surface: a replaced view's teardown can land after
|
||||
// its successor has already lent the app a new one.
|
||||
connection.detachSurface(holder.surface)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
DisposableEffect(connection) { onDispose { connection.detachSurface() } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -137,55 +241,40 @@ private fun ChromeBar(
|
||||
onDisconnect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// The app's name and a way out, nothing else: which template is showing, or whether the
|
||||
// app has drawn yet, is for the log, not the driver.
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.55f),
|
||||
contentColor = Color.White,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier.fillMaxWidth().height(CHROME_BAR),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
status.app ?: "Car app",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val detail = buildList {
|
||||
status.template?.let { add(it) }
|
||||
add(if (status.surfaceAttached) "drawing" else "waiting for the app")
|
||||
}.joinToString(" - ")
|
||||
Text(detail, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
TextButton(onClick = onDisconnect) { Text("Stop", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Corner radius of the home screen's map pane and player. */
|
||||
val EMBEDDED_RADIUS = 18.dp
|
||||
|
||||
/**
|
||||
* Rounds the corners of a SurfaceView, which Compose cannot clip: the app draws into its own
|
||||
* layer, so the corners are covered from above with the surrounding colour instead.
|
||||
*/
|
||||
@Composable
|
||||
private fun CornerMask(radius: androidx.compose.ui.unit.Dp, color: Color) {
|
||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
||||
val bounds = androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)
|
||||
val cut = androidx.compose.ui.graphics.Path().apply {
|
||||
addRect(bounds)
|
||||
addRoundRect(
|
||||
androidx.compose.ui.geometry.RoundRect(
|
||||
bounds, androidx.compose.ui.geometry.CornerRadius(radius.toPx()),
|
||||
),
|
||||
Text(
|
||||
status.app ?: stringResource(R.string.stage_car_app),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsIconButton(
|
||||
androidx.compose.material.icons.Icons.Filled.Close,
|
||||
status.app?.let { stringResource(R.string.stage_close, it) } ?: stringResource(R.string.stage_close_app),
|
||||
onDisconnect,
|
||||
size = 36.dp,
|
||||
iconSize = 20.dp,
|
||||
shape = androidx.compose.foundation.shape.CircleShape,
|
||||
)
|
||||
fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd
|
||||
}
|
||||
drawPath(cut, color)
|
||||
}
|
||||
}
|
||||
|
||||
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||
private val CHROME_BAR = 44.dp
|
||||
|
||||
/** Slower than this when the finger lifts and it was a drag that stopped, not a fling. */
|
||||
private const val FLING_MIN_PX_S = 400f
|
||||
|
||||
/** The app connection a template on screen belongs to: the map app's, or the one opened from the launcher. */
|
||||
internal val LocalCarAppConnection = androidx.compose.runtime.staticCompositionLocalOf { CarServices.nav }
|
||||
@@ -1,7 +1,10 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.List
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -13,7 +16,9 @@ import app.seb3thehacker.gearslip.ProjectedContent
|
||||
/** One tile in the launcher and the screen it opens. */
|
||||
class CarApp(
|
||||
val id: String,
|
||||
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||
val label: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val icon: ImageVector,
|
||||
val content: @Composable () -> Unit,
|
||||
)
|
||||
@@ -22,19 +27,28 @@ class CarApp(
|
||||
object CarApps {
|
||||
|
||||
val all: List<CarApp> = listOf(
|
||||
CarApp("web", "Web", Icons.Filled.Search) {
|
||||
CarApp("web", "Browser", R.string.app_browser, Icons.Filled.Search) {
|
||||
WebApp(content = ProjectedContent.resolve(LocalContext.current))
|
||||
},
|
||||
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||
CarApp("phone", "Screen sharing", R.string.app_screen_sharing, Icons.Filled.Share) {
|
||||
PhoneAppsScreen()
|
||||
},
|
||||
CarApp("calibrate", "Display test", Icons.Filled.Info) {
|
||||
CalibrationApp()
|
||||
CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
|
||||
PhoneDialerScreen()
|
||||
},
|
||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
|
||||
CarSettingsScreen()
|
||||
},
|
||||
CarApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List) {
|
||||
AllAppsScreen()
|
||||
},
|
||||
)
|
||||
|
||||
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
||||
|
||||
/** The screens [BuiltInApps.entries] offers here, so a choice list never names a hidden one. */
|
||||
fun shown(launcher: Boolean, canCall: Boolean): List<CarApp> {
|
||||
val ids = BuiltInApps.entries(launcher, canCall).mapNotNull { (it.builtIn?.screen as? CarScreen.App)?.id }.toSet()
|
||||
return all.filter { it.id in ids }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import androidx.core.content.ContextCompat
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.speech.SpeechEngine
|
||||
import app.seb3thehacker.gearslip.speech.VoiceFocus
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A voice assistant: Gearslip's own [SpeechEngine] (whisper.cpp) to listen, the system's
|
||||
* [TextToSpeech] to answer. Listening used to go through the system's [SpeechRecognizer] alone,
|
||||
* but many phones have no recognizer at all (GrapheneOS ships only text-to-speech), so the
|
||||
* system one is now just the fallback for when the bundled engine can't run.
|
||||
*
|
||||
* Listening and speaking live here; deciding what a command *means* is [AssistantCommands], kept
|
||||
* separate because it needs a [CarNavigator] and a frame to act on, neither of which this object
|
||||
* has - see [pendingCommand].
|
||||
*/
|
||||
object CarAssistant {
|
||||
|
||||
enum class Phase { IDLE, LISTENING, THINKING, SPEAKING, ERROR }
|
||||
|
||||
data class State(
|
||||
val phase: Phase = Phase.IDLE,
|
||||
val heard: String = "",
|
||||
val reply: String = "",
|
||||
val missingPermission: Boolean = false,
|
||||
/** False from the press until the microphone is actually open, so the overlay doesn't claim to listen early. */
|
||||
val ready: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state
|
||||
|
||||
/** Set once a recognition result comes in; [reply] clears it once handled. */
|
||||
private val _pendingCommand = MutableStateFlow<String?>(null)
|
||||
val pendingCommand: StateFlow<String?> = _pendingCommand
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var tts: TextToSpeech? = null
|
||||
private var ttsReady = false
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
if (tts != null) return
|
||||
tts = TextToSpeech(appContext) { status -> ttsReady = status == TextToSpeech.SUCCESS }
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* Starts listening, or does nothing if the assistant is already busy with the last request.
|
||||
* [SpeechRecognizer] must be driven from a thread with a Looper, but this is also called from
|
||||
* the raw wire-protocol worker thread ([GearslipRunner]'s key-event handling for the head
|
||||
* unit's own PTT button) which has none - so this always hops to the main thread itself
|
||||
* rather than asking every caller to remember to.
|
||||
*/
|
||||
fun start() {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) startOnMainThread() else mainHandler.post(::startOnMainThread)
|
||||
}
|
||||
|
||||
private fun startOnMainThread() {
|
||||
val context = appContext ?: return
|
||||
if (_state.value.phase == Phase.LISTENING || _state.value.phase == Phase.THINKING) return
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
_state.value = State(Phase.ERROR, reply = "Needs the microphone permission.", missingPermission = true)
|
||||
return
|
||||
}
|
||||
// Android hands every app but the dialer a silent microphone during a call.
|
||||
if (CarCalls.state.value is CallUiState.Active) {
|
||||
_state.value = State(Phase.ERROR, reply = "Voice commands work once the call ends.")
|
||||
return
|
||||
}
|
||||
tts?.stop()
|
||||
_state.value = State(Phase.LISTENING)
|
||||
listenOnce(
|
||||
onListening = { _state.value = _state.value.copy(ready = true) },
|
||||
onPartial = { text -> _state.value = _state.value.copy(heard = text) },
|
||||
onHeard = { _state.value = _state.value.copy(phase = Phase.THINKING) },
|
||||
onResult = { text ->
|
||||
_state.value = _state.value.copy(phase = Phase.THINKING, heard = text)
|
||||
_pendingCommand.value = text
|
||||
},
|
||||
onError = { message -> _state.value = State(Phase.ERROR, reply = message) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Closes the overlay from wherever it's showing: mid-listen, mid-think, or after speaking. */
|
||||
fun cancel() {
|
||||
releaseRecognizer()
|
||||
tts?.stop()
|
||||
_pendingCommand.value = null
|
||||
_state.value = State(Phase.IDLE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called once [pendingCommand] has been turned into an answer - from [CarUi], the one place
|
||||
* that actually has a [CarNavigator] to have acted on it with.
|
||||
*/
|
||||
fun reply(text: String) {
|
||||
_pendingCommand.value = null
|
||||
_state.value = _state.value.copy(phase = Phase.SPEAKING, reply = text)
|
||||
|
||||
val engine = tts
|
||||
if (engine == null || !ttsReady || text.isBlank()) {
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
return
|
||||
}
|
||||
appContext?.let(VoiceFocus::acquire)
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
VoiceFocus.release()
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
}
|
||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||
override fun onError(utteranceId: String?) {
|
||||
VoiceFocus.release()
|
||||
_state.value = _state.value.copy(phase = Phase.IDLE)
|
||||
}
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||
})
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-assistant")
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaks [text] for someone other than the assistant (a message read aloud), then runs
|
||||
* [onDone] on the main thread - straight away if speech isn't available.
|
||||
*/
|
||||
fun say(text: String, onDone: () -> Unit) {
|
||||
val engine = tts
|
||||
val context = appContext
|
||||
if (engine == null || context == null || !ttsReady || text.isBlank()) {
|
||||
mainHandler.post(onDone)
|
||||
return
|
||||
}
|
||||
VoiceFocus.acquire(context)
|
||||
engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||
@Deprecated("Deprecated in Java, no replacement on this API level")
|
||||
override fun onError(utteranceId: String?) { VoiceFocus.release(); mainHandler.post(onDone) }
|
||||
// Stopped part way: whoever stopped it has already moved on.
|
||||
override fun onStop(utteranceId: String?, interrupted: Boolean) { VoiceFocus.release() }
|
||||
})
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, "gearslip-say")
|
||||
}
|
||||
|
||||
fun stopSpeaking() { tts?.stop() }
|
||||
|
||||
/**
|
||||
* Listens once, the same way the assistant does, and hands back what was said without acting
|
||||
* on it. Callbacks arrive on the main thread; [onError] gets a sentence fit to show.
|
||||
*/
|
||||
fun dictate(
|
||||
onPartial: (String) -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
cue: Boolean = true,
|
||||
onSpeaking: () -> Unit = {},
|
||||
) {
|
||||
cancel()
|
||||
listenOnce(onPartial, onHeard = {}, onResult = onResult, onError = onError, cue = cue, onSpeaking = onSpeaking)
|
||||
}
|
||||
|
||||
/**
|
||||
* One phrase, through Gearslip's own [SpeechEngine], or the phone's recognizer when the driver
|
||||
* chose it in settings. Never the phone's on its own: on most phones that's Google's.
|
||||
*/
|
||||
private fun listenOnce(
|
||||
onPartial: (String) -> Unit,
|
||||
onListening: () -> Unit = {},
|
||||
onHeard: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
cue: Boolean = true,
|
||||
onSpeaking: () -> Unit = {},
|
||||
) {
|
||||
val context = appContext ?: return onError("Speech isn't ready yet.")
|
||||
if (CarSettings.systemSpeech.value && SpeechRecognizer.isRecognitionAvailable(context)) {
|
||||
return systemListen(context, onListening, onPartial, onHeard, onResult, onError)
|
||||
}
|
||||
SpeechEngine.listen(
|
||||
context,
|
||||
cue = cue,
|
||||
onSpeaking = onSpeaking,
|
||||
onPartial = onPartial,
|
||||
onListening = onListening,
|
||||
onHeard = onHeard,
|
||||
onResult = onResult,
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
|
||||
private fun systemListen(
|
||||
context: Context,
|
||||
onListening: () -> Unit,
|
||||
onPartial: (String) -> Unit,
|
||||
onHeard: () -> Unit,
|
||||
onResult: (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
val r = SpeechRecognizer.createSpeechRecognizer(context)
|
||||
recognizer = r
|
||||
r.setRecognitionListener(object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) = onListening()
|
||||
override fun onBeginningOfSpeech() {}
|
||||
override fun onRmsChanged(rmsdB: Float) {}
|
||||
override fun onBufferReceived(buffer: ByteArray?) {}
|
||||
override fun onEndOfSpeech() = onHeard()
|
||||
override fun onEvent(eventType: Int, params: Bundle?) {}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
releaseRecognizer()
|
||||
onError(errorMessage(error))
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
releaseRecognizer()
|
||||
val text = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull().orEmpty()
|
||||
if (text.isBlank()) onError("Didn't catch that.") else onResult(text)
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()?.let(onPartial)
|
||||
}
|
||||
})
|
||||
r.startListening(
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun stopListening() = releaseRecognizer()
|
||||
|
||||
private fun releaseRecognizer() {
|
||||
SpeechEngine.cancel()
|
||||
recognizer?.let { runCatching { it.cancel() }; it.destroy() }
|
||||
recognizer = null
|
||||
}
|
||||
|
||||
private fun errorMessage(code: Int): String = when (code) {
|
||||
SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Didn't catch that."
|
||||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Needs the microphone permission."
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "The speech recognizer is busy."
|
||||
SpeechRecognizer.ERROR_NETWORK, SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "The speech recognizer needs a network it doesn't have."
|
||||
else -> "Speech recognition failed."
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
releaseRecognizer()
|
||||
tts?.shutdown()
|
||||
tts = null
|
||||
ttsReady = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.car.app.CarToast
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.ServiceDiscovery
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Pairs the phone with the car over Bluetooth the first time they meet, so calls (and music, when
|
||||
* the driver chose Bluetooth audio) reach the car's speakers. Android Auto does this on its own;
|
||||
* Gearslip asks first, on the car screen.
|
||||
*
|
||||
* The car names its Bluetooth address in its service list. When the driver taps Pair, Gearslip
|
||||
* opens the car's Bluetooth channel and sends what Android Auto sends:
|
||||
*
|
||||
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||
* -> PairingRequest { phone_address, method }
|
||||
* <- PairingResponse { status, already_paired }
|
||||
*
|
||||
* then starts pairing from the phone. Two things Android Auto has that Gearslip can't: the
|
||||
* phone's own Bluetooth address (apps get a placeholder), and confirming the pairing code without
|
||||
* asking. So a car may turn the request down, and the phone always shows its pairing dialog. The
|
||||
* channel stays closed unless the driver taps Pair: every new channel is a risk to cars that work.
|
||||
*
|
||||
* The one exception is a driver who typed the phone's real address into Settings. A VW MIB2 drops
|
||||
* an already paired phone's Bluetooth when it connects over USB, because nothing tells it the two
|
||||
* are the same phone (issue #28). With the address, Gearslip opens the channel on every connection
|
||||
* and sends the same request, which is how Android Auto says so.
|
||||
*/
|
||||
object CarBluetooth {
|
||||
|
||||
enum class Prompt { NONE, ASK, PAIRING }
|
||||
|
||||
private val _prompt = MutableStateFlow(Prompt.NONE)
|
||||
val prompt: StateFlow<Prompt> = _prompt
|
||||
|
||||
private val log = GearslipLog.tagged("bluetooth")
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private var appContext: Context? = null
|
||||
|
||||
/** This session's car: its channel, and how to open and write to it. */
|
||||
private class Car(
|
||||
val service: ServiceDiscovery.BluetoothService,
|
||||
val open: () -> Unit,
|
||||
val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
)
|
||||
|
||||
@Volatile private var car: Car? = null
|
||||
@Volatile private var bondStarted = false
|
||||
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
|
||||
@Volatile private var pairing = false
|
||||
/** Already paired: the channel is open only to tell the car which phone this is. */
|
||||
@Volatile private var announcing = false
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
|
||||
/** The car's Bluetooth address, when it named one in its service list. */
|
||||
val carAddress: String? get() = car?.service?.carAddress
|
||||
|
||||
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
|
||||
val channelId: Int get() = car?.takeIf { pairing || announcing }?.service?.serviceId ?: -1
|
||||
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
|
||||
fun onCarConnected(
|
||||
service: ServiceDiscovery.BluetoothService,
|
||||
open: () -> Unit,
|
||||
send: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
car = Car(service, open, send)
|
||||
bondStarted = false
|
||||
announcing = false
|
||||
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
|
||||
val device = device() ?: return
|
||||
when {
|
||||
device.bondState == BluetoothDevice.BOND_BONDED && typedAddress() != null -> {
|
||||
log.i("already paired with this car - telling it which phone this is")
|
||||
announcing = true
|
||||
open()
|
||||
}
|
||||
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
|
||||
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
||||
else -> _prompt.value = Prompt.ASK
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the car's Bluetooth channel though there's no pairing to do, as a real phone does on
|
||||
* every connection. Issue #18: a 2021 Bolt hangs up about 20 seconds in, and this is one of
|
||||
* the two channels it offers that Gearslip never opened. Without an address the driver typed,
|
||||
* it only opens the channel: a made-up address could set the car pairing with the wrong phone.
|
||||
*/
|
||||
fun announce(why: String) {
|
||||
val c = car ?: return
|
||||
if (pairing || announcing) return
|
||||
announcing = true
|
||||
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId} ($why)")
|
||||
c.open()
|
||||
}
|
||||
|
||||
fun onCarDisconnected() {
|
||||
car = null
|
||||
pairing = false
|
||||
announcing = false
|
||||
main.post {
|
||||
stopWatching()
|
||||
_prompt.value = Prompt.NONE
|
||||
}
|
||||
}
|
||||
|
||||
/** The driver tapped Pair. */
|
||||
fun pair() {
|
||||
val c = car ?: return
|
||||
val device = device() ?: return
|
||||
pairing = true
|
||||
_prompt.value = Prompt.PAIRING
|
||||
watchBonding(device)
|
||||
c.open()
|
||||
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId}")
|
||||
// If the car never answers on its channel, pair from the phone anyway.
|
||||
main.postDelayed({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
|
||||
main.postDelayed({
|
||||
if (pairing) finish(R.string.bt_pair_unfinished)
|
||||
}, GIVE_UP_MS)
|
||||
}
|
||||
|
||||
/** The driver tapped Not now: this car isn't asked about again. */
|
||||
fun notNow() {
|
||||
car?.service?.carAddress?.let { address ->
|
||||
val context = appContext ?: return@let
|
||||
val list = AppSettings.getString(context, KEY_DECLINED, "").split(",").filter { it.isNotEmpty() }
|
||||
AppSettings.putString(context, KEY_DECLINED, (list + hash(address)).distinct().joinToString(","))
|
||||
}
|
||||
_prompt.value = Prompt.NONE
|
||||
}
|
||||
|
||||
/** Hides the pairing card. The phone's own dialog carries on. */
|
||||
fun hide() {
|
||||
_prompt.value = Prompt.NONE
|
||||
}
|
||||
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
val c = car ?: return
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||
log.i("<- ChannelOpenResponse: status=$status")
|
||||
if (status != 0) {
|
||||
if (announcing) log.i("the car wouldn't open its Bluetooth channel")
|
||||
else main.post { startBond("the car wouldn't open its Bluetooth channel") }
|
||||
return
|
||||
}
|
||||
if (announcing && typedAddress() == null) {
|
||||
log.i("Bluetooth channel open; no address typed in Settings, so no PairingRequest")
|
||||
return
|
||||
}
|
||||
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
|
||||
?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON
|
||||
c.send(
|
||||
MSG_PAIRING_REQUEST,
|
||||
Protobuf.stringField(1, phoneAddress()) + Protobuf.varintField(2, method.toLong()),
|
||||
)
|
||||
log.i("-> PairingRequest(method=$method)")
|
||||
}
|
||||
MSG_PAIRING_RESPONSE -> {
|
||||
val fields = Wire.fields(body)
|
||||
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
|
||||
if (!pairing) return
|
||||
// Some cars start pairing themselves once they answer; give them a moment first.
|
||||
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
|
||||
}
|
||||
// Carries the pairing code. Android shows it to the driver, so it stays out of the log.
|
||||
MSG_AUTHENTICATION_DATA -> log.i("<- AuthenticationData (${body.size} bytes)")
|
||||
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun startBond(why: String) {
|
||||
if (bondStarted || !pairing) return
|
||||
val device = device() ?: return
|
||||
if (device.bondState != BluetoothDevice.BOND_NONE) return
|
||||
bondStarted = true
|
||||
val started = runCatching { device.createBond() }.getOrDefault(false)
|
||||
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
|
||||
if (!started) finish(R.string.bt_pair_not_started)
|
||||
}
|
||||
|
||||
private fun watchBonding(device: BluetoothDevice) {
|
||||
val context = appContext ?: return
|
||||
stopWatching()
|
||||
val r = object : BroadcastReceiver() {
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val changed = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) ?: return
|
||||
if (changed.address != device.address) return
|
||||
val state = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)
|
||||
val previous = intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, BluetoothDevice.BOND_NONE)
|
||||
log.i("bond state $previous -> $state")
|
||||
when {
|
||||
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
|
||||
state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
|
||||
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.registerReceiver(r, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED), Context.RECEIVER_EXPORTED)
|
||||
receiver = r
|
||||
}
|
||||
|
||||
private fun finish(@StringRes message: Int) {
|
||||
pairing = false
|
||||
stopWatching()
|
||||
main.removeCallbacksAndMessages(null)
|
||||
_prompt.value = Prompt.NONE
|
||||
CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG)
|
||||
}
|
||||
|
||||
private fun stopWatching() {
|
||||
val r = receiver ?: return
|
||||
receiver = null
|
||||
runCatching { appContext?.unregisterReceiver(r) }
|
||||
}
|
||||
|
||||
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
|
||||
private fun device(): BluetoothDevice? {
|
||||
val context = appContext ?: return null
|
||||
val address = car?.service?.carAddress ?: return null
|
||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||
log.i("can't check pairing: the Bluetooth permission isn't granted")
|
||||
return null
|
||||
}
|
||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
log.i("can't check pairing: Bluetooth is off")
|
||||
return null
|
||||
}
|
||||
if (!BluetoothAdapter.checkBluetoothAddress(address)) {
|
||||
log.w("the car sent an address that isn't one")
|
||||
return null
|
||||
}
|
||||
return adapter.getRemoteDevice(address)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps get 02:00:00:00:00:00 here, not the real address, so the one the driver typed in wins.
|
||||
* Sent either way, as the field is required. Never logged: it identifies the phone.
|
||||
*/
|
||||
@SuppressLint("MissingPermission", "HardwareIds")
|
||||
private fun phoneAddress(): String =
|
||||
typedAddress()
|
||||
?: runCatching { appContext?.getSystemService(BluetoothManager::class.java)?.adapter?.address }.getOrNull()
|
||||
?: "02:00:00:00:00:00"
|
||||
|
||||
private fun typedAddress(): String? = appContext?.let { typedAddress(it) }
|
||||
|
||||
/** The phone's own address as the driver typed it in Settings, or null. */
|
||||
fun typedAddress(context: Context): String? =
|
||||
AppSettings.getString(context, KEY_PHONE_ADDRESS, "").takeIf { BluetoothAdapter.checkBluetoothAddress(it) }
|
||||
|
||||
/** Saves [address] after tidying it up; false when it isn't one. Empty clears it. */
|
||||
fun setTypedAddress(context: Context, address: String): Boolean {
|
||||
val tidy = address.trim().uppercase().replace('-', ':')
|
||||
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
|
||||
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun declined(address: String): Boolean {
|
||||
val context = appContext ?: return false
|
||||
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
|
||||
}
|
||||
|
||||
/** Settings keep a short hash, not the car's address. */
|
||||
private fun hash(address: String): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
|
||||
.take(8).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private const val KEY_DECLINED = "bluetooth_pair_declined"
|
||||
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
|
||||
private const val CAR_REPLY_WAIT_MS = 5_000L
|
||||
private const val CAR_BOND_WAIT_MS = 3_000L
|
||||
private const val GIVE_UP_MS = 90_000L
|
||||
private const val METHOD_NUMERIC_COMPARISON = 2
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_PAIRING_REQUEST = 32769
|
||||
private const val MSG_PAIRING_RESPONSE = 32770
|
||||
private const val MSG_AUTHENTICATION_DATA = 32771
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
@@ -22,7 +24,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.foundation.clickable
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -43,7 +46,8 @@ import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Opened from the nav bar clock: the week's calendar and the current weather, at a glance. Both
|
||||
* Opened from the nav bar clock: the current weather, the week's calendar and the phone's
|
||||
* notifications, at a glance. The first two
|
||||
* need permissions granted on the phone (READ_CALENDAR, location) - if either was declined
|
||||
* this just shows an empty state instead of asking again from inside the car UI.
|
||||
*/
|
||||
@@ -58,9 +62,10 @@ fun CarDashboardScreen() {
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text("Today", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
ScreenHeader(stringResource(R.string.dash_today))
|
||||
WeatherCard(context)
|
||||
AgendaCard(context)
|
||||
NotificationsSection()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +75,10 @@ private fun WeatherCard(context: Context) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
LaunchedEffect(Unit) { Weather.refresh(context) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
GsIconBox(
|
||||
onClick = { navigator.weather() },
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.clickable { navigator.weather() },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(20.dp),
|
||||
@@ -81,9 +86,9 @@ private fun WeatherCard(context: Context) {
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (val s = state) {
|
||||
WeatherState.Loading -> Text("Getting weather…", style = MaterialTheme.typography.bodyLarge)
|
||||
WeatherState.Loading -> Text(stringResource(R.string.dash_getting_weather), style = MaterialTheme.typography.bodyLarge)
|
||||
is WeatherState.Unavailable -> Text(
|
||||
s.reason, style = MaterialTheme.typography.bodyLarge,
|
||||
stringResource(s.reason), style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is WeatherState.Ready -> {
|
||||
@@ -91,7 +96,7 @@ private fun WeatherCard(context: Context) {
|
||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||
Column {
|
||||
Text(
|
||||
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
||||
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
|
||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
@@ -109,12 +114,12 @@ private fun AgendaCard(context: Context) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Coming up", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.dash_coming_up), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
val list = events
|
||||
when {
|
||||
list == null -> Text("Loading calendar…", style = MaterialTheme.typography.bodyMedium)
|
||||
list == null -> Text(stringResource(R.string.dash_loading_calendar), style = MaterialTheme.typography.bodyMedium)
|
||||
list.isEmpty() -> Text(
|
||||
"Nothing in the next week.",
|
||||
stringResource(R.string.dash_nothing_week),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -132,7 +137,7 @@ private fun EventRow(event: AgendaEvent) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
eventWhen(event),
|
||||
eventWhen(event, stringResource(R.string.dash_all_day)),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
@@ -146,14 +151,14 @@ private fun EventRow(event: AgendaEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
|
||||
private fun eventWhen(event: AgendaEvent): String {
|
||||
/** stringResource(R.string.dash_today) events show just a time; later days get a weekday so a week's list stays readable. */
|
||||
private fun eventWhen(event: AgendaEvent, allDay: String): String {
|
||||
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
|
||||
val today = Calendar.getInstance()
|
||||
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_YEAR)
|
||||
val time = if (event.allDay) "All day" else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||
val time = if (event.allDay) allDay else DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(event.startMillis))
|
||||
if (sameDay || (event.allDay && start.before(today))) return time
|
||||
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* The car's sensors, read into real values for the car apps that ask (a map's speed, an EV app's
|
||||
* range). [CarSensors] keeps the raw readings for the Vehicle data screen; this keeps the few with
|
||||
* a known meaning, decoded the way Android Auto's SensorBatch scales them:
|
||||
*
|
||||
* gps_location = 1 { latitude_e7 = 2; longitude_e7 = 3; accuracy_e3 = 4; altitude_e2 = 5;
|
||||
* speed_e3 = 6; bearing_e6 = 7 }
|
||||
* compass = 2 { bearing_e6 = 1 } speed = 3 { speed_e3 = 1 } (metres per second)
|
||||
* odometer = 5 { kms_e1 = 1 } fuel = 6 { level = 1 (percent); range = 2 (km); low = 3 }
|
||||
*
|
||||
* Each value is null until the car first sends it, and the whole lot clears when it disconnects.
|
||||
*/
|
||||
object CarData {
|
||||
|
||||
class Location(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val accuracyMeters: Float?,
|
||||
val altitudeMeters: Double?,
|
||||
val speedMps: Float?,
|
||||
val bearingDegrees: Float?,
|
||||
)
|
||||
|
||||
data class Snapshot(
|
||||
val speedMps: Float? = null,
|
||||
val odometerKm: Float? = null,
|
||||
val fuelPercent: Int? = null,
|
||||
val rangeKm: Int? = null,
|
||||
val fuelLow: Boolean? = null,
|
||||
val compassDegrees: Float? = null,
|
||||
val location: Location? = null,
|
||||
/** When each kind last changed, by its SensorBatch field number. */
|
||||
val updatedAt: Map<Int, Long> = emptyMap(),
|
||||
)
|
||||
|
||||
/** What the car says it is, from its service discovery: model and year. It names no maker. */
|
||||
class Model(val name: String, val year: Int?)
|
||||
|
||||
private val flow = MutableStateFlow(Snapshot())
|
||||
val state: StateFlow<Snapshot> = flow
|
||||
|
||||
@Volatile var model: Model? = null
|
||||
private set
|
||||
|
||||
fun onModel(name: String, year: String) {
|
||||
model = name.takeIf { it.isNotBlank() }?.let { Model(it, year.toIntOrNull()?.takeIf { y -> y > 0 }) }
|
||||
}
|
||||
|
||||
fun onEvent(body: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
val batch = Wire.fields(body)
|
||||
var s = flow.value
|
||||
val touched = HashSet<Int>()
|
||||
// Each kind may come several times in one batch: the last is the newest.
|
||||
Wire.allBytes(batch, LOCATION).lastOrNull()?.let { bytes ->
|
||||
val f = Wire.fields(bytes)
|
||||
val lat = Wire.varint(f, 2)?.toInt()
|
||||
val lon = Wire.varint(f, 3)?.toInt()
|
||||
if (lat != null && lon != null) {
|
||||
s = s.copy(
|
||||
location = Location(
|
||||
lat / 1e7, lon / 1e7,
|
||||
Wire.varint(f, 4)?.let { it / 1e3f },
|
||||
Wire.varint(f, 5)?.let { it.toInt() / 1e2 },
|
||||
Wire.varint(f, 6)?.let { it.toInt() / 1e3f },
|
||||
Wire.varint(f, 7)?.let { it.toInt() / 1e6f },
|
||||
),
|
||||
)
|
||||
touched += LOCATION
|
||||
}
|
||||
}
|
||||
Wire.allBytes(batch, COMPASS).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(compassDegrees = it.toInt() / 1e6f); touched += COMPASS }
|
||||
}
|
||||
Wire.allBytes(batch, SPEED).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(speedMps = it.toInt() / 1e3f); touched += SPEED }
|
||||
}
|
||||
Wire.allBytes(batch, ODOMETER).lastOrNull()?.let { bytes ->
|
||||
Wire.varint(Wire.fields(bytes), 1)?.let { s = s.copy(odometerKm = it.toInt() / 10f); touched += ODOMETER }
|
||||
}
|
||||
Wire.allBytes(batch, FUEL).lastOrNull()?.let { bytes ->
|
||||
val f = Wire.fields(bytes)
|
||||
s = s.copy(
|
||||
fuelPercent = Wire.varint(f, 1)?.toInt() ?: s.fuelPercent,
|
||||
rangeKm = Wire.varint(f, 2)?.toInt() ?: s.rangeKm,
|
||||
fuelLow = Wire.varint(f, 3)?.let { it != 0L } ?: s.fuelLow,
|
||||
)
|
||||
touched += FUEL
|
||||
}
|
||||
if (touched.isEmpty()) return
|
||||
flow.value = s.copy(updatedAt = s.updatedAt + touched.associateWith { now })
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
flow.value = Snapshot()
|
||||
model = null
|
||||
}
|
||||
|
||||
const val LOCATION = 1
|
||||
const val COMPASS = 2
|
||||
const val SPEED = 3
|
||||
const val ODOMETER = 5
|
||||
const val FUEL = 6
|
||||
}
|
||||
@@ -37,6 +37,9 @@ object CarEnvironment {
|
||||
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
|
||||
val frame: StateFlow<Frame> = frameFlow
|
||||
|
||||
/** The density the car reported; [frame] carries the one the car UI and its apps draw at. */
|
||||
private var carDensityDpi = 160
|
||||
|
||||
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
|
||||
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
|
||||
val battery: StateFlow<Battery> = batteryFlow
|
||||
@@ -64,7 +67,9 @@ object CarEnvironment {
|
||||
}
|
||||
|
||||
@Volatile private var sensorNight: Boolean? = null
|
||||
private var appContext: Context? = null
|
||||
/** Set once the car UI starts; the cluster screen draws with it too. */
|
||||
var appContext: Context? = null
|
||||
private set
|
||||
private var running = false
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
@@ -93,8 +98,18 @@ object CarEnvironment {
|
||||
}
|
||||
|
||||
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
|
||||
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
|
||||
frameFlow.value = Frame(width, height, densityDpi)
|
||||
carDensityDpi = densityDpi
|
||||
val dpi = CarSettings.effectiveDensity(densityDpi)
|
||||
displayFlow.value = "${width}x$height @ ${dpi}dpi"
|
||||
frameFlow.value = Frame(width, height, dpi)
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes up the density setting. It's read only as a session starts, so the display and the
|
||||
* apps on it can't disagree mid-drive; a session with no car behind it, the preview, asks here.
|
||||
*/
|
||||
fun applyDensity() {
|
||||
frameFlow.value = frameFlow.value.copy(densityDpi = CarSettings.effectiveDensity(carDensityDpi))
|
||||
}
|
||||
|
||||
fun start(context: Context) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.os.SystemClock
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Full screen for the screen that asks for it (the browser, or a video in it): no header and no
|
||||
* nav bar. A swipe in from the top or bottom edge brings the nav bar back for a few seconds,
|
||||
* with a button to leave full screen; [revealed] says when.
|
||||
*/
|
||||
object CarFullscreen {
|
||||
|
||||
var active by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var revealed by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var lastTouch = 0L
|
||||
|
||||
fun enter() {
|
||||
active = true
|
||||
revealed = false
|
||||
}
|
||||
|
||||
fun exit() {
|
||||
active = false
|
||||
revealed = false
|
||||
}
|
||||
|
||||
/** A swipe in from an edge: show the nav bar and the way out. */
|
||||
fun reveal() {
|
||||
if (!active) return
|
||||
lastTouch = SystemClock.uptimeMillis()
|
||||
revealed = true
|
||||
}
|
||||
|
||||
/** Any touch keeps a revealed bar up, so it doesn't slide away under a finger reaching for it. */
|
||||
fun touch() {
|
||||
if (revealed) lastTouch = SystemClock.uptimeMillis()
|
||||
}
|
||||
|
||||
/** Called on a timer; hides the bar again after [REVEAL_MS] without a touch. */
|
||||
fun tick() {
|
||||
if (revealed && SystemClock.uptimeMillis() - lastTouch >= REVEAL_MS) revealed = false
|
||||
}
|
||||
|
||||
private const val REVEAL_MS = 4_000L
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
@@ -14,45 +14,66 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import app.seb3thehacker.gearslip.car.theme.gsPanelShadow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.draw.clip
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import android.media.session.PlaybackState
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import app.seb3thehacker.gearslip.media.ArtColor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import kotlinx.coroutines.delay
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* The home screen: the map on the left, what is playing on the right.
|
||||
* The home screen: the map, with the docked player floating over one side of it when asked for.
|
||||
* With the player on the left, the map app's cards move to the map's far edge, so the player, the
|
||||
* map and the cards each keep their own space.
|
||||
*
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player only
|
||||
* takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
|
||||
* The map is whatever navigation app was used last, brought back on its own. The player sits in
|
||||
* the nav bar's pill until its dock button moves it here; the column closes itself back to a
|
||||
* full-width map once nothing is playing.
|
||||
*/
|
||||
@Composable
|
||||
fun CarHome() {
|
||||
@@ -65,25 +86,69 @@ fun CarHome() {
|
||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||
|
||||
val navigator = LocalCarNavigator.current
|
||||
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
|
||||
// Once playback ends there is nothing left to minimise; the next song starts in place.
|
||||
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
|
||||
val panel = navigator.sidePanel
|
||||
|
||||
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(if (showPlayer) 2.1f else 1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
||||
// Coming back to the map from another screen: the map stayed loaded underneath, so its surface
|
||||
// is only replaced if it died meanwhile. The first composition is skipped, since the surface
|
||||
// is only just being lent.
|
||||
val onHome = navigator.current == CarScreen.Home
|
||||
val seenHome = remember { booleanArrayOf(false) }
|
||||
LaunchedEffect(onHome) {
|
||||
if (onHome && seenHome[0]) CarServices.nav.replaceDeadSurface()
|
||||
if (onHome) seenHome[0] = true
|
||||
}
|
||||
val splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
|
||||
// What the card showed last, so it still has something to show while it slides away.
|
||||
val shown = remember { androidx.compose.runtime.mutableStateOf(SidePanel.CONTROLS) }
|
||||
if (panel != SidePanel.NONE) shown.value = panel
|
||||
|
||||
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
|
||||
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
|
||||
// only re-centres its map instead of drawing onto a new surface.
|
||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||
val column = (maxWidth / PLAYER_SHARE).coerceIn(PLAYER_MIN_WIDTH, PLAYER_MAX_WIDTH)
|
||||
androidx.compose.runtime.CompositionLocalProvider(
|
||||
LocalCardsAtEnd provides (splitScreen && playerLeft),
|
||||
LocalSidePanelInset provides if (splitScreen) column else 0.dp,
|
||||
) {
|
||||
MapPane(navStatus, frame)
|
||||
}
|
||||
if (showPlayer) {
|
||||
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
splitScreen,
|
||||
Modifier.align(if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd),
|
||||
enter = androidx.compose.animation.slideInHorizontally(tween(220)) { if (playerLeft) -it / 3 else it / 3 } +
|
||||
androidx.compose.animation.fadeIn(tween(180)),
|
||||
exit = androidx.compose.animation.slideOutHorizontally(tween(180)) { if (playerLeft) -it / 3 else it / 3 } +
|
||||
androidx.compose.animation.fadeOut(tween(140)),
|
||||
) {
|
||||
val card = Modifier
|
||||
.width(column)
|
||||
.fillMaxHeight()
|
||||
.padding(
|
||||
start = if (playerLeft) CARD_GAP else 0.dp,
|
||||
end = if (playerLeft) 0.dp else CARD_GAP,
|
||||
top = CARD_GAP,
|
||||
bottom = LocalNavBarInset.current + CARD_GAP,
|
||||
)
|
||||
if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The player takes one part in this many of the screen's width. */
|
||||
private const val PLAYER_SHARE = 3.6f
|
||||
|
||||
/** Narrow enough to leave the map most of a small screen; on a wide one the map gets the extra room. */
|
||||
private val PLAYER_MIN_WIDTH = 240.dp
|
||||
private val PLAYER_MAX_WIDTH = 340.dp
|
||||
|
||||
/** Between the player card and the screen's edges, and the map's own cards beside it. */
|
||||
private val CARD_GAP = 12.dp
|
||||
|
||||
/** The card's corners: rounder than a button's, as the map app's own cards are. */
|
||||
private val CARD_SHAPE = RoundedCornerShape(24.dp)
|
||||
|
||||
@Composable
|
||||
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
@@ -93,124 +158,167 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
Modifier.fillMaxSize().padding(
|
||||
start = if (LocalCardsAtEnd.current) LocalSidePanelInset.current else 0.dp,
|
||||
end = if (LocalCardsAtEnd.current) 0.dp else LocalSidePanelInset.current,
|
||||
).padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val hasLast = CarSettings.lastNav.value != null
|
||||
val hasNavApps by CarServices.hasNavApps.collectAsStateWithLifecycle()
|
||||
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
when (status.phase) {
|
||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
||||
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
||||
Text(status.app?.let { stringResource(R.string.home_starting, it) } ?: stringResource(R.string.home_starting_map), style = MaterialTheme.typography.titleMedium)
|
||||
CarAppConnection.Phase.REJECTED, CarAppConnection.Phase.FAILED -> {
|
||||
Text("${status.app ?: "The map app"} would not start.", style = MaterialTheme.typography.titleMedium)
|
||||
Text(status.app?.let { stringResource(R.string.home_failed, it) } ?: stringResource(R.string.home_failed_map), style = MaterialTheme.typography.titleMedium)
|
||||
status.detail?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StatusButton(stringResource(R.string.home_choose_app), GsTone.Primary, navigator::apps)
|
||||
}
|
||||
else -> {
|
||||
else -> if (!hasNavApps) {
|
||||
// Nothing to auto-select and nothing to pick from Car apps either - this is the
|
||||
// only case worth a distinct screen, since every other IDLE case resolves itself.
|
||||
Text(stringResource(R.string.home_no_maps), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (hasLast) "Starting the map…" else "No map app chosen yet.",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
stringResource(R.string.home_no_maps_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!hasLast) {
|
||||
Text(
|
||||
"Open Car apps and connect a navigation app; it will start here next time.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||
} else if (hasLast) {
|
||||
// IDLE with a last app on record means the driver stopped it themselves - it will
|
||||
// not come back on its own, so the screen must offer a way to bring it back rather
|
||||
// than sit on a "starting" message that never resolves.
|
||||
Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.home_map_stopped_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Side by side, the same size, the likely one lit: one clear choice, not a stack.
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
StatusButton(stringResource(R.string.home_reopen_map), GsTone.Primary) { scope.launch { CarServices.reconnectNav(frame) } }
|
||||
StatusButton(stringResource(R.string.home_car_apps), GsTone.Tonal) { navigator.apps() }
|
||||
}
|
||||
} else {
|
||||
// Autostart is still picking one - a navigation app is installed, so this resolves
|
||||
// to a "Starting..." state itself, in the next frame or two.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
|
||||
/** A big, even button for the map pane's few choices. */
|
||||
@Composable
|
||||
private fun MiniPlayer(modifier: Modifier) {
|
||||
private fun StatusButton(label: String, tone: GsTone, onClick: () -> Unit) {
|
||||
GsButton(onClick = onClick, tone = tone, modifier = Modifier.width(220.dp).height(64.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The player, docked beside the map: the art as large as it fits, then title, progress, transport,
|
||||
* and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||
* twice. The X sends it back to the nav bar.
|
||||
*/
|
||||
@Composable
|
||||
private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val media = CarServices.media
|
||||
val now by media.now.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
var position by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(now) {
|
||||
while (true) {
|
||||
position = now.currentPosition()
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
val navigator = LocalCarNavigator.current
|
||||
Surface(
|
||||
onClick = { navigator.media() },
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(18.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist,
|
||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
if (now.durationMs > 0) {
|
||||
WavyProgress(
|
||||
fraction = position.toFloat() / now.durationMs,
|
||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
|
||||
// The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
|
||||
// slab of album colour beside the map fought it for attention; this only hints at the song.
|
||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||
AlbumGlow(art, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// The cover gets everything the controls leave, as big a square as fits, sitting
|
||||
// right on top of the title; the controls stay pinned to the bottom.
|
||||
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
|
||||
val artSize = minOf(maxWidth, maxHeight).coerceAtLeast(0.dp)
|
||||
// Art opens the full media screen, same as on the pill.
|
||||
val coverShape = RoundedCornerShape(16.dp)
|
||||
Box(
|
||||
Modifier
|
||||
.size(artSize)
|
||||
.gsPanelShadow(coverShape)
|
||||
.clip(coverShape)
|
||||
.background(scheme.surface)
|
||||
.clickable { navigator.media() },
|
||||
) {
|
||||
// A new song's cover fades in over the old one instead of cutting to it.
|
||||
androidx.compose.animation.Crossfade(art, animationSpec = tween(350), label = "cover") { shown ->
|
||||
shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
}
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
|
||||
Icon(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
Modifier.size(36.dp),
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
now.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
now.artist, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (now.durationMs > 0) {
|
||||
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false)
|
||||
} else {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
}
|
||||
TransportRow(now, media, skip = 56.dp, modifier = Modifier.fillMaxWidth())
|
||||
Spacer(Modifier.height(10.dp))
|
||||
PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
|
||||
GsIconButton(
|
||||
ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
|
||||
size = 50.dp, iconSize = 26.dp,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
val compact = PaddingValues(horizontal = 8.dp)
|
||||
FilledTonalButton(
|
||||
onClick = { navigator.media(lyrics = true) },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Lyrics", maxLines = 1, softWrap = false) }
|
||||
FilledTonalButton(
|
||||
onClick = navigator::minimisePlayer,
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = compact,
|
||||
) { Text("Minimise", maxLines = 1, softWrap = false) }
|
||||
}
|
||||
GsIconButton(
|
||||
Icons.Filled.Close, stringResource(R.string.home_back_nav), navigator::closeSidePanel,
|
||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
|
||||
* player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
|
||||
* lyrics back for the player; the X closes the panel.
|
||||
*/
|
||||
@Composable
|
||||
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
||||
val navigator = LocalCarNavigator.current
|
||||
val context = LocalContext.current
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||
Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Spacer(Modifier.size(10.dp))
|
||||
Text(
|
||||
listOf(now.title, now.artist).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = scheme.onSurfaceVariant,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
// The way back to the player, as the player's own Lyrics button is the way here.
|
||||
GsIconButton(MediaIcons.MusicNote, stringResource(R.string.home_show_player), navigator::showSidePlayer, size = 36.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.size(6.dp))
|
||||
GsIconButton(Icons.Filled.Close, stringResource(R.string.home_hide_lyrics), navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
||||
}
|
||||
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -8,12 +11,14 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,8 +28,29 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||
* bottom nav bar normally holds instead of squeezing it in above - a driver typing has no use
|
||||
* for Home/Apps/Settings shortcuts anyway, and the screen is small enough that the space matters.
|
||||
*/
|
||||
object CarKeyboardVisibility {
|
||||
val state = mutableStateOf(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the car UI the phone's own car preview draws. The phone is in hand there, so a
|
||||
* [CarKeyboard] draws nothing and the phone's keyboard does the typing instead (see [PhoneTyping]).
|
||||
*/
|
||||
val LocalCarPreview = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* A keyboard the host draws itself.
|
||||
@@ -35,7 +61,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
* the same reasons, so this does too - large keys, no long-press, no prediction.
|
||||
*
|
||||
* It is a pure function of [text]: every key reports the new string and holds no state beyond
|
||||
* which layer is showing.
|
||||
* which layer is showing. Laid out like a PC's QWERTY keyboard rather than a phone's: shift and
|
||||
* backspace flank the letter block (top-right and bottom-left of it, same as a physical board),
|
||||
* not the space bar, so a fumbled reach for space never lands on delete instead.
|
||||
*
|
||||
* [onDismiss] is null wherever there's no keyboard-shown state independent of the screen itself
|
||||
* (a search template, sign-in) - there, the header's own back action is the only way out and no
|
||||
* dismiss button is drawn.
|
||||
*/
|
||||
@Composable
|
||||
fun CarKeyboard(
|
||||
@@ -45,48 +77,90 @@ fun CarKeyboard(
|
||||
modifier: Modifier = Modifier,
|
||||
submitIcon: Boolean = true,
|
||||
submitImage: ImageVector? = null,
|
||||
onDismiss: (() -> Unit)? = null,
|
||||
) {
|
||||
var symbols by remember { mutableStateOf(false) }
|
||||
var shifted by remember { mutableStateOf(false) }
|
||||
val preview = LocalCarPreview.current
|
||||
|
||||
DisposableEffect(preview) {
|
||||
if (!preview) CarKeyboardVisibility.state.value = true
|
||||
onDispose { if (!preview) CarKeyboardVisibility.state.value = false }
|
||||
}
|
||||
|
||||
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
|
||||
val latestChange by rememberUpdatedState(onTextChange)
|
||||
val latestSubmit by rememberUpdatedState(onSubmit)
|
||||
val latestDismiss by rememberUpdatedState(onDismiss)
|
||||
val phoneField = remember {
|
||||
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
|
||||
}
|
||||
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
|
||||
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
|
||||
if (preview) return
|
||||
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
rows.forEach { row ->
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
rows.dropLast(1).forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom letter row: shift and backspace bookend it, exactly where a physical
|
||||
// keyboard puts them relative to the letter block, not down beside the space bar.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
|
||||
}
|
||||
rows.last().forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
Key("⌫", Modifier.weight(1.6f)) {
|
||||
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.4f)) { shifted = !shifted }
|
||||
// Control row: no backspace here - it stays with the letters above, not next to the
|
||||
// key most likely to be hit right after it by mistake. Search/enter takes the
|
||||
// bottom-right corner, where a thumb expects it; dismiss (when offered) sits left of it.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key(stringResource(R.string.keyboard_space), Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
}
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
}
|
||||
Key("space", Modifier.weight(3f)) { onTextChange("$text ") }
|
||||
Key("⌫", Modifier.weight(1.4f)) {
|
||||
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||
}
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,11 +168,11 @@ fun CarKeyboard(
|
||||
|
||||
@Composable
|
||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -116,12 +190,30 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.keyboard_hide))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.height(KEY_HEIGHT).clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -130,17 +222,19 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
||||
) {
|
||||
Icon(
|
||||
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
|
||||
contentDescription = "Submit",
|
||||
contentDescription = stringResource(R.string.keyboard_submit),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
|
||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
|
||||
*/
|
||||
private val KEY_HEIGHT = 34.dp
|
||||
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.09f).dp.coerceIn(44.dp, 64.dp)
|
||||
|
||||
private val LocalKeyHeight = compositionLocalOf { 44.dp }
|
||||
|
||||
private val LETTER_ROWS = listOf(
|
||||
"qwertyuiop".map { it.toString() },
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.KeyEvent
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* The car's own buttons: steering wheel, rotary knob, D-pad and the hard keys around the screen.
|
||||
*
|
||||
* The head unit sends Android keycodes as they are, so the numbers here are android.view.KeyEvent's.
|
||||
* Media, voice and call keys act straight away. Keys that move around the car UI (back, home, the
|
||||
* knob) need a [CarNavigator], which only [CarUi] has, so they go out on [screenKeys]. Focus keys
|
||||
* (D-pad, knob turns, OK) become real key events on the car's display through [focusSink], where
|
||||
* Compose moves focus between buttons the same way it would with a keyboard.
|
||||
*/
|
||||
object CarKeys {
|
||||
|
||||
/** A key that changes which screen is showing. */
|
||||
enum class ScreenKey { BACK, HOME, MAP, MEDIA, PHONE }
|
||||
|
||||
private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8)
|
||||
val screenKeys: SharedFlow<ScreenKey> = _screenKeys
|
||||
|
||||
private val _focusMoves = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
/** A knob turn or D-pad press, so controls that hide when left alone can come back. */
|
||||
val focusMoves: SharedFlow<Unit> = _focusMoves
|
||||
|
||||
/** Delivers an Android key event to the car display; set while the car UI is showing. */
|
||||
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
fun init(context: Context) { appContext = context.applicationContext }
|
||||
|
||||
/**
|
||||
* Every key worth asking the head unit for. The binding request lists what the head unit
|
||||
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
|
||||
* this is what's asked for instead.
|
||||
*/
|
||||
val WANTED = listOf(
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_MEDIA_STOP,
|
||||
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST,
|
||||
KeyEvent.KEYCODE_CALL, KeyEvent.KEYCODE_ENDCALL,
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_HOME,
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||
KEYCODE_ROTARY_CONTROLLER, KEYCODE_MEDIA, KEYCODE_NAVIGATION, KEYCODE_TEL,
|
||||
)
|
||||
|
||||
/**
|
||||
* One key edge from the head unit. Most keys act on the press; focus keys pass both edges on,
|
||||
* so a held D-pad key repeats the way it would anywhere else. Returns false for a key nothing
|
||||
* here knows, for the caller to log.
|
||||
*/
|
||||
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
|
||||
if (keyCode in FOCUS_KEYS) {
|
||||
_focusMoves.tryEmit(Unit)
|
||||
focusSink?.invoke(keyCode, down)
|
||||
return true
|
||||
}
|
||||
if (!down) return keyCode in WANTED
|
||||
val media = CarServices.media
|
||||
when (keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay()
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next()
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous()
|
||||
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
|
||||
KeyEvent.KEYCODE_CALL -> call()
|
||||
KeyEvent.KEYCODE_ENDCALL -> endCall()
|
||||
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK)
|
||||
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
|
||||
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
|
||||
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
|
||||
KEYCODE_TEL -> screen(ScreenKey.PHONE)
|
||||
else -> return false
|
||||
}
|
||||
if (longPress) GearslipLog.i("keys: long press on $keyCode")
|
||||
return true
|
||||
}
|
||||
|
||||
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
|
||||
fun onRotate(delta: Int) {
|
||||
val sink = focusSink ?: return
|
||||
_focusMoves.tryEmit(Unit)
|
||||
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
|
||||
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
|
||||
sink(code, true)
|
||||
sink(code, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice key: answers the message on screen if there is one, otherwise starts the
|
||||
* assistant.
|
||||
*/
|
||||
private fun voice() = main.post {
|
||||
if (!VoiceReply.onVoiceKey()) CarAssistant.start()
|
||||
}
|
||||
|
||||
/** Answers a ringing call; with nothing ringing, opens the phone screen. */
|
||||
private fun call() = main.post {
|
||||
val context = appContext ?: return@post
|
||||
if (CarCalls.state.value is CallUiState.Ringing) CarCalls.answer(context) else screen(ScreenKey.PHONE)
|
||||
}
|
||||
|
||||
private fun endCall() = main.post {
|
||||
if (CarCalls.state.value is CallUiState.Ringing) CarCalls.decline()
|
||||
else GearslipLog.i("keys: end call pressed with no ringing call; hanging up needs the dialer")
|
||||
}
|
||||
|
||||
private fun screen(key: ScreenKey) { _screenKeys.tryEmit(key) }
|
||||
|
||||
// aap_protobuf's KeyCode.proto: car-only keys past the end of Android's own range.
|
||||
const val KEYCODE_ROTARY_CONTROLLER = 65536
|
||||
const val KEYCODE_MEDIA = 65537
|
||||
const val KEYCODE_NAVIGATION = 65538
|
||||
const val KEYCODE_TEL = 65540
|
||||
|
||||
/** Stands in for Shift+Tab: the sink sends Tab with the shift meta state. */
|
||||
const val KEYCODE_SHIFT_TAB = -KeyEvent.KEYCODE_TAB
|
||||
|
||||
private const val MAX_ROTARY_STEPS = 5
|
||||
|
||||
private val FOCUS_KEYS = setOf(
|
||||
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -13,17 +17,21 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
@@ -42,14 +50,20 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
import app.seb3thehacker.gearslip.media.MediaCatalog
|
||||
import app.seb3thehacker.gearslip.notify.MessagingApp
|
||||
import app.seb3thehacker.gearslip.notify.MessagingCatalog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -58,26 +72,46 @@ private class Tile(
|
||||
val label: String,
|
||||
val icon: ImageBitmap? = null,
|
||||
val glyph: ImageVector? = null,
|
||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||
val verified: Boolean = false,
|
||||
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
||||
val broken: Boolean = false,
|
||||
val onLongClick: (() -> Unit)? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
||||
private class Entry(
|
||||
internal class Entry(
|
||||
val label: String,
|
||||
val icon: ImageBitmap?,
|
||||
val template: TemplateApp? = null,
|
||||
val media: MediaApp? = null,
|
||||
)
|
||||
val builtIn: BuiltInApp? = null,
|
||||
val messaging: MessagingApp? = null,
|
||||
) {
|
||||
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
|
||||
val componentId: String?
|
||||
get() = builtIn?.let { BUILT_IN_PREFIX + it.id }
|
||||
?: messaging?.let { MESSAGING_PREFIX + it.packageName }
|
||||
?: (template?.component ?: media?.component)?.flattenToString()
|
||||
|
||||
val packageName: String?
|
||||
get() = template?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName
|
||||
}
|
||||
|
||||
/** A messaging app has no component of its own to open, so its pin carries the package instead. */
|
||||
internal const val MESSAGING_PREFIX = "messages:"
|
||||
|
||||
/**
|
||||
* The installed car and media apps with their icons. Scanning the package manager and decoding
|
||||
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
|
||||
* which is when new installs are picked up.
|
||||
*/
|
||||
private object LauncherCache {
|
||||
@Volatile var entries: List<Entry>? = null
|
||||
internal object LauncherCache {
|
||||
/** Observed, not read once: the nav bar is drawn before the car session's rescan finishes,
|
||||
* and an app installed since the last scan has to show up there when it does. */
|
||||
private val flow = MutableStateFlow<List<Entry>?>(null)
|
||||
val state: StateFlow<List<Entry>?> = flow.asStateFlow()
|
||||
|
||||
val entries: List<Entry>? get() = flow.value
|
||||
|
||||
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
||||
@Synchronized
|
||||
@@ -87,16 +121,155 @@ private object LauncherCache {
|
||||
context.packageManager.getApplicationIcon(pkg).toBitmap(128, 128).asImageBitmap()
|
||||
}.getOrNull()
|
||||
|
||||
val nav = CarAppCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), template = it) }
|
||||
val media = MediaCatalog.installed(context).map { Entry(it.label, iconOf(it.component.packageName), media = it) }
|
||||
return (nav + media).sortedBy { it.label.lowercase() }.also { entries = it }
|
||||
val carApps = CarAppCatalog.installed(context).associateBy { it.component.packageName }
|
||||
val mediaApps = MediaCatalog.installed(context).associateBy { it.component.packageName }
|
||||
val messagingApps = MessagingCatalog.installed(context).associateBy { it.packageName }
|
||||
|
||||
// One decision per app over everything it offers a car - a car screen, a player, car
|
||||
// notifications - rather than a filter per kind that has to know about the others.
|
||||
val tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
|
||||
val icon by lazy { iconOf(pkg) }
|
||||
tilesFor(carApps[pkg], mediaApps[pkg], messagingApps[pkg], context.getString(R.string.media_browse)).map { tile ->
|
||||
when (tile) {
|
||||
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
||||
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
||||
is AppTile.Messages -> Entry(tile.app.label, icon, messaging = tile.app)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** What one app gets on the launcher. */
|
||||
private sealed interface AppTile {
|
||||
class Screen(val app: TemplateApp, val label: String) : AppTile
|
||||
class Player(val app: app.seb3thehacker.gearslip.media.MediaApp) : AppTile
|
||||
class Messages(val app: app.seb3thehacker.gearslip.notify.MessagingApp) : AppTile
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles for one app, from what it offers.
|
||||
*
|
||||
* - A car screen that is something other than media or messaging (RF Sentinel's POI screen) is
|
||||
* the app: its media service only makes sound, and its notifications are alerts, so it gets the
|
||||
* one tile under its own name.
|
||||
* - A media app with both a car screen and a player (Spotify) gets both, the screen labelled
|
||||
* "· Browse", since they really are different screens - unless the screen is known to refuse
|
||||
* Gearslip while the player works, when the Browse tile would only report that.
|
||||
* - A messenger's own car screen (Google Messages) won't start under Gearslip, and Gearslip's
|
||||
* Messages screen does the job, so it gets only that - and a messenger that is also a media
|
||||
* app keeps only its player.
|
||||
*/
|
||||
private fun tilesFor(
|
||||
car: TemplateApp?,
|
||||
media: app.seb3thehacker.gearslip.media.MediaApp?,
|
||||
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
|
||||
browse: String,
|
||||
): List<AppTile> {
|
||||
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
|
||||
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
|
||||
return buildList {
|
||||
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
|
||||
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
|
||||
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
|
||||
add(AppTile.Screen(car, if (media != null) "${car.label} · $browse" else car.label))
|
||||
}
|
||||
if (media != null) add(AppTile.Player(media))
|
||||
else if (messaging != null) add(AppTile.Messages(messaging))
|
||||
}
|
||||
}
|
||||
|
||||
/** Every app the launcher knows: Gearslip's own and the installed ones, rescans included. */
|
||||
@Composable
|
||||
internal fun rememberAllEntries(): List<Entry> {
|
||||
val context = LocalContext.current
|
||||
val installed by LauncherCache.state.collectAsState()
|
||||
val launcher by LauncherMode.active.collectAsState()
|
||||
LaunchedEffect(context) {
|
||||
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
}
|
||||
return remember(installed, launcher) {
|
||||
BuiltInApps.entries(launcher, LauncherMode.canCall(context)) + installed.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/** The launcher's groups, in the order they're laid out. */
|
||||
private enum class AppGroup { PHONE, MAPS, MUSIC, MESSAGING, WEB, SCREEN_SHARING, OTHER, SETTINGS, EXIT }
|
||||
|
||||
private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
||||
entry.builtIn?.let {
|
||||
return when (it) {
|
||||
BuiltInApps.Phone -> AppGroup.PHONE
|
||||
BuiltInApps.Web -> AppGroup.WEB
|
||||
BuiltInApps.ScreenSharing -> AppGroup.SCREEN_SHARING
|
||||
BuiltInApps.Exit -> AppGroup.EXIT
|
||||
else -> AppGroup.SETTINGS
|
||||
}
|
||||
}
|
||||
if (entry.media != null) return AppGroup.MUSIC
|
||||
if (entry.messaging != null) return AppGroup.MESSAGING
|
||||
val app = entry.template ?: return AppGroup.OTHER
|
||||
// A media app's "· Browse" tile sits next to its player.
|
||||
if (app.component.packageName in mediaPackages) return AppGroup.MUSIC
|
||||
return when (app.kind) {
|
||||
"calling" -> AppGroup.PHONE
|
||||
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS
|
||||
// Template-only media apps may have no legacy browser/player tile to identify them.
|
||||
"media" -> AppGroup.MUSIC
|
||||
"messaging" -> AppGroup.MESSAGING
|
||||
"settings" -> AppGroup.SETTINGS
|
||||
else -> AppGroup.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
||||
* group with Settings last of all, then with the driver's own moves from [custom] laid over it.
|
||||
* Moved apps only trade places among themselves, so an app installed later still lands in its group.
|
||||
* [hidden] leaves tiles out of the drawer only: a pinned app stays on the nav bar.
|
||||
*/
|
||||
internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: List<String>, hidden: Set<String>): List<Entry> {
|
||||
val shown = all.filter { (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) && it.componentId !in hidden }
|
||||
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||
val grouped = shown.sortedWith(
|
||||
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||
)
|
||||
if (custom.isEmpty()) return grouped
|
||||
val rank = custom.withIndex().associate { (i, id) -> id to i }
|
||||
fun Entry.rank() = componentId?.let { rank[it] }
|
||||
val moved = grouped.filter { it.rank() != null }.sortedBy { it.rank() }.iterator()
|
||||
return grouped.map { if (it.rank() != null) moved.next() else it }
|
||||
}
|
||||
|
||||
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||
internal fun findEntry(id: String): Entry? =
|
||||
BuiltInApps.everyEntry.find { it.componentId == id } ?: LauncherCache.entries?.find { it.componentId == id }
|
||||
|
||||
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||
|
||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||
/**
|
||||
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
|
||||
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
|
||||
* kind of app.
|
||||
*/
|
||||
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
|
||||
entry.builtIn?.let { it.open(navigator); return }
|
||||
entry.template?.let {
|
||||
if (it.isNavigation) {
|
||||
CarServices.connectNav(it, frame)
|
||||
navigator.home()
|
||||
} else {
|
||||
CarServices.connectBrowse(it, frame)
|
||||
navigator.browse(entry.label)
|
||||
}
|
||||
}
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
entry.messaging?.let { navigator.messages(it.packageName, it.label) }
|
||||
}
|
||||
|
||||
/** The app launcher: every app as an icon grid, grouped Phone, Maps, Music, Messaging, Web, Screen sharing, Settings. */
|
||||
@Composable
|
||||
fun CarLauncher() {
|
||||
val context = LocalContext.current
|
||||
@@ -104,22 +277,25 @@ fun CarLauncher() {
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
|
||||
// Painted from the cache at once; scanned only if [Prefetch] has not got there first.
|
||||
val installed by produceState(initialValue = LauncherCache.entries ?: emptyList(), context) {
|
||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||
val all = rememberAllEntries()
|
||||
val running = rememberRunningApps()
|
||||
val showVehicleData = rememberVehicleDataShown()
|
||||
|
||||
fun tileOf(entry: Entry): Tile {
|
||||
// Only apps that don't work get a mark. The X rates an app's own car screens; a messenger
|
||||
// shown on Gearslip's own Messages screen has none, so it never gets one.
|
||||
val pkg = entry.packageName?.takeIf { entry.messaging == null }
|
||||
val id = entry.componentId
|
||||
return Tile(
|
||||
entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
|
||||
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
||||
}
|
||||
|
||||
val tiles = listOf(
|
||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||
) + installed.map { entry ->
|
||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
|
||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||
}
|
||||
} + listOf(
|
||||
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
||||
)
|
||||
val appOrder by CarSettings.appOrder.collectAsState()
|
||||
val hidden by CarSettings.hiddenApps.collectAsState()
|
||||
val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||
@@ -129,49 +305,32 @@ fun CarLauncher() {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(tiles) { AppTile(it) }
|
||||
if (tiles.any { it.verified }) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) { VerifiedLegend() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Says what the check mark on a tile means, once, under the grid. */
|
||||
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
|
||||
@Composable
|
||||
private fun VerifiedLegend() {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
VerifiedBadge(16.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Tested and working with Gearslip",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A green disc with a white check: the same mark on every tile and in the legend. */
|
||||
@Composable
|
||||
private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier.size(size).background(Color(0xFF43A047), CircleShape),
|
||||
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Filled.Check, contentDescription = "Tested and working", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.launcher_broken), tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun AppTile(tile: Tile) {
|
||||
Surface(
|
||||
val shape = MaterialTheme.shapes.extraLarge
|
||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
||||
GsIconBox(
|
||||
onClick = tile.onClick,
|
||||
onLongClick = tile.onLongClick,
|
||||
modifier = Modifier.height(120.dp),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = shape,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -179,23 +338,42 @@ private fun AppTile(tile: Tile) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
Box {
|
||||
TileIcon(tile)
|
||||
if (tile.broken) {
|
||||
// Pinned to the icon's lower corner, ringed in the tile's colour so it
|
||||
// reads as sitting on top of the icon.
|
||||
BrokenBadge(
|
||||
22.dp,
|
||||
Modifier.align(Alignment.BottomEnd).offset(x = 6.dp, y = 4.dp)
|
||||
.border(2.dp, MaterialTheme.colorScheme.surfaceVariant, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
tile.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (tile.verified) {
|
||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||
TileLabel(tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileIcon(tile: Tile) {
|
||||
when {
|
||||
tile.icon != null -> Image(tile.icon, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
tile.glyph != null -> Icon(tile.glyph, contentDescription = null, modifier = Modifier.size(52.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
tile.label,
|
||||
modifier = modifier,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,691 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.notify.PhoneNavigation
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
// The nav bar along the bottom of the car screen: home, apps, the assistant, running apps,
|
||||
// the next turn or a call, the player, and the clock. Split out of CarUi.kt.
|
||||
|
||||
/**
|
||||
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||
* back at all, since a car touchscreen has no hardware button for it.
|
||||
*/
|
||||
@Composable
|
||||
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||
val trail = navigator.trail
|
||||
if (trail.size <= 1) return
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back, size = 32.dp, iconSize = 20.dp)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
trail.forEachIndexed { index, crumb ->
|
||||
val isLast = index == trail.lastIndex
|
||||
Text(
|
||||
crumb.shown(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = if (isLast) Modifier else Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.clickable { navigator.jumpTo(index) }
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
if (!isLast) {
|
||||
Text(
|
||||
"›",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||
* more room.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val now by rememberNow()
|
||||
val weather by Weather.state.collectAsState()
|
||||
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||
|
||||
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||
val entries = rememberAllEntries()
|
||||
val pinnedEntries = remember(entries, pinnedIds) {
|
||||
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
}
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
val running = rememberRunningApps()
|
||||
|
||||
// A full-width strip along the bottom in the screens' own background, so it reads as part of
|
||||
// the screen above rather than a panel on it. Over the player it's see-through, so the
|
||||
// cover's glow carries on behind it.
|
||||
val overPlayer = navigator.current == CarScreen.Media
|
||||
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
|
||||
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Home and Apps are two different places now, not a toggle on one button - the
|
||||
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||
// these two from three screens deep in a media app shouldn't mean reading it
|
||||
// first.
|
||||
NavHomeButton(navigator, entries, running)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavAssistantIcon()
|
||||
|
||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||
// The map app lives on the Home button, so it never gets a second icon here.
|
||||
val runningEntries = running.keys
|
||||
.filter { it !in pinnedIds && running[it] != RunningSlot.NAV }
|
||||
.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||
val shownPins = pinnedEntries.filter { running[it.componentId] != RunningSlot.NAV }
|
||||
if (shownPins.isNotEmpty() || runningEntries.isNotEmpty()) NavDivider()
|
||||
shownPins.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
if (shownPins.isNotEmpty() && runningEntries.isNotEmpty()) NavDivider()
|
||||
runningEntries.forEach { entry -> NavEntryIcon(entry, running, navigator, frame) }
|
||||
|
||||
// Next turn first, off the map only; the player shares the space when it fits,
|
||||
// and gives way to the turn when it doesn't.
|
||||
// A call takes the player's place until it ends.
|
||||
// A turn from the phone has no map in the car to show it, so it shows on Home too.
|
||||
val turn = rememberNextTurn()?.takeIf { it.fromPhone || navigator.current != CarScreen.Home }
|
||||
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (call == null || roomForBoth) turn?.let { NavTurnPill(it) { if (!it.fromPhone) navigator.home() } }
|
||||
if (call != null) {
|
||||
NavCallPill(call, Modifier.weight(1f, fill = false))
|
||||
} else if (turn == null || roomForBoth) {
|
||||
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||
// Home button above.
|
||||
// The clock is also the notifications button: the dashboard lists them under the
|
||||
// calendar and weather, and a dot here marks anything unread.
|
||||
val unread by CarNotifications.unread.collectAsState()
|
||||
NavChip(
|
||||
navigator.current == CarScreen.Dashboard || navigator.current is CarScreen.Notifications,
|
||||
{ navigator.dashboard() },
|
||||
) {
|
||||
if (unread > 0) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 8.dp, y = (-2).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
if (battery.percent >= 0) {
|
||||
Text(
|
||||
"${battery.percent}%",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (battery.charging) {
|
||||
ChargingGlyph(Modifier.size(13.dp))
|
||||
}
|
||||
}
|
||||
weatherData?.let { w ->
|
||||
Text(
|
||||
" · ${w.temp}°",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home: the map, with the player or lyrics beside it. While a map app runs, the button wears that
|
||||
* app's icon and a long press offers to close it, so the app needs no icon of its own on the bar.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running: Map<String, RunningSlot>) {
|
||||
val selected = navigator.current == CarScreen.Home
|
||||
val navId = running.entries.firstOrNull { it.value == RunningSlot.NAV }?.key
|
||||
val navEntry = navId?.let { id -> entries.find { it.componentId == id } }
|
||||
GsIconBox(
|
||||
{ navigator.home() },
|
||||
colors = navColors(selected),
|
||||
latched = selected,
|
||||
onLongClick = navId?.let { id -> { navigator.showAppMenu(id) } },
|
||||
) {
|
||||
val icon = navEntry?.icon
|
||||
if (icon != null) {
|
||||
Box(Modifier.padding(NAV_ITEM_PADDING).size(28.dp), contentAlignment = Alignment.Center) {
|
||||
Image(icon, navEntry.label, Modifier.size(28.dp).clip(RoundedCornerShape(7.dp)))
|
||||
}
|
||||
} else {
|
||||
Icon(
|
||||
ImageVector.vectorResource(R.drawable.navigation_24), contentDescription = stringResource(R.string.crumb_home),
|
||||
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Splits the nav bar's sections: Home and Apps, pinned apps, open apps. */
|
||||
@Composable
|
||||
private fun NavDivider() {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(1.dp)
|
||||
.height(28.dp)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinned or open app on the nav bar. Tapping brings it back without reconnecting if it's
|
||||
* already open; a long press opens its menu (pin, unpin, close).
|
||||
*/
|
||||
@Composable
|
||||
private fun NavEntryIcon(
|
||||
entry: Entry,
|
||||
running: Map<String, RunningSlot>,
|
||||
navigator: CarNavigator,
|
||||
frame: CarEnvironment.Frame,
|
||||
) {
|
||||
val id = entry.componentId ?: return
|
||||
val slot = running[id]
|
||||
Box(Modifier.padding(horizontal = 3.dp)) {
|
||||
NavAppIcon(
|
||||
entry,
|
||||
open = slot != null,
|
||||
showing = slot != null && slot.isShowing(navigator.current),
|
||||
onLongClick = { navigator.showAppMenu(id) },
|
||||
) { openApp(entry, slot, navigator, frame) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||
@Composable
|
||||
private fun NavAppIcon(
|
||||
entry: Entry,
|
||||
open: Boolean,
|
||||
showing: Boolean,
|
||||
onLongClick: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.size(44.dp),
|
||||
colors = if (showing) GsColors(scheme.secondaryContainer, scheme.onSecondaryContainer)
|
||||
else GsColors(scheme.surfaceVariant, scheme.onSurfaceVariant),
|
||||
latched = showing,
|
||||
onLongClick = onLongClick,
|
||||
) {
|
||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||
entry.builtIn?.let { Icon(it.glyph, entry.shownLabel(), Modifier.size(26.dp)) }
|
||||
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||
if (open) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 2.dp)
|
||||
.size(width = 12.dp, height = 3.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(scheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Box(Modifier.padding(horizontal = 10.dp, vertical = 2.dp)) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Every nav bar item is raised, so it reads as pressable; the current screen's takes the accent colour. */
|
||||
@Composable
|
||||
private fun navColors(selected: Boolean): GsColors {
|
||||
val s = MaterialTheme.colorScheme
|
||||
return if (selected) GsColors(s.secondaryContainer, s.onSecondaryContainer)
|
||||
else GsColors(s.surfaceContainerHighest, s.onSurface)
|
||||
}
|
||||
|
||||
/**
|
||||
* Between Apps and whatever's actually open, not with the clock/battery group on the right - a
|
||||
* driver reaches for it right after deciding "not a tap for this", same gesture family as the
|
||||
* two buttons beside it.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavAssistantIcon() {
|
||||
val state by CarAssistant.state.collectAsState()
|
||||
val active = state.phase != CarAssistant.Phase.IDLE
|
||||
GsIconBox({ CarAssistant.start() }, colors = navColors(active), latched = active) {
|
||||
MicGlyph(Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Inner padding of every icon on the nav bar, leaving each button room inside the 56dp bar. */
|
||||
private val NAV_ITEM_PADDING = 8.dp
|
||||
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
GsIconBox(onClick, colors = navColors(selected), latched = selected) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
||||
@Composable
|
||||
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
||||
while (true) {
|
||||
value = System.currentTimeMillis()
|
||||
delay(60_000 - value % 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the pill shows a new song's title before settling to just the controls. */
|
||||
private const val TITLE_SHOWN_MS = 3_000L
|
||||
|
||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
||||
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||
|
||||
/** The next manoeuvre, as the running nav app last described it, or [fromPhone] a map guiding on the phone. */
|
||||
private class NextTurn(val icon: CarIcon?, val distance: String, val road: String, val fromPhone: Boolean = false)
|
||||
|
||||
/**
|
||||
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||
* while it's running, whether or not the map is the screen being shown.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberNextTurn(): NextTurn? {
|
||||
val status by CarServices.nav.status.collectAsState()
|
||||
val template by CarServices.nav.template.collectAsState()
|
||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||
CarSettings.units.collectAsState().value
|
||||
val phone by PhoneNavigation.turn.collectAsState()
|
||||
val routing = ((template as? NavigationTemplate)?.navigationInfo as? RoutingInfo)
|
||||
?.takeIf { status.phase == CarAppConnection.Phase.RUNNING }
|
||||
val step = routing?.takeIf { !it.isLoading }?.currentStep ?: return phone?.let { phoneTurn(it) }
|
||||
return NextTurn(
|
||||
icon = step.maneuver?.icon,
|
||||
distance = routing.currentDistance.display(),
|
||||
road = step.road.text().ifEmpty { step.cue.text() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Google Maps' or Waze's own arrow, and the distance as the app wrote it. */
|
||||
@Composable
|
||||
private fun phoneTurn(turn: PhoneNavigation.Turn): NextTurn {
|
||||
val icon = remember(turn.arrow) {
|
||||
turn.arrow?.let { CarIcon.Builder(androidx.core.graphics.drawable.IconCompat.createWithBitmap(it)).build() }
|
||||
}
|
||||
return NextTurn(icon, turn.distance?.display() ?: turn.distanceText, turn.road, fromPhone = true)
|
||||
}
|
||||
|
||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||
@Composable
|
||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||
// Same finish as every other button on the bar, in the accent colour; the text runs a step
|
||||
// bigger than anything else there, since it's the one thing worth a glance mid-drive.
|
||||
GsIconBox(
|
||||
onClick,
|
||||
Modifier.widthIn(max = 340.dp).height(44.dp),
|
||||
colors = gsColors(GsTone.Primary),
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(NAV_ITEM_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
CarGlyph(turn.icon, Modifier.size(28.dp))
|
||||
if (turn.distance.isNotEmpty()) {
|
||||
Text(turn.distance, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
if (turn.road.isNotEmpty()) {
|
||||
Text(
|
||||
turn.road,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The call under way: who, how long, and Mute and End right here. Ringing has its own screen. */
|
||||
@Composable
|
||||
private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current.applicationContext
|
||||
val muted by CarCalls.muted.collectAsState()
|
||||
val elapsed by produceState(0L, call.since) {
|
||||
while (true) {
|
||||
value = (android.os.SystemClock.uptimeMillis() - call.since) / 1000
|
||||
delay(1000 - (android.os.SystemClock.uptimeMillis() - call.since) % 1000)
|
||||
}
|
||||
}
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Surface(
|
||||
modifier = modifier.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = scheme.secondaryContainer,
|
||||
contentColor = scheme.onSecondaryContainer,
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxHeight().padding(start = 12.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
call.caller?.label ?: stringResource(R.string.navbar_on_call),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Text(
|
||||
"%d:%02d".format(elapsed / 60, elapsed % 60),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
GsIconButton(
|
||||
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
|
||||
stringResource(if (muted) R.string.navbar_unmute else R.string.navbar_mute),
|
||||
CarCalls::toggleMute,
|
||||
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
GsIconButton(
|
||||
MediaIcons.CallEnd, stringResource(R.string.navbar_end_call), { CarCalls.end(context) },
|
||||
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Title - Artist" on one line. The text itself cuts it off, and only where the pill runs out of room. */
|
||||
@Composable
|
||||
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
||||
.ifEmpty { stringResource(R.string.media_nothing_playing) }
|
||||
|
||||
/**
|
||||
* The player in the nav bar, on every screen. Art and title open the full media screen;
|
||||
* play/pause and skip work right here. The dock button moves the player into the column beside
|
||||
* the map on Home (see [CarHome]), and the pill steps aside there while it's docked - unless the
|
||||
* column is showing lyrics instead, when the pill stays and its dock button closes them.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
// Docked beside the map, the player is already on screen - the pill steps aside for it.
|
||||
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
|
||||
// The media screen is the full player already; the pill would only repeat its controls.
|
||||
if (navigator.current == CarScreen.Media) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
val position by produceState(now.currentPosition(), now) {
|
||||
while (true) {
|
||||
value = now.currentPosition()
|
||||
if (!now.playing) break
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// A new song announces itself: title, play/pause and dock for a few seconds, then the title
|
||||
// goes and the pill settles to just the controls - unless the driver keeps the title up.
|
||||
val keepTitle by CarSettings.playerTitle.collectAsState()
|
||||
var announcing by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(now.title, now.artist) {
|
||||
announcing = true
|
||||
delay(TITLE_SHOWN_MS)
|
||||
announcing = false
|
||||
}
|
||||
val showTitle = keepTitle || announcing
|
||||
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val onPill = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.animateContentSize()
|
||||
// With the title up, all the room the bar has, so as much of it shows as can.
|
||||
.then(if (showTitle) Modifier.fillMaxWidth() else Modifier)
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
|
||||
contentColor = onPill,
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
Modifier.fillMaxHeight().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
// Art (and the title while it shows) opens the full media screen.
|
||||
Row(
|
||||
Modifier
|
||||
.then(if (showTitle) Modifier.weight(1f) else Modifier)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surfaceVariant)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
if (showTitle) {
|
||||
Text(
|
||||
nowPlayingLabel(now.title, now.artist),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Previous, stringResource(R.string.player_previous), media::previous,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
GsIconButton(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
stringResource(if (now.playing) R.string.player_pause else R.string.media_play),
|
||||
media::togglePlay,
|
||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
if (!showTitle) {
|
||||
GsIconButton(
|
||||
MediaIcons.Next, stringResource(R.string.player_next), media::next,
|
||||
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
// Drawn on the side the panel opens on, and filled in while it's open.
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val docked = navigator.current == CarScreen.Home && navigator.sidePanel != SidePanel.NONE
|
||||
GsIconButton(
|
||||
when {
|
||||
playerLeft && docked -> MediaIcons.DockLeftOpen
|
||||
playerLeft -> MediaIcons.DockLeft
|
||||
docked -> MediaIcons.DockRightOpen
|
||||
else -> MediaIcons.DockRight
|
||||
},
|
||||
stringResource(if (docked) R.string.navbar_close_panel else R.string.navbar_dock_player),
|
||||
navigator::togglePlayerDock,
|
||||
size = 36.dp, iconSize = 22.dp,
|
||||
)
|
||||
}
|
||||
if (now.durationMs > 0) {
|
||||
// matchParentSize, so the line follows the pill's width rather than widening it.
|
||||
Box(Modifier.matchParentSize(), contentAlignment = Alignment.BottomStart) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth((position.toFloat() / now.durationMs).coerceIn(0f, 1f))
|
||||
.height(2.dp)
|
||||
.background(onPill.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||
* bolt, so this is drawn rather than a vector asset.
|
||||
*/
|
||||
@Composable
|
||||
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||
androidx.compose.foundation.Canvas(modifier) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||
moveTo(w * 0.58f, 0f)
|
||||
lineTo(w * 0.12f, h * 0.58f)
|
||||
lineTo(w * 0.46f, h * 0.58f)
|
||||
lineTo(w * 0.42f, h)
|
||||
lineTo(w * 0.88f, h * 0.40f)
|
||||
lineTo(w * 0.54f, h * 0.40f)
|
||||
close()
|
||||
}
|
||||
drawPath(bolt, tint)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
@@ -16,66 +21,144 @@ sealed interface CarScreen {
|
||||
/** Opened from the weather chip or the dashboard: now, the next hours and the week. */
|
||||
data object Weather : CarScreen
|
||||
|
||||
/** Everything the head unit's sensor channel has advertised and sent, raw. */
|
||||
data object VehicleData : CarScreen
|
||||
|
||||
/** The current media app, full size: browse, lyrics and controls. */
|
||||
data object Media : CarScreen
|
||||
|
||||
/**
|
||||
* A templated app that isn't a navigation app - Spotify's own Car App Library service, among
|
||||
* others - opened on its own screen rather than the Home map slot, which only a genuine
|
||||
* navigation app can claim.
|
||||
*/
|
||||
data object Browse : CarScreen
|
||||
|
||||
/** A messaging app's recent conversations, read from the notifications it posts. */
|
||||
data class Messages(val packageName: String) : CarScreen
|
||||
|
||||
/** [replyTo] is a notification key to open straight into its reply pane. */
|
||||
data class Notifications(val replyTo: String? = null) : CarScreen
|
||||
data class App(val id: String) : CarScreen
|
||||
}
|
||||
|
||||
enum class SidePanel { NONE, CONTROLS, LYRICS }
|
||||
|
||||
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
|
||||
data class Crumb(val screen: CarScreen, val label: String, @param:StringRes val labelRes: Int = 0) {
|
||||
/** [labelRes] when the step is one of Gearslip's own, translated; [label] for an app's own name. */
|
||||
@Composable
|
||||
fun shown(): String = if (labelRes != 0) stringResource(labelRes) else label
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
|
||||
* the on-screen one; an open app can claim it first (a browser going back a page) through
|
||||
* [backInterceptor].
|
||||
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
|
||||
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
|
||||
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
|
||||
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
|
||||
* can claim a back step first (a browser going back a page) through [backInterceptor].
|
||||
*/
|
||||
class CarNavigator(startId: String) {
|
||||
|
||||
var current: CarScreen by mutableStateOf(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
private val stack = mutableStateListOf(
|
||||
Crumb(
|
||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||
"Home",
|
||||
R.string.crumb_home,
|
||||
),
|
||||
)
|
||||
private set
|
||||
|
||||
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
|
||||
val trail: List<Crumb> get() = stack
|
||||
|
||||
val current: CarScreen get() = stack.last().screen
|
||||
|
||||
var backInterceptor: (() -> Boolean)? = null
|
||||
|
||||
fun home() { current = CarScreen.Home }
|
||||
|
||||
/**
|
||||
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
|
||||
* Opening the media screen from the bar restores it, so leaving that screen lands on the
|
||||
* home screen with the player back in place.
|
||||
*/
|
||||
var playerMinimised by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/** Set by the player's Lyrics button; the media screen reads it once when it opens. */
|
||||
var lyricsRequested = false
|
||||
|
||||
fun minimisePlayer() { playerMinimised = true }
|
||||
|
||||
fun restorePlayer() { playerMinimised = false }
|
||||
|
||||
fun media(lyrics: Boolean = false) {
|
||||
lyricsRequested = lyrics
|
||||
playerMinimised = false
|
||||
current = CarScreen.Media
|
||||
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
|
||||
* where you already are shouldn't grow the trail. */
|
||||
private fun push(screen: CarScreen, label: String, @StringRes labelRes: Int = 0) {
|
||||
if (stack.last().screen == screen) return
|
||||
stack.add(Crumb(screen, label, labelRes))
|
||||
}
|
||||
|
||||
fun apps() { current = CarScreen.Apps }
|
||||
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||
fun home() {
|
||||
stack.clear()
|
||||
stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home))
|
||||
}
|
||||
|
||||
fun settings() { current = CarScreen.Settings }
|
||||
/**
|
||||
* What the column beside the map on Home is showing, if anything. The player lives in one
|
||||
* place at a time: with [SidePanel.CONTROLS] it moves into the column and the nav bar's pill
|
||||
* steps aside; with [SidePanel.LYRICS] the column is all lyrics and the pill comes back.
|
||||
* Every other screen has no room for the column, so there the pill stays in the nav bar.
|
||||
*/
|
||||
var sidePanel by mutableStateOf(SidePanel.NONE)
|
||||
private set
|
||||
|
||||
fun dashboard() { current = CarScreen.Dashboard }
|
||||
/**
|
||||
* The pill's dock button, an open/close switch for the panel: moves the player beside the map,
|
||||
* going Home first if need be, or closes the panel when something is already there. Switching
|
||||
* between the player and lyrics is done inside the panel itself.
|
||||
*/
|
||||
fun togglePlayerDock() {
|
||||
if (current != CarScreen.Home) {
|
||||
sidePanel = SidePanel.CONTROLS
|
||||
home()
|
||||
return
|
||||
}
|
||||
sidePanel = if (sidePanel == SidePanel.NONE) SidePanel.CONTROLS else SidePanel.NONE
|
||||
}
|
||||
|
||||
fun weather() { current = CarScreen.Weather }
|
||||
fun showSidePlayer() { sidePanel = SidePanel.CONTROLS }
|
||||
|
||||
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
|
||||
fun showSideLyrics() { sidePanel = SidePanel.LYRICS }
|
||||
|
||||
fun open(id: String) { current = CarScreen.App(id) }
|
||||
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
||||
|
||||
/** Component id of the app whose long-press menu (pin, close) is open, or null. */
|
||||
var appMenu by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
fun showAppMenu(componentId: String) { appMenu = componentId }
|
||||
|
||||
fun dismissAppMenu() { appMenu = null }
|
||||
|
||||
fun media() { push(CarScreen.Media, "Media", R.string.crumb_media) }
|
||||
|
||||
fun browse(label: String) { push(CarScreen.Browse, label) }
|
||||
|
||||
fun apps() { push(CarScreen.Apps, "Apps", R.string.crumb_apps) }
|
||||
|
||||
fun settings() { push(CarScreen.Settings, "Settings", R.string.settings_title) }
|
||||
|
||||
fun dashboard() { push(CarScreen.Dashboard, "Dashboard", R.string.crumb_dashboard) }
|
||||
|
||||
fun weather() { push(CarScreen.Weather, "Weather", R.string.crumb_weather) }
|
||||
|
||||
fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
|
||||
|
||||
fun messages(packageName: String, label: String) { push(CarScreen.Messages(packageName), label) }
|
||||
|
||||
/** The list lives on the dashboard; only a reply gets a screen of its own. */
|
||||
fun notifications(replyTo: String? = null) {
|
||||
if (replyTo == null) dashboard() else push(CarScreen.Notifications(replyTo), "Reply", R.string.crumb_reply)
|
||||
}
|
||||
|
||||
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id, CarApps.find(id)?.labelRes ?: 0) }
|
||||
|
||||
/** Jumps straight to a step already on the trail, dropping everything after it - what
|
||||
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
|
||||
fun jumpTo(index: Int) {
|
||||
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
|
||||
/** One step back, same as tapping the crumb before the current one. */
|
||||
fun back() {
|
||||
if (backInterceptor?.invoke() == true) return
|
||||
current = CarScreen.Home
|
||||
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.Wire
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Raw sensor readings, keyed by SensorBatch field number, which matches the sensor type id. */
|
||||
object CarSensors {
|
||||
|
||||
data class Reading(val typeId: Int, val typeName: String, val raw: String, val updatedAtMs: Long)
|
||||
|
||||
private val readingsFlow = MutableStateFlow<List<Reading>>(emptyList())
|
||||
val readings: StateFlow<List<Reading>> = readingsFlow
|
||||
|
||||
/** Seeds one row per sensor type the head unit advertised, so the screen shows what's on
|
||||
* offer even before the first event for it arrives. */
|
||||
fun onDiscovered(types: List<Int>) {
|
||||
readingsFlow.value = types.map { id ->
|
||||
Reading(id, SensorType.name(id), "(waiting for data)", 0L)
|
||||
}
|
||||
}
|
||||
|
||||
/** Each SensorBatch field contains repeated readings of one sensor type. */
|
||||
fun onEvent(body: ByteArray) {
|
||||
val now = System.currentTimeMillis()
|
||||
val fields = Wire.fields(body).filter { it.wireType == 2 }
|
||||
val byType = fields.groupBy { it.number }
|
||||
val updated = byType.mapValues { (number, group) ->
|
||||
Reading(
|
||||
number,
|
||||
SensorType.name(number),
|
||||
group.joinToString("; ") { field ->
|
||||
field.bytes?.let { Protobuf.describe(it, indent = "").trim().replace("\n", ", ") }
|
||||
?: "varint=${field.varint}"
|
||||
},
|
||||
now,
|
||||
)
|
||||
}
|
||||
val existing = readingsFlow.value.associateBy { it.typeId }
|
||||
readingsFlow.value = (existing + updated).values.sortedBy { it.typeId }
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
readingsFlow.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sensor type values shared by the discovery service and SensorBatch. */
|
||||
object SensorType {
|
||||
private val NAMES = mapOf(
|
||||
1 to "Location (GPS)",
|
||||
2 to "Compass",
|
||||
3 to "Speed",
|
||||
4 to "RPM",
|
||||
5 to "Odometer",
|
||||
6 to "Fuel",
|
||||
7 to "Parking brake",
|
||||
8 to "Gear",
|
||||
9 to "Diagnostics",
|
||||
10 to "Night mode",
|
||||
11 to "Environment",
|
||||
12 to "HVAC",
|
||||
13 to "Driving status",
|
||||
14 to "Dead reckoned location",
|
||||
15 to "Passenger",
|
||||
16 to "Door",
|
||||
17 to "Light",
|
||||
18 to "Tire pressure",
|
||||
19 to "Accelerometer",
|
||||
20 to "Gyroscope",
|
||||
21 to "GPS satellites",
|
||||
22 to "Toll card",
|
||||
23 to "Vehicle energy model",
|
||||
24 to "Trailer",
|
||||
25 to "Raw vehicle energy model",
|
||||
26 to "Raw EV trip settings",
|
||||
)
|
||||
|
||||
fun name(id: Int): String = NAMES[id]?.let { "$it (#$id)" } ?: "Sensor #$id"
|
||||
}
|
||||
@@ -2,13 +2,18 @@ package app.seb3thehacker.gearslip.car
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.media.session.MediaController
|
||||
import android.media.session.MediaSessionManager
|
||||
import android.media.session.PlaybackState
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.KnownApps
|
||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaApp
|
||||
import app.seb3thehacker.gearslip.media.MediaCatalog
|
||||
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -26,34 +31,110 @@ object CarServices {
|
||||
|
||||
private var context: Context? = null
|
||||
|
||||
/** Buffering counts as playing: an app between songs is about to make sound again. */
|
||||
private val PLAYING_STATES = setOf(PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING)
|
||||
/** How long after pausing for the car leaving to look again for an app that started by itself. */
|
||||
private const val REPAUSE_MS = 2_000L
|
||||
|
||||
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
|
||||
|
||||
/** [nav], or null before the car screen has set things up. */
|
||||
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
|
||||
|
||||
/** [media], or null before the car screen has set things up: for code outside the car UI. */
|
||||
fun mediaOrNull(): CarMedia? = if (context != null) media else null
|
||||
|
||||
/**
|
||||
* A second, independent connection for templated apps that aren't navigation apps - Spotify's
|
||||
* own Car App Library service is the first of these. Kept apart from [nav] so opening one
|
||||
* can never bump the map off Home: this one gets its own screen ([CarScreen.Browse]) instead.
|
||||
*/
|
||||
val browse: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||
|
||||
private val _mediaApp = MutableStateFlow<MediaApp?>(null)
|
||||
val mediaApp: StateFlow<MediaApp?> = _mediaApp.asStateFlow()
|
||||
|
||||
/**
|
||||
* Whether the phone has any navigation app installed at all. True until [autostart] has
|
||||
* actually checked, so the home screen never flashes "none found" before it knows.
|
||||
*/
|
||||
private val _hasNavApps = MutableStateFlow(true)
|
||||
val hasNavApps: StateFlow<Boolean> = _hasNavApps.asStateFlow()
|
||||
|
||||
/** Set when the driver stops the map by hand, so going Home doesn't immediately restart it. */
|
||||
private var navStopped = false
|
||||
private var autoplayPending = false
|
||||
|
||||
/**
|
||||
* The app that was playing when the driver opened another one. Opening an app is only a look
|
||||
* at its library: until something plays there, the music carries on in this one, so leaving
|
||||
* the new app's screen goes back to it, as Android Auto does.
|
||||
*/
|
||||
private var stillPlaying: MediaApp? = null
|
||||
|
||||
fun init(appContext: Context) {
|
||||
context = appContext.applicationContext
|
||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
|
||||
CarAssistant.init(appContext)
|
||||
DirectionsVoice.init(appContext)
|
||||
CarBluetooth.init(appContext)
|
||||
BluetoothAudioCheck.init(appContext)
|
||||
LocationCheck.init(appContext)
|
||||
CarKeys.init(appContext)
|
||||
VoiceReply.init(appContext)
|
||||
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
|
||||
app.seb3thehacker.gearslip.speech.SpeechEngine.warm(appContext)
|
||||
}
|
||||
|
||||
// --- map -----------------------------------------------------------------------------
|
||||
|
||||
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame) {
|
||||
/**
|
||||
* [destination] is a search query to start navigating to right away - "navigate to X with Y"
|
||||
* from the voice assistant - left null for a plain app launch. An app that's already the one
|
||||
* running gets the destination sent to its live session instead of being torn down and
|
||||
* rebuilt, same as [CarAppConnection.navigateTo] documents.
|
||||
*/
|
||||
fun connectNav(app: TemplateApp, frame: CarEnvironment.Frame, destination: String? = null) {
|
||||
navStopped = false
|
||||
CarSettings.setLastNav(app.component.flattenToString())
|
||||
nav.connect(app, frame.width, frame.height, frame.densityDpi)
|
||||
if (destination != null && nav.connectedComponent == app.component) {
|
||||
nav.navigateTo(destination)
|
||||
} else {
|
||||
// Another map app taking over ends the old one's route, or it keeps guiding unseen.
|
||||
if (nav.connectedComponent != app.component) endRoute()
|
||||
nav.connect(app, frame.width, frame.height, frame.densityDpi, destination)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopNav() {
|
||||
navStopped = true
|
||||
endRoute()
|
||||
nav.disconnect()
|
||||
}
|
||||
|
||||
private fun endRoute() {
|
||||
if (nav.navigating) nav.stopNavigating()
|
||||
}
|
||||
|
||||
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
|
||||
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
|
||||
navStopped = false
|
||||
autostart(frame)
|
||||
}
|
||||
|
||||
// --- browse (non-navigation templated apps) -------------------------------------------
|
||||
|
||||
/** Opens a templated app that isn't navigation, on its own screen - never on Home. */
|
||||
fun connectBrowse(app: TemplateApp, frame: CarEnvironment.Frame) {
|
||||
browse.connect(app, frame.width, frame.height, frame.densityDpi)
|
||||
}
|
||||
|
||||
fun stopBrowse() {
|
||||
browse.disconnect()
|
||||
}
|
||||
|
||||
// --- media ---------------------------------------------------------------------------
|
||||
|
||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||
@@ -62,6 +143,8 @@ object CarServices {
|
||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||
if (_mediaApp.value?.component == app.component && live) return
|
||||
|
||||
val previous = _mediaApp.value
|
||||
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
|
||||
_mediaApp.value = app
|
||||
autoplayPending = autoplay
|
||||
media.connect(app)
|
||||
@@ -70,6 +153,41 @@ object CarServices {
|
||||
if (CarSettings.pipeAudio.value) CarAudio.request()
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopens the media app used last, for the car's media button when nothing is open. False
|
||||
* when there's none on record or it's been uninstalled since.
|
||||
*/
|
||||
suspend fun reopenLastMedia(): Boolean {
|
||||
val ctx = context ?: return false
|
||||
val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false
|
||||
val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
|
||||
.firstOrNull { it.component == last } ?: return false
|
||||
openMedia(app)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The media screen closed. If nothing started in the app it showed, go back to the one that
|
||||
* kept playing meanwhile, so the player and its buttons follow the music you can hear.
|
||||
*/
|
||||
fun mediaScreenClosed() {
|
||||
val back = stillPlaying ?: return
|
||||
stillPlaying = null
|
||||
// A track loaded here, even one paused since, means the driver used this app.
|
||||
if (media.now.value.playing || media.now.value.title.isNotEmpty()) return
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: nothing played in ${_mediaApp.value?.label}, back to ${back.label}")
|
||||
openMedia(back)
|
||||
}
|
||||
|
||||
/** Closes the media app from the car: stops what it's playing and drops the connection. */
|
||||
fun closeMedia() {
|
||||
if (media.now.value.playing) media.togglePlay()
|
||||
media.disconnect()
|
||||
_mediaApp.value = null
|
||||
autoplayPending = false
|
||||
stillPlaying = null
|
||||
}
|
||||
|
||||
/** Called once the media app is connected: starts playback if that was asked for. */
|
||||
fun autoplayIfDue() {
|
||||
if (!autoplayPending) return
|
||||
@@ -84,29 +202,144 @@ object CarServices {
|
||||
val ctx = context ?: return
|
||||
|
||||
if (!navStopped && nav.status.value.phase == CarAppConnection.Phase.IDLE) {
|
||||
val navApps = withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
|
||||
.filter { it.isNavigation }
|
||||
_hasNavApps.value = navApps.isNotEmpty()
|
||||
// Never auto-pick one already known to fail outright - a driver who plugs in should
|
||||
// land on a map, not on the one tile marked with a red X.
|
||||
val candidates = navApps.filterNot { KnownApps.isBroken(ctx, it.component.packageName) }
|
||||
val last = CarSettings.lastNav.value
|
||||
val app = last?.let { id ->
|
||||
withContext(Dispatchers.IO) { CarAppCatalog.installed(ctx) }
|
||||
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
|
||||
}
|
||||
// The one used last, or - first run, nothing on record yet - whichever comes first:
|
||||
// a driver who plugs in for the first time shouldn't have to go pick one by hand
|
||||
// before the map shows up at all.
|
||||
val lastComponent = last?.let(ComponentName::unflattenFromString)
|
||||
val app = candidates.firstOrNull { it.component == lastComponent } ?: candidates.firstOrNull()
|
||||
if (app != null) connectNav(app, frame)
|
||||
}
|
||||
|
||||
if (_mediaApp.value == null) {
|
||||
val last = CarSettings.lastMedia.value
|
||||
val app = last?.let { id ->
|
||||
withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
|
||||
.firstOrNull { it.component == ComponentName.unflattenFromString(id) }
|
||||
val catalog = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
|
||||
// Something already playing on the phone wins over whatever was last used in the car -
|
||||
// a driver who started a song before plugging in shouldn't have to go open its tile by
|
||||
// hand just to see what's already going.
|
||||
val playing = withContext(Dispatchers.IO) { currentlyPlaying(ctx, catalog) }
|
||||
if (playing != null) {
|
||||
openMedia(playing)
|
||||
} else {
|
||||
val last = CarSettings.lastMedia.value
|
||||
val app = last?.let { id -> catalog.firstOrNull { it.component == ComponentName.unflattenFromString(id) } }
|
||||
if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value)
|
||||
}
|
||||
if (app != null) openMedia(app, autoplay = CarSettings.autoplay.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whichever known car media app has an actively playing session right now, system-wide - not
|
||||
* just the one Gearslip itself last connected to. Reading another app's session needs
|
||||
* notification access, the same permission [app.seb3thehacker.gearslip.media.CarMedia]
|
||||
* already asks for to control apps that won't share their library.
|
||||
*/
|
||||
private fun currentlyPlaying(ctx: Context, catalog: List<MediaApp>): MediaApp? {
|
||||
val manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||
val sessions = try {
|
||||
manager.getActiveSessions(listener)
|
||||
} catch (e: SecurityException) {
|
||||
return null
|
||||
}
|
||||
val playingPackages = sessions
|
||||
.filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
|
||||
.map { it.packageName }
|
||||
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
|
||||
}
|
||||
|
||||
/**
|
||||
* The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of
|
||||
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
|
||||
* headphones are, so the apps won't pause themselves. Every playing session is paused, not
|
||||
* just the one Gearslip opened: anything the car was playing was going through the car.
|
||||
* An app can start again on its own once the speaker is given back a moment later, which a
|
||||
* Deezer driver heard after turning the car off (issue #39), so the ones it paused are
|
||||
* looked at once more and paused again if they're playing.
|
||||
*/
|
||||
fun pausePhoneMedia(why: String = "the car left") {
|
||||
pausedForNow = null
|
||||
pausedOwnForNow = false
|
||||
val paused = pause(why)
|
||||
if (paused.isEmpty()) return
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
|
||||
val again = paused.filter { it.playbackState?.state in PLAYING_STATES }
|
||||
again.forEach { runCatching { it.transportControls.pause() } }
|
||||
if (again.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${again.joinToString { it.packageName }} again: it started by itself after $why")
|
||||
}
|
||||
}, REPAUSE_MS)
|
||||
}
|
||||
|
||||
/** What [pausePhoneMediaForNow] paused, for [resumePhoneMedia] to play again. */
|
||||
@Volatile private var pausedForNow: List<MediaController>? = null
|
||||
/** Without notification access, whether it paused the app Gearslip drives itself. */
|
||||
@Volatile private var pausedOwnForNow = false
|
||||
|
||||
/** The car wants the speakers for a moment: pause, and remember what to play after. */
|
||||
fun pausePhoneMediaForNow() {
|
||||
pausedOwnForNow = false
|
||||
pausedForNow = pause("the car needs the speakers for a moment")
|
||||
}
|
||||
|
||||
/** The car handed the speakers back: play what it paused, unless the driver has since. */
|
||||
fun resumePhoneMedia() {
|
||||
val paused = pausedForNow ?: return
|
||||
pausedForNow = null
|
||||
if (pausedOwnForNow) {
|
||||
pausedOwnForNow = false
|
||||
mediaOrNull()?.let { if (!it.now.value.playing) it.togglePlay() }
|
||||
return
|
||||
}
|
||||
val waiting = paused.filter { it.playbackState?.state == PlaybackState.STATE_PAUSED }
|
||||
waiting.forEach { runCatching { it.transportControls.play() } }
|
||||
if (waiting.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: playing ${waiting.joinToString { it.packageName }} again: the car gave the speakers back")
|
||||
}
|
||||
}
|
||||
|
||||
/** What it paused. Without notification access it can only reach the app Gearslip drives itself. */
|
||||
private fun pause(why: String): List<MediaController> {
|
||||
val ctx = context ?: return emptyList()
|
||||
val manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||
val playing = try {
|
||||
manager.getActiveSessions(listener).filter { it.playbackState?.state in PLAYING_STATES }
|
||||
} catch (e: SecurityException) {
|
||||
null
|
||||
}
|
||||
if (playing == null) {
|
||||
// No notification access: the app Gearslip itself is driving is all it can reach.
|
||||
mediaOrNull()?.let {
|
||||
if (it.now.value.playing) {
|
||||
it.togglePlay()
|
||||
pausedOwnForNow = true
|
||||
}
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
playing.forEach { runCatching { it.transportControls.pause() } }
|
||||
if (playing.isNotEmpty()) {
|
||||
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why")
|
||||
}
|
||||
return playing
|
||||
}
|
||||
|
||||
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||
fun shutdown() {
|
||||
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
|
||||
app.seb3thehacker.gearslip.call.CarCalls.stop()
|
||||
runCatching { nav.disconnect() }
|
||||
runCatching { media.disconnect() }
|
||||
runCatching { browse.disconnect() }
|
||||
CarAssistant.cancel()
|
||||
// About 900 MB that nothing needs once the car's gone.
|
||||
app.seb3thehacker.gearslip.speech.SpeechEngine.unload()
|
||||
CarAudio.release()
|
||||
_mediaApp.value = null
|
||||
navStopped = false
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import android.content.Context
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTheme
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
enum class NightMode { AUTO, DAY, NIGHT }
|
||||
|
||||
/** Distances and temperatures. [AUTO] goes by the phone's region. */
|
||||
enum class Units { AUTO, IMPERIAL, METRIC }
|
||||
|
||||
/** Where a new message shows: [NAV_BAR] turns the clock button into it, [CARD] floats a card top right. */
|
||||
|
||||
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
|
||||
enum class AppTheme { PHONE, LIGHT, DARK }
|
||||
|
||||
/** A screen's width and height in pixels, as someone typed it: "1280x480". */
|
||||
data class ScreenSize(val width: Int, val height: Int) {
|
||||
override fun toString() = "${width}x$height"
|
||||
|
||||
companion object {
|
||||
fun parse(text: String?): ScreenSize? {
|
||||
val parts = text?.lowercase()?.split('x', '×', ' ')?.filter { it.isNotBlank() } ?: return null
|
||||
if (parts.size != 2) return null
|
||||
val w = parts[0].trim().toIntOrNull() ?: return null
|
||||
val h = parts[1].trim().toIntOrNull() ?: return null
|
||||
return if (w in 100..8000 && h in 100..8000) ScreenSize(w, h) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a head unit puts our frame on a screen of another shape. [CROP] cuts the edges off, as
|
||||
* a car's own margins do; [SQUASH] shrinks the whole frame to fit, stretching everything.
|
||||
*/
|
||||
enum class ScreenFit { CROP, SQUASH }
|
||||
|
||||
/** What size the phone's car preview draws: the last car's, this device's whole screen, or one typed in. */
|
||||
enum class PreviewSize { LAST_CAR, FILL, CUSTOM }
|
||||
|
||||
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
|
||||
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
|
||||
|
||||
/**
|
||||
* Settings changed from the car screen itself. Held as flows so the car UI reacts the moment a
|
||||
* button is tapped, and persisted through [AppSettings] so they survive the next connection.
|
||||
@@ -17,13 +53,46 @@ enum class AppTheme { PHONE, LIGHT, DARK }
|
||||
object CarSettings {
|
||||
|
||||
private const val KEY_SCALE = "car_scale"
|
||||
private const val KEY_DENSITY = "car_density"
|
||||
private const val KEY_OPEN = "car_open_on_connect"
|
||||
private const val KEY_NIGHT = "car_night_mode"
|
||||
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
||||
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
||||
private const val KEY_DIRECTIONS_OVER_MUSIC = "car_directions_over_music"
|
||||
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
|
||||
private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
|
||||
private const val KEY_LAST_NAV = "car_last_nav"
|
||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||
private const val KEY_MORE_LYRICS = "car_more_lyrics"
|
||||
private const val KEY_THEME = "car_app_theme"
|
||||
private const val KEY_UI_THEME = "car_ui_theme"
|
||||
private const val KEY_DARK_STYLE = "car_dark_style"
|
||||
private const val KEY_PINNED = "car_pinned_apps"
|
||||
private const val KEY_APP_ORDER = "car_app_order"
|
||||
private const val KEY_HIDDEN_APPS = "car_hidden_apps"
|
||||
private const val KEY_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_enabled"
|
||||
private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
||||
private const val KEY_DEBUG_BADGE = "debug_badge"
|
||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||
private const val KEY_UNITS = "car_units"
|
||||
private const val KEY_NAV_BAR = "car_nav_bar"
|
||||
private const val KEY_SCREEN_SIZE = "car_screen_size"
|
||||
private const val KEY_SCREEN_FIT = "car_screen_fit"
|
||||
private const val KEY_PREVIEW_SIZE = "preview_size"
|
||||
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
|
||||
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
|
||||
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
|
||||
|
||||
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
|
||||
* still be on the bar, not off the edge of a long list. */
|
||||
const val MAX_PINNED = 3
|
||||
|
||||
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
||||
const val HOME = "home"
|
||||
@@ -31,14 +100,132 @@ object CarSettings {
|
||||
private lateinit var app: Context
|
||||
|
||||
private val scaleFlow = MutableStateFlow(1f)
|
||||
private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR)
|
||||
private val openFlow = MutableStateFlow(HOME)
|
||||
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
||||
private val pipeAudioFlow = MutableStateFlow(true)
|
||||
private val speakDirectionsFlow = MutableStateFlow(true)
|
||||
private val directionsOverMusicFlow = MutableStateFlow(false)
|
||||
private val bluetoothWarningFlow = MutableStateFlow(true)
|
||||
private val locationWarningFlow = MutableStateFlow(true)
|
||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||
private val autoplayFlow = MutableStateFlow(false)
|
||||
private val playerTitleFlow = MutableStateFlow(false)
|
||||
private val playerLeftFlow = MutableStateFlow(false)
|
||||
private val moreLyricsFlow = MutableStateFlow(true)
|
||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||
private val darkStyleFlow = MutableStateFlow(DarkStyle.BLACK)
|
||||
|
||||
/** How dark the car's dark mode goes; black until the driver picks another. */
|
||||
val darkStyle: StateFlow<DarkStyle> = darkStyleFlow
|
||||
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||
|
||||
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||
val uiTheme: StateFlow<String> = uiThemeFlow
|
||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||
private val hiddenAppsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||
private val systemSpeechFlow = MutableStateFlow(false)
|
||||
private val carSensorsFlow = MutableStateFlow(false)
|
||||
private val adaptiveVideoFlow = MutableStateFlow(false)
|
||||
private val clusterVideoFlow = MutableStateFlow(false)
|
||||
private val debugBadgeFlow = MutableStateFlow(true)
|
||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||
|
||||
val units: StateFlow<Units> = unitsFlow
|
||||
|
||||
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
|
||||
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
|
||||
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
|
||||
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
|
||||
|
||||
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
|
||||
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
|
||||
val screenFit: StateFlow<ScreenFit> = screenFitFlow
|
||||
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
|
||||
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
|
||||
|
||||
/** Takes effect the next time the car connects: the video size is agreed at the start. */
|
||||
fun setScreenSize(size: ScreenSize?) {
|
||||
screenSizeFlow.value = size
|
||||
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
|
||||
}
|
||||
|
||||
fun setScreenFit(fit: ScreenFit) {
|
||||
screenFitFlow.value = fit
|
||||
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
|
||||
}
|
||||
|
||||
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
|
||||
previewSizeFlow.value = size
|
||||
previewCustomFlow.value = custom
|
||||
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
|
||||
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
|
||||
}
|
||||
|
||||
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
|
||||
val navBarMode: StateFlow<NavBarMode> = navBarFlow
|
||||
|
||||
fun setNavBarMode(mode: NavBarMode) {
|
||||
navBarFlow.value = mode
|
||||
AppSettings.putString(app, KEY_NAV_BAR, mode.name)
|
||||
}
|
||||
|
||||
|
||||
private val whatsNewFlow = MutableStateFlow(true)
|
||||
private val whatsNewSeenFlow = MutableStateFlow(0)
|
||||
private val whatsNewOpenFlow = MutableStateFlow(false)
|
||||
|
||||
/** Whether the car screen shows what's new once after each update. */
|
||||
val whatsNewAfterUpdates: StateFlow<Boolean> = whatsNewFlow
|
||||
|
||||
/** Version code whose notes the car screen last showed. */
|
||||
val whatsNewSeen: StateFlow<Int> = whatsNewSeenFlow
|
||||
|
||||
/** Opened by hand from Settings, whatever the two above say. Not saved. */
|
||||
val whatsNewOpen: StateFlow<Boolean> = whatsNewOpenFlow
|
||||
|
||||
fun setWhatsNewAfterUpdates(on: Boolean) {
|
||||
whatsNewFlow.value = on
|
||||
AppSettings.putString(app, KEY_WHATS_NEW, on.toString())
|
||||
}
|
||||
|
||||
fun openWhatsNew() { whatsNewOpenFlow.value = true }
|
||||
|
||||
/** Closes the notes and marks [versionCode]'s as read. */
|
||||
fun closeWhatsNew(versionCode: Int) {
|
||||
whatsNewOpenFlow.value = false
|
||||
whatsNewSeenFlow.value = versionCode
|
||||
AppSettings.putString(app, KEY_WHATS_NEW_SEEN, versionCode.toString())
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Whether to show miles, feet and °F; [Units.AUTO] follows the countries that still use them. */
|
||||
fun imperial(): Boolean = when (unitsFlow.value) {
|
||||
Units.IMPERIAL -> true
|
||||
Units.METRIC -> false
|
||||
Units.AUTO -> java.util.Locale.getDefault().country in setOf("US", "LR", "MM")
|
||||
}
|
||||
|
||||
fun setUnits(units: Units) {
|
||||
unitsFlow.value = units
|
||||
AppSettings.putString(app, KEY_UNITS, units.name)
|
||||
Weather.refresh(app, force = true)
|
||||
}
|
||||
|
||||
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||
|
||||
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
|
||||
val appOrder: StateFlow<List<String>> = appOrderFlow
|
||||
|
||||
/** The tiles left out of the car's app drawer, by [Entry.componentId]. */
|
||||
val hiddenApps: StateFlow<Set<String>> = hiddenAppsFlow
|
||||
|
||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||
val lastNav: StateFlow<String?> = lastNavFlow
|
||||
@@ -47,7 +234,29 @@ object CarSettings {
|
||||
/** Whether the media app starts playing by itself when the car connects. */
|
||||
val autoplay: StateFlow<Boolean> = autoplayFlow
|
||||
|
||||
/**
|
||||
* The nav bar's player keeps the song title up, with play/pause and the dock button, instead of
|
||||
* settling to the skip buttons once a new song has announced itself.
|
||||
*/
|
||||
val playerTitle: StateFlow<Boolean> = playerTitleFlow
|
||||
|
||||
/** The home screen's player sits left of the map instead of right of it. */
|
||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||
|
||||
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||
|
||||
val scale: StateFlow<Float> = scaleFlow
|
||||
|
||||
/** [carDensity] when it leaves the choice to the car. */
|
||||
const val DENSITY_FROM_CAR = 0
|
||||
val DENSITIES = listOf(160, 180, 200, 220, 240)
|
||||
|
||||
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
|
||||
val carDensity: StateFlow<Int> = carDensityFlow
|
||||
|
||||
/** The density to draw at, for a car or preview that would otherwise use [carDpi]. */
|
||||
fun effectiveDensity(carDpi: Int) = carDensityFlow.value.takeIf { it != DENSITY_FROM_CAR } ?: carDpi
|
||||
val openOnConnect: StateFlow<String> = openFlow
|
||||
val nightMode: StateFlow<NightMode> = nightFlow
|
||||
|
||||
@@ -55,24 +264,144 @@ object CarSettings {
|
||||
* Whether a media app's sound is captured and sent over the car link. On by default, since
|
||||
* sound in the car is the point of the exercise; the platform allows the capture only behind a
|
||||
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
|
||||
* somewhere to play it. Turned off, the app plays through the phone's normal output instead.
|
||||
* somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the
|
||||
* phone's normal output instead, and Android never asks.
|
||||
*/
|
||||
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
||||
|
||||
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
||||
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
|
||||
|
||||
/** The turns go out on the music channel instead, for cars the directions channel upsets. */
|
||||
val directionsOverMusic: StateFlow<Boolean> = directionsOverMusicFlow
|
||||
|
||||
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
|
||||
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
|
||||
|
||||
/** Warn on the car when the phone's location is off; see LocationCheck. */
|
||||
val locationWarning: StateFlow<Boolean> = locationWarningFlow
|
||||
|
||||
/**
|
||||
* Whether the driver has already been offered Bluetooth after turning down the capture
|
||||
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
|
||||
*/
|
||||
/** False until the driver picks USB or Bluetooth, in setup or in settings. */
|
||||
val pipeAudioChosen: Boolean
|
||||
get() = AppSettings.getString(app, KEY_PIPE_AUDIO, "") != ""
|
||||
|
||||
val audioChoiceAsked: Boolean
|
||||
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
|
||||
|
||||
fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
|
||||
|
||||
/**
|
||||
* Off by default: a driver has to opt into experimental features as a whole before any of
|
||||
* them - the voice assistant included - show up to turn on individually.
|
||||
*/
|
||||
val experimentalFeaturesEnabled: StateFlow<Boolean> = experimentalFeaturesFlow
|
||||
|
||||
/** Listen with the phone's own speech service instead of Gearslip's on-device model. */
|
||||
val systemSpeech: StateFlow<Boolean> = systemSpeechFlow
|
||||
|
||||
/**
|
||||
* Vehicle data is experimental and off by default: the launcher's tile for it, and the
|
||||
* voice assistant's "vehicle data" command, only show up once this and
|
||||
* [experimentalFeaturesEnabled] are both on. See [app.seb3thehacker.gearslip.car.CarSensors]
|
||||
* for why the raw sensor feed it shows is worth gating - the field labels are unverified.
|
||||
*/
|
||||
val carSensorsEnabled: StateFlow<Boolean> = carSensorsFlow
|
||||
|
||||
/**
|
||||
* Video bitrate that backs off when the car, or the Wi-Fi to it, falls behind. Takes effect
|
||||
* only while [experimentalFeaturesEnabled] is on too; untested in a car so far.
|
||||
*/
|
||||
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
|
||||
|
||||
/**
|
||||
* Gearslip's turn card on a car's instrument-cluster screen, for a car that offers one as a
|
||||
* second video sink. Applies only while [experimentalFeaturesEnabled] is on too, and from the
|
||||
* next connection; untested in a car so far.
|
||||
*/
|
||||
val clusterVideoEnabled: StateFlow<Boolean> = clusterVideoFlow
|
||||
|
||||
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
|
||||
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
|
||||
|
||||
fun setDebugBadge(on: Boolean) {
|
||||
debugBadgeFlow.value = on
|
||||
AppSettings.putString(app, KEY_DEBUG_BADGE, on.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the car screen's own "drive safely" warning has already been shown and dismissed
|
||||
* once, from this same install. Separate from the phone side's own compat/safety dialogs -
|
||||
* a driver who only ever plugs in and never opens the phone app should still see this once,
|
||||
* on the screen they are actually about to use while driving.
|
||||
*/
|
||||
val hasSeenSafetyWarning: StateFlow<Boolean> = seenSafetyWarningFlow
|
||||
|
||||
fun markSafetyWarningSeen() {
|
||||
seenSafetyWarningFlow.value = true
|
||||
AppSettings.putString(app, KEY_SEEN_SAFETY_WARNING, "true")
|
||||
}
|
||||
|
||||
fun init(context: Context) {
|
||||
app = context.applicationContext
|
||||
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
|
||||
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR
|
||||
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
|
||||
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
|
||||
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
|
||||
themeFlow.value = runCatching {
|
||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||
}.getOrDefault(AppTheme.PHONE)
|
||||
darkStyleFlow.value = runCatching {
|
||||
DarkStyle.valueOf(AppSettings.getString(app, KEY_DARK_STYLE, DarkStyle.BLACK.name))
|
||||
}.getOrDefault(DarkStyle.BLACK)
|
||||
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||
// own voice already reaches the car.
|
||||
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
|
||||
directionsOverMusicFlow.value = AppSettings.getString(app, KEY_DIRECTIONS_OVER_MUSIC, "false") == "true"
|
||||
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
|
||||
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
|
||||
nightFlow.value = runCatching {
|
||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||
}.getOrDefault(NightMode.AUTO)
|
||||
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
||||
.split(",").filter { it.isNotEmpty() }
|
||||
hiddenAppsFlow.value = AppSettings.getString(app, KEY_HIDDEN_APPS, "")
|
||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "false") == "true"
|
||||
clusterVideoFlow.value = AppSettings.getString(app, KEY_CLUSTER_VIDEO, "false") == "true"
|
||||
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
|
||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||
unitsFlow.value = runCatching {
|
||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||
}.getOrDefault(Units.AUTO)
|
||||
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
|
||||
screenFitFlow.value = runCatching {
|
||||
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
|
||||
}.getOrDefault(ScreenFit.CROP)
|
||||
previewSizeFlow.value = runCatching {
|
||||
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
|
||||
}.getOrDefault(PreviewSize.LAST_CAR)
|
||||
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
|
||||
navBarFlow.value = runCatching {
|
||||
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
|
||||
}.getOrDefault(NavBarMode.SHOW)
|
||||
whatsNewFlow.value = AppSettings.getString(app, KEY_WHATS_NEW, "true") == "true"
|
||||
whatsNewSeenFlow.value = AppSettings.getString(app, KEY_WHATS_NEW_SEEN, "0").toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
fun setScale(value: Float) {
|
||||
@@ -80,6 +409,11 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_SCALE, value.toString())
|
||||
}
|
||||
|
||||
fun setCarDensity(dpi: Int) {
|
||||
carDensityFlow.value = dpi
|
||||
AppSettings.putString(app, KEY_DENSITY, dpi.toString())
|
||||
}
|
||||
|
||||
fun setOpenOnConnect(id: String) {
|
||||
openFlow.value = id
|
||||
AppSettings.putString(app, KEY_OPEN, id)
|
||||
@@ -100,14 +434,92 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_THEME, theme.name)
|
||||
}
|
||||
|
||||
fun setDarkStyle(style: DarkStyle) {
|
||||
darkStyleFlow.value = style
|
||||
AppSettings.putString(app, KEY_DARK_STYLE, style.name)
|
||||
}
|
||||
|
||||
fun setUiTheme(id: String) {
|
||||
uiThemeFlow.value = id
|
||||
AppSettings.putString(app, KEY_UI_THEME, id)
|
||||
}
|
||||
|
||||
fun setPlayerTitle(on: Boolean) {
|
||||
playerTitleFlow.value = on
|
||||
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
||||
}
|
||||
|
||||
fun setPlayerLeft(on: Boolean) {
|
||||
playerLeftFlow.value = on
|
||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||
}
|
||||
|
||||
fun setMoreLyrics(on: Boolean) {
|
||||
moreLyricsFlow.value = on
|
||||
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
||||
}
|
||||
|
||||
fun setAutoplay(on: Boolean) {
|
||||
autoplayFlow.value = on
|
||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
|
||||
* back asks for it again (or waits for the car, if none is connected).
|
||||
*/
|
||||
fun setPipeAudio(on: Boolean) {
|
||||
pipeAudioFlow.value = on
|
||||
// Saved even when unchanged, so picking the default still counts as a choice.
|
||||
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
||||
if (pipeAudioFlow.value == on) return
|
||||
pipeAudioFlow.value = on
|
||||
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
|
||||
if (on) CarAudio.request() else CarAudio.release()
|
||||
}
|
||||
|
||||
fun setSpeakDirections(on: Boolean) {
|
||||
speakDirectionsFlow.value = on
|
||||
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
|
||||
}
|
||||
|
||||
fun setDirectionsOverMusic(on: Boolean) {
|
||||
directionsOverMusicFlow.value = on
|
||||
AppSettings.putString(app, KEY_DIRECTIONS_OVER_MUSIC, on.toString())
|
||||
}
|
||||
|
||||
fun setBluetoothWarning(on: Boolean) {
|
||||
bluetoothWarningFlow.value = on
|
||||
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
|
||||
}
|
||||
|
||||
fun setLocationWarning(on: Boolean) {
|
||||
locationWarningFlow.value = on
|
||||
AppSettings.putString(app, KEY_LOCATION_WARNING, on.toString())
|
||||
}
|
||||
|
||||
fun setExperimentalFeaturesEnabled(on: Boolean) {
|
||||
experimentalFeaturesFlow.value = on
|
||||
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
|
||||
}
|
||||
|
||||
fun setSystemSpeech(on: Boolean) {
|
||||
systemSpeechFlow.value = on
|
||||
AppSettings.putString(app, KEY_SYSTEM_SPEECH, on.toString())
|
||||
}
|
||||
|
||||
fun setAdaptiveVideoEnabled(on: Boolean) {
|
||||
adaptiveVideoFlow.value = on
|
||||
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setClusterVideoEnabled(on: Boolean) {
|
||||
clusterVideoFlow.value = on
|
||||
AppSettings.putString(app, KEY_CLUSTER_VIDEO, on.toString())
|
||||
}
|
||||
|
||||
fun setCarSensorsEnabled(on: Boolean) {
|
||||
carSensorsFlow.value = on
|
||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||
}
|
||||
|
||||
fun setNightMode(mode: NightMode) {
|
||||
@@ -115,4 +527,28 @@ object CarSettings {
|
||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||
CarEnvironment.refresh()
|
||||
}
|
||||
|
||||
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
|
||||
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
|
||||
fun togglePin(component: String) {
|
||||
val current = pinnedFlow.value
|
||||
val next = when {
|
||||
component in current -> current - component
|
||||
current.size >= MAX_PINNED -> return
|
||||
else -> current + component
|
||||
}
|
||||
pinnedFlow.value = next
|
||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||
}
|
||||
|
||||
fun setAppHidden(id: String, hide: Boolean) {
|
||||
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
|
||||
hiddenAppsFlow.value = next
|
||||
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
|
||||
}
|
||||
|
||||
fun setAppOrder(ids: List<String>) {
|
||||
appOrderFlow.value = ids
|
||||
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,32 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
|
||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||
import app.seb3thehacker.gearslip.car.theme.*
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -28,6 +41,9 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.AppSettings
|
||||
import app.seb3thehacker.gearslip.ui.CertSummary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -42,10 +58,30 @@ import kotlinx.coroutines.withContext
|
||||
fun CarSettingsScreen() {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val open by CarSettings.openOnConnect.collectAsState()
|
||||
val launcher by LauncherMode.active.collectAsState()
|
||||
val night by CarSettings.nightMode.collectAsState()
|
||||
val units by CarSettings.units.collectAsState()
|
||||
val navBar by CarSettings.navBarMode.collectAsState()
|
||||
val screenSize by CarSettings.screenSize.collectAsState()
|
||||
val screenFit by CarSettings.screenFit.collectAsState()
|
||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||
val directionsOverMusic by CarSettings.directionsOverMusic.collectAsState()
|
||||
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||
val autoplay by CarSettings.autoplay.collectAsState()
|
||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||
val moreLyrics by CarSettings.moreLyrics.collectAsState()
|
||||
val theme by CarSettings.appTheme.collectAsState()
|
||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||
val display by CarEnvironment.display.collectAsState()
|
||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||
val context = LocalContext.current
|
||||
@@ -53,94 +89,274 @@ fun CarSettingsScreen() {
|
||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Text("Settings", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||
// A rail of the groups down the left, and the chosen group's settings beside it: five short
|
||||
// lists instead of one long scroll.
|
||||
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
|
||||
Row(Modifier.fillMaxSize().padding(start = 16.dp, top = 12.dp)) {
|
||||
SettingsRail(listOf(stringResource(R.string.car_group_screen), stringResource(R.string.car_group_nav_bar), stringResource(R.string.car_group_sound), stringResource(R.string.car_group_warnings), stringResource(R.string.car_group_more)), chosen) { chosen = it }
|
||||
LazyColumn(
|
||||
Modifier.weight(1f).fillMaxHeight(),
|
||||
state = listState,
|
||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
var section: Int
|
||||
section = 0
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
|
||||
|
||||
ChoiceRow(
|
||||
"Open on connect",
|
||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_open_on_connect),
|
||||
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
|
||||
open,
|
||||
) { CarSettings.setOpenOnConnect(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Size",
|
||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_size),
|
||||
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
|
||||
scale,
|
||||
) { CarSettings.setScale(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"App theme",
|
||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_app_theme),
|
||||
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
|
||||
theme,
|
||||
) { CarSettings.setAppTheme(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Map day/night",
|
||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
if (section == chosen && theme != AppTheme.LIGHT) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_dark_mode),
|
||||
listOf(
|
||||
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||
),
|
||||
darkStyle,
|
||||
) { CarSettings.setDarkStyle(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Autoplay on connect",
|
||||
listOf(false to "Off", true to "On"),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_theme),
|
||||
GsThemes.builtIn.map { it.id to it.name },
|
||||
uiTheme,
|
||||
) { CarSettings.setUiTheme(it) }
|
||||
}
|
||||
|
||||
ChoiceRow(
|
||||
"Media audio",
|
||||
listOf(false to "Phone output", true to "Through Gearslip"),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
Text(
|
||||
"Through Gearslip sends the sound over the USB link. Android asks for consent the " +
|
||||
"first time a media app plays in the car, and some apps refuse to be captured at " +
|
||||
"all. Phone output needs no permission: the app plays as it normally would, for " +
|
||||
"example over Bluetooth to the car.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_map_day_night),
|
||||
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
|
||||
night,
|
||||
) { CarSettings.setNightMode(it) }
|
||||
}
|
||||
|
||||
WebPageSetting()
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_units),
|
||||
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
|
||||
units,
|
||||
) { CarSettings.setUnits(it) }
|
||||
}
|
||||
|
||||
ScreenFit(vehicle)
|
||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_screen_size),
|
||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
|
||||
screenSize,
|
||||
) { CarSettings.setScreenSize(it) }
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info("Vehicle", vehicle.ifEmpty { "-" })
|
||||
Info("Video", display.ifEmpty { "-" })
|
||||
Info("Certificate", cert?.headline ?: "Checking…")
|
||||
}
|
||||
val navigator = LocalCarNavigator.current
|
||||
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (section == chosen && screenSize != null) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_fit),
|
||||
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
|
||||
screenFit,
|
||||
) { CarSettings.setScreenFit(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
options.forEach { (value, label) ->
|
||||
val on = value == selected
|
||||
Surface(
|
||||
onClick = { onSelect(value) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(64.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
|
||||
section = 1
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_nav_bar),
|
||||
listOf(
|
||||
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
|
||||
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
|
||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||
),
|
||||
navBar,
|
||||
) { CarSettings.setNavBarMode(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_nav_bar),
|
||||
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
|
||||
playerTitle,
|
||||
) { CarSettings.setPlayerTitle(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_player_beside_map),
|
||||
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
|
||||
playerLeft,
|
||||
) { CarSettings.setPlayerLeft(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_autoplay),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
autoplay,
|
||||
) { CarSettings.setAutoplay(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.carset_lyrics_from),
|
||||
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
|
||||
moreLyrics,
|
||||
) { CarSettings.setMoreLyrics(it) }
|
||||
}
|
||||
|
||||
section = 2
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_audio),
|
||||
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
|
||||
pipeAudio,
|
||||
) { CarSettings.setPipeAudio(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_audio_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
section = 3
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
|
||||
|
||||
if (section == chosen && !pipeAudio) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_bluetooth),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
bluetoothWarning,
|
||||
) { CarSettings.setBluetoothWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_warn_location),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
locationWarning,
|
||||
) { CarSettings.setLocationWarning(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_spoken_directions),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
speakDirections,
|
||||
) { CarSettings.setSpeakDirections(it) }
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Text(
|
||||
stringResource(R.string.carset_directions_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen && speakDirections) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_directions_over_music),
|
||||
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||
directionsOverMusic,
|
||||
) { CarSettings.setDirectionsOverMusic(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && speakDirections) item {
|
||||
Text(
|
||||
stringResource(R.string.car_directions_over_music_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_phone_speech),
|
||||
description = stringResource(R.string.carset_phone_speech_note),
|
||||
checked = systemSpeech,
|
||||
) { CarSettings.setSystemSpeech(it) }
|
||||
}
|
||||
|
||||
section = 4
|
||||
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
|
||||
|
||||
if (section == chosen) item {
|
||||
WebPageSetting()
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ScreenFit(vehicle)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
ChoiceRow(
|
||||
stringResource(R.string.car_experimental),
|
||||
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
|
||||
experimentalFeatures,
|
||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||
}
|
||||
|
||||
if (section == chosen && experimentalFeatures) item {
|
||||
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
|
||||
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
|
||||
}
|
||||
}
|
||||
|
||||
if (section == chosen) item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||
ExperimentalFeatureRow(
|
||||
stringResource(R.string.carset_whats_new_after),
|
||||
stringResource(R.string.carset_whats_new_after_sub),
|
||||
whatsNew,
|
||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,7 +364,164 @@ private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selecte
|
||||
}
|
||||
}
|
||||
|
||||
/** The URL (or raw HTML) the Web app shows. Edited with [CarKeyboard] - no phone in hand needed. */
|
||||
@Composable
|
||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(Modifier.fillMaxWidth()) {
|
||||
if (maxWidth >= 720.dp && options.size <= 4) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(0.3f))
|
||||
Box(Modifier.weight(0.7f)) { ChoiceButtons(options, selected, onSelect) }
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
ChoiceButtons(options, selected, onSelect)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The groups, with the chosen one lit: a card down the left of Settings. */
|
||||
@Composable
|
||||
private fun SettingsRail(groups: List<String>, chosen: Int, onChoose: (Int) -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Surface(
|
||||
Modifier.width(250.dp).padding(bottom = 12.dp).fillMaxHeight().gsPanelShadow(shape),
|
||||
shape = shape,
|
||||
color = scheme.surfaceContainer,
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(10.dp).verticalScroll(androidx.compose.foundation.rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.settings_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 14.dp, top = 6.dp, bottom = 10.dp),
|
||||
)
|
||||
groups.forEachIndexed { i, name ->
|
||||
val on = i == chosen
|
||||
GsIconBox(
|
||||
onClick = { onChoose(i) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
|
||||
else GsColors(Color.Transparent, scheme.onSurface),
|
||||
latched = on,
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A group of settings: a heading, so a long list reads as a few short ones. */
|
||||
@Composable
|
||||
private fun Group(title: String) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||
run {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
options.forEach { (value, label) ->
|
||||
val on = value == selected
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
GsIconBox(
|
||||
onClick = { onSelect(value) },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(64.dp),
|
||||
colors = if (on) GsColors(scheme.primaryContainer, scheme.onPrimaryContainer)
|
||||
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
|
||||
latched = on,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individually-switched experimental features, shown once the driver has opted into
|
||||
* experimental features as a whole. Each is its own row - a title, what it does, and a switch -
|
||||
* so the list reads fine whether there's one entry or several.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean, clusterVideo: Boolean) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_vehicle_data),
|
||||
description = stringResource(R.string.carset_vehicle_data_note),
|
||||
checked = carSensors,
|
||||
) { CarSettings.setCarSensorsEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_adaptive_video),
|
||||
description = stringResource(R.string.carset_adaptive_note),
|
||||
checked = adaptiveVideo,
|
||||
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||
ExperimentalFeatureRow(
|
||||
title = stringResource(R.string.car_cluster_video),
|
||||
description = stringResource(R.string.car_cluster_video_note),
|
||||
checked = clusterVideo,
|
||||
) { CarSettings.setClusterVideoEnabled(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExperimentalFeatureRow(
|
||||
title: String,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
warning: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
GsSwitch(checked, onCheckedChange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The URL (or raw HTML) the Browser app shows. Edited with [CarKeyboard] - no phone in hand needed. */
|
||||
@Composable
|
||||
private fun WebPageSetting() {
|
||||
val context = LocalContext.current
|
||||
@@ -156,7 +529,7 @@ private fun WebPageSetting() {
|
||||
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Web page", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Text(stringResource(R.string.web_page), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Surface(
|
||||
onClick = { editing = !editing },
|
||||
shape = MaterialTheme.shapes.large,
|
||||
@@ -164,7 +537,7 @@ private fun WebPageSetting() {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
|
||||
url.ifEmpty { stringResource(R.string.carset_web_default) },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
@@ -173,7 +546,7 @@ private fun WebPageSetting() {
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Shown by the Web app on the launcher. Applies on the next connection.",
|
||||
stringResource(R.string.carset_web_note),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -185,6 +558,7 @@ private fun WebPageSetting() {
|
||||
AppSettings.setStartupUrl(context, it)
|
||||
},
|
||||
onSubmit = { editing = false },
|
||||
onDismiss = { editing = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -215,22 +589,22 @@ private fun ScreenFit(vehicle: String) {
|
||||
}
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper("Bottom", insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||
Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
||||
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||
Text(
|
||||
"Usable height ${480 - insets.top - insets.bottom}px of 480",
|
||||
stringResource(R.string.carset_usable_height, 480 - insets.top - insets.bottom),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
GsButton(
|
||||
onClick = {
|
||||
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
|
||||
saved = if (ok) "Saved to $vehicle" else "Could not save"
|
||||
saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed)
|
||||
},
|
||||
enabled = known,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
|
||||
) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
|
||||
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
@@ -242,7 +616,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
|
||||
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
|
||||
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
|
||||
Text(
|
||||
"${value}px",
|
||||
stringResource(R.string.carset_px, value),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1.3f),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
@@ -254,14 +628,18 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
|
||||
Surface(
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(64.dp),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Text(text, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
}
|
||||
|
||||
/** Common car screen sizes; Settings on the phone takes any other. */
|
||||
private val SCREEN_PRESETS = listOf(
|
||||
ScreenSize(800, 480), ScreenSize(1024, 600), ScreenSize(1280, 480), ScreenSize(1280, 720),
|
||||
ScreenSize(1440, 540), ScreenSize(1920, 720), ScreenSize(1920, 1080),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalAccessibilityManager
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
|
||||
@Composable
|
||||
internal fun CarToastOverlay(modifier: Modifier = Modifier) {
|
||||
val current by CarToasts.message.collectAsState()
|
||||
val message = current ?: return
|
||||
val accessibility = LocalAccessibilityManager.current
|
||||
LaunchedEffect(message, accessibility) {
|
||||
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
|
||||
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
|
||||
) ?: message.durationMs
|
||||
// The full time, unless another toast is waiting: then it gives way after the minimum.
|
||||
delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
|
||||
withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
|
||||
CarToasts.waiting.first { it > 0 }
|
||||
}
|
||||
CarToasts.dismiss(message)
|
||||
}
|
||||
|
||||
// A non-interactive overlay leaves the map's touch handling and focus intact.
|
||||
Box(
|
||||
modifier
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.widthIn(max = 560.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
) {
|
||||
Text(
|
||||
message.text,
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.car.app.CarToast
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* One toast at a time across navigation and browse apps. A newer one waits its turn instead of
|
||||
* replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
|
||||
*/
|
||||
@MainThread
|
||||
internal object CarToasts {
|
||||
// Identity matters: repeating identical text must restart its display timer.
|
||||
class Message(val owner: Any, val text: String, val durationMs: Long)
|
||||
|
||||
/** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
|
||||
const val MIN_SHOWN_MS = 2_000L
|
||||
|
||||
/** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
|
||||
private const val MAX_WAITING = 3
|
||||
|
||||
private val current = MutableStateFlow<Message?>(null)
|
||||
val message: StateFlow<Message?> = current.asStateFlow()
|
||||
|
||||
private val queue = ArrayDeque<Message>()
|
||||
private val _waiting = MutableStateFlow(0)
|
||||
val waiting: StateFlow<Int> = _waiting.asStateFlow()
|
||||
|
||||
fun show(owner: Any, text: String, duration: Int) {
|
||||
if (text.isBlank()) return
|
||||
// Longer than a phone toast: the driver only glances at the screen now and then.
|
||||
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
|
||||
val next = Message(owner, text, durationMs)
|
||||
val shown = current.value
|
||||
when {
|
||||
shown == null -> current.value = next
|
||||
// The same words again restart the one on screen rather than queueing a copy.
|
||||
shown.text == text -> current.value = next
|
||||
queue.lastOrNull()?.text == text -> Unit
|
||||
else -> {
|
||||
queue.addLast(next)
|
||||
while (queue.size > MAX_WAITING) queue.removeFirst()
|
||||
}
|
||||
}
|
||||
_waiting.value = queue.size
|
||||
}
|
||||
|
||||
fun dismiss(message: Message) {
|
||||
// An old timer must not dismiss a newer toast, even if its text is identical.
|
||||
if (current.value === message) advance()
|
||||
}
|
||||
|
||||
fun clear(owner: Any) {
|
||||
// Disconnecting the map must not clear a toast belonging to the browse app.
|
||||
queue.removeAll { it.owner === owner }
|
||||
if (current.value?.owner === owner) advance() else _waiting.value = queue.size
|
||||
}
|
||||
|
||||
private fun advance() {
|
||||
current.value = queue.removeFirstOrNull()
|
||||
_waiting.value = queue.size
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.material.icons.filled.Call
|
||||
import app.seb3thehacker.gearslip.call.CarCalls
|
||||
import app.seb3thehacker.gearslip.call.CallUiState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -12,19 +18,29 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.car.app.model.CarIcon
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||
import app.seb3thehacker.gearslip.host.display
|
||||
import app.seb3thehacker.gearslip.host.text
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,22 +48,44 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||
import app.seb3thehacker.gearslip.car.theme.LocalGsTheme
|
||||
import app.seb3thehacker.gearslip.media.CarMedia
|
||||
import app.seb3thehacker.gearslip.media.MediaArt
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -56,7 +94,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -71,6 +112,8 @@ fun CarUi() {
|
||||
val scale by CarSettings.scale.collectAsState()
|
||||
val phoneDark by CarEnvironment.phoneDark.collectAsState()
|
||||
val appTheme by CarSettings.appTheme.collectAsState()
|
||||
val uiThemeId by CarSettings.uiTheme.collectAsState()
|
||||
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||
val mediaApp by CarServices.mediaApp.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
@@ -79,232 +122,390 @@ fun CarUi() {
|
||||
AppTheme.LIGHT -> false
|
||||
AppTheme.DARK -> true
|
||||
}
|
||||
val colors = if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)
|
||||
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark, darkStyle)
|
||||
val base = LocalDensity.current
|
||||
val insets by CarEnvironment.insets.collectAsState()
|
||||
val navigator = remember { CarNavigator(CarSettings.openOnConnect.value) }
|
||||
val navigator = remember {
|
||||
// A start screen picked for the car may be one launcher mode hides, like Screen sharing.
|
||||
val start = CarSettings.openOnConnect.value
|
||||
val shown = CarApps.shown(LauncherMode.active.value, LauncherMode.canCall(appContext)).any { it.id == start }
|
||||
CarNavigator(if (shown) start else CarSettings.HOME)
|
||||
}
|
||||
val controlsWake = remember { MapControlsWake() }
|
||||
LaunchedEffect(controlsWake) {
|
||||
while (true) {
|
||||
delay(250)
|
||||
controlsWake.tick()
|
||||
CarFullscreen.tick()
|
||||
}
|
||||
}
|
||||
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
|
||||
LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } }
|
||||
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
|
||||
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
|
||||
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
||||
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||
// somewhere to play it.
|
||||
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||
// A new version gets one card in the top right, a few seconds in and only while parked.
|
||||
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
|
||||
// The map follows the light outside, whatever the app's own theme is set to.
|
||||
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
|
||||
|
||||
// The one place with a navigator and a frame to act a voice command on - CarAssistant itself
|
||||
// only knows how to listen and speak, not what "open Spotify" means.
|
||||
val frameForAssistant by CarEnvironment.frame.collectAsState()
|
||||
LaunchedEffect(navigator) {
|
||||
CarAssistant.pendingCommand.collect { heard ->
|
||||
if (heard != null) {
|
||||
val reply = AssistantCommands.handle(appContext, navigator, frameForAssistant, heard)
|
||||
CarAssistant.reply(reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The car's own back, home, map, media and phone keys.
|
||||
LaunchedEffect(navigator) {
|
||||
CarKeys.screenKeys.collect { key ->
|
||||
when (key) {
|
||||
CarKeys.ScreenKey.BACK -> when {
|
||||
navigator.appMenu != null -> navigator.dismissAppMenu()
|
||||
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
|
||||
else -> navigator.back()
|
||||
}
|
||||
// Home is the dashboard, over the map, so Back from it lands on the map.
|
||||
CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() }
|
||||
CarKeys.ScreenKey.MAP -> navigator.home()
|
||||
CarKeys.ScreenKey.MEDIA ->
|
||||
if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media()
|
||||
else navigator.apps()
|
||||
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The car screen is drawn on the phone, so every long press or text-handle drag would buzz
|
||||
// the phone in its mount. Nobody feels that while driving; it's only noise.
|
||||
val view = LocalView.current
|
||||
DisposableEffect(view) {
|
||||
view.isHapticFeedbackEnabled = false
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalHapticFeedback provides NoHaptics,
|
||||
LocalDensity provides Density(base.density * scale, base.fontScale),
|
||||
LocalCarNavigator provides navigator,
|
||||
LocalDarkOutside provides darkOutside,
|
||||
LocalMapControlsWake provides controlsWake,
|
||||
LocalGsTheme provides GsThemes.byId(uiThemeId),
|
||||
) {
|
||||
MaterialTheme(colorScheme = colors) {
|
||||
CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Watches every touch without taking any: first in line, consumes nothing.
|
||||
.pointerInput(controlsWake) {
|
||||
// In full screen, a swipe that starts at the top or bottom edge brings the
|
||||
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
|
||||
val edge = 32.dp.toPx()
|
||||
val slop = 24.dp.toPx()
|
||||
var startY = -1f
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial)
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
controlsWake.touch()
|
||||
CarFullscreen.touch()
|
||||
val change = event.changes.firstOrNull() ?: continue
|
||||
if (!CarFullscreen.active) continue
|
||||
if (change.pressed && !change.previousPressed) {
|
||||
val y = change.position.y
|
||||
startY = if (y < edge || y > size.height - edge) y else -1f
|
||||
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
|
||||
CarFullscreen.reveal()
|
||||
startY = -1f
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
// Insets are in video-frame pixels, so convert with the un-scaled density.
|
||||
val pad = with(base) {
|
||||
// Insets are video-frame pixels. Resolve them with the live (size-scaled) density,
|
||||
// which padding() also uses, so the safe-area margin stays one fixed physical size
|
||||
// at every size setting instead of growing with it and eating the screen's sides.
|
||||
val pad = with(LocalDensity.current) {
|
||||
PaddingValues(
|
||||
start = insets.left.toDp(), top = insets.top.toDp(),
|
||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||
)
|
||||
}
|
||||
// The calibration screen is the raw frame: no insets, no nav bar.
|
||||
val screen = navigator.current
|
||||
if (screen == CarScreen.App("calibrate")) {
|
||||
CarApps.find("calibrate")?.content?.invoke()
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> CarHome()
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
||||
val keyboardVisible by CarKeyboardVisibility.state
|
||||
// The bar always lies over the map and every other screen rather than taking a row
|
||||
// of its own: hiding it then leaves the map's surface the size it was.
|
||||
// Resizing it would hand the map app a new surface each time, and some go black on that.
|
||||
val navBarMode by CarSettings.navBarMode.collectAsState()
|
||||
val appTemplate by CarServices.nav.template.collectAsState()
|
||||
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
|
||||
NavBarMode.SHOW -> false
|
||||
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
|
||||
NavBarMode.HIDE -> true
|
||||
}
|
||||
// A full wait from when hiding becomes possible, not from whenever the last touch was.
|
||||
LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() }
|
||||
// A call keeps the bar up whatever the setting: End and Mute live on it.
|
||||
val onCall = CarCalls.state.collectAsState().value is CallUiState.Active
|
||||
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
|
||||
// Up to the bar's top edge, so the map's cards and the player end above it.
|
||||
val homeInset = if (barShown) NAV_BAR_HEIGHT else 0.dp
|
||||
// Only the screen that asked: going Home or anywhere else ends it.
|
||||
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
|
||||
Box(Modifier.fillMaxSize().padding(pad)) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (!safetyWarningSeen) {
|
||||
CarSafetyWizard(onDone = CarSettings::markSafetyWarningSeen)
|
||||
return@Column
|
||||
}
|
||||
// Header navigation (breadcrumb trail) - off for now, kept to tweak later.
|
||||
// BreadcrumbBar(navigator)
|
||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||
// Home never leaves the composition; other screens cover it. Dropping it
|
||||
// would tear down the map's surface, and coming back would then show black
|
||||
// while the nav app redrew on a new one.
|
||||
CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
|
||||
}
|
||||
// The keyboard takes the nav bar's row instead of squeezing in above it -
|
||||
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||
// show both.
|
||||
}
|
||||
// Every other screen, and everything that pops up, in a layer over Home rather
|
||||
// than inside its area: full screen grows this layer over the nav bar's row while
|
||||
// Home, and the map's surface in it, keeps its size.
|
||||
// Each new screen fades in while rising a little, once, over a fifth of a second.
|
||||
// Coming from Home the backdrop fades in with it, over the map; between two other
|
||||
// screens it stays put, so the map never flashes through.
|
||||
val lastScreen = remember { arrayOf<CarScreen>(CarScreen.Home) }
|
||||
val fromHome = remember(screen) { lastScreen[0] == CarScreen.Home }
|
||||
SideEffect { lastScreen[0] = screen }
|
||||
val enter = remember(screen) { androidx.compose.animation.core.Animatable(0f) }
|
||||
LaunchedEffect(screen) { enter.animateTo(1f, androidx.compose.animation.core.tween(180)) }
|
||||
if (safetyWarningSeen) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (screen != CarScreen.Home) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { if (fromHome) alpha = enter.value }
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
// The bar never hides here, so keep the screen clear of it. The
|
||||
// player runs on under the see-through bar and keeps clear itself.
|
||||
.padding(bottom = if (!keyboardVisible && !fullscreen && screen != CarScreen.Media) NAV_BAR_HEIGHT else 0.dp)
|
||||
// Keeps touches that miss this screen's controls off the map.
|
||||
.pointerInput(Unit) { detectTapGestures { } },
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
if (!fromHome) alpha = enter.value
|
||||
translationY = (1f - enter.value) * 16.dp.toPx()
|
||||
},
|
||||
) {
|
||||
when (screen) {
|
||||
CarScreen.Home -> Unit
|
||||
CarScreen.Apps -> CarLauncher()
|
||||
CarScreen.Media -> CompositionLocalProvider(
|
||||
LocalNavBarInset provides if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp,
|
||||
) { mediaApp?.let { MediaScreen(it) { navigator.back() } } }
|
||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
||||
CarScreen.Settings -> CarSettingsScreen()
|
||||
CarScreen.Dashboard -> CarDashboardScreen()
|
||||
CarScreen.Weather -> WeatherScreen()
|
||||
CarScreen.VehicleData -> VehicleDataScreen()
|
||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
// The history screen is already showing them, so it needs no popup.
|
||||
if (screen !is CarScreen.Notifications) {
|
||||
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
CarAlertOverlay(Modifier.align(Alignment.TopCenter))
|
||||
CallPromptOverlay()
|
||||
BluetoothPairOverlay()
|
||||
PopupTimer()
|
||||
// The dashboard is already listing them, so it needs no popup.
|
||||
NotificationPopup(
|
||||
showNew = screen !is CarScreen.Notifications && screen != CarScreen.Dashboard,
|
||||
modifier = Modifier.align(Alignment.TopEnd),
|
||||
)
|
||||
// Above the popup: a call is more urgent than any notification.
|
||||
VoiceHelpOverlay(Modifier.align(Alignment.Center))
|
||||
CallOverlay()
|
||||
AssistantOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||
val badge by CarSettings.debugBadge.collectAsState()
|
||||
if (app.seb3thehacker.gearslip.BuildConfig.DEBUG && badge) {
|
||||
// Bottom corner, not top: a screen's own header almost always puts
|
||||
// something in the top-right (settings, search, a Liked Songs
|
||||
// shortcut) and this badge used to sit right on top of it, hiding
|
||||
// whatever the screen drew there.
|
||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
||||
}
|
||||
run {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
|
||||
Modifier.align(Alignment.BottomCenter),
|
||||
enter = androidx.compose.animation.slideInVertically { it },
|
||||
exit = androidx.compose.animation.slideOutVertically { it },
|
||||
) { CarNavBar(navigator) }
|
||||
}
|
||||
if (fullscreen) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
CarFullscreen.revealed,
|
||||
Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
||||
enter = androidx.compose.animation.slideInVertically { -it },
|
||||
exit = androidx.compose.animation.slideOutVertically { -it },
|
||||
) {
|
||||
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
|
||||
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.carui_exit_fullscreen), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
CarNavBar(navigator)
|
||||
}
|
||||
}
|
||||
// Over everything, nav bar included: a long press on an app, anywhere.
|
||||
AppMenu(navigator, rememberRunningApps())
|
||||
if (safetyWarningSeen) CarWhatsNew()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val NAV_BAR_HEIGHT = 56.dp
|
||||
|
||||
/**
|
||||
* How much of the bottom of Home the floating nav bar covers right now: its height while it shows,
|
||||
* zero while it's hidden or when it has a row of its own.
|
||||
*/
|
||||
internal val LocalNavBarInset = androidx.compose.runtime.compositionLocalOf { 0.dp }
|
||||
|
||||
/**
|
||||
* A single blocking screen, shown once per install before anything else the car screen can show -
|
||||
* Home included. The phone's own setup guide carries the same warning, but a driver who plugs in
|
||||
* without ever opening the phone app would otherwise never see it, on the one screen they are
|
||||
* actually about to use while driving. [onDone] fires once, from its own button; there is no way
|
||||
* to skip past this without tapping it.
|
||||
*/
|
||||
@Composable
|
||||
private fun CarSafetyWizard(onDone: () -> Unit) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// The card and its button share one width, capped so neither sprawls across a wide screen.
|
||||
Column(
|
||||
Modifier.widthIn(max = 640.dp).fillMaxWidth().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.carui_drive_safely), style = ChromeType.headline, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
stringResource(R.string.carui_unofficial),
|
||||
style = ChromeType.body,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.carui_never_interact),
|
||||
style = ChromeType.body,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||
Text(stringResource(R.string.carui_understand), style = ChromeType.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
|
||||
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
|
||||
* keeps the bar short enough to leave the app underneath more room.
|
||||
* A templated app that isn't navigation, full size with its own title bar and Stop button - the
|
||||
* same [CarAppStage] chrome the map uses on Home, just not embedded there. [CarServices.browse]
|
||||
* is a connection of its own, so opening one of these never bumps the map off Home.
|
||||
*/
|
||||
@Composable
|
||||
private fun CarNavBar(navigator: CarNavigator) {
|
||||
val battery by CarEnvironment.battery.collectAsState()
|
||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||
val now by rememberNow()
|
||||
|
||||
// The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
|
||||
val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
|
||||
val weather by Weather.state.collectAsState()
|
||||
|
||||
// One row, so nothing can sit on top of anything else: the player takes whatever width the
|
||||
// fixed items leave and shortens its title to fit.
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||
Spacer(Modifier.width(6.dp))
|
||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||
|
||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||
}
|
||||
|
||||
(weather as? WeatherState.Ready)?.data?.let { w ->
|
||||
NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
WeatherIcon(w.code, w.isDay, size = 28.dp)
|
||||
Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
|
||||
val light = if (darkOutside) "Night" else "Day"
|
||||
Text(
|
||||
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(10.dp))
|
||||
NavItem(
|
||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
||||
) { navigator.notifications() }
|
||||
private fun BrowseAppScreen(navigator: CarNavigator) {
|
||||
val status by CarServices.browse.status.collectAsState()
|
||||
val frame by CarEnvironment.frame.collectAsState()
|
||||
val template by CarServices.browse.template.collectAsState()
|
||||
val leave = {
|
||||
CarServices.stopBrowse()
|
||||
navigator.back()
|
||||
}
|
||||
// An app that never answers is as stuck as one that says no; don't leave the driver on "Loading".
|
||||
var timedOut by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(template == null) {
|
||||
timedOut = false
|
||||
if (template == null) {
|
||||
delay(APP_START_TIMEOUT_MS)
|
||||
timedOut = true
|
||||
}
|
||||
}
|
||||
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
|
||||
if (failed || timedOut) {
|
||||
AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave)
|
||||
return
|
||||
}
|
||||
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
|
||||
}
|
||||
|
||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||
@Composable
|
||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { content() }
|
||||
}
|
||||
private const val APP_START_TIMEOUT_MS = 15_000L
|
||||
|
||||
/** What a car app that won't run under Gearslip gets instead of a loading screen that never ends. */
|
||||
@Composable
|
||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
private fun AppWontStart(app: String, onBack: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
||||
Text(stringResource(R.string.carui_app_unsupported, app), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
stringResource(R.string.carui_app_unsupported_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
GsButton(onClick = onBack, tone = GsTone.Tonal) { Text(stringResource(R.string.back)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Wall-clock time, ticking on the minute: no reason to redraw (and re-encode) more often. */
|
||||
/**
|
||||
* Marks a debug build on the car screen itself, not just the phone's Home screen - the one
|
||||
* that matters while driving, since a debug build is slower and less tested than what ships.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis()) {
|
||||
while (true) {
|
||||
value = System.currentTimeMillis()
|
||||
delay(60_000 - value % 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
|
||||
@Composable
|
||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val media = CarServices.media
|
||||
val phase by media.phase.collectAsState()
|
||||
val now by media.now.collectAsState()
|
||||
if (phase != CarMedia.Phase.READY || !now.isActive) return
|
||||
val context = LocalContext.current
|
||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
||||
|
||||
private fun DebugBuildBadge(modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(50),
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = modifier.padding(6.dp),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 6.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.weight(1f, fill = false)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.clickable { navigator.media() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Box(Modifier.size(40.dp).clip(RoundedCornerShape(50)).background(MaterialTheme.colorScheme.surfaceVariant)) {
|
||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||
}
|
||||
Text(
|
||||
now.title.ifEmpty { "Nothing playing" },
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(44.dp)) {
|
||||
Icon(
|
||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
||||
if (now.playing) "Pause" else "Play",
|
||||
Modifier.size(26.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
|
||||
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"DEBUG BUILD",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
|
||||
private object NoHaptics : HapticFeedback {
|
||||
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import app.seb3thehacker.gearslip.R
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.Changelog
|
||||
import app.seb3thehacker.gearslip.car.theme.GsButton
|
||||
import app.seb3thehacker.gearslip.car.theme.GsSwitch
|
||||
import app.seb3thehacker.gearslip.notify.CarMotion
|
||||
|
||||
/**
|
||||
* What's new in this build, once, the first time the car screen shows after an update - or any
|
||||
* time from Settings > About. Held back while the car is moving: a list to read is for when
|
||||
* parked. The switch turns the automatic showing off (or back on); it doesn't close the card.
|
||||
*/
|
||||
@Composable
|
||||
fun CarWhatsNew() {
|
||||
val release = Changelog.current ?: return
|
||||
val afterUpdates by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||
val seen by CarSettings.whatsNewSeen.collectAsState()
|
||||
val open by CarSettings.whatsNewOpen.collectAsState()
|
||||
// Polled: CarMotion is a plain value, and the card should appear once the car stops.
|
||||
val moving by produceState(CarMotion.moving) {
|
||||
while (true) {
|
||||
value = CarMotion.moving
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
val due = afterUpdates && seen < release.versionCode && !moving
|
||||
if (!open && !due) return
|
||||
val close = { CarSettings.closeWhatsNew(release.versionCode) }
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.clickable(remember { MutableInteractionSource() }, indication = null, onClick = close),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
// Swallows taps on the card itself, so only the scrim around it closes.
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 640.dp)
|
||||
.clickable(remember { MutableInteractionSource() }, indication = null) {},
|
||||
) {
|
||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.whatsnew_title, release.versionName),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
release.changes.forEach { change ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 9.dp)
|
||||
.size(7.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
)
|
||||
Text(change, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
|
||||
Text(
|
||||
stringResource(R.string.whatsnew_show_after),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
|
||||
Text(stringResource(R.string.whatsnew_got_it), style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import androidx.car.app.model.CarText
|
||||
import androidx.car.app.model.Distance
|
||||
import androidx.car.app.navigation.model.Maneuver
|
||||
import androidx.car.app.navigation.model.NavigationTemplate
|
||||
import androidx.car.app.navigation.model.RoutingInfo
|
||||
import androidx.car.app.navigation.model.Step
|
||||
import app.seb3thehacker.gearslip.GearslipLog
|
||||
import app.seb3thehacker.gearslip.Protobuf
|
||||
import app.seb3thehacker.gearslip.notify.PhoneNavigation
|
||||
|
||||
/**
|
||||
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
|
||||
* field 8), the way Android Auto feeds it:
|
||||
*
|
||||
* -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) }
|
||||
* -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 }
|
||||
* -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2;
|
||||
* display_distance_e3 = 3; display_distance_unit = 4 }
|
||||
*
|
||||
* Newer cars also take a NavigationState message; the deprecated pair above is what a 2018
|
||||
* Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands
|
||||
* to clusters), or from its routing card when it sends no trip.
|
||||
*/
|
||||
class ClusterNav(
|
||||
val channelId: Int,
|
||||
minimumIntervalMs: Long = 0,
|
||||
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
|
||||
) {
|
||||
private val updateIntervalMs = maxOf(TICK_MS, minimumIntervalMs)
|
||||
@Volatile private var running = false
|
||||
private var worker: Thread? = null
|
||||
private var updatesRequested = true
|
||||
private var sentStatus = -1
|
||||
private var sentTurn: String? = null
|
||||
private var sentDistance: String? = null
|
||||
|
||||
@Synchronized
|
||||
fun onMessage(messageId: Int, body: ByteArray) {
|
||||
when (messageId) {
|
||||
MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||
val status = Protobuf.readInt32Field(body, 1)
|
||||
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
|
||||
if (status == 0) start()
|
||||
}
|
||||
MSG_START -> {
|
||||
updatesRequested = true
|
||||
sentStatus = -1
|
||||
sentTurn = null
|
||||
sentDistance = null
|
||||
GearslipLog.i("cluster: <- start")
|
||||
}
|
||||
MSG_STOP -> {
|
||||
updatesRequested = false
|
||||
GearslipLog.i("cluster: <- stop")
|
||||
}
|
||||
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
worker = Thread({
|
||||
while (running) {
|
||||
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
|
||||
try {
|
||||
Thread.sleep(updateIntervalMs)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}, "gearslip-cluster").also { it.start() }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
running = false
|
||||
worker?.interrupt()
|
||||
worker = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun tick() {
|
||||
if (!running || !updatesRequested) return
|
||||
val turn = NavTurn.current()
|
||||
val status = when {
|
||||
turn == null -> STATUS_INACTIVE
|
||||
turn.rerouting -> STATUS_REROUTING
|
||||
else -> STATUS_ACTIVE
|
||||
}
|
||||
if (status != sentStatus) {
|
||||
sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong()))
|
||||
GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}")
|
||||
sentStatus = status
|
||||
if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null }
|
||||
}
|
||||
if (turn == null || status == STATUS_REROUTING) return
|
||||
sendTurn(turn.step)
|
||||
turn.distance?.let { sendDistance(it, turn.seconds) }
|
||||
}
|
||||
|
||||
private fun sendTurn(step: Step) {
|
||||
val maneuver = step.maneuver
|
||||
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
|
||||
val road = step.road.text().ifBlank { step.cue.text() }
|
||||
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}|${maneuver?.roundaboutExitAngle}"
|
||||
if (key == sentTurn) return
|
||||
sentTurn = key
|
||||
var body = Protobuf.stringField(1, road) +
|
||||
Protobuf.varintField(2, side.toLong()) +
|
||||
Protobuf.varintField(3, event.toLong())
|
||||
maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) }
|
||||
maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) }
|
||||
sendOnChannel(MSG_TURN, body)
|
||||
GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"")
|
||||
}
|
||||
|
||||
/**
|
||||
* The Car App Library's distance units use the same numbers as the protocol's (1 metres ...
|
||||
* 7 yards), so the app's own display choice passes straight through.
|
||||
*/
|
||||
private fun sendDistance(distance: Distance, seconds: Long?) {
|
||||
val meters = NavTurn.toMeters(distance)
|
||||
val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}"
|
||||
if (key == sentDistance) return
|
||||
sentDistance = key
|
||||
sendOnChannel(
|
||||
MSG_DISTANCE,
|
||||
Protobuf.varintField(1, meters.toLong()) +
|
||||
Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) +
|
||||
Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) +
|
||||
Protobuf.varintField(4, distance.displayUnit.toLong()),
|
||||
)
|
||||
}
|
||||
|
||||
private fun CarText?.text() = this?.toString().orEmpty()
|
||||
|
||||
companion object {
|
||||
private const val TICK_MS = 500L
|
||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||
private const val MSG_START = 32769
|
||||
private const val MSG_STOP = 32770
|
||||
private const val MSG_STATUS = 32771
|
||||
private const val MSG_TURN = 32772
|
||||
private const val MSG_DISTANCE = 32773
|
||||
|
||||
private const val STATUS_ACTIVE = 1
|
||||
private const val STATUS_INACTIVE = 2
|
||||
private const val STATUS_REROUTING = 3
|
||||
private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting")
|
||||
|
||||
private const val SIDE_LEFT = 1
|
||||
private const val SIDE_RIGHT = 2
|
||||
private const val SIDE_UNSPECIFIED = 3
|
||||
|
||||
private const val EVENT_UNKNOWN = 0
|
||||
|
||||
/** The car's turn icon and side for a Car App Library manoeuvre. */
|
||||
fun eventFor(type: Int): Pair<Int, Int> = when (type) {
|
||||
Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT
|
||||
Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT
|
||||
Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT
|
||||
Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT
|
||||
Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT
|
||||
Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT
|
||||
Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT
|
||||
Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT
|
||||
Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> 7 to SIDE_LEFT
|
||||
Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
|
||||
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> 7 to SIDE_RIGHT
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT
|
||||
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT
|
||||
Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT
|
||||
Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT
|
||||
Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT
|
||||
Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT
|
||||
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
|
||||
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> 13 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED
|
||||
Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT
|
||||
Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT
|
||||
else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One turn as the car wants it, from whichever source the map app fills. */
|
||||
internal class NavTurn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean) {
|
||||
companion object {
|
||||
/** The map app's next turn: from its trip (what clusters get), or its routing card. */
|
||||
fun current(): NavTurn? {
|
||||
val nav = CarServices.navOrNull() ?: return null
|
||||
hosted(nav)?.let { return it }
|
||||
// Google Maps or Waze guiding on the phone, when no map in the car is.
|
||||
val phone = PhoneNavigation.turn.value ?: return null
|
||||
val step = Step.Builder(phone.road).setManeuver(Maneuver.Builder(phone.maneuver).build()).build()
|
||||
return NavTurn(step, phone.distance, null, false)
|
||||
}
|
||||
|
||||
private fun hosted(nav: app.seb3thehacker.gearslip.host.CarAppConnection): NavTurn? {
|
||||
nav.trip.value?.let { trip ->
|
||||
val step = trip.steps.firstOrNull() ?: return@let
|
||||
val estimate = trip.stepTravelEstimates.firstOrNull()
|
||||
return NavTurn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading)
|
||||
}
|
||||
val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
||||
val step = info.currentStep ?: return null
|
||||
return NavTurn(step, info.currentDistance, null, info.isLoading)
|
||||
}
|
||||
|
||||
fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) {
|
||||
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
|
||||
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
|
||||
Distance.UNIT_FEET -> 0.3048
|
||||
Distance.UNIT_YARDS -> 0.9144
|
||||
else -> 1.0
|
||||
}).toInt()
|
||||
}
|
||||
}
|
||||