mirror of
https://github.com/vernu/textbee.git
synced 2026-09-03 03:29:58 +03:00
Compare commits
576
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
670d0819b8 | ||
|
|
dcb12c6b59 | ||
|
|
bd808333b3 | ||
|
|
9c0826016a | ||
|
|
82973444c2 | ||
|
|
290149c8fb | ||
|
|
06295ae7f1 | ||
|
|
5bae4ffd9a | ||
|
|
3e4597294c | ||
|
|
c1942a4ba3 | ||
|
|
366a47913b | ||
|
|
469aa0025c | ||
|
|
5d43866847 | ||
|
|
e75dbc7a67 | ||
|
|
b2acba4c2c | ||
|
|
2b766e8019 | ||
|
|
022a6bd2a6 | ||
|
|
ad7bcd99a5 | ||
|
|
f65af5003b | ||
|
|
6da80ac4e7 | ||
|
|
05143b8752 | ||
|
|
1349134107 | ||
|
|
628e5b09d1 | ||
|
|
1999593e07 | ||
|
|
9913b7cbe7 | ||
|
|
8c818351d6 | ||
|
|
4cdcce6324 | ||
|
|
ffbbabdbd3 | ||
|
|
9cb1213efc | ||
|
|
1979e2f0b5 | ||
|
|
99a3a79b3b | ||
|
|
a949729c07 | ||
|
|
02bd1d42e0 | ||
|
|
0ea85c05e0 | ||
|
|
50d0902535 | ||
|
|
4ce6d00456 | ||
|
|
60e59f0221 | ||
|
|
f5815e1069 | ||
|
|
8a5f12a6eb | ||
|
|
bdae87aaf8 | ||
|
|
516e626213 | ||
|
|
6c3636104e | ||
|
|
e6bb6e2385 | ||
|
|
dda38204fa | ||
|
|
79e6dce8f7 | ||
|
|
eba534d208 | ||
|
|
2623118df3 | ||
|
|
d1548b4001 | ||
|
|
9111ca0975 | ||
|
|
e34dd7ac10 | ||
|
|
f821f480c7 | ||
|
|
6fe0a5dde0 | ||
|
|
c1ac054cd6 | ||
|
|
d906fb1b97 | ||
|
|
55ad2a6a5a | ||
|
|
dbea68ab30 | ||
|
|
497a14074a | ||
|
|
89f2f78706 | ||
|
|
8072904ea2 | ||
|
|
ea1b00ce3f | ||
|
|
c92740c401 | ||
|
|
95caebfdb9 | ||
|
|
92e52679fb | ||
|
|
eda97c154f | ||
|
|
c200ec375d | ||
|
|
ef45421150 | ||
|
|
d6c41a43c6 | ||
|
|
de8df1c53c | ||
|
|
bfc1e8c966 | ||
|
|
164124d616 | ||
|
|
1c4a2d6528 | ||
|
|
c846279d41 | ||
|
|
4fdca11cde | ||
|
|
fd7e6cac3f | ||
|
|
db7880db0d | ||
|
|
3f1037373f | ||
|
|
288ad7d15a | ||
|
|
4753d357ab | ||
|
|
6ce9fce9a2 | ||
|
|
243bbdd1d0 | ||
|
|
9b629d3291 | ||
|
|
954f337b2f | ||
|
|
f6a8f21e64 | ||
|
|
5389117fcd | ||
|
|
08d9015048 | ||
|
|
260cf50780 | ||
|
|
c235526583 | ||
|
|
3bef5f90a0 | ||
|
|
0e4c7481b9 | ||
|
|
3f7864c754 | ||
|
|
110bdbc3ef | ||
|
|
267996ad90 | ||
|
|
93ac880dd7 | ||
|
|
35a98f3288 | ||
|
|
b1c8585e11 | ||
|
|
f4e23ba3d3 | ||
|
|
1b35e76d82 | ||
|
|
8046324c48 | ||
|
|
31a69e62ee | ||
|
|
336bb65e0a | ||
|
|
c952040b2b | ||
|
|
cf02c3c50d | ||
|
|
004f50d82d | ||
|
|
4f321108ff | ||
|
|
f6556ea512 | ||
|
|
b57a55121f | ||
|
|
7e9c036ec1 | ||
|
|
37f99ae662 | ||
|
|
1a5e095c56 | ||
|
|
677b2dda64 | ||
|
|
6f5190aade | ||
|
|
11ddcf3bd7 | ||
|
|
aea2d27fbb | ||
|
|
14b91f19d7 | ||
|
|
9586712712 | ||
|
|
9bf7074de3 | ||
|
|
77f2f38686 | ||
|
|
bd6bcfd985 | ||
|
|
5467a85fb0 | ||
|
|
e3723054b6 | ||
|
|
9b6e044aa2 | ||
|
|
97e60f4ff9 | ||
|
|
8286938b9e | ||
|
|
f5985ceccb | ||
|
|
f64a520b86 | ||
|
|
c5630b30e9 | ||
|
|
4da8570cd2 | ||
|
|
839ad24c83 | ||
|
|
af03623396 | ||
|
|
fb3b2c1165 | ||
|
|
ab76237fbb | ||
|
|
7326bd7b3b | ||
|
|
01af5a9786 | ||
|
|
ef4e42a204 | ||
|
|
7bec9e8acf | ||
|
|
cb616c6bf5 | ||
|
|
002ac9c144 | ||
|
|
ffa66bed9b | ||
|
|
87006c226f | ||
|
|
9d4ec8186b | ||
|
|
dafc433575 | ||
|
|
a87094498c | ||
|
|
c488c16f59 | ||
|
|
2c6e3b8e6c | ||
|
|
60808db77a | ||
|
|
a43cf11bcd | ||
|
|
7a56692586 | ||
|
|
d613217efb | ||
|
|
598d697306 | ||
|
|
5ae010f1c3 | ||
|
|
94a0cd8c0a | ||
|
|
2ec9207779 | ||
|
|
51cb1ca198 | ||
|
|
e49f3954c3 | ||
|
|
17351e2148 | ||
|
|
8eec17ba1b | ||
|
|
25c70eb09d | ||
|
|
acde97f080 | ||
|
|
e14b71a790 | ||
|
|
5023f80f0f | ||
|
|
2bdcdeb9a9 | ||
|
|
b42a681816 | ||
|
|
14fd2f9b03 | ||
|
|
540bb9068b | ||
|
|
3061bd8d66 | ||
|
|
338fb665bc | ||
|
|
71475f0db8 | ||
|
|
40b0df297c | ||
|
|
863aac04e8 | ||
|
|
6e7ed42fe8 | ||
|
|
8fd06f06d1 | ||
|
|
0144cff16e | ||
|
|
7ff6ea23fc | ||
|
|
fd4969c92c | ||
|
|
4d1b53e247 | ||
|
|
c668530188 | ||
|
|
3cd1d94b20 | ||
|
|
30591c4847 | ||
|
|
f5bef39cec | ||
|
|
482049a764 | ||
|
|
ac65907453 | ||
|
|
584e2c5bec | ||
|
|
9685ecb7b3 | ||
|
|
d8460bcb8b | ||
|
|
eb94085f6e | ||
|
|
b1281d4ec1 | ||
|
|
46fa5cda2e | ||
|
|
709f05f33d | ||
|
|
844a815813 | ||
|
|
b8d277a563 | ||
|
|
d335f76088 | ||
|
|
42abcec94b | ||
|
|
014ae354c6 | ||
|
|
2692d4a154 | ||
|
|
1b24cd9754 | ||
|
|
e6ad6a18dc | ||
|
|
d51693d26d | ||
|
|
07f80fdf84 | ||
|
|
b762c6e2ef | ||
|
|
c1952d2030 | ||
|
|
ebc90d8105 | ||
|
|
fd0d86efab | ||
|
|
32b463a10c | ||
|
|
ed55406687 | ||
|
|
b6dde1a096 | ||
|
|
312e3a4fd4 | ||
|
|
3939502db7 | ||
|
|
9ae9f5978b | ||
|
|
58e6c73a72 | ||
|
|
f1c8530257 | ||
|
|
ede8e4c210 | ||
|
|
a6df612bfa | ||
|
|
4b4f3bace9 | ||
|
|
8a42b95e8f | ||
|
|
e0e6be9379 | ||
|
|
0665aa5432 | ||
|
|
dc8ca9d9a7 | ||
|
|
541f32406e | ||
|
|
141b8b334a | ||
|
|
f9fed4d3d8 | ||
|
|
4ef65b08ff | ||
|
|
86060456fa | ||
|
|
ca0ac8cf6e | ||
|
|
ba0437ff01 | ||
|
|
99a9ee3cc1 | ||
|
|
f2ca0cecf6 | ||
|
|
0ff96f4857 | ||
|
|
e2cf5d2e37 | ||
|
|
d6d52e80cf | ||
|
|
30691e2e0d | ||
|
|
7308ab6721 | ||
|
|
fd184a4700 | ||
|
|
af19f6c75a | ||
|
|
10ead48014 | ||
|
|
463747b82f | ||
|
|
d99b53f84c | ||
|
|
bbbe85e0fd | ||
|
|
df0ef06495 | ||
|
|
130a6934d6 | ||
|
|
3ea8c0d5a0 | ||
|
|
fd5ba1c538 | ||
|
|
91ac0b3eb0 | ||
|
|
c26ac378f6 | ||
|
|
bf66e94e6d | ||
|
|
552a90685a | ||
|
|
e2246cf8e8 | ||
|
|
455696275b | ||
|
|
0ffa945668 | ||
|
|
6c40e2311e | ||
|
|
fbac1fae0b | ||
|
|
127d1f4127 | ||
|
|
7ce4efff70 | ||
|
|
dd9ec0aacc | ||
|
|
6d081c7489 | ||
|
|
500d880aea | ||
|
|
4995b6023b | ||
|
|
ea8c21ff21 | ||
|
|
a9b1cd6549 | ||
|
|
e3745d5071 | ||
|
|
82aa3cb425 | ||
|
|
b57ebc7500 | ||
|
|
740b691f67 | ||
|
|
5e509b1b57 | ||
|
|
111dbc43f2 | ||
|
|
d704b04e3d | ||
|
|
0d5f9a7cec | ||
|
|
f66d2855a7 | ||
|
|
fe588ea05a | ||
|
|
704a8dbf95 | ||
|
|
4f64880cf8 | ||
|
|
63733651d5 | ||
|
|
88e0b3df63 | ||
|
|
9b98a9e509 | ||
|
|
52565cc55c | ||
|
|
023df71d3c | ||
|
|
bce6b3be94 | ||
|
|
3227fb6696 | ||
|
|
e5b63dbb6a | ||
|
|
183dbeb29a | ||
|
|
da79575db6 | ||
|
|
b08b732b61 | ||
|
|
3f838ce253 | ||
|
|
938d3d6f17 | ||
|
|
321842a021 | ||
|
|
db640de638 | ||
|
|
3c511a0f53 | ||
|
|
cf80be9611 | ||
|
|
38dd56df26 | ||
|
|
97dd062477 | ||
|
|
771282187d | ||
|
|
445de6b8fc | ||
|
|
d1b311a7c5 | ||
|
|
9f5b858ffb | ||
|
|
1e5c4de4ed | ||
|
|
1f0db4c4d2 | ||
|
|
7a24c0cd2b | ||
|
|
4c164e6bad | ||
|
|
7fc521023f | ||
|
|
99efd20e7a | ||
|
|
2e8403a056 | ||
|
|
8914cc2b0b | ||
|
|
364b876c8c | ||
|
|
453087b3ce | ||
|
|
d023fc385f | ||
|
|
8e81c6d7b0 | ||
|
|
b1b87e1acc | ||
|
|
68593dff9e | ||
|
|
49d65b87cd | ||
|
|
0f6c9eff32 | ||
|
|
e29d145397 | ||
|
|
0fa930a0ff | ||
|
|
400bda9e72 | ||
|
|
dd14c6ba4e | ||
|
|
f9005e6063 | ||
|
|
58b8eef92f | ||
|
|
08afce15b0 | ||
|
|
6ac8361aa6 | ||
|
|
014590fb9e | ||
|
|
217c068bfb | ||
|
|
fb06cfd33f | ||
|
|
aac621bd16 | ||
|
|
6252e4f04e | ||
|
|
0fe0f48ef9 | ||
|
|
f520f378d0 | ||
|
|
b0ce11e392 | ||
|
|
7012b04439 | ||
|
|
375cf63adb | ||
|
|
6886dd2f1a | ||
|
|
5acec3f04a | ||
|
|
6861a4d36b | ||
|
|
54fb586d9b | ||
|
|
60bcd691c8 | ||
|
|
dd2c01678f | ||
|
|
2633fc80cc | ||
|
|
da9efc036b | ||
|
|
7d2b8093ae | ||
|
|
15a67446ea | ||
|
|
50e7b7536d | ||
|
|
d868b43d52 | ||
|
|
c4dc4bcb88 | ||
|
|
c7b206be91 | ||
|
|
f88c94fbbd | ||
|
|
59525cb126 | ||
|
|
383cf213d8 | ||
|
|
9e15483752 | ||
|
|
ea49398e4d | ||
|
|
cde19e01e0 | ||
|
|
ef59f86071 | ||
|
|
1e602b85e1 | ||
|
|
2b110b6fc5 | ||
|
|
89ed36849b | ||
|
|
0ac3ccb144 | ||
|
|
9b9d92592c | ||
|
|
7fc7fbf86f | ||
|
|
cbffeca689 | ||
|
|
dfd6c5c3d2 | ||
|
|
c1af584093 | ||
|
|
db3ac97af8 | ||
|
|
25ca1f7be0 | ||
|
|
2be8b3e8c7 | ||
|
|
7694f3f60a | ||
|
|
52e88e7e36 | ||
|
|
64859b38ad | ||
|
|
601f33d95e | ||
|
|
f355065bfb | ||
|
|
0bd1818678 | ||
|
|
cdf3b7bb51 | ||
|
|
61a666da35 | ||
|
|
fa81292bd8 | ||
|
|
b63d61baa5 | ||
|
|
3aa6740ffe | ||
|
|
ce4ec6820c | ||
|
|
2809d2bd51 | ||
|
|
56fb4b2945 | ||
|
|
e13f8fa4e9 | ||
|
|
fe63728387 | ||
|
|
543507abd5 | ||
|
|
56b0f8f582 | ||
|
|
1f8893d3c5 | ||
|
|
2828de3b12 | ||
|
|
40aa7e6662 | ||
|
|
9569c2b74a | ||
|
|
1aeba8d769 | ||
|
|
0d83e9039f | ||
|
|
b57bcede62 | ||
|
|
80354f7d3f | ||
|
|
c1e7a9c166 | ||
|
|
0952c84d37 | ||
|
|
4ccd1ffb4e | ||
|
|
3add5c0421 | ||
|
|
50f19ebf1f | ||
|
|
fb9440eaee | ||
|
|
27aaaa7c8a | ||
|
|
225d00de5b | ||
|
|
791babe430 | ||
|
|
c77e1abab3 | ||
|
|
a01cf9bbaa | ||
|
|
71905a147a | ||
|
|
756bcd0fc1 | ||
|
|
677a7b3554 | ||
|
|
a20da1cf23 | ||
|
|
26b5ee3107 | ||
|
|
d300bce99b | ||
|
|
0e9290c0cb | ||
|
|
55f098a27e | ||
|
|
5d00b56ea1 | ||
|
|
46da871864 | ||
|
|
fed99855e3 | ||
|
|
34e3abb680 | ||
|
|
4a0964dce5 | ||
|
|
c11fd53026 | ||
|
|
2c419d9778 | ||
|
|
b69852fa4c | ||
|
|
4c77b967ff | ||
|
|
1abbdcd641 | ||
|
|
e4729bc3ae | ||
|
|
3fce2f36a4 | ||
|
|
5233c56021 | ||
|
|
f6763939e2 | ||
|
|
ecfab77cad | ||
|
|
2664daae88 | ||
|
|
8907380303 | ||
|
|
b5e524aafd | ||
|
|
d3269e002f | ||
|
|
39296aa06b | ||
|
|
e5a93bd16a | ||
|
|
4bc97deff1 | ||
|
|
a26d57dff3 | ||
|
|
2cad8dbfb2 | ||
|
|
f060a96848 | ||
|
|
3f9ff8f53e | ||
|
|
7c79c7d007 | ||
|
|
3d0ce469e9 | ||
|
|
98d596e61b | ||
|
|
16b2351d9c | ||
|
|
6019199055 | ||
|
|
4745ea0091 | ||
|
|
3afa9b2e40 | ||
|
|
cce946e83a | ||
|
|
74bd7bc283 | ||
|
|
b72e586d06 | ||
|
|
9f63acfe2f | ||
|
|
f0cbf92489 | ||
|
|
28aee4d978 | ||
|
|
6ba5767aba | ||
|
|
cef2c6a2fe | ||
|
|
fc5a7e654d | ||
|
|
1f90bb8255 | ||
|
|
ebdd93c7fc | ||
|
|
8dec46e01d | ||
|
|
70d35ff1a5 | ||
|
|
7552fc385b | ||
|
|
ca397a4554 | ||
|
|
2ac20c8915 | ||
|
|
7876bd2f74 | ||
|
|
dd142c1452 | ||
|
|
b324bf7715 | ||
|
|
9a1fff7a5e | ||
|
|
2f0566c407 | ||
|
|
621a516002 | ||
|
|
9d748d997a | ||
|
|
3ee58b28f2 | ||
|
|
2d2ffeac44 | ||
|
|
53f5318423 | ||
|
|
3f6aab9d17 | ||
|
|
1b129979fa | ||
|
|
da6c18310b | ||
|
|
e262e23e2b | ||
|
|
f9734c417a | ||
|
|
087bdcc9d6 | ||
|
|
dbc4e3ed4e | ||
|
|
992c260e1a | ||
|
|
a199304e30 | ||
|
|
f76af5ebd3 | ||
|
|
bd683ca5dd | ||
|
|
107671840a | ||
|
|
5d8dcf87dc | ||
|
|
c572070270 | ||
|
|
2e924f277a | ||
|
|
f3e999e9d9 | ||
|
|
6641fb83a4 | ||
|
|
c898849ef4 | ||
|
|
fe1d4d3f1d | ||
|
|
078208c0fc | ||
|
|
fa45dcb0d1 | ||
|
|
9c2c70270f | ||
|
|
a9327c5bb9 | ||
|
|
014ca7cb52 | ||
|
|
d3e0bb5885 | ||
|
|
0be27d021d | ||
|
|
ecaa24c0f6 | ||
|
|
b41262f5d3 | ||
|
|
e53b65ee24 | ||
|
|
b663259ede | ||
|
|
31ba75a816 | ||
|
|
2bd94474cd | ||
|
|
1dcde4b345 | ||
|
|
85dddc61fb | ||
|
|
53a46bd2c4 | ||
|
|
9409d162ce | ||
|
|
9f3b257588 | ||
|
|
3a2b714fdd | ||
|
|
b390d4d11d | ||
|
|
3122e34797 | ||
|
|
114db66621 | ||
|
|
379e8e70e4 | ||
|
|
7c7d632949 | ||
|
|
46c9c623c3 | ||
|
|
87ee347a9e | ||
|
|
9b7d5cea4b | ||
|
|
409ac5f5cd | ||
|
|
717e4fb1a1 | ||
|
|
a61c1c4ce8 | ||
|
|
f04fc47a66 | ||
|
|
cd31316c86 | ||
|
|
3e053c6b51 | ||
|
|
45ff1b2b4f | ||
|
|
48b3ddc24f | ||
|
|
dd8fcd26f1 | ||
|
|
3670415ea7 | ||
|
|
cae4240afb | ||
|
|
3c01873fb0 | ||
|
|
f0d0a80868 | ||
|
|
a794408ccb | ||
|
|
6136034774 | ||
|
|
216bffcb28 | ||
|
|
f37001afff | ||
|
|
2e82e8ff6f | ||
|
|
685ddd133c | ||
|
|
979aa9d9d1 | ||
|
|
a3bfb7ca3b | ||
|
|
13192aceb3 | ||
|
|
57934046ee | ||
|
|
4786feaf80 | ||
|
|
6b2fbb574a | ||
|
|
d485219c68 | ||
|
|
7f31a36631 | ||
|
|
1ed2f9ff59 | ||
|
|
6cfe7381b5 | ||
|
|
405f737449 | ||
|
|
bb2cc320ec | ||
|
|
e2acbc077d | ||
|
|
efb3c03c5c | ||
|
|
07321df860 | ||
|
|
9b8fa3ebb0 | ||
|
|
44636fe1b5 | ||
|
|
8e83e0deb3 | ||
|
|
c3a21ba69f | ||
|
|
4faf422d3f | ||
|
|
da295dfe4b | ||
|
|
f207cf2439 | ||
|
|
6f250192da | ||
|
|
0cee3a244c | ||
|
|
b3d3389dca | ||
|
|
0991db5ab8 | ||
|
|
7bc2d38204 | ||
|
|
3c8a505f4b | ||
|
|
1ad80eb6a7 | ||
|
|
2bf69e1b5e | ||
|
|
2398c50fdf | ||
|
|
8ae764f8c6 | ||
|
|
7dcefba229 | ||
|
|
a3eeb6da9a | ||
|
|
110401a3b8 | ||
|
|
bfb08ea86f | ||
|
|
d2ab8944e7 | ||
|
|
ac693b5163 | ||
|
|
6cf6492cb4 | ||
|
|
c0a4f2673a | ||
|
|
33324f5515 | ||
|
|
f81961c5b6 | ||
|
|
93c8e3887a | ||
|
|
dd6bd9d3ad | ||
|
|
3ce839bbd5 | ||
|
|
38652c3a82 | ||
|
|
11653896a5 |
+1
-1
@@ -9,6 +9,6 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
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
|
||||
polar: textbee
|
||||
# buy_me_a_coffee: vernu
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'web/**'
|
||||
- 'android/**'
|
||||
- '.github/workflows/build-and-test.yaml'
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to run workflow on'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
android_variant:
|
||||
description: 'Android build variant'
|
||||
required: true
|
||||
default: 'dev'
|
||||
type: choice
|
||||
options:
|
||||
- dev
|
||||
- prod
|
||||
|
||||
jobs:
|
||||
build-and-test-web-and-api:
|
||||
name: Build and Test web and api
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Build and test API
|
||||
run: |
|
||||
cd api
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm test
|
||||
|
||||
# Web previously ran `build` only, so its unit and e2e suites never
|
||||
# gated a merge. Lint, typecheck and unit tests are fast and
|
||||
# deterministic, so they block.
|
||||
- name: Install web dependencies
|
||||
run: |
|
||||
cd web
|
||||
pnpm install
|
||||
|
||||
- name: Lint web
|
||||
run: |
|
||||
cd web
|
||||
pnpm lint
|
||||
|
||||
- name: Typecheck web
|
||||
run: |
|
||||
cd web
|
||||
pnpm typecheck
|
||||
|
||||
- name: Unit test web
|
||||
run: |
|
||||
cd web
|
||||
pnpm test
|
||||
|
||||
- name: Build web
|
||||
run: |
|
||||
cd web
|
||||
pnpm run build
|
||||
|
||||
# E2e is fully mocked (e2e/mock-api.ts intercepts every backend call),
|
||||
# but it drives a real browser, so it is reported without blocking until
|
||||
# it has proven stable across a few merges.
|
||||
#
|
||||
# TODO: remove continue-on-error once that is established. It is here to
|
||||
# avoid wedging the merge queue on a browser flake, not because e2e
|
||||
# failures are acceptable.
|
||||
- name: Install Playwright browser
|
||||
run: |
|
||||
cd web
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: E2e test web (non-blocking for now)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd web
|
||||
pnpm test:e2e
|
||||
|
||||
build-and-test-android:
|
||||
name: Build and Test Android
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
variant: [dev, prod]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || github.ref }}
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
cache: gradle
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
gradle-version: '7.2'
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x android/gradlew
|
||||
|
||||
- name: Create debug keystore
|
||||
run: |
|
||||
mkdir -p ~/.android
|
||||
keytool -genkey -v -keystore ~/.android/debug.keystore -storepass android -alias androiddebugkey -keypass android -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
|
||||
|
||||
- name: Create google-services.json for dev
|
||||
if: matrix.variant == 'dev'
|
||||
run: |
|
||||
mkdir -p android/app/src/dev
|
||||
echo '${{ secrets.GOOGLE_SERVICES_JSON_DEV }}' > android/app/src/dev/google-services.json
|
||||
|
||||
- name: Create google-services.json for prod
|
||||
if: matrix.variant == 'prod'
|
||||
run: |
|
||||
mkdir -p android/app/src/prod
|
||||
echo '${{ secrets.GOOGLE_SERVICES_JSON_PROD }}' > android/app/src/prod/google-services.json
|
||||
|
||||
- name: Build Android app
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assemble${{ matrix.variant }}Debug
|
||||
|
||||
# - name: Run Android tests
|
||||
# run: |
|
||||
# cd android
|
||||
# ./gradlew test${{ matrix.variant }}DebugUnitTest
|
||||
|
||||
- name: Sanitize ref name for artifact
|
||||
shell: bash
|
||||
run: echo "SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: textbee-${{ matrix.variant }}-build-${{ env.SAFE_REF_NAME }}-${{ github.sha }}.apk
|
||||
path: android/app/build/outputs/apk/${{ matrix.variant }}/debug/app-${{ matrix.variant }}-debug.apk
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,124 @@
|
||||
name: Build and push a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["v*.*.*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
git_ref:
|
||||
description: 'Git Ref (Branch, Tag or Release)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
image_name:
|
||||
description: 'Docker image name (api, web, or both)'
|
||||
required: true
|
||||
default: 'both'
|
||||
type: choice
|
||||
options:
|
||||
- api
|
||||
- web
|
||||
- both
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
#IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and push Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.git_ref || github.ref }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: "arm64,amd64"
|
||||
|
||||
# Workaround: https://github.com/docker/build-push-action/issues/461
|
||||
- name: Setup Docker buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata for API
|
||||
id: meta-api
|
||||
if: ${{ github.event.inputs.image_name == 'api' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}/api
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=raw,value=${{ github.event.inputs.git_ref }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image for API
|
||||
id: build-and-push-api
|
||||
if: ${{ github.event.inputs.image_name == 'api' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: api
|
||||
build-args: VERSION=${{ github.event.inputs.git_ref || github.ref_name }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-api.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.meta-api.outputs.labels }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata for Web
|
||||
id: meta-web
|
||||
if: ${{ github.event.inputs.image_name == 'web' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository }}/web
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=semver,pattern=v{{version}}
|
||||
type=semver,pattern=v{{major}}.{{minor}}
|
||||
type=semver,pattern=v{{major}}
|
||||
type=raw,value=${{ github.event.inputs.git_ref }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image for Web
|
||||
id: build-and-push-web
|
||||
if: ${{ github.event.inputs.image_name == 'web' || github.event.inputs.image_name == 'both' || github.event_name != 'workflow_dispatch' }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: web
|
||||
build-args: VERSION=${{ github.event.inputs.git_ref || github.ref_name }}
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta-web.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.meta-web.outputs.labels }}
|
||||
@@ -1,2 +1,7 @@
|
||||
.vercel
|
||||
.DS_Store
|
||||
*-monitor
|
||||
|
||||
.cursor
|
||||
.claude/
|
||||
.omc/
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Israel Abebe Kokiso
|
||||
Copyright (c) 2022 - 2026 textbee.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,38 +1,244 @@
|
||||
# TextBee - Android SMS Gateway
|
||||

|
||||

|
||||

|
||||
[](https://discord.gg/d7vyfBpWbQ)
|
||||
|
||||
A simple SMS gateway that allows users to send SMS messages from a web interface or
|
||||
from their application via a REST API. It utilizes android phones as SMS gateways.
|
||||
# textbee.dev - android sms gateway
|
||||
|
||||
- **Technology stack**: React, Next.js, Node.js, NestJs, MongoDB, Android, Java
|
||||
- **Link**: [https://textbee.dev](https://textbee.dev/)
|
||||
Send and receive SMS messages using your own Android phone - no Twilio, no per-message fees. Free, open-source, and self-hostable.
|
||||
|
||||

|
||||
Manage SMS messages through a web dashboard or a REST API. textbee is ideal for businesses, developers, and hobbyists looking for a reliable and cost-effective solution to automate SMS messaging.
|
||||
|
||||
## Usage
|
||||
**Website:** [https://textbee.dev](https://textbee.dev?ref=gh-readme)
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
## Why textbee?
|
||||
|
||||
| | textbee | Twilio & similar APIs |
|
||||
|---|---|---|
|
||||
| Cost per SMS | Your carrier plan (often free/unlimited) | ~$0.008+ per message |
|
||||
| Phone number | Your own SIM | Rented number |
|
||||
| Self-hostable | ✅ Full control over your data | ❌ |
|
||||
| Open source | ✅ | ❌ |
|
||||
| Setup time | ~2 minutes | Account approval, compliance forms |
|
||||
|
||||
## Features
|
||||
|
||||
- Send & receive SMS messages via API & dashboard
|
||||
- Use your own Android phone as an SMS gateway
|
||||
- REST API for easy integration with apps & services
|
||||
- Send bulk SMS with CSV file
|
||||
- Multi-device support for higher SMS throughput
|
||||
- Secure API authentication with API keys
|
||||
- Webhook support for incoming messages
|
||||
- Self-hosting support for full control over your data
|
||||
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Go to [textbee.dev](https://textbee.dev) and register or login with your account
|
||||
2. Install the app on your android phone from [dl.textbee.dev](https://dl.textbee.dev)
|
||||
2. Install the app on your Android phone from [textbee.dev/download](https://textbee.dev/download)
|
||||
3. Open the app and grant the permissions for SMS
|
||||
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device/ generate API Key
|
||||
4. Go to [textbee.dev/dashboard](https://textbee.dev/dashboard) and click register device / generate API key
|
||||
5. Scan the QR code with the app or enter the API key manually
|
||||
6. You are ready to send SMS messages from the dashboard or from your application via the REST API
|
||||
6. You're ready to send SMS from the dashboard or from your application via the REST API
|
||||
|
||||
**Code Snippet**: Few lines of code showing how to send an SMS message via the REST API
|
||||
|
||||
|
||||
### Sending an SMS
|
||||
|
||||
```javascript
|
||||
const API_KEY = 'YOUR_API_KEY';
|
||||
const DEVICE_ID = 'YOUR_DEVICE_ID';
|
||||
|
||||
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/sendSMS`, {
|
||||
|
||||
await axios.post(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/send-sms`, {
|
||||
recipients: [ '+251912345678' ],
|
||||
message: 'Hello World!',
|
||||
}, {
|
||||
headers: {
|
||||
'x-api-key': API_KEY,
|
||||
},
|
||||
headers: { 'x-api-key': API_KEY },
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Python</b></summary>
|
||||
```python
|
||||
import requests
|
||||
|
||||
API_KEY = 'YOUR_API_KEY'
|
||||
DEVICE_ID = 'YOUR_DEVICE_ID'
|
||||
|
||||
requests.post(
|
||||
f'https://api.textbee.dev/api/v1/gateway/devices/{DEVICE_ID}/send-sms',
|
||||
json={
|
||||
'recipients': ['+251912345678'],
|
||||
'message': 'Hello World!',
|
||||
},
|
||||
headers={'x-api-key': API_KEY},
|
||||
)
|
||||
```
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>curl</b></summary>
|
||||
```bash
|
||||
curl -X POST "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/send-sms" \
|
||||
-H 'x-api-key: YOUR_API_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"recipients": [ "+251912345678" ],
|
||||
"message": "Hello World!"
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
### Receiving SMS
|
||||
|
||||
Enable SMS receiving in the mobile app, then access incoming messages via the REST API, the dashboard, or webhook notifications delivered to your preferred URL.
|
||||
|
||||
```javascript
|
||||
const API_KEY = 'YOUR_API_KEY';
|
||||
const DEVICE_ID = 'YOUR_DEVICE_ID';
|
||||
|
||||
await axios.get(`https://api.textbee.dev/api/v1/gateway/devices/${DEVICE_ID}/get-received-sms`, {
|
||||
headers: { 'x-api-key': API_KEY },
|
||||
});
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>curl</b></summary>
|
||||
```bash
|
||||
curl -X GET "https://api.textbee.dev/api/v1/gateway/devices/YOUR_DEVICE_ID/get-received-sms" \
|
||||
-H "x-api-key: YOUR_API_KEY"
|
||||
```
|
||||
|
||||
</details>
|
||||
## Use Cases
|
||||
|
||||
- OTP / 2FA delivery for your app
|
||||
- Order and appointment notifications
|
||||
- Alerts from servers, cron jobs, and home automation
|
||||
- Form-to-SMS and lead follow-ups
|
||||
- Bulk announcements to a contact list (CSV upload)
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Will my carrier block my number for sending too many messages?</b></summary>
|
||||
Carriers apply their own rate limits and anti-spam policies, which vary by country and plan. For personal and low-volume use this is rarely an issue. For higher throughput, use multiple devices/SIMs and keep sending rates reasonable. You are responsible for staying within your carrier's terms.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Is it legal to send marketing SMS this way?</b></summary>
|
||||
SMS marketing is regulated in most countries (e.g., TCPA in the US, GDPR/ePrivacy in the EU). textbee is a tool — you are responsible for obtaining consent and complying with the laws that apply to you and your recipients.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Does my phone need to stay on?</b></summary>
|
||||
Yes — messages are sent through your phone, so it needs to be powered on with the app running and connected to the internet. A spare Android phone plugged into a charger works great as a dedicated gateway.
|
||||
|
||||
</details>
|
||||
<details>
|
||||
<summary><b>Is there a limit on the cloud-hosted version?</b></summary>
|
||||
See [textbee.dev](https://textbee.dev) for current plans and limits. You can always self-host for full control.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
**Technology stack**: React, Next.js, Node.js, NestJS, MongoDB, Android, Kotlin, Jetpack Compose, Java (legacy)
|
||||
|
||||
### Setting Up Database
|
||||
|
||||
1. **Install MongoDB on Your Server**: Follow the official MongoDB installation guide for your operating system.
|
||||
2. **Using MongoDB Atlas**: Alternatively, you can create a free database on MongoDB Atlas. Sign up at [MongoDB Atlas](https://www.mongodb.com/cloud/atlas) and follow the instructions to set up your database.
|
||||
|
||||
|
||||
### Firebase Setup
|
||||
|
||||
1. Create a Firebase project.
|
||||
2. Enable Firebase Cloud Messaging (FCM) in your Firebase project.
|
||||
3. Obtain the Firebase credentials for backend use and the Android app.
|
||||
|
||||
### Building the Android App
|
||||
|
||||
1. Clone the repository and navigate to the Android project directory.
|
||||
2. Update the `google-services.json` file with your Firebase project configuration.
|
||||
3. Update every occurrence of `textbee.dev` with your own domain in the project.
|
||||
4. Build the app using Android Studio or the command line:
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
### Building the Web
|
||||
|
||||
1. Navigate to the `web` directory.
|
||||
2. Copy the `.env.example` file to `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
3. Update the `.env` file with your own credentials.
|
||||
4. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
5. Build the web application:
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Building the API
|
||||
|
||||
1. Navigate to the `api` directory.
|
||||
2. Copy the `.env.example` file to `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
3. Update the `.env` file with your own credentials.
|
||||
4. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
5. Build the API:
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Hosting on a VPS
|
||||
|
||||
1. Install `pnpm`, `pm2`, and `Caddy` on your VPS.
|
||||
2. Use `pm2` to manage your Node.js processes:
|
||||
```bash
|
||||
pm2 start dist/main.js --name textbee-api
|
||||
```
|
||||
3. Configure `Caddy` to serve your web application and API. Example Caddyfile:
|
||||
```
|
||||
textbee.dev {
|
||||
reverse_proxy /api/* localhost:3000
|
||||
reverse_proxy /* localhost:3001
|
||||
}
|
||||
```
|
||||
4. Ensure your domain points to your VPS and Caddy is configured properly.
|
||||
|
||||
### Dockerized env
|
||||
#### Requirements:
|
||||
- Docker installed
|
||||
1. After setting up Firebase, update your `.env` in `web` && `api` folder.
|
||||
```bash
|
||||
cd web && cp .env.example .env \
|
||||
&& cd ../api && cp .env.example .env
|
||||
```
|
||||
2. Navigate to root folder and execute docker-compose.yml file.
|
||||
This will spin up `web` container, `api` container alongside with `MongoDB` and `MongoExpress`. `TextBee` database will be automatically created.
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
To stop the containers simply type
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -47,4 +253,7 @@ Contributions are welcome!
|
||||
|
||||
Please feel free to [create an issue](https://github.com/vernu/textbee/issues/new) in the repository for any bug reports or feature requests. Make sure to provide a detailed description of the issue or feature you are requesting and properly label whether it is a bug or a feature request.
|
||||
|
||||
Please note that if you discover any vulnerability or security issue, we kindly request that you refrain from creating a public issue. Instead, send an email detailing the vulnerability to textbee.dev@gmail.com.
|
||||
Please note that if you discover any vulnerability or security issue, we kindly request that you refrain from creating a public issue. Instead, send an email detailing the vulnerability to contact@textbee.dev.
|
||||
|
||||
## For support, feedback, and questions
|
||||
Feel free to reach out to us at contact@textbee.dev or [Join our Discord server](https://discord.gg/d7vyfBpWbQ)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover any vulnerability or security issue, we kindly request that you refrain from creating a public issue. Instead, send an email detailing the vulnerability to contact@textbee.dev.
|
||||
+8
-1
@@ -13,4 +13,11 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/release/*
|
||||
/app/release/*
|
||||
|
||||
*.apk
|
||||
|
||||
# Firebase configuration files
|
||||
/app/src/dev/google-services.json
|
||||
/app/src/prod/google-services.json
|
||||
/app/google-services.json
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
<bytecodeTargetLevel target="17" />
|
||||
</component>
|
||||
</project>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="deploymentTargetDropDown">
|
||||
<value>
|
||||
<entry key="app">
|
||||
<State>
|
||||
<runningDeviceTargetSelectedWithDropDown>
|
||||
<Target>
|
||||
<type value="RUNNING_DEVICE_TARGET" />
|
||||
<deviceKey>
|
||||
<Key>
|
||||
<type value="SERIAL_NUMBER" />
|
||||
<value value="adb-RFCX417HC5V-ZWjtU2._adb-tls-connect._tcp." />
|
||||
</Key>
|
||||
</deviceKey>
|
||||
</Target>
|
||||
</runningDeviceTargetSelectedWithDropDown>
|
||||
<timeTargetWasSelectedWithDropDown value="2025-08-09T15:50:54.600783Z" />
|
||||
</State>
|
||||
</entry>
|
||||
</value>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+2
-2
@@ -4,15 +4,15 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
</set>
|
||||
</option>
|
||||
<option name="resolveExternalAnnotations" value="false" />
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
|
||||
Generated
+10
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-2
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DesignSurface">
|
||||
<option name="filePathToZoomLevelMap">
|
||||
@@ -7,7 +6,7 @@
|
||||
</map>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="jbr-17" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Android Build Variants Setup (Dev vs Prod)
|
||||
|
||||
This document explains how to use the dev and prod build variants for the SMS Gateway Android app.
|
||||
|
||||
## Overview
|
||||
|
||||
The app now supports two build variants:
|
||||
- **Dev**: For development and testing
|
||||
- **Prod**: For production releases
|
||||
|
||||
## Key Differences
|
||||
|
||||
| Feature | Dev | Prod |
|
||||
|---------|-----|------|
|
||||
| Package Name | `com.vernu.sms.dev` | `com.vernu.sms` |
|
||||
| App Name | "SMS Gateway (Dev)" | "SMS Gateway" |
|
||||
| API Base URL | `https://api-dev.textbee.dev/api/v1/` | `https://api.textbee.dev/api/v1/` |
|
||||
| Firebase Config | `app/src/dev/google-services.json` | `app/src/prod/google-services.json` |
|
||||
| Version Suffix | `-dev` appended | No suffix |
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Firebase Configuration
|
||||
|
||||
#### For Production:
|
||||
- The current `google-services.json` has been moved to `app/src/prod/google-services.json`
|
||||
- No changes needed if you're already using the production Firebase project
|
||||
|
||||
#### For Development:
|
||||
- Create a new Firebase project for development
|
||||
- Download the `google-services.json` for your dev project
|
||||
- **Important**: Make sure the package name in Firebase is set to `com.vernu.sms.dev`
|
||||
- Replace the template file at `app/src/dev/google-services.json` with your actual dev configuration
|
||||
|
||||
### 2. API Configuration
|
||||
|
||||
The API base URLs are now configured via build variants:
|
||||
- **Dev**: `https://api-dev.textbee.dev/api/v1/`
|
||||
- **Prod**: `https://api.textbee.dev/api/v1/`
|
||||
|
||||
To change the dev API URL, edit the `buildConfigField` in `app/build.gradle`:
|
||||
```gradle
|
||||
dev {
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api-dev.textbee.dev/api/v1/"'
|
||||
}
|
||||
```
|
||||
|
||||
## Building the App
|
||||
|
||||
### Using Android Studio:
|
||||
1. Open the "Build Variants" panel (View → Tool Windows → Build Variants)
|
||||
2. Select the desired variant:
|
||||
- `devDebug` - Development build for debugging
|
||||
- `devRelease` - Development release build
|
||||
- `prodDebug` - Production build for debugging
|
||||
- `prodRelease` - Production release build
|
||||
|
||||
### Using Command Line:
|
||||
```bash
|
||||
# Build dev debug
|
||||
./gradlew assembleDevDebug
|
||||
|
||||
# Build dev release
|
||||
./gradlew assembleDevRelease
|
||||
|
||||
# Build prod debug
|
||||
./gradlew assembleProdDebug
|
||||
|
||||
# Build prod release
|
||||
./gradlew assembleProdRelease
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Both variants can be installed simultaneously on the same device since they have different package names:
|
||||
- Dev app will show as "SMS Gateway (Dev)"
|
||||
- Prod app will show as "SMS Gateway"
|
||||
|
||||
## Environment Detection
|
||||
|
||||
You can detect which environment the app is running in using:
|
||||
```java
|
||||
if (BuildConfig.ENVIRONMENT.equals("development")) {
|
||||
// Development-specific code
|
||||
} else {
|
||||
// Production code
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Different Package Names**: Dev and prod apps are completely separate and can coexist
|
||||
2. **Separate Data**: Each variant maintains its own app data and preferences
|
||||
3. **Firebase Projects**: Use separate Firebase projects for dev and prod
|
||||
4. **API Endpoints**: Ensure your backend has corresponding dev/prod endpoints
|
||||
5. **Testing**: Always test the prod build before releasing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors:
|
||||
- Ensure both `google-services.json` files are properly configured
|
||||
- Check that package names match between Firebase console and app configuration
|
||||
- Verify API URLs are accessible
|
||||
|
||||
### Firebase Issues:
|
||||
- Confirm the package name in Firebase matches the variant (`com.vernu.sms.dev` for dev)
|
||||
- Ensure SHA-1 fingerprints are added to Firebase if using authentication
|
||||
@@ -0,0 +1,191 @@
|
||||
# Android Kotlin/Compose Migration
|
||||
|
||||
## Overview
|
||||
|
||||
TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jetpack Compose. The new UI runs in parallel with the legacy UI — users can switch between them via Settings. The SplashActivity routes to the appropriate UI on launch.
|
||||
|
||||
---
|
||||
|
||||
## What's Done
|
||||
|
||||
### Theme
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/theme/Color.kt` | Brand orange (`#C4620A`), full light/dark palette |
|
||||
| `ui/theme/Theme.kt` | `TextBeeTheme` wrapper, `dynamicColor = false` to preserve brand color |
|
||||
| `ui/theme/Type.kt` | Material3 typography scale |
|
||||
|
||||
### Infrastructure
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ApiManagerKt.kt` | Kotlin singleton Retrofit client, mirrors `ApiManager.java` |
|
||||
| `services/GatewayApiServiceKt.kt` | Kotlin suspend-function Retrofit interface (all new UI endpoints) |
|
||||
| `dtos/GatewayStatsResponse.kt` | Nullable stats DTO |
|
||||
| `dtos/SubscriptionResponse.kt` | Plan, usage, renewal DTO |
|
||||
| `dtos/UserProfileResponse.kt` | `name`, `email` from `/auth/who-am-i` |
|
||||
| `dtos/MessagesResponse.kt` | `SmsMessage`, `PaginationMeta`, `SendSmsRequest` |
|
||||
|
||||
### UI — Onboarding
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/onboarding/OnboardingActivity.kt` | Compose NavHost shell |
|
||||
| `ui/onboarding/OnboardingViewModel.kt` | Registration state + API calls |
|
||||
| `ui/onboarding/screens/WelcomeScreen.kt` | Get Started / I have a Device ID |
|
||||
| `ui/onboarding/screens/CredentialsScreen.kt` | QR scan + manual API key entry |
|
||||
| `ui/onboarding/screens/DeviceSetupScreen.kt` | Register or reconnect device |
|
||||
| `ui/onboarding/screens/PermissionsScreen.kt` | SMS + phone state permissions |
|
||||
| `ui/onboarding/screens/SetupCompleteScreen.kt` | Success + navigate to dashboard |
|
||||
| `ui/onboarding/screens/OnboardingComponents.kt` | Shared step indicator, etc. |
|
||||
|
||||
### UI — Main App
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/splash/SplashActivity.kt` | Routes to legacy/onboarding/main based on SharedPrefs |
|
||||
| `ui/main/NewMainActivity.kt` | Bottom nav (Dashboard / Messages / Settings) + compose + filters routes |
|
||||
| `ui/dashboard/DashboardScreen.kt` | Device card, stats, subscription, quick actions |
|
||||
| `ui/dashboard/DashboardViewModel.kt` | Stats, subscription, user profile, gateway toggle |
|
||||
| `ui/messages/MessagesScreen.kt` | Filter chips, message list, detail dialog, FAB |
|
||||
| `ui/messages/MessagesViewModel.kt` | Paginated message fetch, filter state |
|
||||
| `ui/messages/ComposeScreen.kt` | Multi-recipient input, message field, send with snackbar feedback |
|
||||
| `ui/messages/ComposeViewModel.kt` | Send SMS, error body parsing, success/error state |
|
||||
| `ui/settings/SettingsScreen.kt` | Account, Gateway, SMS, Legal, System, UI sections |
|
||||
| `ui/settings/SettingsViewModel.kt` | Device name save, gateway toggle, SIM picker |
|
||||
|
||||
### UI — Settings (Phase 3) ✅
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `ui/settings/SMSFilterScreen.kt` | Full Compose SMS filter screen: enable switch, allow/block mode chips, rule list with FAB, add/edit dialog |
|
||||
| `ui/settings/SMSFilterViewModel.kt` | `AndroidViewModel` — loads/saves `FilterConfig` via StateFlow; deep-copies config on each mutation |
|
||||
|
||||
`SMSFilterActivity.java` is still present for the legacy UI. The new UI navigates to the `"filters"` composable route inside `NewMainActivity`; bottom bar is hidden on both `"compose"` and `"filters"` routes.
|
||||
|
||||
### Data Layer — Kotlin Stubs (Phase 4) ✅
|
||||
|
||||
**Room DB (block-commented — feature not yet enabled):**
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `database/local/Sms.kt` | Replaces `SMS.java`; `@Entity` data class inside `/* */` block comment |
|
||||
| `database/local/SmsDao.kt` | Replaces `SMSDao.java`; `@Dao` interface with suspend funs, block-commented |
|
||||
| `database/local/AppDatabase.kt` | Replaces `AppDatabase.java`; singleton with companion object, block-commented |
|
||||
| `database/local/DateConverter.kt` | Replaces `DateConverter.java`; `object` with `@TypeConverter`, block-commented |
|
||||
|
||||
**DTOs (all Java originals deleted):**
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `dtos/RegisterDeviceInputDTO.kt` | `class` with `var`; `@get:JvmName("isEnabled")` so Java/Kotlin callers get `isEnabled()` not `getEnabled()` |
|
||||
| `dtos/RegisterDeviceResponseDTO.kt` | Regular `class` with `@JvmField var` — `MainActivity.java` accesses `.data`/`.error` as direct fields |
|
||||
| `dtos/SMSDTO.kt` | Regular `class`; `message: String = ""` to avoid null default |
|
||||
| `dtos/HeartbeatInputDTO.kt` | `var isCharging: Boolean?` (nullable) — generates `getIsCharging()`/`setIsCharging()` matching Java non-standard getter name |
|
||||
| `dtos/HeartbeatResponseDTO.kt` | `@JvmField var` on all properties — `HeartbeatHelper` accesses fields directly (`.fcmTokenUpdated`) |
|
||||
| `dtos/SimInfoDTO.kt` | `subscriptionId: Int = 0` (was primitive in Java) |
|
||||
| `dtos/SimInfoCollectionDTO.kt` | `sims: MutableList<SimInfoDTO>? = null` |
|
||||
| `dtos/SMSForwardResponseDTO.kt` | Empty class body |
|
||||
|
||||
### Helpers & Models (Phase 5) ✅
|
||||
|
||||
All helpers are Kotlin `object` with `@JvmStatic` on every public method — at the time of porting, Java workers/receivers called them; those callers were ported in Phase 6.
|
||||
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `helpers/SharedPreferenceHelper.kt` | Replaces `SharedPreferenceHelper.java`; `PREF_FILE = "PREF"`, 7 methods |
|
||||
| `helpers/SMSFilterHelper.kt` | Replaces `SMSFilterHelper.java`; nested `FilterMode` enum + `FilterConfig` class; Gson-compatible field names |
|
||||
| `helpers/SMSHelper.kt` | Replaces `SMSHelper.java`; `FLAG_MUTABLE` on API >= S; private PendingIntent helpers |
|
||||
| `helpers/HeartbeatHelper.kt` | Replaces `HeartbeatHelper.java`; `CountDownLatch` FCM token wait, `@Suppress("DEPRECATION")` for legacy network API |
|
||||
| `helpers/HeartbeatManager.kt` | Replaces `HeartbeatManager.java`; `PeriodicWorkRequest.Builder(HeartbeatWorker::class.java, ...)` |
|
||||
| `models/SMSFilterRule.kt` | Replaces `SMSFilterRule.java`; `@JvmOverloads constructor` for Java callers; nested `MatchType` + `FilterTarget` enums |
|
||||
| `models/SMSPayload.kt` | Replaces `SMSPayload.java`; keeps legacy `receivers` + `smsBody` fields |
|
||||
|
||||
---
|
||||
|
||||
## What's Left (Java / Legacy)
|
||||
|
||||
### Core App
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `AppConstants.java` | Low | Constants only — convert when touching other things |
|
||||
| `SMSGatewayApplication.java` | Low | Application class, minimal logic |
|
||||
| `TextBeeUtils.java` | Medium | Heavily used utility; convert once helpers are stable |
|
||||
| `ApiManager.java` | Low | Still used by legacy UI; delete after legacy removal |
|
||||
|
||||
### Activities (Legacy UI)
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `activities/MainActivity.java` | High | Legacy main UI — remove after full Compose rollout |
|
||||
| `activities/SMSFilterActivity.java` | Medium | Legacy filter screen — still reachable from legacy UI only |
|
||||
|
||||
### Helpers
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `helpers/VersionTracker.java` | Low | Update check logic — left for later |
|
||||
|
||||
### Services
|
||||
| File | Priority | Notes |
|
||||
|---|---|---|
|
||||
| `services/GatewayApiService.java` | High | Java Retrofit interface — delete after legacy UI removed |
|
||||
|
||||
---
|
||||
|
||||
## Migration Roadmap
|
||||
|
||||
### Phase 3 — SMS Filter Screen ✅ Complete
|
||||
Ported `SMSFilterActivity.java` to `SMSFilterScreen.kt` (Compose). Integrated as a nested `"filters"` route inside `NewMainActivity`. Legacy `SMSFilterActivity.java` unchanged — still reachable from legacy UI.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Data Layer ✅ Complete
|
||||
All DTOs ported to Kotlin; Java originals deleted. Room DB ported to Kotlin stubs with all logic still inside `/* */` block comments (feature remains disabled).
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Helpers & Utilities ✅ Complete
|
||||
All helpers and models ported to Kotlin `object`s with `@JvmStatic`. Java originals deleted. Java callers (workers, receivers — Phase 6) continue to work unchanged via `@JvmStatic` interop.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Background Services & Receivers ✅ Complete
|
||||
All workers, receivers, and services ported to Kotlin; Java originals deleted.
|
||||
|
||||
| File | Notes |
|
||||
|---|---|
|
||||
| `receivers/BootCompletedReceiver.kt` | Restarts sticky notification + schedules heartbeat on boot |
|
||||
| `receivers/SMSBroadcastReceiver.kt` | Deduplication fingerprint cache; Kotlin property access on `SMSDTO` |
|
||||
| `receivers/SMSStatusReceiver.kt` | `setFailed()` private helper avoids `errorMessage` property shadowing |
|
||||
| `workers/HeartbeatWorker.kt` | Simple `Worker` subclass; delegates to `HeartbeatHelper` |
|
||||
| `workers/SmsSendWorker.kt` | SIM resolution priority chain; `Thread.sleep` rate limiting |
|
||||
| `workers/SMSReceivedWorker.kt` | Fingerprint-based unique work name for deduplication |
|
||||
| `workers/SMSStatusUpdateWorker.kt` | Exponential backoff, max 5 retries |
|
||||
| `services/StickyNotificationService.kt` | Broad `Exception` catch replaces API-31-only `ForegroundServiceStartNotAllowedException` |
|
||||
| `services/FCMService.kt` | Handles `heartbeat_check` type + SMS payload dispatch |
|
||||
|
||||
**Sticky notification fix**: Added service restart to `DashboardViewModel.loadLocalState()` — on every app launch, if gateway + sticky notification are enabled, the service is restarted. This matches legacy `MainActivity` behaviour and fixes the notification disappearing after Android kills the service on newer OS versions.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7 — Legacy UI Removal
|
||||
Once Compose UI is stable and rolled out to all users, remove the legacy UI entirely.
|
||||
|
||||
**Steps:**
|
||||
1. Remove the "Switch to Legacy UI" row from `SettingsScreen.kt`
|
||||
2. Remove `USE_NEW_UI_KEY` logic from `SplashActivity.kt` (always route to new UI)
|
||||
3. Delete `activities/MainActivity.java` and its XML layouts
|
||||
4. Delete `activities/SMSFilterActivity.java`
|
||||
5. Delete `services/GatewayApiService.java` (Java Retrofit interface)
|
||||
6. Delete `ApiManager.java`
|
||||
7. Remove "Try New UI" button from any remaining legacy layout XML
|
||||
8. Clean up `AppConstants.java` — remove `SHARED_PREFS_USE_NEW_UI_KEY`
|
||||
9. Convert `SMSGatewayApplication.java` → Kotlin
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints to Keep in Mind
|
||||
|
||||
- **`dynamicColor = false`** in `Theme.kt` — Material You overrides the brand orange on Android 12+; must stay false
|
||||
- **`primaryContainer` avoided** in TopAppBar/nav — causes orange-on-orange in dark mode; use `surface` for bars, `surfaceVariant` for nav indicator
|
||||
- **Java/Kotlin interop** — Only `ApiManager.java`, `TextBeeUtils.java`, legacy activities, and `GatewayApiService.java` remain Java; all others are Kotlin
|
||||
- **WorkManager workers** — kept as `Worker` subclass (not `CoroutineWorker`) to avoid adding `work-runtime-ktx`; straightforward conversion candidate in a future cleanup
|
||||
- **Sticky notification on Android 12+** — `ForegroundServiceStartNotAllowedException` is caught broadly; `DashboardViewModel` restarts service on every launch to compensate for OS killing it in the background
|
||||
- **Room DB** — all DB logic remains commented out; do not uncomment until the feature is explicitly re-enabled
|
||||
- **`@JvmField`** on `HeartbeatResponseDTO` — `HeartbeatHelper.kt` accesses `.fcmTokenUpdated`/`.name` as fields; `@JvmField` keeps direct field access instead of generating getters
|
||||
- **`@JvmField`** on `RegisterDeviceResponseDTO` — `MainActivity.java` (legacy, still Java) accesses `.data`/`.error` as direct fields; remove once Phase 7 deletes the legacy activity
|
||||
- **`@JvmOverloads`** on `SMSFilterRule` — generates no-arg and partial constructors needed by Gson deserialization of persisted filter config JSON
|
||||
- **`@get:JvmName("isEnabled")`** on `RegisterDeviceInputDTO.enabled` and `@get:JvmName("isCaseSensitive")`on `SMSFilterRule.caseSensitive` — renames generated getter to match Java boolean convention; `MainActivity.java` and `SMSFilterActivity.java` use `isEnabled()`/`isCaseSensitive()`
|
||||
@@ -1,17 +1,18 @@
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'com.google.gms.google-services'
|
||||
id 'com.google.firebase.crashlytics'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 32
|
||||
compileSdk 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.vernu.sms"
|
||||
minSdk 24
|
||||
targetSdk 32
|
||||
versionCode 11
|
||||
versionName "2.3.1"
|
||||
targetSdk 34
|
||||
versionCode 18
|
||||
versionName "2.8.0"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -21,23 +22,72 @@ android {
|
||||
// }
|
||||
// }
|
||||
}
|
||||
signingConfigs {
|
||||
debug {
|
||||
storeFile file("${System.getenv("HOME")}/.android/debug.keystore")
|
||||
storePassword "android"
|
||||
keyAlias "androiddebugkey"
|
||||
keyPassword "android"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
// TODO: Change to release signing config
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
debug {
|
||||
minifyEnabled false
|
||||
debuggable true
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "environment"
|
||||
|
||||
productFlavors {
|
||||
dev {
|
||||
dimension "environment"
|
||||
applicationId "com.vernu.sms.dev"
|
||||
versionNameSuffix "-dev"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api.dev.textbee.dev/api/v1/"'
|
||||
buildConfigField "String", "ENVIRONMENT", '"development"'
|
||||
resValue "string", "app_name", "textbee.dev (Dev)"
|
||||
}
|
||||
|
||||
prod {
|
||||
dimension "environment"
|
||||
applicationId "com.vernu.sms"
|
||||
buildConfigField "String", "API_BASE_URL", '"https://api.textbee.dev/api/v1/"'
|
||||
buildConfigField "String", "ENVIRONMENT", '"production"'
|
||||
resValue "string", "app_name", "textbee.dev"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.4.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.3.0'
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation 'com.google.android.material:material:1.8.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
@@ -47,11 +97,31 @@ dependencies {
|
||||
implementation 'com.google.firebase:firebase-analytics'
|
||||
implementation 'com.google.firebase:firebase-messaging'
|
||||
implementation 'com.google.firebase:firebase-messaging-directboot'
|
||||
implementation 'com.google.firebase:firebase-crashlytics'
|
||||
|
||||
implementation 'com.google.code.gson:gson:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.1.0'
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.7.1'
|
||||
|
||||
// Jetpack Compose
|
||||
implementation platform('androidx.compose:compose-bom:2023.08.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
|
||||
// Compose integration
|
||||
implementation 'androidx.activity:activity-compose:1.7.2'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
|
||||
implementation 'androidx.navigation:navigation-compose:2.7.2'
|
||||
|
||||
// Coroutines + Lifecycle
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
|
||||
|
||||
// def room_version = "2.4.2"
|
||||
// implementation "androidx.room:room-runtime:$room_version"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "14326313838",
|
||||
"project_id": "vernu-sms",
|
||||
"storage_bucket": "vernu-sms.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:14326313838:android:4ea4380d88b057e9eb877f",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vernu.sms"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "14326313838-0kmpefogqk8h5ntf2mth4e9cht9pbfai.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyDNZP2HeVPZ6_V9oq2UTYWmJ4FfenrRBIQ"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "14326313838-0kmpefogqk8h5ntf2mth4e9cht9pbfai.apps.googleusercontent.com",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "YOUR_DEV_PROJECT_NUMBER",
|
||||
"project_id": "your-dev-project-id",
|
||||
"storage_bucket": "your-dev-project-id.appspot.com"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:YOUR_DEV_PROJECT_NUMBER:android:YOUR_DEV_APP_ID",
|
||||
"android_client_info": {
|
||||
"package_name": "com.vernu.sms.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [
|
||||
{
|
||||
"client_id": "YOUR_DEV_CLIENT_ID",
|
||||
"client_type": 3
|
||||
}
|
||||
],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "YOUR_DEV_API_KEY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": [
|
||||
{
|
||||
"client_id": "YOUR_DEV_CLIENT_ID",
|
||||
"client_type": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -9,19 +9,26 @@
|
||||
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
||||
<uses-permission android:name="android.provider.Telephony.SMS_RECEIVED" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<application
|
||||
android:name=".SMSGatewayApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SMSGateway"
|
||||
android:usesCleartextTraffic="true" >
|
||||
android:theme="@style/Theme.SMSGateway" >
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<service
|
||||
android:name=".services.FCMService"
|
||||
android:exported="false"
|
||||
@@ -33,17 +40,27 @@
|
||||
<service
|
||||
android:name=".services.StickyNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="remoteMessaging">
|
||||
</service>
|
||||
<receiver
|
||||
android:name=".receivers.SMSBroadcastReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter
|
||||
android:priority="2147483647">
|
||||
android:priority="1000">
|
||||
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".receivers.SMSStatusReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="SMS_SENT" />
|
||||
<action android:name="SMS_DELIVERED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:enabled="true"
|
||||
android:name=".receivers.BootCompletedReceiver"
|
||||
android:exported="true"
|
||||
@@ -54,14 +71,31 @@
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:exported="true">
|
||||
android:name=".ui.splash.SplashActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar" />
|
||||
<activity
|
||||
android:name=".ui.main.NewMainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar" />
|
||||
<activity
|
||||
android:name=".activities.SMSFilterActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.SMSGateway.NoActionBar"
|
||||
android:parentActivityName=".activities.MainActivity" />
|
||||
<activity
|
||||
android:name="com.journeyapps.barcodescanner.CaptureActivity"
|
||||
android:screenOrientation="portrait"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms
|
||||
|
||||
import com.vernu.sms.services.GatewayApiServiceKt
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
object ApiManagerKt {
|
||||
@Volatile
|
||||
private var instance: GatewayApiServiceKt? = null
|
||||
|
||||
fun getApiService(): GatewayApiServiceKt =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Retrofit.Builder()
|
||||
.baseUrl(AppConstants.API_BASE_URL)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(GatewayApiServiceKt::class.java)
|
||||
.also { instance = it }
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,9 @@ package com.vernu.sms;
|
||||
import android.Manifest;
|
||||
|
||||
public class AppConstants {
|
||||
public static final String API_BASE_URL = "https://api.textbee.dev/api/v1/";
|
||||
public static final String API_BASE_URL = BuildConfig.API_BASE_URL;
|
||||
public static final String[] requiredPermissions = new String[]{
|
||||
Manifest.permission.SEND_SMS,
|
||||
Manifest.permission.READ_SMS,
|
||||
Manifest.permission.RECEIVE_SMS,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
};
|
||||
@@ -16,4 +15,17 @@ public class AppConstants {
|
||||
public static final String SHARED_PREFS_PREFERRED_SIM_KEY = "PREFERRED_SIM";
|
||||
public static final String SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY = "RECEIVE_SMS_ENABLED";
|
||||
public static final String SHARED_PREFS_TRACK_SENT_SMS_STATUS_KEY = "TRACK_SENT_SMS_STATUS";
|
||||
public static final String SHARED_PREFS_LAST_VERSION_CODE_KEY = "LAST_VERSION_CODE";
|
||||
public static final String SHARED_PREFS_LAST_VERSION_NAME_KEY = "LAST_VERSION_NAME";
|
||||
public static final String SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY = "STICKY_NOTIFICATION_ENABLED";
|
||||
public static final String HEARTBEAT_WORK_TAG = "heartbeat";
|
||||
public static final String SHARED_PREFS_HEARTBEAT_ENABLED_KEY = "HEARTBEAT_ENABLED";
|
||||
public static final String SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY = "HEARTBEAT_INTERVAL_MINUTES";
|
||||
public static final String SHARED_PREFS_SMS_FILTER_CONFIG_KEY = "SMS_FILTER_CONFIG";
|
||||
public static final String SHARED_PREFS_DEVICE_NAME_KEY = "DEVICE_NAME";
|
||||
public static final String SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY = "SMS_SEND_DELAY_SECONDS";
|
||||
/** Default delay between SMS sends (seconds). 5s helps avoid carrier/device throttling. */
|
||||
public static final int DEFAULT_SMS_SEND_DELAY_SECONDS = 5;
|
||||
public static final String SHARED_PREFS_USE_NEW_UI_KEY = "USE_NEW_UI";
|
||||
public static final String SHARED_PREFS_LAST_HEARTBEAT_MS_KEY = "LAST_HEARTBEAT_MS";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vernu.sms;
|
||||
|
||||
import android.app.Application;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Configuration;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
public class SMSGatewayApplication extends Application implements Configuration.Provider {
|
||||
private static final String TAG = "SMSGatewayApplication";
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
// Initialize WorkManager early to ensure it's ready for background work
|
||||
// This is important for background tasks like heartbeat
|
||||
try {
|
||||
WorkManager.initialize(this, getWorkManagerConfiguration());
|
||||
Log.d(TAG, "WorkManager initialized successfully");
|
||||
} catch (IllegalStateException e) {
|
||||
// WorkManager might already be initialized (e.g., by androidx.startup)
|
||||
// This is fine, we can continue
|
||||
Log.d(TAG, "WorkManager already initialized or will be initialized automatically");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Configuration getWorkManagerConfiguration() {
|
||||
return new Configuration.Builder()
|
||||
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,23 @@ import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.telephony.SubscriptionInfo;
|
||||
import android.telephony.SubscriptionManager;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.dtos.SimInfoDTO;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class TextBeeUtils {
|
||||
private static final String TAG = "TextBeeUtils";
|
||||
|
||||
public static boolean isPermissionGranted(Context context, String permission) {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
@@ -33,21 +40,266 @@ public class TextBeeUtils {
|
||||
}
|
||||
|
||||
public static void startStickyNotificationService(Context context) {
|
||||
|
||||
if(!isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
|
||||
return;
|
||||
}
|
||||
|
||||
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(notificationIntent);
|
||||
|
||||
// Only start service if user has enabled sticky notification
|
||||
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
|
||||
if (stickyNotificationEnabled) {
|
||||
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(notificationIntent);
|
||||
} else {
|
||||
context.startService(notificationIntent);
|
||||
}
|
||||
Log.i(TAG, "Starting sticky notification service");
|
||||
} else {
|
||||
context.startService(notificationIntent);
|
||||
Log.i(TAG, "Sticky notification disabled by user, not starting service");
|
||||
}
|
||||
}
|
||||
|
||||
public static void stopStickyNotificationService(Context context) {
|
||||
Intent notificationIntent = new Intent(context, StickyNotificationService.class);
|
||||
context.stopService(notificationIntent);
|
||||
Log.i(TAG, "Stopping sticky notification service");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a non-fatal exception to Crashlytics with additional context information
|
||||
*
|
||||
* @param throwable The exception to log
|
||||
* @param message A message describing what happened
|
||||
* @param customData Optional map of custom key-value pairs to add as context
|
||||
*/
|
||||
public static void logException(Throwable throwable, String message, Map<String, Object> customData) {
|
||||
try {
|
||||
Log.e(TAG, message, throwable);
|
||||
|
||||
FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
|
||||
crashlytics.log(message);
|
||||
|
||||
// Add any custom data as key-value pairs
|
||||
if (customData != null) {
|
||||
for (Map.Entry<String, Object> entry : customData.entrySet()) {
|
||||
if (entry.getValue() instanceof String) {
|
||||
crashlytics.setCustomKey(entry.getKey(), (String) entry.getValue());
|
||||
} else if (entry.getValue() instanceof Boolean) {
|
||||
crashlytics.setCustomKey(entry.getKey(), (Boolean) entry.getValue());
|
||||
} else if (entry.getValue() instanceof Integer) {
|
||||
crashlytics.setCustomKey(entry.getKey(), (Integer) entry.getValue());
|
||||
} else if (entry.getValue() instanceof Long) {
|
||||
crashlytics.setCustomKey(entry.getKey(), (Long) entry.getValue());
|
||||
} else if (entry.getValue() instanceof Float) {
|
||||
crashlytics.setCustomKey(entry.getKey(), (Float) entry.getValue());
|
||||
} else if (entry.getValue() instanceof Double) {
|
||||
crashlytics.setCustomKey(entry.getKey(), (Double) entry.getValue());
|
||||
} else if (entry.getValue() != null) {
|
||||
crashlytics.setCustomKey(entry.getKey(), entry.getValue().toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record the exception
|
||||
crashlytics.recordException(throwable);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error logging exception to Crashlytics", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified method to log a non-fatal exception with just a message
|
||||
*/
|
||||
public static void logException(Throwable throwable, String message) {
|
||||
logException(throwable, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all available SIM information (physical SIMs and eSIMs) from the device
|
||||
*
|
||||
* @param context The application context
|
||||
* @return List of SimInfoDTO objects containing SIM information, or empty list if permission not granted
|
||||
*/
|
||||
public static List<SimInfoDTO> collectSimInfo(Context context) {
|
||||
List<SimInfoDTO> simInfoList = new ArrayList<>();
|
||||
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "READ_PHONE_STATE permission not granted, cannot collect SIM info");
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
try {
|
||||
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
|
||||
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
|
||||
|
||||
if (subscriptionInfoList == null) {
|
||||
Log.d(TAG, "No active subscriptions found");
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
|
||||
SimInfoDTO simInfo = new SimInfoDTO();
|
||||
simInfo.setSubscriptionId(subscriptionInfo.getSubscriptionId());
|
||||
|
||||
// Get ICCID (may be null for eSIM)
|
||||
try {
|
||||
String iccId = subscriptionInfo.getIccId();
|
||||
if (iccId != null && !iccId.isEmpty()) {
|
||||
simInfo.setIccId(iccId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get ICCID for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get Card ID
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int cardId = subscriptionInfo.getCardId();
|
||||
// INVALID_CARD_ID is -1, check for valid card ID (>= 0)
|
||||
if (cardId >= 0) {
|
||||
simInfo.setCardId(cardId);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get Card ID for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get carrier name
|
||||
try {
|
||||
CharSequence carrierName = subscriptionInfo.getCarrierName();
|
||||
if (carrierName != null) {
|
||||
simInfo.setCarrierName(carrierName.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get carrier name for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get display name
|
||||
try {
|
||||
CharSequence displayName = subscriptionInfo.getDisplayName();
|
||||
if (displayName != null) {
|
||||
simInfo.setDisplayName(displayName.toString());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get display name for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get SIM slot index
|
||||
try {
|
||||
int simSlotIndex = subscriptionInfo.getSimSlotIndex();
|
||||
if (simSlotIndex >= 0) {
|
||||
simInfo.setSimSlotIndex(simSlotIndex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get MCC (getMccString() is API 29+; use getMcc() on older devices)
|
||||
try {
|
||||
String mcc = null;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
mcc = subscriptionInfo.getMccString();
|
||||
} else {
|
||||
int mccInt = subscriptionInfo.getMcc();
|
||||
if (mccInt != Integer.MAX_VALUE) {
|
||||
mcc = String.format("%03d", mccInt);
|
||||
}
|
||||
}
|
||||
if (mcc != null && !mcc.isEmpty()) {
|
||||
simInfo.setMcc(mcc);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get MNC (getMncString() is API 29+; use getMnc() on older devices)
|
||||
try {
|
||||
String mnc = null;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
mnc = subscriptionInfo.getMncString();
|
||||
} else {
|
||||
int mncInt = subscriptionInfo.getMnc();
|
||||
if (mncInt != Integer.MAX_VALUE) {
|
||||
mnc = String.valueOf(mncInt);
|
||||
}
|
||||
}
|
||||
if (mnc != null && !mnc.isEmpty()) {
|
||||
simInfo.setMnc(mnc);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get MNC for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get country ISO
|
||||
try {
|
||||
String countryIso = subscriptionInfo.getCountryIso();
|
||||
if (countryIso != null && !countryIso.isEmpty()) {
|
||||
simInfo.setCountryIso(countryIso);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get country ISO for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
// Get subscription type (0 = physical SIM, 1 = eSIM)
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
int subscriptionType = subscriptionInfo.getSubscriptionType();
|
||||
if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_LOCAL_SIM) {
|
||||
simInfo.setSubscriptionType("PHYSICAL_SIM");
|
||||
} else if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) {
|
||||
simInfo.setSubscriptionType("ESIM");
|
||||
}
|
||||
} else {
|
||||
// For older Android versions, default to PHYSICAL_SIM
|
||||
simInfo.setSubscriptionType("PHYSICAL_SIM");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.d(TAG, "Could not get subscription type for subscription " + subscriptionInfo.getSubscriptionId());
|
||||
}
|
||||
|
||||
simInfoList.add(simInfo);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error collecting SIM info: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return simInfoList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a subscription ID exists in the active subscriptions
|
||||
*
|
||||
* @param context The application context
|
||||
* @param subscriptionId The subscription ID to validate
|
||||
* @return true if the subscription ID exists, false otherwise
|
||||
*/
|
||||
public static boolean isValidSubscriptionId(Context context, int subscriptionId) {
|
||||
if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
|
||||
List<SubscriptionInfo> subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
|
||||
|
||||
if (subscriptionInfoList == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
|
||||
if (subscriptionInfo.getSubscriptionId() == subscriptionId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error validating subscription ID: " + e.getMessage(), e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,13 @@ import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import com.vernu.sms.activities.SMSFilterActivity;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
@@ -30,7 +35,14 @@ import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.VersionTracker;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics;
|
||||
import com.google.gson.Gson;
|
||||
import okhttp3.ResponseBody;
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import retrofit2.Call;
|
||||
@@ -40,14 +52,17 @@ import retrofit2.Response;
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private Context mContext;
|
||||
private Switch gatewaySwitch, receiveSMSSwitch;
|
||||
private EditText apiKeyEditText, fcmTokenEditText;
|
||||
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn;
|
||||
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
|
||||
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText, smsSendDelayEditText;
|
||||
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn, tryNewUIBtn;
|
||||
private ImageButton copyDeviceIdImgBtn;
|
||||
private TextView deviceBrandAndModelTxt, deviceIdTxt;
|
||||
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
|
||||
private RadioGroup defaultSimSlotRadioGroup;
|
||||
private static final int SCAN_QR_REQUEST_CODE = 49374;
|
||||
private static final int PERMISSION_REQUEST_CODE = 0;
|
||||
private static final long SMS_DELAY_SAVE_DEBOUNCE_MS = 3000L;
|
||||
private final Handler smsDelaySaveHandler = new Handler(Looper.getMainLooper());
|
||||
private Runnable smsDelaySaveRunnable;
|
||||
private String deviceId = null;
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
@@ -60,8 +75,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
setContentView(R.layout.activity_main);
|
||||
gatewaySwitch = findViewById(R.id.gatewaySwitch);
|
||||
receiveSMSSwitch = findViewById(R.id.receiveSMSSwitch);
|
||||
stickyNotificationSwitch = findViewById(R.id.stickyNotificationSwitch);
|
||||
apiKeyEditText = findViewById(R.id.apiKeyEditText);
|
||||
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
|
||||
deviceIdEditText = findViewById(R.id.deviceIdEditText);
|
||||
deviceNameEditText = findViewById(R.id.deviceNameEditText);
|
||||
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
|
||||
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
|
||||
scanQRBtn = findViewById(R.id.scanQRButton);
|
||||
@@ -69,9 +87,48 @@ public class MainActivity extends AppCompatActivity {
|
||||
deviceIdTxt = findViewById(R.id.deviceIdTxt);
|
||||
copyDeviceIdImgBtn = findViewById(R.id.copyDeviceIdImgBtn);
|
||||
defaultSimSlotRadioGroup = findViewById(R.id.defaultSimSlotRadioGroup);
|
||||
appVersionNameTxt = findViewById(R.id.appVersionNameTxt);
|
||||
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
|
||||
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
|
||||
configureFilterBtn = findViewById(R.id.configureFilterBtn);
|
||||
smsSendDelayEditText = findViewById(R.id.smsSendDelayEditText);
|
||||
tryNewUIBtn = findViewById(R.id.tryNewUIBtn);
|
||||
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
deviceBrandAndModelTxt.setText(Build.BRAND + " " + Build.MODEL);
|
||||
|
||||
// Set app version information
|
||||
String versionName = BuildConfig.VERSION_NAME;
|
||||
appVersionNameTxt.setText(versionName);
|
||||
appVersionCodeTxt.setText(String.valueOf(BuildConfig.VERSION_CODE));
|
||||
|
||||
// Check for app version changes and report if needed
|
||||
if (VersionTracker.hasVersionChanged(mContext)) {
|
||||
Log.d(TAG, "App version changed or first launch, reporting to server");
|
||||
VersionTracker.reportVersionToServer(mContext);
|
||||
}
|
||||
|
||||
// Initialize Crashlytics with user information
|
||||
FirebaseCrashlytics crashlytics = FirebaseCrashlytics.getInstance();
|
||||
crashlytics.setCustomKey("device_id", deviceId != null ? deviceId : "not_registered");
|
||||
crashlytics.setCustomKey("device_model", Build.MODEL);
|
||||
crashlytics.setCustomKey("app_version", versionName);
|
||||
crashlytics.setCustomKey("app_version_code", BuildConfig.VERSION_CODE);
|
||||
|
||||
// Start sticky notification service if enabled
|
||||
boolean gatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false);
|
||||
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false);
|
||||
if (gatewayEnabled && stickyNotificationEnabled) {
|
||||
TextBeeUtils.startStickyNotificationService(mContext);
|
||||
Log.d(TAG, "Starting sticky notification service on app start");
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled and registered
|
||||
if (gatewayEnabled && !deviceId.isEmpty()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
Log.d(TAG, "Scheduling heartbeat on app start");
|
||||
}
|
||||
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
registerDeviceBtn.setText("Register");
|
||||
@@ -100,6 +157,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
|
||||
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, ""));
|
||||
String storedDeviceName = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "");
|
||||
if (storedDeviceName.isEmpty()) {
|
||||
deviceNameEditText.setText(Build.BRAND + " " + Build.MODEL);
|
||||
} else {
|
||||
deviceNameEditText.setText(storedDeviceName);
|
||||
}
|
||||
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false));
|
||||
gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
|
||||
View view = compoundButton.getRootView();
|
||||
@@ -117,7 +180,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
compoundButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
@@ -125,16 +188,26 @@ public class MainActivity extends AppCompatActivity {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, isCheked);
|
||||
boolean enabled = Boolean.TRUE.equals(Objects.requireNonNull(response.body()).data.get("enabled"));
|
||||
compoundButton.setChecked(enabled);
|
||||
// if (enabled) {
|
||||
// TextBeeUtils.startStickyNotificationService(mContext);
|
||||
// } else {
|
||||
// TextBeeUtils.stopStickyNotificationService(mContext);
|
||||
// }
|
||||
if (enabled) {
|
||||
// Check if sticky notification is enabled
|
||||
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
|
||||
TextBeeUtils.startStickyNotificationService(mContext);
|
||||
}
|
||||
// Schedule heartbeat
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(mContext);
|
||||
// Cancel heartbeat
|
||||
HeartbeatManager.cancelHeartbeat(mContext);
|
||||
}
|
||||
compoundButton.setEnabled(true);
|
||||
}
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
|
||||
Log.e(TAG, "API_ERROR "+ t.getMessage());
|
||||
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
|
||||
TextBeeUtils.logException(t, "Error updating device");
|
||||
compoundButton.setEnabled(true);
|
||||
}
|
||||
});
|
||||
@@ -148,37 +221,150 @@ public class MainActivity extends AppCompatActivity {
|
||||
Snackbar.make(view, "Receive SMS " + (isCheked ? "enabled" : "disabled"), Snackbar.LENGTH_LONG).show();
|
||||
});
|
||||
|
||||
// Setup sticky notification switch
|
||||
stickyNotificationSwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false));
|
||||
stickyNotificationSwitch.setOnCheckedChangeListener((compoundButton, isChecked) -> {
|
||||
View view = compoundButton.getRootView();
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, isChecked);
|
||||
|
||||
if (isChecked) {
|
||||
TextBeeUtils.startStickyNotificationService(mContext);
|
||||
Snackbar.make(view, "Background service enabled - app will be more reliable", Snackbar.LENGTH_LONG).show();
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(mContext);
|
||||
Snackbar.make(view, "Background service disabled - app may be killed when in background", Snackbar.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: check gateway status/api key/device validity and update UI accordingly
|
||||
registerDeviceBtn.setOnClickListener(view -> handleRegisterDevice());
|
||||
registerDeviceBtn.setOnClickListener(view -> {
|
||||
String _deviceId = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
if (_deviceId == null || _deviceId.isEmpty()) {
|
||||
handleRegisterDevice();
|
||||
} else {
|
||||
handleUpdateDevice();
|
||||
}
|
||||
});
|
||||
scanQRBtn.setOnClickListener(view -> {
|
||||
IntentIntegrator intentIntegrator = new IntentIntegrator(MainActivity.this);
|
||||
intentIntegrator.setPrompt("Go to textbee.dev/dashboard and click Register Device to generate QR Code");
|
||||
intentIntegrator.setRequestCode(SCAN_QR_REQUEST_CODE);
|
||||
intentIntegrator.initiateScan();
|
||||
});
|
||||
|
||||
checkUpdatesBtn.setOnClickListener(view -> {
|
||||
String versionInfo = BuildConfig.VERSION_NAME + "(" + BuildConfig.VERSION_CODE + ")";
|
||||
String encodedVersionInfo = android.net.Uri.encode(versionInfo);
|
||||
String downloadUrl = "https://textbee.dev/download?currentVersion=" + encodedVersionInfo;
|
||||
Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl));
|
||||
startActivity(browserIntent);
|
||||
});
|
||||
|
||||
configureFilterBtn.setOnClickListener(view -> {
|
||||
Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class);
|
||||
startActivity(filterIntent);
|
||||
});
|
||||
|
||||
tryNewUIBtn.setOnClickListener(view -> {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true);
|
||||
Intent intent = new Intent(MainActivity.this, com.vernu.sms.ui.splash.SplashActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
startActivity(intent);
|
||||
});
|
||||
|
||||
// SMS Send Delay setting: save 3 seconds after user stops typing
|
||||
int currentDelay = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
|
||||
smsSendDelayEditText.setText(String.valueOf(currentDelay));
|
||||
smsDelaySaveRunnable = this::saveSendDelay;
|
||||
smsSendDelayEditText.addTextChangedListener(new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
|
||||
smsDelaySaveHandler.postDelayed(smsDelaySaveRunnable, SMS_DELAY_SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
});
|
||||
smsSendDelayEditText.setOnEditorActionListener((v, actionId, event) -> {
|
||||
smsDelaySaveHandler.removeCallbacks(smsDelaySaveRunnable);
|
||||
saveSendDelay();
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void saveSendDelay() {
|
||||
String text = smsSendDelayEditText.getText().toString().trim();
|
||||
if (text.isEmpty()) {
|
||||
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
|
||||
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
|
||||
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + defaultDelay + " sec)", Snackbar.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int value = Integer.parseInt(text);
|
||||
if (value < 0) {
|
||||
value = 0;
|
||||
smsSendDelayEditText.setText("0");
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 0);
|
||||
Snackbar.make(smsSendDelayEditText, "Minimum delay is 0 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
|
||||
} else if (value > 3600) {
|
||||
value = 3600;
|
||||
smsSendDelayEditText.setText("3600");
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, 3600);
|
||||
Snackbar.make(smsSendDelayEditText, "Maximum delay is 3600 seconds. Saved.", Snackbar.LENGTH_SHORT).show();
|
||||
} else {
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, value);
|
||||
Snackbar.make(smsSendDelayEditText, "SMS send delay saved (" + value + " sec)", Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
int defaultDelay = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS;
|
||||
smsSendDelayEditText.setText(String.valueOf(defaultDelay));
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, defaultDelay);
|
||||
Snackbar.make(smsSendDelayEditText, "Invalid value. Reset to " + defaultDelay + " sec.", Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
private void renderAvailableSimOptions() {
|
||||
try {
|
||||
defaultSimSlotRadioGroup.removeAllViews();
|
||||
|
||||
// Set radio group styling for dark mode compatibility
|
||||
defaultSimSlotRadioGroup.setBackgroundColor(getResources().getColor(R.color.background_secondary));
|
||||
defaultSimSlotRadioGroup.setPadding(16, 8, 16, 8);
|
||||
|
||||
// Create the default radio button with proper styling
|
||||
RadioButton defaultSimSlotRadioBtn = new RadioButton(mContext);
|
||||
defaultSimSlotRadioBtn.setText("Device Default");
|
||||
defaultSimSlotRadioBtn.setId((int)123456);
|
||||
applyRadioButtonStyle(defaultSimSlotRadioBtn);
|
||||
defaultSimSlotRadioGroup.addView(defaultSimSlotRadioBtn);
|
||||
|
||||
// Create radio buttons for each SIM with proper styling
|
||||
TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
|
||||
String simInfo = "SIM " + (subscriptionInfo.getSimSlotIndex() + 1) + " (" + subscriptionInfo.getDisplayName() + ")";
|
||||
String displayName = subscriptionInfo.getDisplayName() != null ? subscriptionInfo.getDisplayName().toString() : "Unknown";
|
||||
String simInfo = displayName + " (Subscription ID: " + subscriptionInfo.getSubscriptionId() + ")";
|
||||
RadioButton radioButton = new RadioButton(mContext);
|
||||
radioButton.setText(simInfo);
|
||||
radioButton.setId(subscriptionInfo.getSubscriptionId());
|
||||
applyRadioButtonStyle(radioButton);
|
||||
defaultSimSlotRadioGroup.addView(radioButton);
|
||||
});
|
||||
|
||||
// Check the preferred SIM based on saved preferences
|
||||
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
|
||||
if (preferredSim == -1) {
|
||||
defaultSimSlotRadioGroup.check(defaultSimSlotRadioBtn.getId());
|
||||
} else {
|
||||
defaultSimSlotRadioGroup.check(preferredSim);
|
||||
}
|
||||
|
||||
// Set the listener for SIM selection changes
|
||||
defaultSimSlotRadioGroup.setOnCheckedChangeListener((radioGroup, i) -> {
|
||||
RadioButton radioButton = findViewById(i);
|
||||
if (radioButton == null) {
|
||||
@@ -196,6 +382,80 @@ public class MainActivity extends AppCompatActivity {
|
||||
Log.e(TAG, "SIM_SLOT_ERROR "+ e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts error message from API response, trying multiple sources:
|
||||
* 1. Error message from response body (error field)
|
||||
* 2. Response message from HTTP headers
|
||||
* 3. Generic error with status code as fallback
|
||||
*/
|
||||
private String extractErrorMessage(Response<?> response) {
|
||||
// Try to parse error from response body
|
||||
try {
|
||||
ResponseBody errorBody = response.errorBody();
|
||||
if (errorBody != null) {
|
||||
String errorBodyString = errorBody.string();
|
||||
if (errorBodyString != null && !errorBodyString.isEmpty()) {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
RegisterDeviceResponseDTO errorResponse = gson.fromJson(errorBodyString, RegisterDeviceResponseDTO.class);
|
||||
if (errorResponse != null && errorResponse.error != null && !errorResponse.error.isEmpty()) {
|
||||
return errorResponse.error;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// If JSON parsing fails, try to extract message from raw string
|
||||
Log.d(TAG, "Could not parse error response as JSON: " + errorBodyString);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.d(TAG, "Could not read error body: " + e.getMessage());
|
||||
}
|
||||
|
||||
// Fall back to response message
|
||||
if (response.message() != null && !response.message().isEmpty()) {
|
||||
return response.message();
|
||||
}
|
||||
|
||||
// Final fallback to generic error with status code
|
||||
return "An error occurred :( " + response.code();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the custom radio button style to a programmatically created radio button
|
||||
*/
|
||||
private void applyRadioButtonStyle(RadioButton radioButton) {
|
||||
// Set text color using the color state list for proper dark/light mode handling
|
||||
setRadioButtonTextColor(radioButton);
|
||||
|
||||
// Set button tint for the radio circle
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint, getTheme()));
|
||||
} else {
|
||||
radioButton.setButtonTintList(getResources().getColorStateList(R.color.radio_button_tint));
|
||||
}
|
||||
}
|
||||
|
||||
// Add proper padding for better touch experience
|
||||
radioButton.setPadding(
|
||||
radioButton.getPaddingLeft() + 8,
|
||||
radioButton.getPaddingTop() + 12,
|
||||
radioButton.getPaddingRight(),
|
||||
radioButton.getPaddingBottom() + 12
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to set radio button text color in a backward-compatible way
|
||||
*/
|
||||
private void setRadioButtonTextColor(RadioButton radioButton) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color, getTheme()));
|
||||
} else {
|
||||
radioButton.setTextColor(getResources().getColorStateList(R.color.radio_button_text_color));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String permissions[], int[] grantResults) {
|
||||
@@ -216,8 +476,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void handleRegisterDevice() {
|
||||
|
||||
String newKey = apiKeyEditText.getText().toString();
|
||||
String deviceIdInput = deviceIdEditText.getText().toString();
|
||||
|
||||
registerDeviceBtn.setEnabled(false);
|
||||
registerDeviceBtn.setText("Loading...");
|
||||
View view = findViewById(R.id.registerDeviceBtn);
|
||||
@@ -243,30 +504,257 @@ public class MainActivity extends AppCompatActivity {
|
||||
registerDeviceInput.setOs(Build.VERSION.BASE_OS);
|
||||
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
// Get device name from input field or default to "brand model"
|
||||
String deviceName = deviceNameEditText.getText().toString().trim();
|
||||
if (deviceName.isEmpty()) {
|
||||
deviceName = Build.BRAND + " " + Build.MODEL;
|
||||
}
|
||||
registerDeviceInput.setName(deviceName);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
|
||||
registerDeviceInput.setSimInfo(simInfoCollection);
|
||||
|
||||
// If the user provided a device ID, use it for updating instead of creating new
|
||||
if (!deviceIdInput.isEmpty()) {
|
||||
Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput);
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdInput, newKey, registerDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show();
|
||||
|
||||
// Update deviceId from response if available
|
||||
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
|
||||
deviceId = response.body().data.get("_id").toString();
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
|
||||
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (registerDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
VersionTracker.updateStoredVersion(mContext);
|
||||
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
|
||||
Log.e(TAG, "API_ERROR "+ t.getMessage());
|
||||
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
|
||||
TextBeeUtils.logException(t, "Error registering device");
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
|
||||
|
||||
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
|
||||
deviceId = response.body().data.get("_id").toString();
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
|
||||
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (registerDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
VersionTracker.updateStoredVersion(mContext);
|
||||
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
}
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
|
||||
Log.e(TAG, "API_ERROR "+ t.getMessage());
|
||||
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
|
||||
TextBeeUtils.logException(t, "Error registering device");
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private void handleUpdateDevice() {
|
||||
String apiKey = apiKeyEditText.getText().toString();
|
||||
String deviceIdInput = deviceIdEditText.getText().toString();
|
||||
String deviceIdToUse = !deviceIdInput.isEmpty() ? deviceIdInput : deviceId;
|
||||
|
||||
registerDeviceBtn.setEnabled(false);
|
||||
registerDeviceBtn.setText("Loading...");
|
||||
View view = findViewById(R.id.registerDeviceBtn);
|
||||
|
||||
FirebaseMessaging.getInstance().getToken()
|
||||
.addOnCompleteListener(task -> {
|
||||
if (!task.isSuccessful()) {
|
||||
Snackbar.make(view, "Failed to obtain FCM Token :(", Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
String token = task.getResult();
|
||||
fcmTokenEditText.setText(token);
|
||||
|
||||
RegisterDeviceInputDTO updateDeviceInput = new RegisterDeviceInputDTO();
|
||||
updateDeviceInput.setEnabled(true);
|
||||
updateDeviceInput.setFcmToken(token);
|
||||
updateDeviceInput.setBrand(Build.BRAND);
|
||||
updateDeviceInput.setManufacturer(Build.MANUFACTURER);
|
||||
updateDeviceInput.setModel(Build.MODEL);
|
||||
updateDeviceInput.setBuildId(Build.ID);
|
||||
updateDeviceInput.setOs(Build.VERSION.BASE_OS);
|
||||
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
// Get device name from input field or default to "brand model"
|
||||
String deviceName = deviceNameEditText.getText().toString().trim();
|
||||
if (deviceName.isEmpty()) {
|
||||
deviceName = Build.BRAND + " " + Build.MODEL;
|
||||
}
|
||||
updateDeviceInput.setName(deviceName);
|
||||
|
||||
// Collect SIM information
|
||||
SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
|
||||
simInfoCollection.setLastUpdated(System.currentTimeMillis());
|
||||
simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
|
||||
updateDeviceInput.setSimInfo(simInfoCollection);
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
Log.d(TAG, response.toString());
|
||||
if (!response.isSuccessful()) {
|
||||
Snackbar.make(view, response.message(), Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
return;
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
|
||||
Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
|
||||
deviceId = response.body().data.get("_id").toString();
|
||||
deviceIdTxt.setText(deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey);
|
||||
|
||||
// Update deviceId from response if available
|
||||
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
|
||||
deviceId = response.body().data.get("_id").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
|
||||
deviceIdTxt.setText(deviceId);
|
||||
deviceIdEditText.setText(deviceId);
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync device name from server response
|
||||
if (response.body().data.get("name") != null) {
|
||||
String deviceName = response.body().data.get("name").toString();
|
||||
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
|
||||
deviceNameEditText.setText(deviceName);
|
||||
Log.d(TAG, "Synced device name from server: " + deviceName);
|
||||
}
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
if (updateDeviceInput.isEnabled()) {
|
||||
HeartbeatManager.scheduleHeartbeat(mContext);
|
||||
}
|
||||
}
|
||||
|
||||
// Update stored version information
|
||||
VersionTracker.updateStoredVersion(mContext);
|
||||
|
||||
Snackbar.make(view, "Device Updated Successfully :)", Snackbar.LENGTH_LONG).show();
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Snackbar.make(view, "An error occured :(", Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(view, "An error occurred :(", Snackbar.LENGTH_LONG).show();
|
||||
Log.e(TAG, "API_ERROR "+ t.getMessage());
|
||||
Log.e(TAG, "API_ERROR "+ t.getLocalizedMessage());
|
||||
TextBeeUtils.logException(t, "Error updating device");
|
||||
registerDeviceBtn.setEnabled(true);
|
||||
registerDeviceBtn.setText("Update");
|
||||
}
|
||||
@@ -296,8 +784,12 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
String scannedQR = intentResult.getContents();
|
||||
apiKeyEditText.setText(scannedQR);
|
||||
handleRegisterDevice();
|
||||
if(deviceIdEditText.getText().toString().isEmpty()) {
|
||||
handleRegisterDevice();
|
||||
} else {
|
||||
handleUpdateDevice();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.vernu.sms.activities;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.RadioButton;
|
||||
import android.widget.RadioGroup;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.Switch;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
import com.google.android.material.textfield.TextInputEditText;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.helpers.SMSFilterHelper;
|
||||
import com.vernu.sms.models.SMSFilterRule;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SMSFilterActivity extends AppCompatActivity {
|
||||
private Context mContext;
|
||||
private Switch filterEnabledSwitch;
|
||||
private RadioGroup filterModeRadioGroup;
|
||||
private RadioButton allowListRadio;
|
||||
private RadioButton blockListRadio;
|
||||
private RecyclerView filterRulesRecyclerView;
|
||||
private FloatingActionButton addRuleFab;
|
||||
private FilterRulesAdapter adapter;
|
||||
private SMSFilterHelper.FilterConfig filterConfig;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_sms_filter);
|
||||
|
||||
mContext = getApplicationContext();
|
||||
|
||||
// Initialize views
|
||||
ImageButton backButton = findViewById(R.id.backButton);
|
||||
filterEnabledSwitch = findViewById(R.id.filterEnabledSwitch);
|
||||
filterModeRadioGroup = findViewById(R.id.filterModeRadioGroup);
|
||||
allowListRadio = findViewById(R.id.allowListRadio);
|
||||
blockListRadio = findViewById(R.id.blockListRadio);
|
||||
filterRulesRecyclerView = findViewById(R.id.filterRulesRecyclerView);
|
||||
addRuleFab = findViewById(R.id.addRuleFab);
|
||||
|
||||
// Setup back button
|
||||
backButton.setOnClickListener(v -> finish());
|
||||
|
||||
// Load filter config
|
||||
filterConfig = SMSFilterHelper.loadFilterConfig(mContext);
|
||||
|
||||
// Setup RecyclerView
|
||||
adapter = new FilterRulesAdapter(filterConfig.getRules());
|
||||
filterRulesRecyclerView.setLayoutManager(new LinearLayoutManager(this));
|
||||
filterRulesRecyclerView.setAdapter(adapter);
|
||||
|
||||
// Load current settings
|
||||
filterEnabledSwitch.setChecked(filterConfig.isEnabled());
|
||||
// Default to block list if mode is not set
|
||||
if (filterConfig.getMode() == SMSFilterHelper.FilterMode.ALLOW_LIST) {
|
||||
allowListRadio.setChecked(true);
|
||||
} else {
|
||||
blockListRadio.setChecked(true);
|
||||
}
|
||||
|
||||
// Setup listeners
|
||||
filterEnabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||
filterConfig.setEnabled(isChecked);
|
||||
saveFilterConfig();
|
||||
});
|
||||
|
||||
filterModeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> {
|
||||
if (checkedId == R.id.allowListRadio) {
|
||||
filterConfig.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST);
|
||||
} else {
|
||||
filterConfig.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST);
|
||||
}
|
||||
saveFilterConfig();
|
||||
});
|
||||
|
||||
addRuleFab.setOnClickListener(v -> showAddEditRuleDialog(-1));
|
||||
}
|
||||
|
||||
private void saveFilterConfig() {
|
||||
SMSFilterHelper.saveFilterConfig(mContext, filterConfig);
|
||||
}
|
||||
|
||||
private void showAddEditRuleDialog(int position) {
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||
View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_filter_rule, null);
|
||||
builder.setView(dialogView);
|
||||
|
||||
TextInputEditText patternEditText = dialogView.findViewById(R.id.patternEditText);
|
||||
Spinner filterTargetSpinner = dialogView.findViewById(R.id.filterTargetSpinner);
|
||||
Spinner matchTypeSpinner = dialogView.findViewById(R.id.matchTypeSpinner);
|
||||
Switch caseSensitiveSwitch = dialogView.findViewById(R.id.caseSensitiveSwitch);
|
||||
Button cancelButton = dialogView.findViewById(R.id.cancelButton);
|
||||
Button saveButton = dialogView.findViewById(R.id.saveButton);
|
||||
|
||||
// Setup filter target spinner
|
||||
String[] filterTargets = {"Sender", "Message", "Both"};
|
||||
ArrayAdapter<String> targetAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, filterTargets);
|
||||
targetAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
filterTargetSpinner.setAdapter(targetAdapter);
|
||||
|
||||
// Setup match type spinner
|
||||
String[] matchTypes = {"Exact Match", "Starts With", "Ends With", "Contains"};
|
||||
ArrayAdapter<String> spinnerAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, matchTypes);
|
||||
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
matchTypeSpinner.setAdapter(spinnerAdapter);
|
||||
|
||||
// If editing, populate fields
|
||||
boolean isEdit = position >= 0;
|
||||
TextView dialogTitle = dialogView.findViewById(R.id.dialogTitle);
|
||||
if (isEdit) {
|
||||
SMSFilterRule rule = filterConfig.getRules().get(position);
|
||||
patternEditText.setText(rule.getPattern());
|
||||
filterTargetSpinner.setSelection(rule.getFilterTarget().ordinal());
|
||||
matchTypeSpinner.setSelection(rule.getMatchType().ordinal());
|
||||
caseSensitiveSwitch.setChecked(rule.isCaseSensitive());
|
||||
if (dialogTitle != null) {
|
||||
dialogTitle.setText("Edit Filter Rule");
|
||||
}
|
||||
} else {
|
||||
// Default to case insensitive
|
||||
caseSensitiveSwitch.setChecked(false);
|
||||
}
|
||||
|
||||
AlertDialog dialog = builder.create();
|
||||
|
||||
cancelButton.setOnClickListener(v -> dialog.dismiss());
|
||||
|
||||
saveButton.setOnClickListener(v -> {
|
||||
String pattern = patternEditText.getText() != null ? patternEditText.getText().toString().trim() : "";
|
||||
if (pattern.isEmpty()) {
|
||||
Toast.makeText(this, "Please enter a pattern", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
SMSFilterRule.FilterTarget filterTarget = SMSFilterRule.FilterTarget.values()[filterTargetSpinner.getSelectedItemPosition()];
|
||||
SMSFilterRule.MatchType matchType = SMSFilterRule.MatchType.values()[matchTypeSpinner.getSelectedItemPosition()];
|
||||
boolean caseSensitive = caseSensitiveSwitch.isChecked();
|
||||
|
||||
if (isEdit) {
|
||||
SMSFilterRule rule = filterConfig.getRules().get(position);
|
||||
rule.setPattern(pattern);
|
||||
rule.setFilterTarget(filterTarget);
|
||||
rule.setMatchType(matchType);
|
||||
rule.setCaseSensitive(caseSensitive);
|
||||
adapter.notifyItemChanged(position);
|
||||
} else {
|
||||
SMSFilterRule newRule = new SMSFilterRule(pattern, matchType, filterTarget, caseSensitive);
|
||||
filterConfig.getRules().add(newRule);
|
||||
adapter.notifyItemInserted(filterConfig.getRules().size() - 1);
|
||||
}
|
||||
|
||||
saveFilterConfig();
|
||||
dialog.dismiss();
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
private void deleteRule(int position) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Delete Rule")
|
||||
.setMessage("Are you sure you want to delete this filter rule?")
|
||||
.setPositiveButton("Delete", (dialog, which) -> {
|
||||
filterConfig.getRules().remove(position);
|
||||
adapter.notifyItemRemoved(position);
|
||||
saveFilterConfig();
|
||||
})
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private class FilterRulesAdapter extends RecyclerView.Adapter<FilterRulesAdapter.ViewHolder> {
|
||||
private List<SMSFilterRule> rules;
|
||||
|
||||
public FilterRulesAdapter(List<SMSFilterRule> rules) {
|
||||
this.rules = rules != null ? rules : new ArrayList<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
View view = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.item_filter_rule, parent, false);
|
||||
return new ViewHolder(view);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||
SMSFilterRule rule = rules.get(position);
|
||||
holder.patternText.setText(rule.getPattern());
|
||||
|
||||
String matchTypeText = "";
|
||||
switch (rule.getMatchType()) {
|
||||
case EXACT:
|
||||
matchTypeText = "Exact Match";
|
||||
break;
|
||||
case STARTS_WITH:
|
||||
matchTypeText = "Starts With";
|
||||
break;
|
||||
case ENDS_WITH:
|
||||
matchTypeText = "Ends With";
|
||||
break;
|
||||
case CONTAINS:
|
||||
matchTypeText = "Contains";
|
||||
break;
|
||||
}
|
||||
holder.matchTypeText.setText(matchTypeText);
|
||||
|
||||
String filterTargetText = "";
|
||||
switch (rule.getFilterTarget()) {
|
||||
case SENDER:
|
||||
filterTargetText = "Filter: Sender";
|
||||
break;
|
||||
case MESSAGE:
|
||||
filterTargetText = "Filter: Message";
|
||||
break;
|
||||
case BOTH:
|
||||
filterTargetText = "Filter: Sender or Message";
|
||||
break;
|
||||
}
|
||||
String caseText = rule.isCaseSensitive() ? " (Case Sensitive)" : " (Case Insensitive)";
|
||||
holder.filterTargetText.setText(filterTargetText + caseText);
|
||||
|
||||
holder.editButton.setOnClickListener(v -> showAddEditRuleDialog(position));
|
||||
holder.deleteButton.setOnClickListener(v -> deleteRule(position));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return rules.size();
|
||||
}
|
||||
|
||||
class ViewHolder extends RecyclerView.ViewHolder {
|
||||
TextView patternText;
|
||||
TextView matchTypeText;
|
||||
TextView filterTargetText;
|
||||
ImageButton editButton;
|
||||
ImageButton deleteButton;
|
||||
|
||||
ViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
patternText = itemView.findViewById(R.id.patternText);
|
||||
matchTypeText = itemView.findViewById(R.id.matchTypeText);
|
||||
filterTargetText = itemView.findViewById(R.id.filterTargetText);
|
||||
editButton = itemView.findViewById(R.id.editButton);
|
||||
deleteButton = itemView.findViewById(R.id.deleteButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import android.content.Context;
|
||||
//import androidx.room.Database;
|
||||
//import androidx.room.Room;
|
||||
//import androidx.room.RoomDatabase;
|
||||
//
|
||||
//@Database(entities = {SMS.class}, version = 2)
|
||||
//public abstract class AppDatabase extends RoomDatabase {
|
||||
// private static volatile AppDatabase INSTANCE;
|
||||
//
|
||||
// public static AppDatabase getInstance(Context context) {
|
||||
// if (INSTANCE == null) {
|
||||
// synchronized (AppDatabase.class) {
|
||||
// if (INSTANCE == null) {
|
||||
// INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "db1")
|
||||
// .build();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return INSTANCE;
|
||||
// }
|
||||
//
|
||||
// public abstract SMSDao localReceivedSMSDao();
|
||||
//}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
|
||||
@Database(entities = [Sms::class], version = 2)
|
||||
@TypeConverters(DateConverter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun localReceivedSMSDao(): SmsDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"db1"
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,17 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.room.TypeConverter;
|
||||
//
|
||||
//import java.util.Date;
|
||||
//
|
||||
//public class DateConverter {
|
||||
// @TypeConverter
|
||||
// public static Date toDate(Long dateLong) {
|
||||
// return dateLong == null ? null : new Date(dateLong);
|
||||
// }
|
||||
//
|
||||
// @TypeConverter
|
||||
// public static Long fromDate(Date date) {
|
||||
// return date == null ? null : date.getTime();
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.Date
|
||||
|
||||
object DateConverter {
|
||||
@TypeConverter
|
||||
fun toDate(value: Long?): Date? = value?.let { Date(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun fromDate(date: Date?): Long? = date?.time
|
||||
}
|
||||
*/
|
||||
@@ -1,193 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.annotation.NonNull;
|
||||
//import androidx.room.ColumnInfo;
|
||||
//import androidx.room.Entity;
|
||||
//import androidx.room.PrimaryKey;
|
||||
//import androidx.room.TypeConverters;
|
||||
//
|
||||
//import java.util.Date;
|
||||
//
|
||||
//@Entity(tableName = "sms")
|
||||
//@TypeConverters(DateConverter.class)
|
||||
//public class SMS {
|
||||
//
|
||||
// public SMS() {
|
||||
// type = null;
|
||||
// }
|
||||
//
|
||||
// @PrimaryKey(autoGenerate = true)
|
||||
// private int id;
|
||||
//
|
||||
// // This is the ID of the SMS in the server
|
||||
// @ColumnInfo(name = "_id")
|
||||
// private String _id;
|
||||
//
|
||||
// @ColumnInfo(name = "message")
|
||||
// private String message = "";
|
||||
//
|
||||
// @ColumnInfo(name = "encrypted_message")
|
||||
// private String encryptedMessage = "";
|
||||
//
|
||||
// @ColumnInfo(name = "is_encrypted", defaultValue = "0")
|
||||
// private boolean isEncrypted = false;
|
||||
//
|
||||
// @ColumnInfo(name = "sender")
|
||||
// private String sender;
|
||||
//
|
||||
// @ColumnInfo(name = "recipient")
|
||||
// private String recipient;
|
||||
//
|
||||
// @ColumnInfo(name = "requested_at")
|
||||
// private Date requestedAt;
|
||||
//
|
||||
// @ColumnInfo(name = "sent_at")
|
||||
// private Date sentAt;
|
||||
//
|
||||
// @ColumnInfo(name = "delivered_at")
|
||||
// private Date deliveredAt;
|
||||
//
|
||||
// @ColumnInfo(name = "received_at")
|
||||
// private Date receivedAt;
|
||||
//
|
||||
// @NonNull
|
||||
// @ColumnInfo(name = "type")
|
||||
// private String type;
|
||||
//
|
||||
// @ColumnInfo(name = "server_acknowledged_at")
|
||||
// private Date serverAcknowledgedAt;
|
||||
//
|
||||
// public boolean hasServerAcknowledged() {
|
||||
// return serverAcknowledgedAt != null;
|
||||
// }
|
||||
//
|
||||
// @ColumnInfo(name = "last_acknowledged_request_at")
|
||||
// private Date lastAcknowledgedRequestAt;
|
||||
//
|
||||
// @ColumnInfo(name = "retry_count", defaultValue = "0")
|
||||
// private int retryCount = 0;
|
||||
//
|
||||
// public int getId() {
|
||||
// return id;
|
||||
// }
|
||||
//
|
||||
// public void setId(int id) {
|
||||
// this.id = id;
|
||||
// }
|
||||
//
|
||||
// public String get_id() {
|
||||
// return _id;
|
||||
// }
|
||||
//
|
||||
// public void set_id(String _id) {
|
||||
// this._id = _id;
|
||||
// }
|
||||
//
|
||||
// public String getMessage() {
|
||||
// return message;
|
||||
// }
|
||||
//
|
||||
// public void setMessage(String message) {
|
||||
// this.message = message;
|
||||
// }
|
||||
//
|
||||
// public String getEncryptedMessage() {
|
||||
// return encryptedMessage;
|
||||
// }
|
||||
//
|
||||
// public void setEncryptedMessage(String encryptedMessage) {
|
||||
// this.encryptedMessage = encryptedMessage;
|
||||
// }
|
||||
//
|
||||
// public boolean getIsEncrypted() {
|
||||
// return isEncrypted;
|
||||
// }
|
||||
//
|
||||
// public void setIsEncrypted(boolean isEncrypted) {
|
||||
// this.isEncrypted = isEncrypted;
|
||||
// }
|
||||
//
|
||||
// public String getSender() {
|
||||
// return sender;
|
||||
// }
|
||||
//
|
||||
// public void setSender(String sender) {
|
||||
// this.sender = sender;
|
||||
// }
|
||||
//
|
||||
// public String getRecipient() {
|
||||
// return recipient;
|
||||
// }
|
||||
//
|
||||
// public void setRecipient(String recipient) {
|
||||
// this.recipient = recipient;
|
||||
// }
|
||||
//
|
||||
// public Date getServerAcknowledgedAt() {
|
||||
// return serverAcknowledgedAt;
|
||||
// }
|
||||
//
|
||||
// public void setServerAcknowledgedAt(Date serverAcknowledgedAt) {
|
||||
// this.serverAcknowledgedAt = serverAcknowledgedAt;
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// public Date getRequestedAt() {
|
||||
// return requestedAt;
|
||||
// }
|
||||
//
|
||||
// public void setRequestedAt(Date requestedAt) {
|
||||
// this.requestedAt = requestedAt;
|
||||
// }
|
||||
//
|
||||
// public Date getSentAt() {
|
||||
// return sentAt;
|
||||
// }
|
||||
//
|
||||
// public void setSentAt(Date sentAt) {
|
||||
// this.sentAt = sentAt;
|
||||
// }
|
||||
//
|
||||
// public Date getDeliveredAt() {
|
||||
// return deliveredAt;
|
||||
// }
|
||||
//
|
||||
// public void setDeliveredAt(Date deliveredAt) {
|
||||
// this.deliveredAt = deliveredAt;
|
||||
// }
|
||||
//
|
||||
// public Date getReceivedAt() {
|
||||
// return receivedAt;
|
||||
// }
|
||||
//
|
||||
// public void setReceivedAt(Date receivedAt) {
|
||||
// this.receivedAt = receivedAt;
|
||||
// }
|
||||
//
|
||||
// @NonNull
|
||||
// public String getType() {
|
||||
// return type;
|
||||
// }
|
||||
//
|
||||
// public void setType(@NonNull String type) {
|
||||
// this.type = type;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public Date getLastAcknowledgedRequestAt() {
|
||||
// return lastAcknowledgedRequestAt;
|
||||
// }
|
||||
//
|
||||
// public void setLastAcknowledgedRequestAt(Date lastAcknowledgedRequestAt) {
|
||||
// this.lastAcknowledgedRequestAt = lastAcknowledgedRequestAt;
|
||||
// }
|
||||
//
|
||||
// public int getRetryCount() {
|
||||
// return retryCount;
|
||||
// }
|
||||
//
|
||||
// public void setRetryCount(int retryCount) {
|
||||
// this.retryCount = retryCount;
|
||||
// }
|
||||
//}
|
||||
@@ -1,27 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.room.Dao;
|
||||
//import androidx.room.Delete;
|
||||
//import androidx.room.Insert;
|
||||
//import androidx.room.OnConflictStrategy;
|
||||
//import androidx.room.Query;
|
||||
//
|
||||
//import java.util.List;
|
||||
//
|
||||
//@Dao
|
||||
//public interface SMSDao {
|
||||
//
|
||||
// @Query("SELECT * FROM sms")
|
||||
// List<SMS> getAll();
|
||||
//
|
||||
// @Query("SELECT * FROM sms WHERE id IN (:smsIds)")
|
||||
// List<SMS> loadAllByIds(int[] smsIds);
|
||||
//
|
||||
// @Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
// void insertAll(SMS... sms);
|
||||
//
|
||||
//
|
||||
// @Delete
|
||||
// void delete(SMS sms);
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.room.*
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "sms")
|
||||
@TypeConverters(DateConverter::class)
|
||||
data class Sms(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
@ColumnInfo(name = "_id") val serverId: String? = null,
|
||||
@ColumnInfo(name = "message") val message: String = "",
|
||||
@ColumnInfo(name = "encrypted_message") val encryptedMessage: String = "",
|
||||
@ColumnInfo(name = "is_encrypted", defaultValue = "0") val isEncrypted: Boolean = false,
|
||||
@ColumnInfo(name = "sender") val sender: String? = null,
|
||||
@ColumnInfo(name = "recipient") val recipient: String? = null,
|
||||
@ColumnInfo(name = "requested_at") val requestedAt: Date? = null,
|
||||
@ColumnInfo(name = "sent_at") val sentAt: Date? = null,
|
||||
@ColumnInfo(name = "delivered_at") val deliveredAt: Date? = null,
|
||||
@ColumnInfo(name = "received_at") val receivedAt: Date? = null,
|
||||
@ColumnInfo(name = "type") @field:NonNull val type: String = "",
|
||||
@ColumnInfo(name = "server_acknowledged_at") val serverAcknowledgedAt: Date? = null,
|
||||
@ColumnInfo(name = "last_acknowledged_request_at") val lastAcknowledgedRequestAt: Date? = null,
|
||||
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0
|
||||
) {
|
||||
fun hasServerAcknowledged(): Boolean = serverAcknowledgedAt != null
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface SmsDao {
|
||||
@Query("SELECT * FROM sms")
|
||||
suspend fun getAll(): List<Sms>
|
||||
|
||||
@Query("SELECT * FROM sms WHERE id IN (:smsIds)")
|
||||
suspend fun loadAllByIds(smsIds: IntArray): List<Sms>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(vararg sms: Sms)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(sms: Sms)
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class GatewayStatsResponse(
|
||||
@SerializedName("data") val data: GatewayStatsData? = null
|
||||
)
|
||||
|
||||
data class GatewayStatsData(
|
||||
@SerializedName("totalSentSMSCount") val totalSentSMSCount: Int? = null,
|
||||
@SerializedName("totalReceivedSMSCount") val totalReceivedSMSCount: Int? = null,
|
||||
@SerializedName("totalDeviceCount") val totalDeviceCount: Int? = null,
|
||||
@SerializedName("totalApiKeyCount") val totalApiKeyCount: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class HeartbeatInputDTO {
|
||||
var fcmToken: String? = null
|
||||
var batteryPercentage: Int? = null
|
||||
var isCharging: Boolean? = null
|
||||
var networkType: String? = null
|
||||
var appVersionName: String? = null
|
||||
var appVersionCode: Int? = null
|
||||
var deviceUptimeMillis: Long? = null
|
||||
var memoryFreeBytes: Long? = null
|
||||
var memoryTotalBytes: Long? = null
|
||||
var memoryMaxBytes: Long? = null
|
||||
var storageAvailableBytes: Long? = null
|
||||
var storageTotalBytes: Long? = null
|
||||
var timezone: String? = null
|
||||
var locale: String? = null
|
||||
var receiveSMSEnabled: Boolean? = null
|
||||
var smsSendDelaySeconds: Int? = null
|
||||
var simInfo: SimInfoCollectionDTO? = null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class HeartbeatResponseDTO {
|
||||
@JvmField var success: Boolean = false
|
||||
@JvmField var fcmTokenUpdated: Boolean = false
|
||||
@JvmField var lastHeartbeat: Long = 0
|
||||
@JvmField var name: String? = null
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class MessagesResponse(
|
||||
@SerializedName("data") val data: List<SmsMessage>? = null,
|
||||
@SerializedName("meta") val meta: PaginationMeta? = null
|
||||
)
|
||||
|
||||
data class SmsMessage(
|
||||
@SerializedName("_id") val id: String? = null,
|
||||
@SerializedName("message") val message: String? = null,
|
||||
@SerializedName("sender") val sender: String? = null,
|
||||
@SerializedName("recipient") val recipient: String? = null,
|
||||
@SerializedName("recipients") val recipients: List<String>? = null,
|
||||
@SerializedName("requestedAt") val requestedAt: String? = null,
|
||||
@SerializedName("receivedAt") val receivedAt: String? = null,
|
||||
@SerializedName("createdAt") val createdAt: String? = null,
|
||||
@SerializedName("status") val status: String? = null,
|
||||
@SerializedName("errorCode") val errorCode: String? = null,
|
||||
@SerializedName("errorMessage") val errorMessage: String? = null
|
||||
) {
|
||||
val isReceived: Boolean get() = sender != null
|
||||
val counterparty: String get() = if (isReceived) sender ?: "Unknown"
|
||||
else recipient ?: recipients?.firstOrNull() ?: "Unknown"
|
||||
}
|
||||
|
||||
data class PaginationMeta(
|
||||
@SerializedName("page") val page: Int? = null,
|
||||
@SerializedName("limit") val limit: Int? = null,
|
||||
@SerializedName("total") val total: Int? = null,
|
||||
@SerializedName("totalPages") val totalPages: Int? = null
|
||||
)
|
||||
|
||||
data class SendSmsRequest(
|
||||
@SerializedName("message") val message: String,
|
||||
@SerializedName("recipients") val recipients: List<String>
|
||||
)
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class RegisterDeviceInputDTO {
|
||||
private String fcmToken;
|
||||
private boolean enabled;
|
||||
private String brand;
|
||||
private String manufacturer;
|
||||
private String model;
|
||||
private String serial;
|
||||
private String buildId;
|
||||
private String os;
|
||||
private String osVersion;
|
||||
private String appVersionName;
|
||||
private int appVersionCode;
|
||||
|
||||
public RegisterDeviceInputDTO() {
|
||||
}
|
||||
|
||||
public RegisterDeviceInputDTO(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public String getFcmToken() {
|
||||
return fcmToken;
|
||||
}
|
||||
|
||||
public void setFcmToken(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getBrand() {
|
||||
return brand;
|
||||
}
|
||||
|
||||
public void setBrand(String brand) {
|
||||
this.brand = brand;
|
||||
}
|
||||
|
||||
public String getManufacturer() {
|
||||
return manufacturer;
|
||||
}
|
||||
|
||||
public void setManufacturer(String manufacturer) {
|
||||
this.manufacturer = manufacturer;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public String getSerial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
public void setSerial(String serial) {
|
||||
this.serial = serial;
|
||||
}
|
||||
|
||||
public String getBuildId() {
|
||||
return buildId;
|
||||
}
|
||||
|
||||
public void setBuildId(String buildId) {
|
||||
this.buildId = buildId;
|
||||
}
|
||||
|
||||
public String getOs() {
|
||||
return os;
|
||||
}
|
||||
|
||||
public void setOs(String os) {
|
||||
this.os = os;
|
||||
}
|
||||
|
||||
public String getOsVersion() {
|
||||
return osVersion;
|
||||
}
|
||||
|
||||
public void setOsVersion(String osVersion) {
|
||||
this.osVersion = osVersion;
|
||||
}
|
||||
|
||||
public String getAppVersionName() {
|
||||
return appVersionName;
|
||||
}
|
||||
|
||||
public void setAppVersionName(String appVersionName) {
|
||||
this.appVersionName = appVersionName;
|
||||
}
|
||||
|
||||
public int getAppVersionCode() {
|
||||
return appVersionCode;
|
||||
}
|
||||
|
||||
public void setAppVersionCode(int appVersionCode) {
|
||||
this.appVersionCode = appVersionCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class RegisterDeviceInputDTO {
|
||||
var fcmToken: String? = null
|
||||
@get:JvmName("isEnabled") var enabled: Boolean? = null
|
||||
var brand: String? = null
|
||||
var manufacturer: String? = null
|
||||
var model: String? = null
|
||||
var name: String? = null
|
||||
var serial: String? = null
|
||||
var buildId: String? = null
|
||||
var os: String? = null
|
||||
var osVersion: String? = null
|
||||
var appVersionName: String? = null
|
||||
var appVersionCode: Int = 0
|
||||
var simInfo: SimInfoCollectionDTO? = null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class RegisterDeviceResponseDTO {
|
||||
public boolean success;
|
||||
public Map<String, Object> data;
|
||||
public String error;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class RegisterDeviceResponseDTO {
|
||||
@JvmField var success: Boolean = false
|
||||
@JvmField var data: Map<String, Any?>? = null
|
||||
@JvmField var error: String? = null
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class SMSDTO {
|
||||
private String sender;
|
||||
private String message = "";
|
||||
private long receivedAtInMillis;
|
||||
|
||||
public SMSDTO() {
|
||||
}
|
||||
|
||||
|
||||
public String getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public void setSender(String sender) {
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public long getReceivedAtInMillis() {
|
||||
return receivedAtInMillis;
|
||||
}
|
||||
|
||||
public void setReceivedAtInMillis(long receivedAtInMillis) {
|
||||
this.receivedAtInMillis = receivedAtInMillis;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSDTO {
|
||||
var sender: String? = null
|
||||
var message: String = ""
|
||||
var receivedAtInMillis: Long = 0
|
||||
var fingerprint: String? = null
|
||||
var smsId: String? = null
|
||||
var smsBatchId: String? = null
|
||||
var status: String? = null
|
||||
var sentAtInMillis: Long = 0
|
||||
var deliveredAtInMillis: Long = 0
|
||||
var failedAtInMillis: Long = 0
|
||||
var errorCode: String? = null
|
||||
var errorMessage: String? = null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class SMSForwardResponseDTO {
|
||||
|
||||
public SMSForwardResponseDTO() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSForwardResponseDTO
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoCollectionDTO {
|
||||
var lastUpdated: Long = 0
|
||||
var sims: MutableList<SimInfoDTO>? = null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoDTO {
|
||||
var subscriptionId: Int = 0
|
||||
var iccId: String? = null
|
||||
var cardId: Int? = null
|
||||
var carrierName: String? = null
|
||||
var displayName: String? = null
|
||||
var simSlotIndex: Int? = null
|
||||
var mcc: String? = null
|
||||
var mnc: String? = null
|
||||
var countryIso: String? = null
|
||||
var subscriptionType: String? = null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class SubscriptionResponse(
|
||||
@SerializedName("plan") val plan: SubscriptionPlan? = null,
|
||||
@SerializedName("currentPeriodStart") val currentPeriodStart: String? = null,
|
||||
@SerializedName("currentPeriodEnd") val currentPeriodEnd: String? = null,
|
||||
@SerializedName("isActive") val isActive: Boolean? = null,
|
||||
@SerializedName("usage") val usage: SubscriptionUsage? = null
|
||||
)
|
||||
|
||||
data class SubscriptionPlan(
|
||||
@SerializedName("name") val name: String? = null,
|
||||
@SerializedName("displayName") val displayName: String? = null
|
||||
)
|
||||
|
||||
data class SubscriptionUsage(
|
||||
@SerializedName("processedSmsToday") val processedSmsToday: Int? = null,
|
||||
@SerializedName("processedSmsLastMonth") val processedSmsLastMonth: Int? = null,
|
||||
@SerializedName("dailyLimit") val dailyLimit: Int? = null,
|
||||
@SerializedName("monthlyLimit") val monthlyLimit: Int? = null,
|
||||
@SerializedName("dailyRemaining") val dailyRemaining: Int? = null,
|
||||
@SerializedName("monthlyRemaining") val monthlyRemaining: Int? = null,
|
||||
@SerializedName("dailyUsagePercentage") val dailyUsagePercentage: Int? = null,
|
||||
@SerializedName("monthlyUsagePercentage") val monthlyUsagePercentage: Int? = null
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class UserProfileWrapper(
|
||||
@SerializedName("data") val data: UserProfile? = null
|
||||
)
|
||||
|
||||
data class UserProfile(
|
||||
@SerializedName("name") val name: String? = null,
|
||||
@SerializedName("email") val email: String? = null
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Extracts the human-readable `message` field the API returns in error bodies
|
||||
* (e.g. the device-limit 429), falling back to null when it can't be parsed.
|
||||
*/
|
||||
fun Response<*>.serverErrorMessage(): String? {
|
||||
val raw = errorBody()?.string()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return try {
|
||||
JSONObject(raw).optString("message").takeIf { it.isNotBlank() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.BatteryManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object HeartbeatHelper {
|
||||
private const val TAG = "HeartbeatHelper"
|
||||
|
||||
@JvmStatic
|
||||
fun sendHeartbeat(context: Context, deviceId: String, apiKey: String): Boolean {
|
||||
if (deviceId.isEmpty()) {
|
||||
Log.d(TAG, "Device not registered, skipping heartbeat")
|
||||
return false
|
||||
}
|
||||
if (apiKey.isEmpty()) {
|
||||
Log.e(TAG, "API key not available, skipping heartbeat")
|
||||
return false
|
||||
}
|
||||
|
||||
val heartbeatInput = HeartbeatInputDTO()
|
||||
|
||||
return try {
|
||||
// FCM token (blocking wait up to 5 seconds)
|
||||
try {
|
||||
val latch = CountDownLatch(1)
|
||||
var fcmTokenResult: String? = null
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) fcmTokenResult = task.result
|
||||
latch.countDown()
|
||||
}
|
||||
if (latch.await(5, TimeUnit.SECONDS) && fcmTokenResult != null) {
|
||||
heartbeatInput.fcmToken = fcmTokenResult
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get FCM token: ${e.message}")
|
||||
}
|
||||
|
||||
// Battery info
|
||||
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
if (batteryStatus != null) {
|
||||
val level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
heartbeatInput.batteryPercentage = ((level / scale.toFloat()) * 100).toInt()
|
||||
val status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
heartbeatInput.isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL
|
||||
}
|
||||
|
||||
// Network type
|
||||
@Suppress("DEPRECATION")
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
val activeNetwork = cm?.activeNetworkInfo
|
||||
@Suppress("DEPRECATION")
|
||||
heartbeatInput.networkType = when {
|
||||
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_WIFI -> "wifi"
|
||||
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_MOBILE -> "cellular"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
// App version
|
||||
heartbeatInput.appVersionName = BuildConfig.VERSION_NAME
|
||||
heartbeatInput.appVersionCode = BuildConfig.VERSION_CODE
|
||||
|
||||
// Device uptime
|
||||
heartbeatInput.deviceUptimeMillis = SystemClock.uptimeMillis()
|
||||
|
||||
// Memory
|
||||
val runtime = Runtime.getRuntime()
|
||||
heartbeatInput.memoryFreeBytes = runtime.freeMemory()
|
||||
heartbeatInput.memoryTotalBytes = runtime.totalMemory()
|
||||
heartbeatInput.memoryMaxBytes = runtime.maxMemory()
|
||||
|
||||
// Storage
|
||||
val statFs = StatFs(context.filesDir.path)
|
||||
heartbeatInput.storageAvailableBytes = statFs.availableBytes
|
||||
heartbeatInput.storageTotalBytes = statFs.totalBytes
|
||||
|
||||
// Locale / timezone
|
||||
heartbeatInput.timezone = TimeZone.getDefault().id
|
||||
heartbeatInput.locale = Locale.getDefault().toString()
|
||||
|
||||
// Preferences
|
||||
heartbeatInput.receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
heartbeatInput.smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
)
|
||||
|
||||
// SIM info
|
||||
heartbeatInput.simInfo = SimInfoCollectionDTO().apply {
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
sims = TextBeeUtils.collectSimInfo(context)
|
||||
}
|
||||
|
||||
// Send heartbeat (blocking)
|
||||
val response = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput).execute()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.fcmTokenUpdated) Log.d(TAG, "FCM token was updated during heartbeat")
|
||||
if (!body.name.isNullOrBlank()) {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, body.name!!
|
||||
)
|
||||
Log.d(TAG, "Synced device name from heartbeat: ${body.name}")
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY,
|
||||
System.currentTimeMillis().toString()
|
||||
)
|
||||
Log.d(TAG, "Heartbeat sent successfully")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat. Response code: ${response.code()}")
|
||||
false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Heartbeat API call failed: ${e.message}")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error collecting device information: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isDeviceEligibleForHeartbeat(context: Context): Boolean {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty()) return false
|
||||
|
||||
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
if (!deviceEnabled) return false
|
||||
|
||||
return SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.workers.HeartbeatWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object HeartbeatManager {
|
||||
private const val TAG = "HeartbeatManager"
|
||||
private const val MIN_INTERVAL_MINUTES = 15
|
||||
private const val UNIQUE_WORK_NAME = "heartbeat_unique_work"
|
||||
|
||||
@JvmStatic
|
||||
fun scheduleHeartbeat(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
var intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
appContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, 30
|
||||
)
|
||||
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
|
||||
Log.w(TAG, "Interval $intervalMinutes minutes is less than minimum $MIN_INTERVAL_MINUTES minutes, using minimum")
|
||||
intervalMinutes = MIN_INTERVAL_MINUTES
|
||||
}
|
||||
Log.d(TAG, "Scheduling heartbeat with interval: $intervalMinutes minutes")
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val heartbeatWork = PeriodicWorkRequest.Builder(
|
||||
HeartbeatWorker::class.java,
|
||||
intervalMinutes.toLong(),
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
heartbeatWork
|
||||
)
|
||||
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: $UNIQUE_WORK_NAME")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun cancelHeartbeat(context: Context) {
|
||||
Log.d(TAG, "Cancelling heartbeat work")
|
||||
val appContext = context.applicationContext
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
WorkManager.getInstance(appContext).cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun triggerHeartbeat(context: Context) {
|
||||
Log.d(TAG, "Triggering immediate heartbeat")
|
||||
scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
|
||||
object SMSFilterHelper {
|
||||
private const val TAG = "SMSFilterHelper"
|
||||
|
||||
enum class FilterMode { ALLOW_LIST, BLOCK_LIST }
|
||||
|
||||
class FilterConfig {
|
||||
@get:JvmName("isEnabled") var enabled: Boolean = false
|
||||
var mode: FilterMode = FilterMode.BLOCK_LIST
|
||||
var rules: MutableList<SMSFilterRule> = mutableListOf()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun loadFilterConfig(context: Context): FilterConfig {
|
||||
val json = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, null
|
||||
)
|
||||
if (json.isNullOrEmpty()) return FilterConfig()
|
||||
return try {
|
||||
val type = object : TypeToken<FilterConfig>() {}.type
|
||||
Gson().fromJson<FilterConfig>(json, type) ?: FilterConfig()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading filter config: ${e.message}")
|
||||
FilterConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun saveFilterConfig(context: Context, config: FilterConfig) {
|
||||
try {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
Gson().toJson(config)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error saving filter config: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldProcessSMS(sender: String?, message: String?, context: Context): Boolean {
|
||||
val config = loadFilterConfig(context)
|
||||
if (!config.enabled) return true
|
||||
if (config.rules.isEmpty()) return true
|
||||
val matchesAnyRule = config.rules.any { it.matches(sender, message) }
|
||||
return if (config.mode == FilterMode.ALLOW_LIST) matchesAnyRule else !matchesAnyRule
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldProcessSMS(sender: String?, context: Context): Boolean =
|
||||
shouldProcessSMS(sender, null, context)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.telephony.SmsManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class SMSHelper {
|
||||
public static void sendSMS(String phoneNo, String message) {
|
||||
SmsManager smsManager = SmsManager.getDefault();
|
||||
|
||||
//for sms with more than 160 chars
|
||||
ArrayList<String> parts = smsManager.divideMessage(message);
|
||||
if (parts.size() > 1) {
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null);
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, null, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static void sendSMSFromSpecificSim(String phoneNo, String message, int simSlot) {
|
||||
SmsManager smsManager = SmsManager.getSmsManagerForSubscriptionId(simSlot);
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, smsManager.divideMessage(message), null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.receivers.SMSStatusReceiver
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker
|
||||
|
||||
object SMSHelper {
|
||||
private const val TAG = "SMSHelper"
|
||||
|
||||
@JvmStatic
|
||||
fun sendSMS(
|
||||
phoneNo: String,
|
||||
message: String,
|
||||
smsId: String,
|
||||
smsBatchId: String,
|
||||
context: Context
|
||||
): Boolean {
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
|
||||
Log.e(TAG, "SMS permission not granted. Unable to send SMS.")
|
||||
reportPermissionError(context, smsId, smsBatchId)
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val smsManager = SmsManager.getDefault()
|
||||
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
|
||||
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
|
||||
val parts = smsManager.divideMessage(message)
|
||||
if (parts.size > 1) {
|
||||
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(sentIntent) }
|
||||
}
|
||||
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(deliveredIntent) }
|
||||
}
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when sending SMS: ${e.message}")
|
||||
reportSendingError(context, smsId, smsBatchId, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun sendSMSFromSpecificSim(
|
||||
phoneNo: String,
|
||||
message: String,
|
||||
simSubscriptionId: Int,
|
||||
smsId: String,
|
||||
smsBatchId: String,
|
||||
context: Context
|
||||
): Boolean {
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
|
||||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)
|
||||
) {
|
||||
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.")
|
||||
reportPermissionError(context, smsId, smsBatchId)
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
@Suppress("DEPRECATION")
|
||||
val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId)
|
||||
} else {
|
||||
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version")
|
||||
SmsManager.getDefault()
|
||||
}
|
||||
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
|
||||
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
|
||||
val parts = smsManager.divideMessage(message)
|
||||
if (parts.size > 1) {
|
||||
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(sentIntent) }
|
||||
}
|
||||
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(deliveredIntent) }
|
||||
}
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when sending SMS from specific SIM: ${e.message}")
|
||||
reportSendingError(context, smsId, smsBatchId, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportPermissionError(context: Context, smsId: String, smsBatchId: String) {
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
status = "FAILED"
|
||||
failedAtInMillis = System.currentTimeMillis()
|
||||
errorCode = "PERMISSION_DENIED"
|
||||
errorMessage = "SMS permission not granted"
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun reportSendingError(context: Context, smsId: String, smsBatchId: String, error: String?) {
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
status = "FAILED"
|
||||
failedAtInMillis = System.currentTimeMillis()
|
||||
errorCode = "SENDING_EXCEPTION"
|
||||
errorMessage = error
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found")
|
||||
return
|
||||
}
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
|
||||
}
|
||||
|
||||
private fun createSentPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
|
||||
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
|
||||
action = SMSStatusReceiver.SMS_SENT
|
||||
putExtra("sms_id", smsId)
|
||||
putExtra("sms_batch_id", smsBatchId)
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
return PendingIntent.getBroadcast(context, (smsId + "_sent").hashCode(), intent, flags)
|
||||
}
|
||||
|
||||
private fun createDeliveredPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
|
||||
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
|
||||
action = SMSStatusReceiver.SMS_DELIVERED
|
||||
putExtra("sms_id", smsId)
|
||||
putExtra("sms_batch_id", smsBatchId)
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
return PendingIntent.getBroadcast(context, (smsId + "_delivered").hashCode(), intent, flags)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
public class SharedPreferenceHelper {
|
||||
private final static String PREF_FILE = "PREF";
|
||||
|
||||
|
||||
public static void setSharedPreferenceString(Context context, String key, String value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putString(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static void setSharedPreferenceInt(Context context, String key, int value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putInt(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static void setSharedPreferenceBoolean(Context context, String key, boolean value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putBoolean(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static String getSharedPreferenceString(Context context, String key, String defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getString(key, defValue);
|
||||
}
|
||||
|
||||
|
||||
public static int getSharedPreferenceInt(Context context, String key, int defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getInt(key, defValue);
|
||||
}
|
||||
|
||||
|
||||
public static boolean getSharedPreferenceBoolean(Context context, String key, boolean defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getBoolean(key, defValue);
|
||||
}
|
||||
|
||||
public static void clearSharedPreference(Context context, String key) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.remove(key);
|
||||
editor.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SharedPreferenceHelper {
|
||||
private const val PREF_FILE = "PREF"
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceString(context: Context, key: String, value: String) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceInt(context: Context, key: String, value: Int) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putInt(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceBoolean(context: Context, key: String, value: Boolean) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putBoolean(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceString(context: Context, key: String, defValue: String?): String? {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getString(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceInt(context: Context, key: String, defValue: Int): Int {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getInt(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceBoolean(context: Context, key: String, defValue: Boolean): Boolean {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getBoolean(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun clearSharedPreference(context: Context, key: String) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().remove(key).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.BuildConfig;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class VersionTracker {
|
||||
private static final String TAG = "VersionTracker";
|
||||
|
||||
/**
|
||||
* Checks if the app version has changed since the last time it was run
|
||||
* @param context Application context
|
||||
* @return true if version has changed, false otherwise
|
||||
*/
|
||||
public static boolean hasVersionChanged(Context context) {
|
||||
int lastVersionCode = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_LAST_VERSION_CODE_KEY,
|
||||
-1
|
||||
);
|
||||
|
||||
String lastVersionName = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_LAST_VERSION_NAME_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
int currentVersionCode = BuildConfig.VERSION_CODE;
|
||||
String currentVersionName = BuildConfig.VERSION_NAME;
|
||||
|
||||
// First app launch or version changed
|
||||
return lastVersionCode == -1 ||
|
||||
lastVersionCode != currentVersionCode ||
|
||||
!lastVersionName.equals(currentVersionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored version information with current version
|
||||
* @param context Application context
|
||||
*/
|
||||
public static void updateStoredVersion(Context context) {
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_LAST_VERSION_CODE_KEY,
|
||||
BuildConfig.VERSION_CODE
|
||||
);
|
||||
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_LAST_VERSION_NAME_KEY,
|
||||
BuildConfig.VERSION_NAME
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports current app version to the server
|
||||
* @param context Application context
|
||||
*/
|
||||
public static void reportVersionToServer(Context context) {
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// If device is not registered or no API key, can't report version
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.d(TAG, "Can't report version: device not registered or no API key");
|
||||
return;
|
||||
}
|
||||
|
||||
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
|
||||
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
Call<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput);
|
||||
|
||||
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Version update reported successfully");
|
||||
updateStoredVersion(context);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to report version update: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "Error reporting version update: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.vernu.sms.models
|
||||
|
||||
class SMSFilterRule @JvmOverloads constructor(
|
||||
var pattern: String? = null,
|
||||
var matchType: MatchType? = null,
|
||||
var filterTarget: FilterTarget = FilterTarget.SENDER,
|
||||
@get:JvmName("isCaseSensitive") var caseSensitive: Boolean = false
|
||||
) {
|
||||
enum class MatchType { EXACT, STARTS_WITH, ENDS_WITH, CONTAINS }
|
||||
enum class FilterTarget { SENDER, MESSAGE, BOTH }
|
||||
|
||||
private fun matchesString(text: String?): Boolean {
|
||||
val p = pattern ?: return false
|
||||
val t = text ?: return false
|
||||
val pat = if (caseSensitive) p else p.lowercase()
|
||||
val txt = if (caseSensitive) t else t.lowercase()
|
||||
return when (matchType) {
|
||||
MatchType.EXACT -> txt == pat
|
||||
MatchType.STARTS_WITH -> txt.startsWith(pat)
|
||||
MatchType.ENDS_WITH -> txt.endsWith(pat)
|
||||
MatchType.CONTAINS -> txt.contains(pat)
|
||||
null -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(sender: String?, message: String?): Boolean {
|
||||
if (pattern == null) return false
|
||||
return when (filterTarget) {
|
||||
FilterTarget.SENDER -> matchesString(sender)
|
||||
FilterTarget.MESSAGE -> matchesString(message)
|
||||
FilterTarget.BOTH -> matchesString(sender) || matchesString(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(sender: String?): Boolean = matches(sender, null)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package com.vernu.sms.models;
|
||||
|
||||
public class SMSPayload {
|
||||
|
||||
private String[] recipients;
|
||||
private String message;
|
||||
|
||||
// Legacy fields that are no longer used
|
||||
private String[] receivers;
|
||||
private String smsBody;
|
||||
|
||||
public SMSPayload() {
|
||||
}
|
||||
|
||||
public String[] getRecipients() {
|
||||
return recipients;
|
||||
}
|
||||
|
||||
public void setRecipients(String[] recipients) {
|
||||
this.recipients = recipients;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.vernu.sms.models
|
||||
|
||||
class SMSPayload {
|
||||
var recipients: Array<String>? = null
|
||||
var message: String? = null
|
||||
var smsId: String? = null
|
||||
var smsBatchId: String? = null
|
||||
var simSubscriptionId: Int? = null
|
||||
|
||||
// Legacy fields — no longer actively used but kept for backward compatibility
|
||||
var receivers: Array<String>? = null
|
||||
var smsBody: String? = null
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
|
||||
public class BootCompletedReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
|
||||
// if(TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
|
||||
// TextBeeUtils.startStickyNotificationService(context);
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class BootCompletedReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "BootCompletedReceiver"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
|
||||
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
if (stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)) {
|
||||
Log.i(TAG, "Device booted, starting sticky notification service")
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
if (deviceId.isNotEmpty() && apiKey.isNotEmpty()) {
|
||||
updateDeviceInfo(context, deviceId, apiKey)
|
||||
|
||||
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
if (deviceEnabled) {
|
||||
Log.i(TAG, "Device booted, scheduling heartbeat")
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDeviceInfo(context: Context, deviceId: String, apiKey: String) {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (!task.isSuccessful) {
|
||||
Log.e(TAG, "Failed to obtain FCM token after boot")
|
||||
return@addOnCompleteListener
|
||||
}
|
||||
|
||||
val input = RegisterDeviceInputDTO().apply {
|
||||
fcmToken = task.result
|
||||
appVersionCode = BuildConfig.VERSION_CODE
|
||||
appVersionName = BuildConfig.VERSION_NAME
|
||||
}
|
||||
|
||||
Log.d(TAG, "Updating device info after boot - deviceId: $deviceId")
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, input)
|
||||
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
|
||||
override fun onResponse(
|
||||
call: Call<RegisterDeviceResponseDTO>,
|
||||
response: Response<RegisterDeviceResponseDTO>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Device info updated successfully after boot")
|
||||
val data = response.body()?.data ?: return
|
||||
val intervalObj = data["heartbeatIntervalMinutes"] as? Number ?: return
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
|
||||
intervalObj.toInt()
|
||||
)
|
||||
Log.d(TAG, "Synced heartbeat interval from server: ${intervalObj.toInt()} minutes")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update device info after boot. Response code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
|
||||
Log.e(TAG, "Error updating device info after boot: ${t.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.provider.Telephony;
|
||||
import android.telephony.SmsMessage;
|
||||
import android.util.Log;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SMSBroadcastReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "SMSBroadcastReceiver";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "onReceive: " + intent.getAction());
|
||||
|
||||
if (!Objects.equals(intent.getAction(), Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) {
|
||||
Log.d(TAG, "Not Valid intent");
|
||||
return;
|
||||
}
|
||||
|
||||
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
|
||||
if (messages == null) {
|
||||
Log.d(TAG, "No messages found");
|
||||
return;
|
||||
}
|
||||
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false);
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
|
||||
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS receivedSMS = new SMS();
|
||||
// receivedSMS.setType("RECEIVED");
|
||||
// for (SmsMessage message : messages) {
|
||||
// receivedSMS.setMessage(receivedSMS.getMessage() + message.getMessageBody());
|
||||
// receivedSMS.setSender(message.getOriginatingAddress());
|
||||
// receivedSMS.setReceivedAt(new Date(message.getTimestampMillis()));
|
||||
// }
|
||||
|
||||
SMSDTO receivedSMSDTO = new SMSDTO();
|
||||
|
||||
for (SmsMessage message : messages) {
|
||||
receivedSMSDTO.setMessage(receivedSMSDTO.getMessage() + message.getMessageBody());
|
||||
receivedSMSDTO.setSender(message.getOriginatingAddress());
|
||||
receivedSMSDTO.setReceivedAtInMillis(message.getTimestampMillis());
|
||||
}
|
||||
// receivedSMSDTO.setSender(receivedSMS.getSender());
|
||||
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
|
||||
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
|
||||
|
||||
Call<SMSForwardResponseDTO> apiCall = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, receivedSMSDTO);
|
||||
apiCall.enqueue(new retrofit2.Callback<SMSForwardResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<SMSForwardResponseDTO> call, Response<SMSForwardResponseDTO> response) {
|
||||
// Date now = new Date();
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "SMS sent to server successfully");
|
||||
// receivedSMS.setLastAcknowledgedRequestAt(now);
|
||||
// receivedSMS.setServerAcknowledgedAt(now);
|
||||
// updateLocalReceivedSMS(receivedSMS, context);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send SMS to server");
|
||||
// receivedSMS.setServerAcknowledgedAt(null);
|
||||
// receivedSMS.setLastAcknowledgedRequestAt(now);
|
||||
// receivedSMS.setRetryCount(localReceivedSMS.getRetryCount() + 1);
|
||||
// updateLocalReceivedSMS(receivedSMS, context);
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void onFailure(Call<SMSForwardResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "Failed to send SMS to server", t);
|
||||
// receivedSMS.setServerAcknowledgedAt(null);
|
||||
// receivedSMS.setLastAcknowledgedRequestAt(new Date());
|
||||
// updateLocalReceivedSMS(receivedSMS, context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
|
||||
// Executors.newSingleThreadExecutor().execute(() -> {
|
||||
// AppDatabase appDatabase = AppDatabase.getInstance(context);
|
||||
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
|
||||
// });
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.workers.SMSReceivedWorker
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class SMSBroadcastReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SMSBroadcastReceiver"
|
||||
private val processedFingerprints = ConcurrentHashMap<String, Long>()
|
||||
private const val CACHE_TTL_MS = 5000L
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "onReceive: ${intent.action}")
|
||||
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
|
||||
Log.d(TAG, "Not Valid intent")
|
||||
return
|
||||
}
|
||||
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: run {
|
||||
Log.d(TAG, "No messages found")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
|
||||
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val dto = SMSDTO()
|
||||
for (message in messages) {
|
||||
dto.message += message.messageBody ?: ""
|
||||
dto.sender = message.originatingAddress
|
||||
dto.receivedAtInMillis = message.timestampMillis
|
||||
}
|
||||
|
||||
val sender = dto.sender
|
||||
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, dto.message, context)) {
|
||||
Log.d(TAG, "SMS from $sender filtered out by filter rules")
|
||||
return
|
||||
}
|
||||
|
||||
val fingerprint = generateFingerprint(dto.sender, dto.message, dto.receivedAtInMillis)
|
||||
dto.fingerprint = fingerprint
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastProcessedTime = processedFingerprints[fingerprint]
|
||||
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
|
||||
Log.d(TAG, "Duplicate SMS detected in cache, skipping: $fingerprint")
|
||||
return
|
||||
}
|
||||
|
||||
processedFingerprints[fingerprint] = currentTime
|
||||
cleanupCache(currentTime)
|
||||
|
||||
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, dto)
|
||||
}
|
||||
|
||||
private fun generateFingerprint(sender: String?, message: String, timestamp: Long): String {
|
||||
return try {
|
||||
val data = "${sender ?: ""}|$message|$timestamp"
|
||||
val hashBytes = MessageDigest.getInstance("MD5").digest(data.toByteArray(Charsets.UTF_8))
|
||||
hashBytes.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error generating fingerprint: ${e.message}")
|
||||
"${sender ?: ""}_${message}_$timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupCache(currentTime: Long) {
|
||||
if (processedFingerprints.size > 100) {
|
||||
val keysToRemove = processedFingerprints.entries
|
||||
.filter { (currentTime - it.value) > CACHE_TTL_MS }
|
||||
.map { it.key }
|
||||
keysToRemove.forEach { processedFingerprints.remove(it) }
|
||||
Log.d(TAG, "Cleaned up ${keysToRemove.size} expired cache entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
class SMSStatusReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SMSStatusReceiver"
|
||||
const val SMS_SENT = "SMS_SENT"
|
||||
const val SMS_DELIVERED = "SMS_DELIVERED"
|
||||
|
||||
private fun getResultCodeName(resultCode: Int): String? {
|
||||
for (clazz in arrayOf<Class<*>>(SmsManager::class.java, Activity::class.java)) {
|
||||
try {
|
||||
for (field in clazz.declaredFields) {
|
||||
if (field.type != Int::class.javaPrimitiveType) continue
|
||||
if (!Modifier.isStatic(field.modifiers) || !Modifier.isFinal(field.modifiers)) continue
|
||||
if (!field.name.startsWith("RESULT_")) continue
|
||||
field.isAccessible = true
|
||||
if (field.getInt(null) == resultCode) return "${clazz.simpleName}.${field.name}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reflection failed for ${clazz.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val smsId = intent.getStringExtra("sms_id")
|
||||
val smsBatchId = intent.getStringExtra("sms_batch_id")
|
||||
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
}
|
||||
|
||||
when (intent.action) {
|
||||
SMS_SENT -> handleSentStatus(context, intent, resultCode, smsDTO)
|
||||
SMS_DELIVERED -> handleDeliveredStatus(context, resultCode, smsDTO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSentStatus(context: Context, intent: Intent, resultCode: Int, smsDTO: SMSDTO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
smsDTO.status = "SENT"
|
||||
smsDTO.sentAtInMillis = timestamp
|
||||
Log.d(TAG, "SMS sent successfully - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> {
|
||||
val radioCode = intent.getIntExtra("errorCode", -1)
|
||||
var msg = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again."
|
||||
if (radioCode != -1) msg += " (code $radioCode)"
|
||||
setFailed(smsDTO, timestamp, resultCode, msg)
|
||||
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error code: $resultCode, Error: $msg")
|
||||
}
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.")
|
||||
SmsManager.RESULT_ERROR_NULL_PDU -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.")
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"No cellular service. Check signal and try again when you have coverage.")
|
||||
SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.")
|
||||
SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Short code not allowed on this carrier. Use a full phone number.")
|
||||
SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Short codes are not supported on this carrier. Use a full phone number.")
|
||||
SmsManager.RESULT_NETWORK_ERROR -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Network error while sending. Check signal and try again.")
|
||||
else -> {
|
||||
val msg = getResultCodeName(resultCode) ?: "Unknown error (code $resultCode)"
|
||||
setFailed(smsDTO, timestamp, resultCode, msg)
|
||||
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun handleDeliveredStatus(context: Context, resultCode: Int, smsDTO: SMSDTO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
smsDTO.status = "DELIVERED"
|
||||
smsDTO.deliveredAtInMillis = timestamp
|
||||
Log.d(TAG, "SMS delivered successfully - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
val msg = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered."
|
||||
smsDTO.status = "DELIVERY_FAILED"
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
else -> {
|
||||
val msg = getResultCodeName(resultCode) ?: "Unknown delivery error (code $resultCode)"
|
||||
smsDTO.status = "DELIVERY_FAILED"
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun setFailed(smsDTO: SMSDTO, timestamp: Long, resultCode: Int, msg: String) {
|
||||
smsDTO.status = "FAILED"
|
||||
smsDTO.failedAtInMillis = timestamp
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
}
|
||||
|
||||
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found")
|
||||
return
|
||||
}
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package com.vernu.sms.services;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.media.RingtoneManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.activities.MainActivity;
|
||||
import com.vernu.sms.helpers.SMSHelper;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.models.SMSPayload;
|
||||
|
||||
public class FCMService extends FirebaseMessagingService {
|
||||
|
||||
private static final String TAG = "FirebaseMessagingService";
|
||||
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||
|
||||
Log.d(TAG, remoteMessage.getData().toString());
|
||||
|
||||
Gson gson = new Gson();
|
||||
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
|
||||
|
||||
// Check if message contains a data payload.
|
||||
if (remoteMessage.getData().size() > 0) {
|
||||
int preferedSim = SharedPreferenceHelper.getSharedPreferenceInt(this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
|
||||
for (String receiver : smsPayload.getRecipients()) {
|
||||
if(preferedSim == -1) {
|
||||
SMSHelper.sendSMS(receiver, smsPayload.getMessage());
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getMessage(), preferedSim);
|
||||
} catch(Exception e) {
|
||||
Log.d("SMS_SEND_ERROR", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Handle FCM Notification Messages
|
||||
if (remoteMessage.getNotification() != null) {
|
||||
// sendNotification("notif msg", "msg body");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewToken(String token) {
|
||||
sendRegistrationToServer(token);
|
||||
}
|
||||
|
||||
private void sendRegistrationToServer(String token) {
|
||||
|
||||
}
|
||||
|
||||
/* build and show notification */
|
||||
private void sendNotification(String title, String messageBody) {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent,
|
||||
PendingIntent.FLAG_ONE_SHOT);
|
||||
|
||||
String channelId = DEFAULT_NOTIFICATION_CHANNEL_ID;
|
||||
Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
|
||||
NotificationCompat.Builder notificationBuilder =
|
||||
new NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent);
|
||||
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
// Since android Oreo notification channel is needed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(channelId,
|
||||
"Channel human readable title",
|
||||
NotificationManager.IMPORTANCE_DEFAULT);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.helpers.HeartbeatHelper
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.models.SMSPayload
|
||||
import com.vernu.sms.workers.SmsSendWorker
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class FCMService : FirebaseMessagingService() {
|
||||
companion object {
|
||||
private const val TAG = "FirebaseMessagingService"
|
||||
private const val DEFAULT_NOTIFICATION_CHANNEL_ID = "N1"
|
||||
}
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
Log.d(TAG, remoteMessage.data.toString())
|
||||
|
||||
try {
|
||||
val messageType = remoteMessage.data["type"]
|
||||
if (messageType == "heartbeat_check") {
|
||||
handleHeartbeatCheck()
|
||||
return
|
||||
}
|
||||
|
||||
val smsPayload = Gson().fromJson(remoteMessage.data["smsData"], SMSPayload::class.java)
|
||||
|
||||
if (remoteMessage.data.isNotEmpty()) {
|
||||
sendSMS(smsPayload)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing FCM message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHeartbeatCheck() {
|
||||
Log.d(TAG, "Received heartbeat check request from backend")
|
||||
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
val success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey)
|
||||
if (success) {
|
||||
Log.d(TAG, "Heartbeat sent successfully in response to backend check")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat in response to backend check")
|
||||
}
|
||||
HeartbeatManager.scheduleHeartbeat(this)
|
||||
}
|
||||
|
||||
private fun sendSMS(smsPayload: SMSPayload?) {
|
||||
if (smsPayload == null) {
|
||||
Log.e(TAG, "SMS payload is null")
|
||||
return
|
||||
}
|
||||
|
||||
val recipients = smsPayload.recipients
|
||||
if (recipients == null || recipients.isEmpty()) {
|
||||
Log.e(TAG, "No recipients found in SMS payload")
|
||||
return
|
||||
}
|
||||
|
||||
for (recipient in recipients) {
|
||||
SmsSendWorker.enqueue(
|
||||
this, recipient, smsPayload.message ?: "",
|
||||
smsPayload.smsId, smsPayload.smsBatchId, smsPayload.simSubscriptionId
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Enqueued ${recipients.size} SMS for sending - Batch: ${smsPayload.smsBatchId}")
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
sendRegistrationToServer(token)
|
||||
}
|
||||
|
||||
private fun sendRegistrationToServer(token: String) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.d(TAG, "Device ID or API key not available, skipping FCM token update")
|
||||
return
|
||||
}
|
||||
|
||||
val updateInput = RegisterDeviceInputDTO().apply { fcmToken = token }
|
||||
Log.d(TAG, "Updating FCM token for device: $deviceId")
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
|
||||
override fun onResponse(
|
||||
call: Call<RegisterDeviceResponseDTO>,
|
||||
response: Response<RegisterDeviceResponseDTO>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "FCM token updated successfully")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update FCM token. Response code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
|
||||
Log.e(TAG, "Error updating FCM token: ${t.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, messageBody: String) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
val notificationBuilder = NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
DEFAULT_NOTIFICATION_CHANNEL_ID,
|
||||
"Channel human readable title",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
notificationManager.notify(0, notificationBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO;
|
||||
import com.vernu.sms.dtos.HeartbeatResponseDTO;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
@@ -19,6 +21,12 @@ public interface GatewayApiService {
|
||||
@PATCH("gateway/devices/{deviceId}")
|
||||
Call<RegisterDeviceResponseDTO> updateDevice(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() RegisterDeviceInputDTO body);
|
||||
|
||||
@POST("gateway/devices/{deviceId}/receiveSMS")
|
||||
@POST("gateway/devices/{deviceId}/receive-sms")
|
||||
Call<SMSForwardResponseDTO> sendReceivedSMS(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
|
||||
|
||||
@PATCH("gateway/devices/{deviceId}/sms-status")
|
||||
Call<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
|
||||
|
||||
@POST("gateway/devices/{deviceId}/heartbeat")
|
||||
Call<HeartbeatResponseDTO> heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import com.vernu.sms.dtos.GatewayStatsResponse
|
||||
import com.vernu.sms.dtos.MessagesResponse
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.dtos.SendSmsRequest
|
||||
import com.vernu.sms.dtos.SubscriptionResponse
|
||||
import com.vernu.sms.dtos.UserProfileWrapper
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.*
|
||||
|
||||
interface GatewayApiServiceKt {
|
||||
|
||||
@GET("auth/who-am-i")
|
||||
suspend fun whoAmI(
|
||||
@Header("x-api-key") apiKey: String
|
||||
): Response<UserProfileWrapper>
|
||||
|
||||
@GET("gateway/stats")
|
||||
suspend fun getStats(
|
||||
@Header("x-api-key") apiKey: String
|
||||
): Response<GatewayStatsResponse>
|
||||
|
||||
@POST("gateway/devices")
|
||||
suspend fun registerDevice(
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Body body: RegisterDeviceInputDTO
|
||||
): Response<RegisterDeviceResponseDTO>
|
||||
|
||||
@GET("billing/current-subscription")
|
||||
suspend fun getCurrentSubscription(
|
||||
@Header("x-api-key") apiKey: String
|
||||
): Response<SubscriptionResponse>
|
||||
|
||||
@PATCH("gateway/devices/{deviceId}")
|
||||
suspend fun updateDevice(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Body body: RegisterDeviceInputDTO
|
||||
): Response<RegisterDeviceResponseDTO>
|
||||
|
||||
@GET("gateway/devices/{deviceId}/messages")
|
||||
suspend fun getMessages(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Query("page") page: Int,
|
||||
@Query("limit") limit: Int,
|
||||
@Query("type") type: String
|
||||
): Response<MessagesResponse>
|
||||
|
||||
@POST("gateway/devices/{deviceId}/send-sms")
|
||||
suspend fun sendSms(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Header("x-api-key") apiKey: String,
|
||||
@Body body: SendSmsRequest
|
||||
): Response<Any>
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package com.vernu.sms.services;
|
||||
|
||||
import android.app.*;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.IBinder;
|
||||
import android.provider.Telephony;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.activities.MainActivity;
|
||||
import com.vernu.sms.receivers.SMSBroadcastReceiver;
|
||||
|
||||
public class StickyNotificationService extends Service {
|
||||
|
||||
private static final String TAG = "StickyNotificationService";
|
||||
private final BroadcastReceiver receiver = new SMSBroadcastReceiver();
|
||||
|
||||
@Override
|
||||
public IBinder onBind(Intent intent) {
|
||||
Log.i(TAG, "Service onBind " + intent.getAction());
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
Log.i(TAG, "Service Started");
|
||||
|
||||
|
||||
// IntentFilter filter = new IntentFilter();
|
||||
// filter.addAction(Telephony.Sms.Intents.SMS_RECEIVED_ACTION);
|
||||
// filter.addAction(android.telephony.TelephonyManager.ACTION_PHONE_STATE_CHANGED);
|
||||
// registerReceiver(receiver, filter);
|
||||
//
|
||||
// Notification notification = createNotification();
|
||||
// startForeground(1, notification);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||
Log.i(TAG, "Received start id " + startId + ": " + intent);
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
// unregisterReceiver(receiver);
|
||||
Log.i(TAG, "StickyNotificationService destroyed");
|
||||
// Toast.makeText(this, "Service destroyed", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private Notification createNotification() {
|
||||
String notificationChannelId = "stickyNotificationChannel";
|
||||
|
||||
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
NotificationChannel channel = null;
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||
channel = new NotificationChannel(notificationChannelId, notificationChannelId, NotificationManager.IMPORTANCE_HIGH);
|
||||
channel.enableVibration(false);
|
||||
channel.setShowBadge(false);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
|
||||
Intent notificationIntent = new Intent(this, MainActivity.class);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
|
||||
Notification.Builder builder = new Notification.Builder(this, notificationChannelId);
|
||||
return builder.setContentTitle("TextBee is running").setContentText("TextBee is running in the background.").setContentIntent(pendingIntent).setOngoing(true).setSmallIcon(R.drawable.ic_launcher_foreground).build();
|
||||
} else {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationChannelId);
|
||||
return builder.setContentTitle("TextBee is running").setContentText("TextBee is running in the background.").setOngoing(true).setSmallIcon(R.drawable.ic_launcher_foreground).build();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class StickyNotificationService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "StickyNotificationService"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "stickyNotificationChannel"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
Log.i(TAG, "Service onBind ${intent.action}")
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "Service Started")
|
||||
|
||||
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
applicationContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
if (stickyNotificationEnabled) {
|
||||
val notification = createNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
Log.i(TAG, "Started foreground service with sticky notification")
|
||||
} catch (e: Exception) {
|
||||
// ForegroundServiceStartNotAllowedException on API 31+ when app is in background
|
||||
Log.w(TAG, "Cannot start foreground service (likely background restriction): ${e.message}")
|
||||
stopSelf()
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "Sticky notification disabled by user preference")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(TAG, "Received start id $startId: $intent")
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(TAG, "StickyNotificationService destroyed")
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(false)
|
||||
setShowBadge(false)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("TextBee Active")
|
||||
.setContentText("SMS gateway service is active")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle("TextBee Active")
|
||||
.setContentText("SMS gateway service is active")
|
||||
.setOngoing(true)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
package com.vernu.sms.ui.dashboard
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.MenuBook
|
||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.dtos.SimInfoDTO
|
||||
import com.vernu.sms.dtos.SubscriptionResponse
|
||||
import com.vernu.sms.dtos.UserProfile
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
private val REQUIRED_PERMISSIONS = listOf(
|
||||
Manifest.permission.SEND_SMS,
|
||||
Manifest.permission.RECEIVE_SMS,
|
||||
Manifest.permission.READ_PHONE_STATE
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DashboardScreen(
|
||||
viewModel: DashboardViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.userMessage) {
|
||||
state.userMessage?.let { message ->
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
viewModel.consumeUserMessage()
|
||||
}
|
||||
}
|
||||
|
||||
fun checkMissingPermissions() = REQUIRED_PERMISSIONS.filter {
|
||||
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
var missingPermissions by remember { mutableStateOf(checkMissingPermissions()) }
|
||||
var permissionsDenied by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) {
|
||||
val stillMissing = checkMissingPermissions()
|
||||
missingPermissions = stillMissing
|
||||
if (stillMissing.isNotEmpty()) permissionsDenied = true
|
||||
}
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
missingPermissions = checkMissingPermissions()
|
||||
if (missingPermissions.isEmpty()) permissionsDenied = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "textbee.dev",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
val greeting = state.userProfile?.name?.takeIf { it.isNotBlank() }
|
||||
?: state.userProfile?.email?.takeIf { it.isNotBlank() }
|
||||
if (greeting != null) {
|
||||
Text(
|
||||
text = "Hello, $greeting",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
PermissionWarningCard(
|
||||
missingPermissions = missingPermissions,
|
||||
showOpenSettings = permissionsDenied,
|
||||
onGrant = { permissionLauncher.launch(missingPermissions.toTypedArray()) },
|
||||
onOpenSettings = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
DeviceStatusCard(
|
||||
state = state,
|
||||
onToggle = { viewModel.toggleGateway(it) },
|
||||
onReceiveSmsToggle = { viewModel.setReceiveSms(it) }
|
||||
)
|
||||
SubscriptionCard(
|
||||
subscription = state.subscription,
|
||||
isLoading = state.isSubscriptionLoading,
|
||||
unavailable = state.subscriptionUnavailable
|
||||
)
|
||||
QuickActionsSection()
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceStatusCard(
|
||||
state: DashboardState,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
onReceiveSmsToggle: (Boolean) -> Unit
|
||||
) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val statusColor = if (state.isGatewayEnabled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
val statusText = if (state.isGatewayEnabled) "Enabled" else "Disabled"
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
val hardwareModel = "${Build.BRAND.replaceFirstChar { it.uppercase() }} ${Build.MODEL}"
|
||||
val customName = state.deviceName.trim()
|
||||
val displayName = customName.ifEmpty { hardwareModel }
|
||||
val showModel = customName.isNotEmpty() &&
|
||||
customName.lowercase() != hardwareModel.lowercase()
|
||||
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
if (showModel) {
|
||||
Text(
|
||||
text = hardwareModel,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (state.deviceId.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = state.deviceId,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(state.deviceId)) },
|
||||
modifier = Modifier.size(20.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy Device ID",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Switch(
|
||||
checked = state.isGatewayEnabled,
|
||||
onCheckedChange = onToggle,
|
||||
enabled = !state.isTogglingGateway
|
||||
)
|
||||
Text(
|
||||
text = "Gateway",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!state.isGatewayEnabled) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Surface(
|
||||
color = statusColor.copy(alpha = 0.15f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = statusText,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = statusColor,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
|
||||
Text(
|
||||
text = "Receive SMS",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "Received messages appear in your dashboard and API",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.isReceiveSmsEnabled,
|
||||
onCheckedChange = onReceiveSmsToggle,
|
||||
modifier = Modifier.scale(0.75f)
|
||||
)
|
||||
}
|
||||
if (state.availableSims.isNotEmpty()) {
|
||||
SimCardsSection(sims = state.availableSims)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionWarningCard(
|
||||
missingPermissions: List<String>,
|
||||
showOpenSettings: Boolean,
|
||||
onGrant: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Permissions Required",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "The gateway won't work without: ${missingPermissions.joinToString { friendlyPermissionName(it) }}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = if (showOpenSettings) onOpenSettings else onGrant,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Text(if (showOpenSettings) "Open App Settings" else "Grant Permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun friendlyPermissionName(permission: String) = when (permission) {
|
||||
Manifest.permission.SEND_SMS -> "Send SMS"
|
||||
Manifest.permission.RECEIVE_SMS -> "Receive SMS"
|
||||
Manifest.permission.READ_PHONE_STATE -> "Phone State"
|
||||
else -> permission.substringAfterLast(".")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimCardsSection(sims: List<SimInfoDTO>) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
Divider(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "SIM Cards",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "simSubscriptionId",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
sims.forEach { sim ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "SIM ${(sim.simSlotIndex ?: 0) + 1} · ${sim.carrierName ?: sim.displayName ?: "Unknown"}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = sim.subscriptionId.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboard.setText(AnnotatedString(sim.subscriptionId.toString()))
|
||||
Toast.makeText(context, "Subscription ID copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy subscription ID",
|
||||
modifier = Modifier.size(13.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubscriptionCard(
|
||||
subscription: SubscriptionResponse?,
|
||||
isLoading: Boolean,
|
||||
unavailable: Boolean
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
when {
|
||||
isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
unavailable || subscription == null -> {
|
||||
// silently hide rather than show an error card
|
||||
}
|
||||
else -> {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
val planName = subscription.plan?.name?.uppercase() ?: "FREE"
|
||||
val isFree = subscription.plan?.name?.lowercase() == "free" ||
|
||||
subscription.plan?.name == null
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Subscription",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Surface(
|
||||
color = if (isFree) MaterialTheme.colorScheme.surfaceVariant
|
||||
else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Text(
|
||||
text = planName,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isFree) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isFree) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/pricing"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Upgrade")
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Manage")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isFree) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Unlock higher limits, more devices & priority support",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
subscription.usage?.let { usage ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
UsageRow(
|
||||
label = "Today",
|
||||
used = usage.processedSmsToday,
|
||||
limit = usage.dailyLimit,
|
||||
pct = usage.dailyUsagePercentage
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
UsageRow(
|
||||
label = "This month",
|
||||
used = usage.processedSmsLastMonth,
|
||||
limit = usage.monthlyLimit,
|
||||
pct = usage.monthlyUsagePercentage
|
||||
)
|
||||
}
|
||||
|
||||
subscription.currentPeriodEnd?.let { dateStr ->
|
||||
formatDate(dateStr)?.let { formatted ->
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Renews $formatted",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) {
|
||||
val isUnlimited = limit == -1
|
||||
val progress = if (isUnlimited || limit == null || limit == 0) 0f
|
||||
else (pct ?: 0).coerceIn(0, 100) / 100f
|
||||
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(
|
||||
text = if (isUnlimited) "${used ?: 0} / Unlimited"
|
||||
else "${used ?: 0} / ${limit ?: "—"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
if (!isUnlimited && limit != null && limit > 0) {
|
||||
LinearProgressIndicator(
|
||||
progress = progress,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp),
|
||||
color = if (progress > 0.8f) MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuickActionsSection() {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "Quick Actions",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Dashboard")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("Explore Docs")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDate(isoDate: String): String? {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(isoDate.take(19)) ?: return null
|
||||
SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package com.vernu.sms.ui.dashboard
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoDTO
|
||||
import com.vernu.sms.dtos.SubscriptionResponse
|
||||
import com.vernu.sms.dtos.UserProfile
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.helpers.serverErrorMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class DashboardState(
|
||||
val deviceName: String = "",
|
||||
val deviceId: String = "",
|
||||
val isGatewayEnabled: Boolean = false,
|
||||
val lastHeartbeatMs: Long? = null,
|
||||
val isTogglingGateway: Boolean = false,
|
||||
val subscription: SubscriptionResponse? = null,
|
||||
val isSubscriptionLoading: Boolean = true,
|
||||
val subscriptionUnavailable: Boolean = false,
|
||||
val userProfile: UserProfile? = null,
|
||||
val availableSims: List<SimInfoDTO> = emptyList(),
|
||||
val isReceiveSmsEnabled: Boolean = false,
|
||||
val userMessage: String? = null
|
||||
)
|
||||
|
||||
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(DashboardState())
|
||||
val state: StateFlow<DashboardState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadLocalState()
|
||||
fetchSubscription()
|
||||
fetchUserProfile()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
loadLocalState()
|
||||
fetchSubscription()
|
||||
fetchUserProfile()
|
||||
}
|
||||
|
||||
private fun loadLocalState() {
|
||||
val deviceName = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, ""
|
||||
) ?: ""
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val isEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
val lastHeartbeatStr = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY, ""
|
||||
)
|
||||
val lastHeartbeatMs = lastHeartbeatStr?.toLongOrNull()
|
||||
|
||||
val sims = try {
|
||||
TextBeeUtils.collectSimInfo(context)
|
||||
} catch (e: Exception) {
|
||||
emptyList<SimInfoDTO>()
|
||||
}
|
||||
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceName = deviceName,
|
||||
deviceId = deviceId,
|
||||
isGatewayEnabled = isEnabled,
|
||||
lastHeartbeatMs = lastHeartbeatMs,
|
||||
availableSims = sims,
|
||||
isReceiveSmsEnabled = isReceiveSms
|
||||
)
|
||||
}
|
||||
|
||||
// Restart sticky notification service on every launch if it should be running,
|
||||
// matching legacy MainActivity behaviour (service is killed by OS on modern Android).
|
||||
if (isEnabled) {
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
if (deviceId.isNotEmpty()) HeartbeatManager.scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchUserProfile() {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService().whoAmI(apiKey)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(userProfile = response.body()?.data) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "User profile fetch failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchSubscription() {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty()) {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSubscriptionLoading = true, subscriptionUnavailable = false) }
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService().getCurrentSubscription(apiKey)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscription = response.body()) }
|
||||
} else {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) }
|
||||
TextBeeUtils.logException(e, "Subscription fetch failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeUserMessage() {
|
||||
_state.update { it.copy(userMessage = null) }
|
||||
}
|
||||
|
||||
fun setReceiveSms(enabled: Boolean) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun toggleGateway(enabled: Boolean) {
|
||||
val deviceId = _state.value.deviceId
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isTogglingGateway = true) }
|
||||
try {
|
||||
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
|
||||
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
if (response.isSuccessful) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isGatewayEnabled = enabled) }
|
||||
try {
|
||||
if (enabled) {
|
||||
if (SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
) {
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
}
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(context)
|
||||
HeartbeatManager.cancelHeartbeat(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "Gateway service toggle failed")
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
userMessage = if (enabled) "Gateway enabled" else "Gateway disabled"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Surface the server's reason (e.g. device-limit 429) so the
|
||||
// toggle doesn't silently snap back with no explanation.
|
||||
val message = response.serverErrorMessage()
|
||||
?: if (response.code() == 429) {
|
||||
"You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
|
||||
} else {
|
||||
"Couldn't update the gateway. Please try again."
|
||||
}
|
||||
_state.update { it.copy(userMessage = message) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "Gateway toggle failed")
|
||||
_state.update { it.copy(userMessage = "Couldn't update the gateway. Please check your connection.") }
|
||||
} finally {
|
||||
_state.update { it.copy(isTogglingGateway = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.vernu.sms.ui.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Message
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.navigation.NavGraph.Companion.findStartDestination
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.ui.dashboard.DashboardScreen
|
||||
import com.vernu.sms.ui.messages.ComposeScreen
|
||||
import com.vernu.sms.ui.messages.MessagesScreen
|
||||
import com.vernu.sms.ui.onboarding.OnboardingActivity
|
||||
import com.vernu.sms.ui.settings.SMSFilterScreen
|
||||
import com.vernu.sms.ui.settings.SettingsScreen
|
||||
import com.vernu.sms.ui.theme.TextBeeTheme
|
||||
|
||||
enum class MainDestination(val label: String, val icon: ImageVector) {
|
||||
DASHBOARD("Dashboard", Icons.Default.Dashboard),
|
||||
MESSAGES("Messages", Icons.Default.Message),
|
||||
SETTINGS("Settings", Icons.Default.Settings)
|
||||
}
|
||||
|
||||
class NewMainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TextBeeTheme {
|
||||
val navController = rememberNavController()
|
||||
MainScaffold(
|
||||
navController = navController,
|
||||
onSwitchToLegacy = {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, false
|
||||
)
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
)
|
||||
},
|
||||
onDisconnect = {
|
||||
listOf(
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
|
||||
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
|
||||
AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY
|
||||
).forEach { key ->
|
||||
SharedPreferenceHelper.clearSharedPreference(this, key)
|
||||
}
|
||||
HeartbeatManager.cancelHeartbeat(this)
|
||||
startActivity(
|
||||
Intent(this, OnboardingActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainScaffold(
|
||||
navController: NavHostController,
|
||||
onSwitchToLegacy: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val showBottomBar = currentRoute != "compose" && currentRoute != "filters"
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 4.dp
|
||||
) {
|
||||
MainDestination.values().forEach { dest ->
|
||||
val selected = currentRoute == dest.name
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(dest.name) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
dest.icon,
|
||||
contentDescription = dest.label,
|
||||
modifier = Modifier.size(if (selected) 26.dp else 22.dp)
|
||||
)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
dest.label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
indicatorColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = MainDestination.DASHBOARD.name,
|
||||
modifier = Modifier.padding(innerPadding)
|
||||
) {
|
||||
composable(MainDestination.DASHBOARD.name) {
|
||||
DashboardScreen()
|
||||
}
|
||||
composable(MainDestination.MESSAGES.name) {
|
||||
MessagesScreen(
|
||||
onNavigateToCompose = { navController.navigate("compose") }
|
||||
)
|
||||
}
|
||||
composable(MainDestination.SETTINGS.name) {
|
||||
SettingsScreen(
|
||||
onSwitchToLegacy = onSwitchToLegacy,
|
||||
onNavigateToFilters = { navController.navigate("filters") },
|
||||
onDisconnect = onDisconnect
|
||||
)
|
||||
}
|
||||
composable("compose") {
|
||||
ComposeScreen(
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("filters") {
|
||||
SMSFilterScreen(onNavigateBack = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.Send
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ComposeScreen(
|
||||
viewModel: ComposeViewModel = viewModel(),
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var recipientInput by remember { mutableStateOf("") }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(state.sendSuccess) {
|
||||
if (state.sendSuccess) {
|
||||
snackbarHostState.showSnackbar("Message sent successfully!")
|
||||
viewModel.clearSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.sendError) {
|
||||
state.sendError?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("New Message", fontWeight = FontWeight.SemiBold)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Recipients",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = recipientInput,
|
||||
onValueChange = { recipientInput = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
label = { Text("Phone number") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
singleLine = true
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val trimmed = recipientInput.trim()
|
||||
if (trimmed.isNotEmpty() && !state.recipients.contains(trimmed)) {
|
||||
viewModel.addRecipient(trimmed)
|
||||
recipientInput = ""
|
||||
}
|
||||
},
|
||||
enabled = recipientInput.trim().isNotEmpty()
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
|
||||
if (state.recipients.isNotEmpty()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
state.recipients.forEach { number ->
|
||||
InputChip(
|
||||
selected = false,
|
||||
onClick = {},
|
||||
label = { Text(number) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { viewModel.removeRecipient(number) },
|
||||
modifier = Modifier.size(18.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Remove",
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text(
|
||||
text = "Message",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.message,
|
||||
onValueChange = { viewModel.setMessage(it) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp),
|
||||
label = { Text("Type your message") },
|
||||
maxLines = 8,
|
||||
supportingText = {
|
||||
Text("${state.message.length} characters")
|
||||
}
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.sendSms() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isSending &&
|
||||
state.recipients.isNotEmpty() &&
|
||||
state.message.isNotEmpty()
|
||||
) {
|
||||
if (state.isSending) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Sending...")
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Default.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Send Message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SendSmsRequest
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Response
|
||||
|
||||
data class ComposeState(
|
||||
val recipients: List<String> = emptyList(),
|
||||
val message: String = "",
|
||||
val isSending: Boolean = false,
|
||||
val sendError: String? = null,
|
||||
val sendSuccess: Boolean = false
|
||||
)
|
||||
|
||||
class ComposeViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(ComposeState())
|
||||
val state: StateFlow<ComposeState> = _state.asStateFlow()
|
||||
|
||||
fun addRecipient(number: String) {
|
||||
_state.update { it.copy(recipients = it.recipients + number) }
|
||||
}
|
||||
|
||||
fun removeRecipient(number: String) {
|
||||
_state.update { it.copy(recipients = it.recipients - number) }
|
||||
}
|
||||
|
||||
fun setMessage(msg: String) {
|
||||
_state.update { it.copy(message = msg) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_state.update { it.copy(sendError = null) }
|
||||
}
|
||||
|
||||
fun clearSuccess() {
|
||||
_state.update { it.copy(sendSuccess = false, message = "") }
|
||||
}
|
||||
|
||||
private fun extractErrorMessage(response: Response<*>): String {
|
||||
return try {
|
||||
val body = response.errorBody()?.string()
|
||||
if (!body.isNullOrBlank()) {
|
||||
val json = JSONObject(body)
|
||||
json.optString("message").takeIf { it.isNotBlank() }
|
||||
?: json.optString("error").takeIf { it.isNotBlank() }
|
||||
?: "Failed to send (${response.code()})"
|
||||
} else {
|
||||
"Failed to send (${response.code()})"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"Failed to send (${response.code()})"
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSms() {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty() || deviceId.isEmpty()) return
|
||||
|
||||
val s = _state.value
|
||||
if (s.recipients.isEmpty() || s.message.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSending = true, sendError = null) }
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService().sendSms(
|
||||
deviceId, apiKey, SendSmsRequest(s.message, s.recipients)
|
||||
)
|
||||
if (response.isSuccessful) {
|
||||
_state.update { it.copy(isSending = false, sendSuccess = true) }
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(isSending = false, sendError = extractErrorMessage(response))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(isSending = false, sendError = "Network error. Please try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Forum
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.dtos.SmsMessage
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessagesScreen(
|
||||
viewModel: MessagesViewModel = viewModel(),
|
||||
onNavigateToCompose: () -> Unit = {}
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedMessage by remember { mutableStateOf<SmsMessage?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Default.Forum,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Messages", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = onNavigateToCompose,
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Create,
|
||||
contentDescription = "Compose",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
listOf("all" to "All", "sent" to "Sent", "received" to "Received").forEach { (value, label) ->
|
||||
FilterChip(
|
||||
selected = state.filter == value,
|
||||
onClick = { viewModel.setFilter(value) },
|
||||
label = { Text(label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
state.isLoading -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
state.error != null -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = state.error ?: "Error",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = { viewModel.refresh() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
state.messages.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
Icons.Default.Forum,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(state.messages) { message ->
|
||||
MessageItem(
|
||||
message = message,
|
||||
onClick = { selectedMessage = message }
|
||||
)
|
||||
}
|
||||
|
||||
if (state.currentPage < state.totalPages) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (state.isLoadingMore) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
} else {
|
||||
OutlinedButton(onClick = { viewModel.loadMore() }) {
|
||||
Text("Load more")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(80.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedMessage?.let { msg ->
|
||||
MessageDetailDialog(message = msg, onDismiss = { selectedMessage = null })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageItem(message: SmsMessage, onClick: () -> Unit) {
|
||||
val accentColor = if (message.isReceived) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.background(accentColor)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (message.isReceived) Icons.Default.ArrowDownward
|
||||
else Icons.Default.ArrowUpward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = accentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = message.counterparty,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatRelativeTime(message.createdAt),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = message.message ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (!message.isReceived && message.status != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
StatusBadge(status = message.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusBadge(status: String) {
|
||||
val (color, label) = when (status.lowercase()) {
|
||||
"delivered" -> MaterialTheme.colorScheme.tertiary to "Delivered"
|
||||
"sent" -> MaterialTheme.colorScheme.primary to "Sent"
|
||||
"failed" -> MaterialTheme.colorScheme.error to "Failed"
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant to "Pending"
|
||||
}
|
||||
Surface(
|
||||
color = color.copy(alpha = 0.15f),
|
||||
shape = MaterialTheme.shapes.extraSmall
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = color,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageDetailDialog(message: SmsMessage, onDismiss: () -> Unit) {
|
||||
val accentColor = if (message.isReceived) Color(0xFF4CAF50)
|
||||
else MaterialTheme.colorScheme.primary
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (message.isReceived) Icons.Default.ArrowDownward
|
||||
else Icons.Default.ArrowUpward,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (message.isReceived) "Received from" else "Sent to",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = message.counterparty,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = accentColor
|
||||
)
|
||||
if (!message.isReceived && message.status != null) {
|
||||
StatusBadge(status = message.status)
|
||||
}
|
||||
Divider()
|
||||
Text(
|
||||
text = message.message ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
val timestamp = if (message.isReceived) message.receivedAt else message.requestedAt
|
||||
timestamp?.let {
|
||||
Text(
|
||||
text = formatFullDate(it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatRelativeTime(isoDate: String?): String {
|
||||
if (isoDate == null) return ""
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(isoDate.take(19)) ?: return ""
|
||||
val diffMs = System.currentTimeMillis() - date.time
|
||||
when {
|
||||
diffMs < 60_000 -> "Just now"
|
||||
diffMs < 3_600_000 -> "${diffMs / 60_000}m ago"
|
||||
diffMs < 86_400_000 -> "${diffMs / 3_600_000}h ago"
|
||||
diffMs < 604_800_000 -> "${diffMs / 86_400_000}d ago"
|
||||
else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFullDate(isoDate: String): String {
|
||||
return try {
|
||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
val date = sdf.parse(isoDate.take(19)) ?: return ""
|
||||
SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.vernu.sms.ui.messages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SmsMessage
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class MessagesState(
|
||||
val messages: List<SmsMessage> = emptyList(),
|
||||
val isLoading: Boolean = true,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val error: String? = null,
|
||||
val filter: String = "all",
|
||||
val currentPage: Int = 1,
|
||||
val totalPages: Int = 1,
|
||||
val total: Int = 0
|
||||
)
|
||||
|
||||
class MessagesViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(MessagesState())
|
||||
val state: StateFlow<MessagesState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
fetchMessages(reset = true)
|
||||
}
|
||||
|
||||
fun setFilter(filter: String) {
|
||||
_state.update { it.copy(filter = filter, currentPage = 1) }
|
||||
fetchMessages(reset = true)
|
||||
}
|
||||
|
||||
fun refresh() = fetchMessages(reset = true)
|
||||
|
||||
fun loadMore() {
|
||||
val s = _state.value
|
||||
if (s.isLoadingMore || s.currentPage >= s.totalPages) return
|
||||
_state.update { it.copy(currentPage = it.currentPage + 1) }
|
||||
fetchMessages(reset = false)
|
||||
}
|
||||
|
||||
private fun fetchMessages(reset: Boolean) {
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (apiKey.isEmpty() || deviceId.isEmpty()) {
|
||||
_state.update { it.copy(isLoading = false, error = "Device not connected") }
|
||||
return
|
||||
}
|
||||
|
||||
val page = if (reset) 1 else _state.value.currentPage
|
||||
val filter = _state.value.filter
|
||||
|
||||
viewModelScope.launch {
|
||||
if (reset) {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
} else {
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
}
|
||||
try {
|
||||
val response = ApiManagerKt.getApiService()
|
||||
.getMessages(deviceId, apiKey, page, 20, filter)
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val newMessages = body?.data ?: emptyList()
|
||||
val meta = body?.meta
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = if (reset) newMessages else it.messages + newMessages,
|
||||
isLoading = false,
|
||||
isLoadingMore = false,
|
||||
error = null,
|
||||
currentPage = page,
|
||||
totalPages = meta?.totalPages ?: 1,
|
||||
total = meta?.total ?: 0
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(isLoading = false, isLoadingMore = false, error = "Failed to load messages")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(isLoading = false, isLoadingMore = false, error = "Network error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.vernu.sms.ui.onboarding
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.vernu.sms.ui.main.NewMainActivity
|
||||
import com.vernu.sms.ui.onboarding.screens.*
|
||||
import com.vernu.sms.ui.theme.TextBeeTheme
|
||||
|
||||
class OnboardingActivity : ComponentActivity() {
|
||||
|
||||
private val viewModel: OnboardingViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val qrLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||
result?.contents?.let { scanned ->
|
||||
viewModel.onQrScanned(scanned.trim())
|
||||
}
|
||||
}
|
||||
|
||||
setContent {
|
||||
TextBeeTheme {
|
||||
val navController = rememberNavController()
|
||||
OnboardingNavGraph(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
onScanQr = {
|
||||
qrLauncher.launch(ScanOptions().apply {
|
||||
setPrompt("Scan the QR code from textbee.dev/dashboard")
|
||||
setBeepEnabled(true)
|
||||
setOrientationLocked(false)
|
||||
})
|
||||
},
|
||||
onComplete = {
|
||||
val intent = Intent(this, NewMainActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnboardingNavGraph(
|
||||
navController: NavHostController,
|
||||
viewModel: OnboardingViewModel,
|
||||
onScanQr: () -> Unit,
|
||||
onComplete: () -> Unit
|
||||
) {
|
||||
NavHost(navController = navController, startDestination = "welcome") {
|
||||
composable("welcome") {
|
||||
WelcomeScreen(
|
||||
onGetStarted = {
|
||||
viewModel.setReturningUser(false)
|
||||
navController.navigate("credentials")
|
||||
},
|
||||
onHaveDeviceId = {
|
||||
viewModel.setReturningUser(true)
|
||||
navController.navigate("credentials")
|
||||
}
|
||||
)
|
||||
}
|
||||
composable("credentials") {
|
||||
CredentialsScreen(
|
||||
viewModel = viewModel,
|
||||
onScanQr = onScanQr,
|
||||
onNext = { navController.navigate("device_setup") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("device_setup") {
|
||||
DeviceSetupScreen(
|
||||
viewModel = viewModel,
|
||||
onSuccess = { navController.navigate("permissions") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("permissions") {
|
||||
PermissionsScreen(
|
||||
onContinue = { navController.navigate("setup_complete") },
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable("setup_complete") {
|
||||
SetupCompleteScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenDashboard = onComplete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.vernu.sms.ui.onboarding
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.helpers.serverErrorMessage
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
data class OnboardingState(
|
||||
val apiKey: String = "",
|
||||
val deviceId: String = "",
|
||||
val deviceName: String = "${Build.BRAND} ${Build.MODEL}",
|
||||
val isReturningUser: Boolean = false,
|
||||
val useExistingDeviceId: Boolean = false,
|
||||
val isQrScanned: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val registeredDeviceId: String? = null,
|
||||
val registeredDeviceName: String? = null
|
||||
)
|
||||
|
||||
class OnboardingViewModel : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(OnboardingState())
|
||||
val state: StateFlow<OnboardingState> = _state.asStateFlow()
|
||||
|
||||
private val _registrationSuccess = Channel<Unit>(Channel.CONFLATED)
|
||||
val registrationSuccess = _registrationSuccess.receiveAsFlow()
|
||||
|
||||
fun setApiKey(key: String) {
|
||||
_state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = false) }
|
||||
}
|
||||
|
||||
fun onQrScanned(key: String) {
|
||||
_state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = true) }
|
||||
}
|
||||
|
||||
fun setDeviceId(id: String) {
|
||||
_state.update { it.copy(deviceId = id.trim(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun setDeviceName(name: String) {
|
||||
_state.update { it.copy(deviceName = name) }
|
||||
}
|
||||
|
||||
fun setReturningUser(returning: Boolean) {
|
||||
_state.update { it.copy(isReturningUser = returning, useExistingDeviceId = returning) }
|
||||
}
|
||||
|
||||
fun setUseExistingDeviceId(use: Boolean) {
|
||||
_state.update { it.copy(useExistingDeviceId = use, deviceId = if (!use) "" else it.deviceId) }
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_state.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
fun registerOrUpdateDevice(context: Context) {
|
||||
val current = _state.value
|
||||
val apiKey = current.apiKey
|
||||
val deviceId = current.deviceId
|
||||
val shouldUpdate = current.isReturningUser || (current.useExistingDeviceId && deviceId.isNotEmpty())
|
||||
|
||||
if (apiKey.isEmpty()) {
|
||||
_state.update { it.copy(errorMessage = "Please enter your API key.") }
|
||||
return
|
||||
}
|
||||
if ((current.isReturningUser || current.useExistingDeviceId) && deviceId.isEmpty()) {
|
||||
_state.update { it.copy(errorMessage = "Please enter your Device ID.") }
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
try {
|
||||
val fcmToken = getFcmToken()
|
||||
val collectedSimInfo = SimInfoCollectionDTO().apply {
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
sims = TextBeeUtils.collectSimInfo(context)
|
||||
}
|
||||
val input = RegisterDeviceInputDTO().apply {
|
||||
this.fcmToken = fcmToken
|
||||
enabled = true
|
||||
brand = Build.BRAND
|
||||
manufacturer = Build.MANUFACTURER
|
||||
model = Build.MODEL
|
||||
buildId = Build.ID
|
||||
os = Build.VERSION.BASE_OS
|
||||
appVersionCode = BuildConfig.VERSION_CODE
|
||||
appVersionName = BuildConfig.VERSION_NAME
|
||||
name = current.deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }
|
||||
simInfo = collectedSimInfo
|
||||
}
|
||||
|
||||
val response = if (shouldUpdate) {
|
||||
ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
} else {
|
||||
ApiManagerKt.getApiService().registerDevice(apiKey, input)
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val data = response.body()?.data
|
||||
?: throw IllegalStateException("missing_response")
|
||||
val registeredId = data["_id"] as? String
|
||||
?: throw IllegalStateException("missing_id")
|
||||
val heartbeatInterval = (data["heartbeatIntervalMinutes"] as? Double)?.toInt() ?: 30
|
||||
val name = data["name"] as? String ?: ""
|
||||
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, registeredId
|
||||
)
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey
|
||||
)
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, heartbeatInterval
|
||||
)
|
||||
val resolvedName = name.ifEmpty { current.deviceName }
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, resolvedName
|
||||
)
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, true
|
||||
)
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
registeredDeviceId = registeredId,
|
||||
registeredDeviceName = resolvedName
|
||||
)
|
||||
}
|
||||
_registrationSuccess.send(Unit)
|
||||
} else {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = when (response.code()) {
|
||||
401 -> "Invalid API key. Go back and check your key."
|
||||
404 -> "Device ID not found. Verify it in your dashboard."
|
||||
429 -> response.serverErrorMessage()
|
||||
?: "You've reached your plan's device limit. Disable or remove another device, or upgrade your plan."
|
||||
in 500..599 -> "Server error. Please try again in a moment."
|
||||
else -> "Request failed (${response.code()}). Please try again."
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val message = when {
|
||||
e.message == "missing_id" -> "Unexpected server response. Please try again."
|
||||
e.message?.contains("Unable to resolve host") == true ||
|
||||
e.message?.contains("timeout") == true ||
|
||||
e.message?.contains("connect") == true ->
|
||||
"No internet connection. Check your network and retry."
|
||||
else -> "Something went wrong. Please try again."
|
||||
}
|
||||
_state.update { it.copy(isLoading = false, errorMessage = message) }
|
||||
TextBeeUtils.logException(e, "Onboarding device registration failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getFcmToken(): String = suspendCoroutine { cont ->
|
||||
FirebaseMessaging.getInstance().token
|
||||
.addOnSuccessListener { token -> cont.resume(token) }
|
||||
.addOnFailureListener { e -> cont.resumeWithException(e) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.ui.onboarding.OnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CredentialsScreen(
|
||||
viewModel: OnboardingViewModel,
|
||||
onScanQr: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var showApiKey by remember { mutableStateOf(false) }
|
||||
val tabs = listOf("Scan QR Code", "Enter Manually")
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StepIndicator(current = 1, total = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Connect your account",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Enter your API key from your textbee.dev dashboard",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Don't have an account? Sign up free",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TabRow(selectedTabIndex = selectedTab) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
when (selectedTab) {
|
||||
0 -> QrTab(
|
||||
apiKey = state.apiKey,
|
||||
isQrScanned = state.isQrScanned,
|
||||
onScanQr = onScanQr,
|
||||
onSwitchToManual = { selectedTab = 1 }
|
||||
)
|
||||
1 -> ManualTab(
|
||||
apiKey = state.apiKey,
|
||||
showApiKey = showApiKey,
|
||||
onApiKeyChange = { viewModel.setApiKey(it) },
|
||||
onToggleVisibility = { showApiKey = !showApiKey }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onNext,
|
||||
enabled = state.apiKey.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Next →")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QrTab(
|
||||
apiKey: String,
|
||||
isQrScanned: Boolean,
|
||||
onScanQr: () -> Unit,
|
||||
onSwitchToManual: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "1. Go to your textbee.dev dashboard",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
|
||||
) {
|
||||
Text("Open", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "2. Click \"Register Device\" or \"Generate API Key\"",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
text = "3. Scan the QR code shown on screen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (isQrScanned && apiKey.isNotEmpty()) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "QR scanned successfully",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = apiKey.take(8) + "••••••••",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
OutlinedButton(onClick = onScanQr) {
|
||||
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan Again")
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = onScanQr,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Icon(Icons.Default.QrCodeScanner, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Scan QR Code")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(onClick = onSwitchToManual) {
|
||||
Text("Enter manually instead")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManualTab(
|
||||
apiKey: String,
|
||||
showApiKey: Boolean,
|
||||
onApiKeyChange: (String) -> Unit,
|
||||
onToggleVisibility: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
OutlinedTextField(
|
||||
value = apiKey,
|
||||
onValueChange = onApiKeyChange,
|
||||
label = { Text("API Key") },
|
||||
placeholder = { Text("Paste your API key here") },
|
||||
visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onToggleVisibility) {
|
||||
Icon(
|
||||
imageVector = if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||
contentDescription = if (showApiKey) "Hide" else "Show"
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Get your API key at app.textbee.dev/dashboard",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.ui.onboarding.OnboardingViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DeviceSetupScreen(
|
||||
viewModel: OnboardingViewModel,
|
||||
onSuccess: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.registrationSuccess.collect { onSuccess() }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StepIndicator(current = 2, total = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = if (state.isReturningUser) "Reconnect your device" else "Set up your device",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (state.isReturningUser)
|
||||
"Enter your Device ID to reconnect this device to your account"
|
||||
else
|
||||
"Give this device a name and register it to start sending SMS",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (state.isReturningUser) {
|
||||
OutlinedTextField(
|
||||
value = state.deviceId,
|
||||
onValueChange = { viewModel.setDeviceId(it) },
|
||||
label = { Text("Device ID") },
|
||||
placeholder = { Text("Enter your Device ID") },
|
||||
supportingText = { Text("Find it at textbee.dev/dashboard → Devices") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
var expandDeviceId by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Checkbox(
|
||||
checked = expandDeviceId,
|
||||
onCheckedChange = {
|
||||
expandDeviceId = it
|
||||
viewModel.setUseExistingDeviceId(it)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "This device was previously registered",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Use this if you reinstalled the app and want to reconnect your existing device rather than create a new one in your dashboard",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 48.dp)
|
||||
)
|
||||
|
||||
if (expandDeviceId) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = state.deviceId,
|
||||
onValueChange = { viewModel.setDeviceId(it) },
|
||||
label = { Text("Device ID") },
|
||||
placeholder = { Text("Enter your Device ID") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.deviceName,
|
||||
onValueChange = { viewModel.setDeviceName(it) },
|
||||
label = { Text("Device Name (optional)") },
|
||||
placeholder = { Text("e.g. My Gateway Phone") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
state.errorMessage?.let { error ->
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { viewModel.clearError() }, modifier = Modifier.size(20.dp)) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = "Dismiss",
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.registerOrUpdateDevice(context) },
|
||||
enabled = !state.isLoading,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text(if (state.isReturningUser) "Reconnect Device" else "Register Device")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Connecting your device…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun StepIndicator(current: Int, total: Int) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
repeat(total) { index ->
|
||||
val step = index + 1
|
||||
val isActive = step == current
|
||||
val isDone = step < current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(if (isActive) 28.dp else 24.dp)
|
||||
.background(
|
||||
color = when {
|
||||
isActive -> MaterialTheme.colorScheme.primary
|
||||
isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = step.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (isActive || isDone) Color.White
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (index < total - 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(24.dp)
|
||||
.height(2.dp)
|
||||
.background(
|
||||
if (isDone) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Step $current of $total",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.Sms
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PermissionsScreen(
|
||||
onContinue: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val permissions = remember {
|
||||
listOf(
|
||||
PermissionItem(
|
||||
permission = Manifest.permission.SEND_SMS,
|
||||
label = "Send SMS",
|
||||
rationale = "Required to send messages from your device",
|
||||
icon = Icons.Default.Sms
|
||||
),
|
||||
PermissionItem(
|
||||
permission = Manifest.permission.RECEIVE_SMS,
|
||||
label = "Receive SMS",
|
||||
rationale = "Required to receive and forward incoming messages",
|
||||
icon = Icons.Default.Sms
|
||||
),
|
||||
PermissionItem(
|
||||
permission = Manifest.permission.READ_PHONE_STATE,
|
||||
label = "Phone State",
|
||||
rationale = "Required to detect SIM cards for multi-SIM support",
|
||||
icon = Icons.Default.PhoneAndroid
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var grantedMap by remember {
|
||||
mutableStateOf(
|
||||
permissions.associate { item ->
|
||||
item.permission to (ContextCompat.checkSelfPermission(context, item.permission) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { results ->
|
||||
grantedMap = grantedMap + results
|
||||
}
|
||||
|
||||
val allGranted = grantedMap.values.all { it }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StepIndicator(current = 3, total = 3)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = "Grant Permissions",
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "These permissions are required for the SMS gateway to work",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
if (!allGranted) {
|
||||
Button(
|
||||
onClick = {
|
||||
val missing = permissions
|
||||
.filter { grantedMap[it.permission] == false }
|
||||
.map { it.permission }
|
||||
.toTypedArray()
|
||||
launcher.launch(missing)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
) {
|
||||
Text("Grant All Permissions")
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
permissions.forEach { item ->
|
||||
val isGranted = grantedMap[item.permission] == true
|
||||
PermissionRow(
|
||||
item = item,
|
||||
isGranted = isGranted,
|
||||
onGrant = { launcher.launch(arrayOf(item.permission)) },
|
||||
onOpenSettings = {
|
||||
context.startActivity(
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "These permissions are only used to send and receive SMS on your behalf. textbee never accesses your existing message history.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy"))
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text("Privacy Policy", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text(if (allGranted) "Continue" else "Continue Anyway")
|
||||
}
|
||||
|
||||
if (!allGranted) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Some features may be limited without all permissions",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionRow(
|
||||
item: PermissionItem,
|
||||
isGranted: Boolean,
|
||||
onGrant: () -> Unit,
|
||||
onOpenSettings: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isGranted)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
tint = if (isGranted) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = item.rationale,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
if (isGranted) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Granted",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
TextButton(onClick = onGrant) {
|
||||
Text("Grant")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class PermissionItem(
|
||||
val permission: String,
|
||||
val label: String,
|
||||
val rationale: String,
|
||||
val icon: ImageVector
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.ui.onboarding.OnboardingViewModel
|
||||
|
||||
@Composable
|
||||
fun SetupCompleteScreen(
|
||||
viewModel: OnboardingViewModel,
|
||||
onOpenDashboard: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
var receiveSmsEnabled by remember { mutableStateOf(true) }
|
||||
|
||||
val scale = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) {
|
||||
scale.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.scale(scale.value)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "You're all set!",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = state.registeredDeviceName ?: "Your device",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
state.registeredDeviceId?.let { deviceId ->
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Device ID",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = deviceId,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(deviceId))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.ContentCopy,
|
||||
contentDescription = "Copy",
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Keep this handy. You will need it for API calls and managing devices in your dashboard.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = "Forward received SMS",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "SMS you receive on this phone will appear in your textbee dashboard and be accessible via API",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = receiveSmsEnabled,
|
||||
onCheckedChange = { enabled ->
|
||||
receiveSmsEnabled = enabled
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
|
||||
enabled
|
||||
)
|
||||
},
|
||||
modifier = Modifier.scale(0.75f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Your device is registered and ready. Head to your dashboard to send your first SMS or connect via API.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onOpenDashboard,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Open App Dashboard", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Open Web Dashboard", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "New to textbee? Read the quickstart guide",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.vernu.sms.ui.onboarding.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.R
|
||||
|
||||
@Composable
|
||||
fun WelcomeScreen(
|
||||
onGetStarted: () -> Unit,
|
||||
onHaveDeviceId: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(96.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(60.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Text(
|
||||
text = "textbee",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = "Turn your Android into a\nprogrammatic SMS gateway",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
listOf(
|
||||
"Create a free account at textbee.dev",
|
||||
"Connect this phone as your SMS gateway",
|
||||
"Send SMS via API from any app or automation"
|
||||
).forEachIndexed { i, step ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "${i + 1}.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.width(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = step,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Button(
|
||||
onClick = onGetStarted,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Get Started", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onHaveDeviceId,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp)
|
||||
) {
|
||||
Text("Reconnect a device", style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "Don't have an account? Sign up free",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev"))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "textbee.dev",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SMSFilterScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: SMSFilterViewModel = viewModel()
|
||||
) {
|
||||
val config by viewModel.config.collectAsState()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var editingIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("SMS Filters", fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {
|
||||
editingIndex = null
|
||||
showDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add rule")
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Enable SMS Filtering", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Filter incoming SMS based on rules below",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = config.enabled,
|
||||
onCheckedChange = { viewModel.setEnabled(it) }
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if (config.enabled) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST,
|
||||
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST) },
|
||||
label = { Text("Allow List") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = config.mode == SMSFilterHelper.FilterMode.BLOCK_LIST,
|
||||
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST) },
|
||||
label = { Text("Block List") }
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST)
|
||||
"Only SMS matching a rule will be forwarded"
|
||||
else
|
||||
"SMS matching a rule will be blocked",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (config.rules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"No filter rules",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"Tap + to add a rule",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
itemsIndexed(config.rules) { index, rule ->
|
||||
FilterRuleRow(
|
||||
rule = rule,
|
||||
onEdit = {
|
||||
editingIndex = index
|
||||
showDialog = true
|
||||
},
|
||||
onDelete = { viewModel.deleteRule(index) }
|
||||
)
|
||||
if (index < config.rules.lastIndex) {
|
||||
Divider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
FilterRuleDialog(
|
||||
rule = editingIndex?.let { config.rules.getOrNull(it) },
|
||||
onConfirm = { rule ->
|
||||
val idx = editingIndex
|
||||
if (idx != null) viewModel.updateRule(idx, rule) else viewModel.addRule(rule)
|
||||
showDialog = false
|
||||
},
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterRuleRow(
|
||||
rule: SMSFilterRule,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = rule.pattern ?: "",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
rule.matchType?.name?.replace('_', ' ')
|
||||
?.lowercase()?.replaceFirstChar { it.uppercaseChar() } ?: "",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
rule.filterTarget.name.lowercase().replaceFirstChar { it.uppercaseChar() },
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
if (rule.caseSensitive) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = { Text("Case sensitive", style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete rule",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FilterRuleDialog(
|
||||
rule: SMSFilterRule?,
|
||||
onConfirm: (SMSFilterRule) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var pattern by remember(rule) { mutableStateOf(rule?.pattern ?: "") }
|
||||
var matchType by remember(rule) {
|
||||
mutableStateOf(rule?.matchType ?: SMSFilterRule.MatchType.CONTAINS)
|
||||
}
|
||||
var filterTarget by remember(rule) {
|
||||
mutableStateOf(rule?.filterTarget ?: SMSFilterRule.FilterTarget.SENDER)
|
||||
}
|
||||
var caseSensitive by remember(rule) { mutableStateOf(rule?.caseSensitive ?: false) }
|
||||
var matchTypeExpanded by remember { mutableStateOf(false) }
|
||||
var filterTargetExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (rule == null) "Add Rule" else "Edit Rule") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = pattern,
|
||||
onValueChange = { pattern = it },
|
||||
label = { Text("Pattern") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = filterTargetExpanded,
|
||||
onExpandedChange = { filterTargetExpanded = !filterTargetExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = filterTarget.name.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Filter Target") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterTargetExpanded)
|
||||
},
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = filterTargetExpanded,
|
||||
onDismissRequest = { filterTargetExpanded = false }
|
||||
) {
|
||||
SMSFilterRule.FilterTarget.values().forEach { target ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(target.name.lowercase().replaceFirstChar { it.uppercaseChar() })
|
||||
},
|
||||
onClick = {
|
||||
filterTarget = target
|
||||
filterTargetExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = matchTypeExpanded,
|
||||
onExpandedChange = { matchTypeExpanded = !matchTypeExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = matchType.name.replace('_', ' ').lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Match Type") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = matchTypeExpanded)
|
||||
},
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = matchTypeExpanded,
|
||||
onDismissRequest = { matchTypeExpanded = false }
|
||||
) {
|
||||
SMSFilterRule.MatchType.values().forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
type.name.replace('_', ' ').lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
matchType = type
|
||||
matchTypeExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Case sensitive",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(checked = caseSensitive, onCheckedChange = { caseSensitive = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onConfirm(SMSFilterRule(pattern.trim(), matchType, filterTarget, caseSensitive))
|
||||
},
|
||||
enabled = pattern.isNotBlank()
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SMSFilterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val _config = MutableStateFlow(SMSFilterHelper.loadFilterConfig(app))
|
||||
val config: StateFlow<SMSFilterHelper.FilterConfig> = _config.asStateFlow()
|
||||
|
||||
fun setEnabled(enabled: Boolean) = mutate { it.enabled = enabled }
|
||||
|
||||
fun setMode(mode: SMSFilterHelper.FilterMode) = mutate { it.mode = mode }
|
||||
|
||||
fun addRule(rule: SMSFilterRule) = mutate { it.rules.add(rule) }
|
||||
|
||||
fun updateRule(index: Int, rule: SMSFilterRule) = mutate { it.rules[index] = rule }
|
||||
|
||||
fun deleteRule(index: Int) = mutate { it.rules.removeAt(index) }
|
||||
|
||||
private fun mutate(block: (SMSFilterHelper.FilterConfig) -> Unit) {
|
||||
val current = _config.value
|
||||
val copy = SMSFilterHelper.FilterConfig().apply {
|
||||
enabled = current.enabled
|
||||
mode = current.mode
|
||||
rules = ArrayList(current.rules)
|
||||
}
|
||||
block(copy)
|
||||
_config.value = copy
|
||||
SMSFilterHelper.saveFilterConfig(getApplication(), copy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.BuildConfig
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onSwitchToLegacy: () -> Unit,
|
||||
onNavigateToFilters: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var showDisconnectDialog by remember { mutableStateOf(false) }
|
||||
var showLegacyDialog by remember { mutableStateOf(false) }
|
||||
var showAboutDialog by remember { mutableStateOf(false) }
|
||||
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||
var editedDeviceName by remember(state.deviceName) { mutableStateOf(state.deviceName) }
|
||||
var showDelayDialog by remember { mutableStateOf(false) }
|
||||
var editedDelay by remember(state.smsSendDelaySeconds) { mutableStateOf(state.smsSendDelaySeconds.toString()) }
|
||||
|
||||
LaunchedEffect(state.snackbarMessage) {
|
||||
state.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Settings", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SettingsSectionHeader("Account")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Fingerprint,
|
||||
title = "Device ID",
|
||||
subtitle = state.deviceId.ifEmpty { "—" },
|
||||
subtitleFont = FontFamily.Monospace,
|
||||
trailing = {
|
||||
IconButton(onClick = { clipboard.setText(AnnotatedString(state.deviceId)) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Key,
|
||||
title = "API Key",
|
||||
subtitle = if (state.apiKey.isEmpty()) "—" else "••••••••" + state.apiKey.takeLast(4),
|
||||
trailing = {
|
||||
IconButton(onClick = { clipboard.setText(AnnotatedString(state.apiKey)) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Edit,
|
||||
title = "Device Name",
|
||||
subtitle = state.deviceName,
|
||||
onClick = {
|
||||
editedDeviceName = state.deviceName
|
||||
showDeviceNameDialog = true
|
||||
},
|
||||
trailing = {
|
||||
if (state.isSavingDeviceName) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(start = 56.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.LinkOff,
|
||||
title = "Disconnect Device",
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
onClick = { showDisconnectDialog = true }
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Gateway")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.Power,
|
||||
title = "Gateway Enabled",
|
||||
subtitle = "Allow sending and receiving SMS",
|
||||
checked = state.isGatewayEnabled,
|
||||
onCheckedChange = { viewModel.setGatewayEnabled(it) }
|
||||
)
|
||||
|
||||
if (state.availableSims.size > 1) {
|
||||
SimSelectionRow(
|
||||
sims = state.availableSims,
|
||||
selectedId = state.preferredSimSubscriptionId,
|
||||
onSelect = { viewModel.setPreferredSim(it) }
|
||||
)
|
||||
Text(
|
||||
text = "Use the simSubscriptionId field in your API requests to override this setting",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSectionHeader("SMS")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.MoveToInbox,
|
||||
title = "Receive SMS",
|
||||
subtitle = "Forward incoming SMS to backend",
|
||||
checked = state.isReceiveSmsEnabled,
|
||||
onCheckedChange = { viewModel.setReceiveSms(it) }
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Timer,
|
||||
title = "Send Delay",
|
||||
subtitle = "${state.smsSendDelaySeconds}s between each SMS",
|
||||
onClick = {
|
||||
editedDelay = state.smsSendDelaySeconds.toString()
|
||||
showDelayDialog = true
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.FilterList,
|
||||
title = "Configure Filters",
|
||||
subtitle = "Allow/block list for incoming SMS",
|
||||
onClick = onNavigateToFilters,
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("System")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.NotificationsActive,
|
||||
title = "Sticky Notification",
|
||||
subtitle = "Keeps the gateway alive in the background",
|
||||
checked = state.isStickyNotificationEnabled,
|
||||
onCheckedChange = { viewModel.setStickyNotification(it) }
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Info,
|
||||
title = "App Version",
|
||||
subtitle = "${state.appVersionName} (Build ${state.appVersionCode})"
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.AutoAwesome,
|
||||
title = "About",
|
||||
subtitle = "textbee.dev",
|
||||
onClick = { showAboutDialog = true },
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SystemUpdate,
|
||||
title = "Check for Updates",
|
||||
onClick = {
|
||||
val versionInfo = "${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})"
|
||||
val url = "https://textbee.dev/download?currentVersion=${Uri.encode(versionInfo)}"
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Community")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SupportAgent,
|
||||
title = "Get Support",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Share,
|
||||
title = "Share textbee",
|
||||
subtitle = "Help spread the word",
|
||||
onClick = {
|
||||
val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " +
|
||||
"no Twilio or paid services needed. works great for automations, alerts, " +
|
||||
"notifications, or anything that needs programmatic SMS. open source and free to start\n\n" +
|
||||
"https://textbee.dev"
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
},
|
||||
"Share TextBee"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Legal")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Gavel,
|
||||
title = "Terms of Service",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/terms-of-service")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Policy,
|
||||
title = "Privacy Policy",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("UI")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SwapHoriz,
|
||||
title = "Switch to Legacy UI",
|
||||
subtitle = "Use the original interface",
|
||||
onClick = { showLegacyDialog = true },
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeviceNameDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeviceNameDialog = false },
|
||||
title = { Text("Edit Device Name") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = editedDeviceName,
|
||||
onValueChange = { editedDeviceName = it },
|
||||
label = { Text("Device Name") },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.saveDeviceName(editedDeviceName)
|
||||
showDeviceNameDialog = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeviceNameDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDelayDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDelayDialog = false },
|
||||
title = { Text("SMS Send Delay") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Delay between each SMS in seconds (0–3600)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = editedDelay,
|
||||
onValueChange = { editedDelay = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Seconds") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.setSmsSendDelay(editedDelay.toIntOrNull() ?: 5)
|
||||
showDelayDialog = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDelayDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showLegacyDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLegacyDialog = false },
|
||||
title = { Text("Switch to Legacy UI?") },
|
||||
text = { Text("You can switch back from the Legacy Settings screen anytime.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showLegacyDialog = false
|
||||
onSwitchToLegacy()
|
||||
}) { Text("Switch") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLegacyDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showAboutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAboutDialog = false },
|
||||
title = {
|
||||
Text("textbee.dev", fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"An open-source SMS gateway that turns your Android into a personal SMS API. " +
|
||||
"Send and receive messages programmatically without relying on expensive third-party services.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev")))
|
||||
}
|
||||
) {
|
||||
Text("textbee.dev")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/vernu/textbee")))
|
||||
}
|
||||
) {
|
||||
Text("GitHub")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showAboutDialog = false }) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDisconnectDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDisconnectDialog = false },
|
||||
title = { Text("Disconnect Device?") },
|
||||
text = { Text("This will remove all credentials from this device. You'll need to reconnect to use the gateway.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDisconnectDialog = false
|
||||
onDisconnect()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Disconnect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
subtitleFont: FontFamily = FontFamily.Default,
|
||||
titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
|
||||
onClick: (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = subtitleFont),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSwitchRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
subtitle?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SimSelectionRow(
|
||||
sims: List<SimOption>,
|
||||
selectedId: Int,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedSim = sims.find { it.subscriptionId == selectedId }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SimCard,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = "Default SIM", style = MaterialTheme.typography.bodyLarge)
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedSim?.let { "${it.displayName} · ID: ${it.subscriptionId}" }
|
||||
?: "Device Default",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor()
|
||||
.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Device Default") },
|
||||
onClick = {
|
||||
onSelect(-1)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
sims.forEach { sim ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${sim.displayName} · ID: ${sim.subscriptionId}") },
|
||||
onClick = {
|
||||
onSelect(sim.subscriptionId)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.telephony.SubscriptionManager
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vernu.sms.ApiManagerKt
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import retrofit2.Response
|
||||
|
||||
data class SimOption(val subscriptionId: Int, val displayName: String)
|
||||
|
||||
data class SettingsState(
|
||||
val deviceId: String = "",
|
||||
val apiKey: String = "",
|
||||
val deviceName: String = "",
|
||||
val isGatewayEnabled: Boolean = false,
|
||||
val isReceiveSmsEnabled: Boolean = false,
|
||||
val isStickyNotificationEnabled: Boolean = false,
|
||||
val smsSendDelaySeconds: Int = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS,
|
||||
val preferredSimSubscriptionId: Int = -1,
|
||||
val availableSims: List<SimOption> = emptyList(),
|
||||
val appVersionName: String = BuildConfig.VERSION_NAME,
|
||||
val appVersionCode: Int = BuildConfig.VERSION_CODE,
|
||||
val isSavingDeviceName: Boolean = false,
|
||||
val snackbarMessage: String? = null
|
||||
)
|
||||
|
||||
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val context get() = getApplication<Application>().applicationContext
|
||||
|
||||
private val _state = MutableStateFlow(SettingsState())
|
||||
val state: StateFlow<SettingsState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val deviceName = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, ""
|
||||
) ?: ""
|
||||
val isGatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
val isSticky = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
val smsDelay = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
)
|
||||
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
|
||||
)
|
||||
|
||||
val sims = try {
|
||||
TextBeeUtils.getAvailableSimSlots(context).map { info ->
|
||||
SimOption(
|
||||
subscriptionId = info.subscriptionId,
|
||||
displayName = "${info.carrierName} (SIM ${info.simSlotIndex + 1})"
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceId = deviceId,
|
||||
apiKey = apiKey,
|
||||
deviceName = deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" },
|
||||
isGatewayEnabled = isGatewayEnabled,
|
||||
isReceiveSmsEnabled = isReceiveSms,
|
||||
isStickyNotificationEnabled = isSticky,
|
||||
smsSendDelaySeconds = smsDelay,
|
||||
preferredSimSubscriptionId = preferredSim,
|
||||
availableSims = sims
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setGatewayEnabled(enabled: Boolean) {
|
||||
val deviceId = _state.value.deviceId
|
||||
val apiKey = _state.value.apiKey
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
|
||||
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
if (response.isSuccessful) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isGatewayEnabled = enabled) }
|
||||
if (enabled) {
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
com.vernu.sms.helpers.HeartbeatManager.scheduleHeartbeat(context)
|
||||
} else {
|
||||
TextBeeUtils.stopStickyNotificationService(context)
|
||||
com.vernu.sms.helpers.HeartbeatManager.cancelHeartbeat(context)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to update gateway status")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(snackbarMessage = "Network error. Try again.") }
|
||||
TextBeeUtils.logException(e, "Gateway toggle from settings failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setReceiveSms(enabled: Boolean) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
|
||||
)
|
||||
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setStickyNotification(enabled: Boolean) {
|
||||
SharedPreferenceHelper.setSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, enabled
|
||||
)
|
||||
try {
|
||||
if (enabled) TextBeeUtils.startStickyNotificationService(context)
|
||||
else TextBeeUtils.stopStickyNotificationService(context)
|
||||
} catch (e: Exception) {
|
||||
TextBeeUtils.logException(e, "Sticky notification toggle failed")
|
||||
_state.update { it.copy(snackbarMessage = "Could not start notification service") }
|
||||
}
|
||||
_state.update { it.copy(isStickyNotificationEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setSmsSendDelay(seconds: Int) {
|
||||
val clamped = seconds.coerceIn(0, 3600)
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, clamped
|
||||
)
|
||||
_state.update { it.copy(smsSendDelaySeconds = clamped) }
|
||||
}
|
||||
|
||||
fun setPreferredSim(subscriptionId: Int) {
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, subscriptionId
|
||||
)
|
||||
_state.update { it.copy(preferredSimSubscriptionId = subscriptionId) }
|
||||
}
|
||||
|
||||
fun saveDeviceName(name: String) {
|
||||
val deviceId = _state.value.deviceId
|
||||
val apiKey = _state.value.apiKey
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || name.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isSavingDeviceName = true) }
|
||||
try {
|
||||
val input = RegisterDeviceInputDTO().apply { this.name = name.trim() }
|
||||
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
|
||||
if (response.isSuccessful) {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, name.trim()
|
||||
)
|
||||
_state.update { it.copy(deviceName = name.trim(), snackbarMessage = "Device name saved") }
|
||||
} else {
|
||||
_state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to save device name")) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(snackbarMessage = "Network error. Try again.") }
|
||||
TextBeeUtils.logException(e, "Save device name failed")
|
||||
} finally {
|
||||
_state.update { it.copy(isSavingDeviceName = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSnackbar() = _state.update { it.copy(snackbarMessage = null) }
|
||||
|
||||
private fun extractErrorMessage(response: Response<*>, fallback: String): String {
|
||||
return try {
|
||||
val body = response.errorBody()?.string()
|
||||
if (!body.isNullOrBlank()) {
|
||||
val json = JSONObject(body)
|
||||
json.optString("message").takeIf { it.isNotBlank() }
|
||||
?: json.optString("error").takeIf { it.isNotBlank() }
|
||||
?: fallback
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.vernu.sms.ui.splash
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.ui.main.NewMainActivity
|
||||
import com.vernu.sms.ui.onboarding.OnboardingActivity
|
||||
import com.vernu.sms.ui.theme.TextBeeTheme
|
||||
|
||||
class SplashActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
TextBeeTheme {
|
||||
SplashContent()
|
||||
}
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed({ route() }, 400)
|
||||
}
|
||||
|
||||
private fun route() {
|
||||
val useNewUi = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true
|
||||
)
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
)
|
||||
val target = when {
|
||||
!useNewUi -> Intent(this, MainActivity::class.java)
|
||||
deviceId.isNullOrEmpty() -> Intent(this, OnboardingActivity::class.java)
|
||||
else -> Intent(this, NewMainActivity::class.java)
|
||||
}
|
||||
startActivity(target)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SplashContent() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_app_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "textbee",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "SMS Gateway",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val Orange700 = Color(0xFFC4620A)
|
||||
val Orange800 = Color(0xFFA04405)
|
||||
val Orange600 = Color(0xFFB45309)
|
||||
val Orange900 = Color(0xFF92400E)
|
||||
val OrangeLight = Color(0xFFFFF7ED)
|
||||
|
||||
val Gray50 = Color(0xFFF9FAFB)
|
||||
val Gray100 = Color(0xFFF3F4F6)
|
||||
val Gray200 = Color(0xFFE5E7EB)
|
||||
val Gray400 = Color(0xFF9CA3AF)
|
||||
val Gray600 = Color(0xFF4B5563)
|
||||
val Gray900 = Color(0xFF111827)
|
||||
|
||||
val Green500 = Color(0xFF22C55E)
|
||||
val Red500 = Color(0xFFEF4444)
|
||||
val Blue500 = Color(0xFF3B82F6)
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.vernu.sms.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LightColorScheme = lightColorScheme(
|
||||
primary = Orange700,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = OrangeLight,
|
||||
onPrimaryContainer = Orange900,
|
||||
secondary = Orange600,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = OrangeLight,
|
||||
onSecondaryContainer = Orange900,
|
||||
background = Gray50,
|
||||
onBackground = Gray900,
|
||||
surface = Color.White,
|
||||
onSurface = Gray900,
|
||||
surfaceVariant = Gray100,
|
||||
onSurfaceVariant = Gray600,
|
||||
outline = Gray200,
|
||||
error = Red500,
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Orange600,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Orange900,
|
||||
onPrimaryContainer = OrangeLight,
|
||||
secondary = Orange700,
|
||||
onSecondary = Color.White,
|
||||
background = Gray900,
|
||||
onBackground = Gray100,
|
||||
surface = Color(0xFF1F2937),
|
||||
onSurface = Gray100,
|
||||
surfaceVariant = Color(0xFF374151),
|
||||
onSurfaceVariant = Gray400,
|
||||
outline = Color(0xFF4B5563),
|
||||
error = Red500,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TextBeeTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colorScheme.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.vernu.sms.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
val Typography = Typography(
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 30.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 26.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.helpers.HeartbeatHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class HeartbeatWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "HeartbeatWorker"
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val context = applicationContext
|
||||
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
return if (HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey)) {
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat, will retry")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SMSReceivedWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SMSReceivedWorker"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SMS_DTO = "sms_dto"
|
||||
const val KEY_RETRY_COUNT = "retry_count"
|
||||
|
||||
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SMSReceivedWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("sms_received")
|
||||
.build()
|
||||
|
||||
val fp = smsDTO.fingerprint
|
||||
val uniqueWorkName = if (!fp.isNullOrEmpty()) {
|
||||
"sms_received_$fp"
|
||||
} else {
|
||||
Log.w(TAG, "Fingerprint not available, using timestamp for work name")
|
||||
"sms_received_${System.currentTimeMillis()}"
|
||||
}
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "Work enqueued for received SMS from: ${smsDTO.sender} with fingerprint: $uniqueWorkName")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val deviceId = inputData.getString(KEY_DEVICE_ID)
|
||||
val apiKey = inputData.getString(KEY_API_KEY)
|
||||
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
|
||||
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for received SMS")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
|
||||
|
||||
return try {
|
||||
val response = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO).execute()
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Received SMS sent to server successfully")
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send received SMS to server. Response code: ${response.code()}")
|
||||
Result.retry()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "API call failed: ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SMSStatusUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SMSStatusUpdateWorker"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SMS_DTO = "sms_dto"
|
||||
const val KEY_RETRY_COUNT = "retry_count"
|
||||
|
||||
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SMSStatusUpdateWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
val uniqueWorkName = "sms_status_${smsDTO.status}_${System.currentTimeMillis()}"
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "Work enqueued for SMS status update - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val deviceId = inputData.getString(KEY_DEVICE_ID)
|
||||
val apiKey = inputData.getString(KEY_API_KEY)
|
||||
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
|
||||
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for SMS status update")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
|
||||
|
||||
return try {
|
||||
val response = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO).execute()
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "SMS status updated successfully - ID: ${smsDTO.smsId}, Status: ${smsDTO.status}")
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update SMS status. Response code: ${response.code()}")
|
||||
Result.retry()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "API call failed: ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.helpers.SMSHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class SmsSendWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SmsSendWorker"
|
||||
private const val QUEUE_NAME = "sms_send_queue"
|
||||
|
||||
const val KEY_PHONE = "phone"
|
||||
const val KEY_MESSAGE = "message"
|
||||
const val KEY_SMS_ID = "sms_id"
|
||||
const val KEY_SMS_BATCH_ID = "sms_batch_id"
|
||||
const val KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id"
|
||||
|
||||
fun enqueue(
|
||||
context: Context, phone: String, message: String,
|
||||
smsId: String?, smsBatchId: String?, simSubscriptionId: Int?
|
||||
) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_PHONE, phone)
|
||||
.putString(KEY_MESSAGE, message)
|
||||
.putString(KEY_SMS_ID, smsId)
|
||||
.putString(KEY_SMS_BATCH_ID, smsBatchId)
|
||||
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId ?: -1)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SmsSendWorker::class.java)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "SMS enqueued for sending - ID: $smsId, Phone: $phone")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val phone = inputData.getString(KEY_PHONE)
|
||||
val message = inputData.getString(KEY_MESSAGE)
|
||||
val smsId = inputData.getString(KEY_SMS_ID)
|
||||
val smsBatchId = inputData.getString(KEY_SMS_BATCH_ID)
|
||||
val simSubscriptionId = inputData.getInt(KEY_SIM_SUBSCRIPTION_ID, -1)
|
||||
|
||||
if (phone == null || message == null || smsId == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val context = applicationContext
|
||||
val resolvedSim = resolveSim(context, simSubscriptionId)
|
||||
|
||||
if (resolvedSim != null) {
|
||||
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId ?: "", context)
|
||||
} else {
|
||||
SMSHelper.sendSMS(phone, message, smsId, smsBatchId ?: "", context)
|
||||
}
|
||||
|
||||
val delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
).coerceIn(0, 3600)
|
||||
|
||||
if (delaySeconds > 0) {
|
||||
try {
|
||||
Thread.sleep(delaySeconds * 1000L)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun resolveSim(context: Context, backendSimId: Int): Int? {
|
||||
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
|
||||
Log.d(TAG, "Using backend-provided SIM subscription ID: $backendSimId")
|
||||
return backendSimId
|
||||
}
|
||||
|
||||
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
|
||||
)
|
||||
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
|
||||
Log.d(TAG, "Using app-preferred SIM subscription ID: $preferredSim")
|
||||
return preferredSim
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user