mirror of
https://github.com/topjohnwu/Magisk.git
synced 2025-08-14 07:07:27 +00:00
Compare commits
1248 Commits
manager-v7
...
manager-v8
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2d5cf8a6fe | ||
![]() |
290959f74c | ||
![]() |
4d9f58ee72 | ||
![]() |
9241246de6 | ||
![]() |
58a5d52b78 | ||
![]() |
2906178ac3 | ||
![]() |
e0afbb647b | ||
![]() |
50be50cf6a | ||
![]() |
77a9d3a5bc | ||
![]() |
f9c7a4c933 | ||
![]() |
2b759b84b0 | ||
![]() |
1e45c63ea5 | ||
![]() |
b14a260827 | ||
![]() |
ade1597e03 | ||
![]() |
2739d3cb67 | ||
![]() |
dc5e78e142 | ||
![]() |
e9759a5868 | ||
![]() |
e7ab802498 | ||
![]() |
42672c2e27 | ||
![]() |
e65d61d313 | ||
![]() |
076da5c7c4 | ||
![]() |
9deaf2507c | ||
![]() |
5c114c67de | ||
![]() |
d904cb0441 | ||
![]() |
bd1dd9d863 | ||
![]() |
afebe734b8 | ||
![]() |
e21a78164e | ||
![]() |
1e0f96d0fd | ||
![]() |
bf650332d8 | ||
![]() |
f32e0af830 | ||
![]() |
4c94f90e5d | ||
![]() |
ffb4224640 | ||
![]() |
89fff4830b | ||
![]() |
16e4c67992 | ||
![]() |
cf47214ee4 | ||
![]() |
0feab753fb | ||
![]() |
d0b6318b90 | ||
![]() |
966e23b846 | ||
![]() |
5b8a1fc2a7 | ||
![]() |
02ea3ca525 | ||
![]() |
0632b146b8 | ||
![]() |
1b0b180761 | ||
![]() |
0d11f73a1d | ||
![]() |
533cb8eb58 | ||
![]() |
8ac1181e9a | ||
![]() |
5ca1892eb0 | ||
![]() |
e32db6a0e8 | ||
![]() |
82fff615d6 | ||
![]() |
24a8f0808d | ||
![]() |
4a7c3c06bc | ||
![]() |
da93bbc1fe | ||
![]() |
fa2dbe981e | ||
![]() |
ce6cceae8b | ||
![]() |
7b26e8b818 | ||
![]() |
2da5fcb00b | ||
![]() |
a079966f97 | ||
![]() |
468796c23d | ||
![]() |
5833aadef5 | ||
![]() |
eb261c8026 | ||
![]() |
a4c48847d1 | ||
![]() |
43288be091 | ||
![]() |
1ad7a6fe93 | ||
![]() |
4e0a3f5e72 | ||
![]() |
d7c33f647d | ||
![]() |
9087207dc0 | ||
![]() |
2760f37e6b | ||
![]() |
3fa3426032 | ||
![]() |
2e4dc91b96 | ||
![]() |
aaaaa3d044 | ||
![]() |
1edc4449d5 | ||
![]() |
f3cd4da026 | ||
![]() |
872c55207c | ||
![]() |
339ca6d666 | ||
![]() |
4aeac3b8f4 | ||
![]() |
d625beb7f3 | ||
![]() |
735b65c50c | ||
![]() |
efb1eab327 | ||
![]() |
49d4785da0 | ||
![]() |
28e65ce383 | ||
![]() |
c3b6a48373 | ||
![]() |
a42ebd429b | ||
![]() |
8f89010752 | ||
![]() |
105a18f719 | ||
![]() |
eb04ca4c4a | ||
![]() |
6092d7ca88 | ||
![]() |
66cad101c0 | ||
![]() |
0a14f43f9c | ||
![]() |
311c1f0dfd | ||
![]() |
0499588107 | ||
![]() |
d4d837a562 | ||
![]() |
fbcbb20178 | ||
![]() |
0914700fc6 | ||
![]() |
eeced2fb5b | ||
![]() |
6509e3d4f5 | ||
![]() |
317052604b | ||
![]() |
5538f7168c | ||
![]() |
dcb9e4cd93 | ||
![]() |
d9382f59bf | ||
![]() |
403a0c770a | ||
![]() |
f0f1cdc501 | ||
![]() |
4e272b70ef | ||
![]() |
8dc62a0232 | ||
![]() |
9225b47568 | ||
![]() |
d462873e74 | ||
![]() |
fc19b50290 | ||
![]() |
333fe6da0e | ||
![]() |
75fcda9f81 | ||
![]() |
44ba2a9903 | ||
![]() |
2fceb1ad96 | ||
![]() |
bacb5fa462 | ||
![]() |
67f8dc494e | ||
![]() |
3e4caabecb | ||
![]() |
dcd5183b24 | ||
![]() |
d80c6b42a6 | ||
![]() |
64effe9385 | ||
![]() |
96dd24e91d | ||
![]() |
fbb4f85ef0 | ||
![]() |
716f06846b | ||
![]() |
241f2656fa | ||
![]() |
e973d49517 | ||
![]() |
c3bf9a095b | ||
![]() |
abfc28db32 | ||
![]() |
8b5652ced5 | ||
![]() |
d6dbab53cd | ||
![]() |
46de1ed968 | ||
![]() |
9bebe07d5a | ||
![]() |
ee4db43136 | ||
![]() |
efac220998 | ||
![]() |
31026b43f4 | ||
![]() |
bc3fbe09f5 | ||
![]() |
7ac55068db | ||
![]() |
6abd9aa8a4 | ||
![]() |
c91ebfbcc1 | ||
![]() |
2f232fc670 | ||
![]() |
41f5c8d96c | ||
![]() |
4fd04e62af | ||
![]() |
63a9a7d643 | ||
![]() |
a63d6c03fd | ||
![]() |
fd552e68a9 | ||
![]() |
de4e26b488 | ||
![]() |
fa3865e962 | ||
![]() |
a6950b8aca | ||
![]() |
8df96ff664 | ||
![]() |
8b29267ad6 | ||
![]() |
0ef92a4866 | ||
![]() |
85bef8fa96 | ||
![]() |
ca9f9fee9a | ||
![]() |
b59e05c63e | ||
![]() |
3c0630bfc0 | ||
![]() |
bf84dd6518 | ||
![]() |
f575155a41 | ||
![]() |
bd240ba48c | ||
![]() |
106a2bb7df | ||
![]() |
82bbbe05b2 | ||
![]() |
9956dc0995 | ||
![]() |
fc76673802 | ||
![]() |
17b5291bbb | ||
![]() |
9908dfd79a | ||
![]() |
2dbaf9595c | ||
![]() |
9a16ab1bd7 | ||
![]() |
9e5cb6cb91 | ||
![]() |
8c19654d20 | ||
![]() |
d5a7a75d9d | ||
![]() |
851b676077 | ||
![]() |
765b51285a | ||
![]() |
8a338de696 | ||
![]() |
8a61ae621d | ||
![]() |
60e1e07e87 | ||
![]() |
e51a3dacb9 | ||
![]() |
9a8a27dbb9 | ||
![]() |
2eb001876a | ||
![]() |
b510dc51ac | ||
![]() |
d7f7508fa2 | ||
![]() |
e66b0bf3b2 | ||
![]() |
0555b73a19 | ||
![]() |
877a297de4 | ||
![]() |
49559ec0ec | ||
![]() |
30e45f863d | ||
![]() |
434efec860 | ||
![]() |
5022f00a55 | ||
![]() |
8aac373ca3 | ||
![]() |
c3586fe0a5 | ||
![]() |
11f254e5e5 | ||
![]() |
c61ec2465f | ||
![]() |
fd5ad91d26 | ||
![]() |
5c4c391f94 | ||
![]() |
4dacffd7a1 | ||
![]() |
61599059d5 | ||
![]() |
f32a29911b | ||
![]() |
b73d5753f2 | ||
![]() |
2eee335b5f | ||
![]() |
013a2e1336 | ||
![]() |
fbaf2bded6 | ||
![]() |
38a34a7eeb | ||
![]() |
70174e093b | ||
![]() |
0333e82e86 | ||
![]() |
36a8839cf8 | ||
![]() |
d0ed6e7fe3 | ||
![]() |
72dfbf5e44 | ||
![]() |
114a3c037f | ||
![]() |
782adc9a9f | ||
![]() |
e0642b018d | ||
![]() |
6bd4006652 | ||
![]() |
01efe7a4ea | ||
![]() |
7e133b0cf4 | ||
![]() |
fd808bd51e | ||
![]() |
b4e8860ee4 | ||
![]() |
fb3f8605fd | ||
![]() |
e394445f1b | ||
![]() |
ca1b0bf1ce | ||
![]() |
bf5798190d | ||
![]() |
ca5030a646 | ||
![]() |
e22324e434 | ||
![]() |
e46d4ecd3e | ||
![]() |
84f92bd661 | ||
![]() |
b44dcc2da0 | ||
![]() |
d6062944f1 | ||
![]() |
79f549795b | ||
![]() |
eaf7c3c486 | ||
![]() |
1ac379c17a | ||
![]() |
51a4dbf263 | ||
![]() |
2d91bfd9e6 | ||
![]() |
e437ffdbae | ||
![]() |
ccde8b73a2 | ||
![]() |
65f88e4ae2 | ||
![]() |
354440ee8a | ||
![]() |
59106e4f52 | ||
![]() |
d76c266fbc | ||
![]() |
31681c9c5f | ||
![]() |
0e5a32b476 | ||
![]() |
a22a1dd284 | ||
![]() |
27c59dbb65 | ||
![]() |
fb04e32480 | ||
![]() |
14a2f63b8b | ||
![]() |
9e81db8692 | ||
![]() |
1ed67eed35 | ||
![]() |
abc5457136 | ||
![]() |
4b238a9cd0 | ||
![]() |
f200d472ef | ||
![]() |
105b2fc114 | ||
![]() |
5ed4071f74 | ||
![]() |
551a478fdc | ||
![]() |
7c319f5fc3 | ||
![]() |
1fcf35ebeb | ||
![]() |
6d749a58c6 | ||
![]() |
34450cdddd | ||
![]() |
846bbb4da1 | ||
![]() |
d7a26dbf27 | ||
![]() |
a86d5b3e61 | ||
![]() |
b2bece9ef6 | ||
![]() |
f9cbf883ac | ||
![]() |
7f225b3973 | ||
![]() |
72e7605fce | ||
![]() |
a4c1ddd9f2 | ||
![]() |
ddd513110f | ||
![]() |
e33d623d40 | ||
![]() |
eec19ba9af | ||
![]() |
413b3f394b | ||
![]() |
88cee1212b | ||
![]() |
cf25fa8ed8 | ||
![]() |
3f053b8547 | ||
![]() |
79aa261ca2 | ||
![]() |
ac2a9da4c4 | ||
![]() |
d8b1d79879 | ||
![]() |
feb0f4b7b5 | ||
![]() |
6c8fe46590 | ||
![]() |
5e3c9e5022 | ||
![]() |
f7f821b93c | ||
![]() |
36a70e995f | ||
![]() |
537ae1a315 | ||
![]() |
87b6bf2c26 | ||
![]() |
9df6b0618a | ||
![]() |
c7e30ac63e | ||
![]() |
f5e547944a | ||
![]() |
d10680187d | ||
![]() |
f5aa6a3cf8 | ||
![]() |
c944277e78 | ||
![]() |
2e5402d741 | ||
![]() |
24f6024383 | ||
![]() |
15b1215972 | ||
![]() |
11222c89d4 | ||
![]() |
893a8ec8d9 | ||
![]() |
da2b00de59 | ||
![]() |
1276c28e03 | ||
![]() |
e458215f27 | ||
![]() |
fee4031d0f | ||
![]() |
0835ff88b2 | ||
![]() |
2e95d9f07e | ||
![]() |
fe2388394d | ||
![]() |
7fc9b908d4 | ||
![]() |
0ed524f173 | ||
![]() |
aed3ab994e | ||
![]() |
5347cedfa6 | ||
![]() |
5b28a713e0 | ||
![]() |
f1fb7404c2 | ||
![]() |
fc67c0195f | ||
![]() |
2f02f9a580 | ||
![]() |
07f712a1ce | ||
![]() |
c7044b0d20 | ||
![]() |
15866cfba9 | ||
![]() |
4c2570628d | ||
![]() |
113eec59f9 | ||
![]() |
f7abc03dac | ||
![]() |
ef3f188a2c | ||
![]() |
dd62fe89f7 | ||
![]() |
ec2d7d77eb | ||
![]() |
6c6368fd81 | ||
![]() |
ba31c6b625 | ||
![]() |
cad189d2dc | ||
![]() |
7cf3da1b3b | ||
![]() |
45fabf8e03 | ||
![]() |
2c12fe6eb2 | ||
![]() |
b41b2283f4 | ||
![]() |
e8e7cd5008 | ||
![]() |
7873433977 | ||
![]() |
52d19d3ea2 | ||
![]() |
6348d0a6fb | ||
![]() |
f7a650b9a4 | ||
![]() |
a97d278bcd | ||
![]() |
8647ba4729 | ||
![]() |
4631077c49 | ||
![]() |
18dab28c32 | ||
![]() |
8ffbffddb3 | ||
![]() |
f191db2fe0 | ||
![]() |
dc8f0f6feb | ||
![]() |
01a43b03bd | ||
![]() |
86db0cd2cd | ||
![]() |
ae6dd50ccd | ||
![]() |
77032eced1 | ||
![]() |
820427e93b | ||
![]() |
89e11c9cc8 | ||
![]() |
05cf53fe6f | ||
![]() |
97b72a5941 | ||
![]() |
7922f65243 | ||
![]() |
67f7935421 | ||
![]() |
9348c5bad9 | ||
![]() |
0f7caa66fb | ||
![]() |
bd14994eb9 | ||
![]() |
08818e8542 | ||
![]() |
706eba329d | ||
![]() |
f6a2b1c882 | ||
![]() |
c2e6622016 | ||
![]() |
53904b0627 | ||
![]() |
cef14d4576 | ||
![]() |
73203a55ca | ||
![]() |
397f7326a3 | ||
![]() |
4bbd7989dd | ||
![]() |
a0b47f3ca3 | ||
![]() |
89e9e7c176 | ||
![]() |
ddc2f317ab | ||
![]() |
867bab8513 | ||
![]() |
b1e0c5ff38 | ||
![]() |
6dbd9bfb12 | ||
![]() |
3c78344812 | ||
![]() |
594f268885 | ||
![]() |
93d5716414 | ||
![]() |
4b8e92f00a | ||
![]() |
fc6ef7dd57 | ||
![]() |
c881fd4964 | ||
![]() |
4bcc2b2f03 | ||
![]() |
6150055a05 | ||
![]() |
23a33b4351 | ||
![]() |
e02386a6ac | ||
![]() |
099e703834 | ||
![]() |
1ededc637e | ||
![]() |
0850bca9d3 | ||
![]() |
6d2fd480bf | ||
![]() |
ddf0c379be | ||
![]() |
45b5e89912 | ||
![]() |
a748d5291a | ||
![]() |
f5131fae56 | ||
![]() |
f79a40a67a | ||
![]() |
43146b8316 | ||
![]() |
b71b4bd4e5 | ||
![]() |
44895a86b8 | ||
![]() |
eecb66f4f1 | ||
![]() |
e7f1c03151 | ||
![]() |
56602cb9a3 | ||
![]() |
1e2f776b83 | ||
![]() |
ec3705f2ed | ||
![]() |
ae0dcabf43 | ||
![]() |
6030b00ee2 | ||
![]() |
a17908f6e1 | ||
![]() |
cb7148a24c | ||
![]() |
2f824f59dc | ||
![]() |
ad94f10205 | ||
![]() |
02b2290b16 | ||
![]() |
f8a814a588 | ||
![]() |
4c4338cc02 | ||
![]() |
5675a1ae7d | ||
![]() |
0952224c3d | ||
![]() |
4e26c10287 | ||
![]() |
f3e82b9ef1 | ||
![]() |
e50295d337 | ||
![]() |
fde78be2b4 | ||
![]() |
c071ac8973 | ||
![]() |
599ee57d39 | ||
![]() |
4499cebcd9 | ||
![]() |
cd6eca1dc2 | ||
![]() |
951273f8ef | ||
![]() |
51eeb89f67 | ||
![]() |
0efa73d96c | ||
![]() |
63512b39b2 | ||
![]() |
f392ade78d | ||
![]() |
0236ab887e | ||
![]() |
d4baae411b | ||
![]() |
e02e46d0fc | ||
![]() |
3c04dab472 | ||
![]() |
fc1844b4df | ||
![]() |
99ef20627a | ||
![]() |
4497e0aaca | ||
![]() |
c3e045e367 | ||
![]() |
501d3e6c32 | ||
![]() |
b27b9c1d18 | ||
![]() |
f7d3d1eeaf | ||
![]() |
0d72a4c8ba | ||
![]() |
dbdb0a2560 | ||
![]() |
18a09703de | ||
![]() |
bc6a14d30f | ||
![]() |
97db49a57b | ||
![]() |
eca2168685 | ||
![]() |
1bcef38739 | ||
![]() |
aac6ad73da | ||
![]() |
122b4d66b6 | ||
![]() |
0f8f4e361b | ||
![]() |
3733b589ac | ||
![]() |
6a2e781db2 | ||
![]() |
c6569ce022 | ||
![]() |
a62bdc58cb | ||
![]() |
912009494d | ||
![]() |
a5d7c41d20 | ||
![]() |
232ae2a189 | ||
![]() |
aa8b23105f | ||
![]() |
c113f854a2 | ||
![]() |
87de0e7a0e | ||
![]() |
85755e3022 | ||
![]() |
02dc1172be | ||
![]() |
dbf8c41209 | ||
![]() |
8c4fd759c6 | ||
![]() |
23dc19ad94 | ||
![]() |
0c99c4d93f | ||
![]() |
8ab045331b | ||
![]() |
a8d0936e04 | ||
![]() |
4e349acb50 | ||
![]() |
947e3b06b4 | ||
![]() |
5fd574a14f | ||
![]() |
03c1053871 | ||
![]() |
c7ed0ef5eb | ||
![]() |
2aede97754 | ||
![]() |
9b8a5e9bf3 | ||
![]() |
0f910f2d40 | ||
![]() |
15f155100c | ||
![]() |
2468f5a6c4 | ||
![]() |
945a52a99f | ||
![]() |
486b2c82a7 | ||
![]() |
800b7f4370 | ||
![]() |
8ca5a048d6 | ||
![]() |
44b7a3c3f1 | ||
![]() |
554ebe7206 | ||
![]() |
d7b87fcb8e | ||
![]() |
c94f9e1cc9 | ||
![]() |
68532fade3 | ||
![]() |
e219867cdf | ||
![]() |
765d5d9729 | ||
![]() |
43029f37b1 | ||
![]() |
7188462c55 | ||
![]() |
f9ff814955 | ||
![]() |
dfbd1305b3 | ||
![]() |
c9255ab31b | ||
![]() |
1e714af3cf | ||
![]() |
4c959cd983 | ||
![]() |
d959c35723 | ||
![]() |
69a9d7485b | ||
![]() |
dcf07ad8c7 | ||
![]() |
ed6cdb2eb4 | ||
![]() |
a73e7e9f99 | ||
![]() |
ab853e1fcf | ||
![]() |
37d38b62b1 | ||
![]() |
f9bb517142 | ||
![]() |
efe9b867d5 | ||
![]() |
d9cf33d1ba | ||
![]() |
ee3028e67d | ||
![]() |
d810e6c82d | ||
![]() |
e0a281583d | ||
![]() |
d739dcac2b | ||
![]() |
cdd4cb8ec2 | ||
![]() |
93ef90cd24 | ||
![]() |
e165a1e65c | ||
![]() |
4066e5bf14 | ||
![]() |
4729514a22 | ||
![]() |
93aedcfeb7 | ||
![]() |
47d18bb896 | ||
![]() |
61dafbe06e | ||
![]() |
474325da68 | ||
![]() |
9317401d57 | ||
![]() |
67d746a62c | ||
![]() |
2f1f68f12f | ||
![]() |
2742edd73f | ||
![]() |
834561a5de | ||
![]() |
11102b4dd6 | ||
![]() |
fef2da3c0b | ||
![]() |
9820296e92 | ||
![]() |
dbfde74c1e | ||
![]() |
b28668e18d | ||
![]() |
5f1174de27 | ||
![]() |
543ce937ec | ||
![]() |
5537b083a8 | ||
![]() |
6b0854749f | ||
![]() |
09ba4772b8 | ||
![]() |
06a1d08465 | ||
![]() |
d510ead877 | ||
![]() |
2968a1559e | ||
![]() |
cba26eedb5 | ||
![]() |
23e74b2781 | ||
![]() |
ef0277d10e | ||
![]() |
a623a5b7cc | ||
![]() |
be8479fdba | ||
![]() |
e97e6d467c | ||
![]() |
75ec890d46 | ||
![]() |
871a9c29c8 | ||
![]() |
a4f903d947 | ||
![]() |
1920a52829 | ||
![]() |
6e14a727b1 | ||
![]() |
ea855837df | ||
![]() |
d05ed0e59c | ||
![]() |
a9eb443072 | ||
![]() |
d382b00efd | ||
![]() |
ef9d077c7f | ||
![]() |
e4b20abf8e | ||
![]() |
e9f0a10175 | ||
![]() |
c3968a26cf | ||
![]() |
9371515ecc | ||
![]() |
a83e055b19 | ||
![]() |
6907651756 | ||
![]() |
fc2d0246e6 | ||
![]() |
bb9c362bab | ||
![]() |
51402e68d2 | ||
![]() |
1b8813228b | ||
![]() |
922e36cfb0 | ||
![]() |
edff094626 | ||
![]() |
aa72a080b0 | ||
![]() |
2a93d1c652 | ||
![]() |
6b2f23712c | ||
![]() |
375ab93ee3 | ||
![]() |
d5962e9d71 | ||
![]() |
ffaa264bd3 | ||
![]() |
ba7cb47383 | ||
![]() |
48d417f9af | ||
![]() |
df4db6bf6b | ||
![]() |
b8ef491bc7 | ||
![]() |
ea1ebb8d00 | ||
![]() |
91b6d2852a | ||
![]() |
d7cd1b37f8 | ||
![]() |
160ff7bb07 | ||
![]() |
31142180cb | ||
![]() |
38b0fa04a8 | ||
![]() |
29817245ba | ||
![]() |
925fe6f152 | ||
![]() |
93fd574b75 | ||
![]() |
0de88bcbb9 | ||
![]() |
0b70bd2b60 | ||
![]() |
84ecba4629 | ||
![]() |
f7142e69b6 | ||
![]() |
ed7e560849 | ||
![]() |
47e50e8511 | ||
![]() |
72f6770d61 | ||
![]() |
7da35e5468 | ||
![]() |
7768274b2f | ||
![]() |
33f006655d | ||
![]() |
612b51d48f | ||
![]() |
8101f3f67d | ||
![]() |
e3c8d723e3 | ||
![]() |
4579825758 | ||
![]() |
ef91c33f55 | ||
![]() |
511d5993df | ||
![]() |
9f4958e869 | ||
![]() |
c07775f5e3 | ||
![]() |
e261579e72 | ||
![]() |
cf54cad3ce | ||
![]() |
a0998009c1 | ||
![]() |
d6fdbfe9b7 | ||
![]() |
07228279a3 | ||
![]() |
6877ef790f | ||
![]() |
a3809648dd | ||
![]() |
df15606b00 | ||
![]() |
4dc0d13688 | ||
![]() |
541fa5cb1f | ||
![]() |
ab9442d4ae | ||
![]() |
f5c099e9a7 | ||
![]() |
9582379e1b | ||
![]() |
db9a4b31f9 | ||
![]() |
409cb06ea0 | ||
![]() |
88d917b662 | ||
![]() |
faf077b494 | ||
![]() |
ee1f45aa91 | ||
![]() |
915fd3020b | ||
![]() |
642788abec | ||
![]() |
3cd11dd9a0 | ||
![]() |
bf2c5ce368 | ||
![]() |
65c510a211 | ||
![]() |
6fbc38d764 | ||
![]() |
200bf993d8 | ||
![]() |
38af82e152 | ||
![]() |
fc05f377fb | ||
![]() |
5c0e86383c | ||
![]() |
64f5ff5475 | ||
![]() |
758777111a | ||
![]() |
b90e0430f8 | ||
![]() |
0ce7da1bf6 | ||
![]() |
e6464c5c7f | ||
![]() |
c6b3f06b95 | ||
![]() |
581419b6a3 | ||
![]() |
696ab677be | ||
![]() |
0d229dac3b | ||
![]() |
3b8ea599f0 | ||
![]() |
3e70a61e33 | ||
![]() |
76f35d02b7 | ||
![]() |
356b417a04 | ||
![]() |
56147a80b5 | ||
![]() |
91728991d7 | ||
![]() |
0f7e59d288 | ||
![]() |
f33028c645 | ||
![]() |
f9149ad433 | ||
![]() |
0d7474cc88 | ||
![]() |
1e7e06d1cc | ||
![]() |
8453282fa6 | ||
![]() |
40f971d18a | ||
![]() |
ce7cb1eeae | ||
![]() |
d2701616da | ||
![]() |
10eb159e1b | ||
![]() |
36897ceb19 | ||
![]() |
9a8274130b | ||
![]() |
c8d050c3e3 | ||
![]() |
a46cd63c9d | ||
![]() |
e9e6eaf079 | ||
![]() |
cb5897af93 | ||
![]() |
d701d6eb82 | ||
![]() |
470ebb54e2 | ||
![]() |
632cab398e | ||
![]() |
189c4cc9d8 | ||
![]() |
70d5e2dee8 | ||
![]() |
c586106e51 | ||
![]() |
ffa85a616a | ||
![]() |
e5ea3e4a43 | ||
![]() |
0492e63862 | ||
![]() |
9952387356 | ||
![]() |
d7653e6e42 | ||
![]() |
e9fc40d285 | ||
![]() |
740559e3bc | ||
![]() |
9471577b3b | ||
![]() |
e85d5e54e2 | ||
![]() |
5fb071d80b | ||
![]() |
022151fefd | ||
![]() |
3b8d2fe8b7 | ||
![]() |
d51d549a28 | ||
![]() |
b5ac24f239 | ||
![]() |
3ca99005f8 | ||
![]() |
0b9f2921d2 | ||
![]() |
389501ad0c | ||
![]() |
082e4eb05c | ||
![]() |
47f885a566 | ||
![]() |
bc964b8588 | ||
![]() |
b57b3313e4 | ||
![]() |
f185cefa11 | ||
![]() |
9d256e02d7 | ||
![]() |
086c64c0be | ||
![]() |
798fe57025 | ||
![]() |
a03f744648 | ||
![]() |
64f35744c4 | ||
![]() |
b512528148 | ||
![]() |
fdfa037dca | ||
![]() |
db4ef1443d | ||
![]() |
810468c279 | ||
![]() |
8146d0830d | ||
![]() |
7e946b040c | ||
![]() |
97d24a7d4d | ||
![]() |
f8bea66313 | ||
![]() |
dd9129017f | ||
![]() |
cbe3602cb7 | ||
![]() |
1d831d65f3 | ||
![]() |
c35d020731 | ||
![]() |
c18db555a4 | ||
![]() |
373092af16 | ||
![]() |
1a2e157cda | ||
![]() |
b3bc1a3907 | ||
![]() |
4dd8d75cc0 | ||
![]() |
e5f50bb7e0 | ||
![]() |
45d5b4bea6 | ||
![]() |
ed58cf953a | ||
![]() |
ec26bc5ab7 | ||
![]() |
84e4bd3d41 | ||
![]() |
0ecfb63cd6 | ||
![]() |
ebdd6ec40c | ||
![]() |
0586760347 | ||
![]() |
d535f244ad | ||
![]() |
613d46824d | ||
![]() |
041355f182 | ||
![]() |
6977dc082f | ||
![]() |
d3dffe8165 | ||
![]() |
6812f9d202 | ||
![]() |
555e7cc907 | ||
![]() |
6180558068 | ||
![]() |
cf589f8c64 | ||
![]() |
e864919c0b | ||
![]() |
c72d83b637 | ||
![]() |
f2d2f28e23 | ||
![]() |
a7435dad6d | ||
![]() |
793f0b605c | ||
![]() |
5b56ca7ffc | ||
![]() |
5c988510b3 | ||
![]() |
290624844b | ||
![]() |
497efc9f5e | ||
![]() |
19d76b635c | ||
![]() |
4875def31c | ||
![]() |
155c0e3609 | ||
![]() |
00ea15dc19 | ||
![]() |
f04c4cb78a | ||
![]() |
6e4777692e | ||
![]() |
4638fdf2d7 | ||
![]() |
0783d385d5 | ||
![]() |
cf918e7df8 | ||
![]() |
1ba9faf35b | ||
![]() |
6e48294f2a | ||
![]() |
dea607b148 | ||
![]() |
e938e717b0 | ||
![]() |
2eed09ef1b | ||
![]() |
8a6b3644be | ||
![]() |
1d89fe503b | ||
![]() |
788db036fd | ||
![]() |
c38c473e11 | ||
![]() |
aef1f8f701 | ||
![]() |
83f9767254 | ||
![]() |
3e0352eee6 | ||
![]() |
28faff6425 | ||
![]() |
d0112f989c | ||
![]() |
9c4c310f46 | ||
![]() |
7bf7bfb9c6 | ||
![]() |
fbe776db0b | ||
![]() |
1e2de1bb14 | ||
![]() |
e395c9442f | ||
![]() |
30286f0ea5 | ||
![]() |
60ee742855 | ||
![]() |
a913ede48f | ||
![]() |
9592583783 | ||
![]() |
ad49d3ad26 | ||
![]() |
21ee73c2a3 | ||
![]() |
f5d0cc9f32 | ||
![]() |
b90c65370e | ||
![]() |
88920e0546 | ||
![]() |
d27773de03 | ||
![]() |
8abdaeb044 | ||
![]() |
9682d2f84a | ||
![]() |
a86b9e81e9 | ||
![]() |
a8bb7c68a3 | ||
![]() |
bdad29adab | ||
![]() |
fadcfe5f7a | ||
![]() |
fbd83b5ff3 | ||
![]() |
c351174fa4 | ||
![]() |
cc4f99fe28 | ||
![]() |
b2a9b88fe5 | ||
![]() |
da06e0ec76 | ||
![]() |
851ee81486 | ||
![]() |
0dc9f5c324 | ||
![]() |
36513c2301 | ||
![]() |
3a10597aed | ||
![]() |
2291be5d26 | ||
![]() |
345c3ef15e | ||
![]() |
c1dad11cb3 | ||
![]() |
12b219e7b2 | ||
![]() |
f8b48cf18d | ||
![]() |
12a9792c7d | ||
![]() |
ba55e2bc32 | ||
![]() |
c5e5b70e08 | ||
![]() |
327b186240 | ||
![]() |
5c1417e276 | ||
![]() |
0a2c99f1dc | ||
![]() |
836bfbdd02 | ||
![]() |
b13a35057a | ||
![]() |
c3e77b1ec1 | ||
![]() |
fb60bea659 | ||
![]() |
b2ddba4cbf | ||
![]() |
053251d566 | ||
![]() |
cf161a5dd9 | ||
![]() |
e4bcdbd0c4 | ||
![]() |
cae43b26f4 | ||
![]() |
b95cf9b9a3 | ||
![]() |
e6f443cb24 | ||
![]() |
087ccd69c9 | ||
![]() |
7532477a2f | ||
![]() |
433ae89e53 | ||
![]() |
de853a2651 | ||
![]() |
47c3045980 | ||
![]() |
dd50c19ba3 | ||
![]() |
707d7b3342 | ||
![]() |
ba1a2fbce4 | ||
![]() |
84f1e78660 | ||
![]() |
3490ba0a56 | ||
![]() |
1449486958 | ||
![]() |
9094cf7ce3 | ||
![]() |
df0a5b59f8 | ||
![]() |
0827044caf | ||
![]() |
342ae7c8cd | ||
![]() |
fc690b9f02 | ||
![]() |
22c9d836e0 | ||
![]() |
984997e73b | ||
![]() |
b39f407596 | ||
![]() |
615ad0cc5a | ||
![]() |
0b41cd8564 | ||
![]() |
7db523071d | ||
![]() |
974ee58b9c | ||
![]() |
1e88f2c382 | ||
![]() |
0bdcfcaaf5 | ||
![]() |
5f9c78d04f | ||
![]() |
afa178fdec | ||
![]() |
3a0e3c98f7 | ||
![]() |
fafa92d44b | ||
![]() |
fcedd06e72 | ||
![]() |
6a2acbe929 | ||
![]() |
4cfff40475 | ||
![]() |
904948dc7d | ||
![]() |
7342509b2e | ||
![]() |
ed837ba26f | ||
![]() |
13262fdb18 | ||
![]() |
baf18a8762 | ||
![]() |
c0b56b927f | ||
![]() |
242e64d72f | ||
![]() |
2262af728e | ||
![]() |
ea9947081f | ||
![]() |
e04f943980 | ||
![]() |
b38e940088 | ||
![]() |
bc0bb92f7a | ||
![]() |
8737be2623 | ||
![]() |
eb929160b3 | ||
![]() |
b8b0f257db | ||
![]() |
67b5f39df2 | ||
![]() |
7e9b3f1a60 | ||
![]() |
bce777d7c6 | ||
![]() |
465aaeff82 | ||
![]() |
40c64d50d5 | ||
![]() |
15bd2da824 | ||
![]() |
bd438ca288 | ||
![]() |
e0d02a61a9 | ||
![]() |
b3328a0ec2 | ||
![]() |
3c2041933f | ||
![]() |
e88b1cc443 | ||
![]() |
71b05b18a0 | ||
![]() |
b07b528e2a | ||
![]() |
1aeb6315ff | ||
![]() |
1b4a3d2d9f | ||
![]() |
3049a81c3b | ||
![]() |
2db1e5cb74 | ||
![]() |
78c64d39ec | ||
![]() |
46ba726232 | ||
![]() |
eb26e62889 | ||
![]() |
7f667fed18 | ||
![]() |
b2cb2b8b75 | ||
![]() |
d19f65ce4a | ||
![]() |
025b060506 | ||
![]() |
7fa2625a03 | ||
![]() |
33d62d7f21 | ||
![]() |
b336655a79 | ||
![]() |
3beffd84d6 | ||
![]() |
02761f5f35 | ||
![]() |
3b9f7885e0 | ||
![]() |
7668e45890 | ||
![]() |
695c8bc5d0 | ||
![]() |
06c42d05c3 | ||
![]() |
404104208f | ||
![]() |
b4d0ad9713 | ||
![]() |
89b1fa341b | ||
![]() |
3bda7cb26b | ||
![]() |
4f4f54a059 | ||
![]() |
12fda29280 | ||
![]() |
af060b3132 | ||
![]() |
8c500709e4 | ||
![]() |
490e6a6f23 | ||
![]() |
08177c3dd8 | ||
![]() |
d22b9c26b6 | ||
![]() |
85a350b6c8 | ||
![]() |
eae4eff92f | ||
![]() |
848be8f806 | ||
![]() |
4bb8ad19cf | ||
![]() |
c79b79b37e | ||
![]() |
8a03c366b8 | ||
![]() |
37677f389c | ||
![]() |
3e275b7dba | ||
![]() |
11b7076a43 | ||
![]() |
291c718ba2 | ||
![]() |
fcd6071c57 | ||
![]() |
476b61c4c9 | ||
![]() |
8cc5f096a2 | ||
![]() |
474d65207e | ||
![]() |
03428329ef | ||
![]() |
2692234b8c | ||
![]() |
bfb5d7e5ac | ||
![]() |
8c818e707f | ||
![]() |
3efea47ca8 | ||
![]() |
8d21988656 | ||
![]() |
89da45f9ac | ||
![]() |
34a0a00e3c | ||
![]() |
dec1094a59 | ||
![]() |
02e323133d | ||
![]() |
cb96b536a2 | ||
![]() |
627b40799c | ||
![]() |
73c4b21285 | ||
![]() |
78d7c45be3 | ||
![]() |
72edbfc455 | ||
![]() |
276535dad6 | ||
![]() |
e373e59661 | ||
![]() |
ac5ecf222e | ||
![]() |
a20594ed48 | ||
![]() |
cb59cc92a3 | ||
![]() |
34bb18448c | ||
![]() |
01253f050a | ||
![]() |
cc7e47bbb6 | ||
![]() |
5bee1c56a9 | ||
![]() |
474cc7d56d | ||
![]() |
bffdedddb4 | ||
![]() |
fd72f658c0 | ||
![]() |
42606162b2 | ||
![]() |
e82bc1b7bc | ||
![]() |
4f0e1c6c61 | ||
![]() |
550f6aff7e | ||
![]() |
67c50d7504 | ||
![]() |
94f0c61619 | ||
![]() |
8a86b30fd1 | ||
![]() |
d3b5cf82d8 | ||
![]() |
d26d804cc2 | ||
![]() |
4f9a25ee89 | ||
![]() |
6379108a75 | ||
![]() |
bb9ce0e897 | ||
![]() |
fbeaad077f | ||
![]() |
8918113a31 | ||
![]() |
c5385b5b4c | ||
![]() |
35475e1d25 | ||
![]() |
fb2c292f35 | ||
![]() |
afc3fb10c7 | ||
![]() |
0a239c2fef | ||
![]() |
f5342a09d3 | ||
![]() |
f72de687c5 | ||
![]() |
d6fb9868bf | ||
![]() |
9aff1a57d3 | ||
![]() |
7681fde4d0 | ||
![]() |
d3b7b41927 | ||
![]() |
833269fd0a | ||
![]() |
332c1a6c59 | ||
![]() |
0f1f43057e | ||
![]() |
784a7a7f24 | ||
![]() |
8e34baa59f | ||
![]() |
2926772bba | ||
![]() |
da159e4655 | ||
![]() |
a7f4496db7 | ||
![]() |
f972f02fff | ||
![]() |
1c77e26c05 | ||
![]() |
59c5363933 | ||
![]() |
b744bb0a5a | ||
![]() |
0f140b408c | ||
![]() |
7f6a6016d6 | ||
![]() |
44ed0a3279 | ||
![]() |
9964e1bb8e | ||
![]() |
8b8f725499 | ||
![]() |
bab856bce2 | ||
![]() |
711799b194 | ||
![]() |
3d285b91c6 | ||
![]() |
1dc531930d | ||
![]() |
3d3345acac | ||
![]() |
2105cacce3 | ||
![]() |
9d1d1710eb | ||
![]() |
c69dcf3e20 | ||
![]() |
eec5b37da1 | ||
![]() |
b29f0ca4d1 | ||
![]() |
576efbdc1b | ||
![]() |
a7f0510a3e | ||
![]() |
2ef088cb60 | ||
![]() |
7c320b6fc4 | ||
![]() |
e1bda4ee8b | ||
![]() |
5a4c82b860 | ||
![]() |
9b297b752e | ||
![]() |
1d6ba58ccd | ||
![]() |
1542447822 | ||
![]() |
a6f0aff659 | ||
![]() |
54930024f5 | ||
![]() |
c5f2f63458 | ||
![]() |
b2b81a5d0f | ||
![]() |
265dca3723 | ||
![]() |
171ddab32b | ||
![]() |
2aee0b0be0 | ||
![]() |
817cdf7113 | ||
![]() |
495e734428 | ||
![]() |
82120cf47f | ||
![]() |
027a5695f2 | ||
![]() |
d6d82edff5 | ||
![]() |
a12eb3fc6f | ||
![]() |
6c84574366 | ||
![]() |
1a38f25bd9 | ||
![]() |
ad40e53349 | ||
![]() |
a2ddf362d8 | ||
![]() |
65eca31635 | ||
![]() |
8b0b4a2c39 | ||
![]() |
bc5cbe9fba | ||
![]() |
f83f92d3fa | ||
![]() |
c0216c0653 | ||
![]() |
61de63a518 | ||
![]() |
d952cc2327 | ||
![]() |
19fd4dd89c | ||
![]() |
f941f5c0b0 | ||
![]() |
c7cad7e4aa | ||
![]() |
1c8988d3f7 | ||
![]() |
70a3dbe2b0 | ||
![]() |
efbb3ab25f | ||
![]() |
46447f7cfd | ||
![]() |
a6e62e07a2 | ||
![]() |
b1d25e0503 | ||
![]() |
25c557248c | ||
![]() |
b0e7c65504 | ||
![]() |
b18b044b63 | ||
![]() |
8f5f8db717 | ||
![]() |
016e28383b | ||
![]() |
f1427e9279 | ||
![]() |
169e9ab5ad | ||
![]() |
472cde29b8 | ||
![]() |
73525d19e9 | ||
![]() |
26618f8d73 | ||
![]() |
6f7c13b814 | ||
![]() |
e7d668502c | ||
![]() |
6fd357962f | ||
![]() |
0c9feedb37 | ||
![]() |
dad52724db | ||
![]() |
14ba002cbc | ||
![]() |
d48e9d5d72 | ||
![]() |
7da97489cc | ||
![]() |
a9f11b28c8 | ||
![]() |
b31d986c8d | ||
![]() |
2dad751889 | ||
![]() |
c85b1c56af | ||
![]() |
6dd34aec47 | ||
![]() |
4cd154675f | ||
![]() |
24e2c3a5e9 | ||
![]() |
064523ef25 | ||
![]() |
85f293a44e | ||
![]() |
8e412bee5f | ||
![]() |
7d5555f82e | ||
![]() |
6720725d27 | ||
![]() |
fe5c65d798 | ||
![]() |
253f3cf1ba | ||
![]() |
d8d72f92b3 | ||
![]() |
a30f5b175f | ||
![]() |
8277896ca1 | ||
![]() |
493068c073 | ||
![]() |
f4299fbea8 | ||
![]() |
10ce11d671 | ||
![]() |
db2e48b49f | ||
![]() |
5e089451af | ||
![]() |
6aa22267f4 | ||
![]() |
0f34457a10 | ||
![]() |
34c65e13bc | ||
![]() |
17a77e2577 | ||
![]() |
0f219e5ae6 | ||
![]() |
353c3c7d81 | ||
![]() |
0a89edf3b0 | ||
![]() |
e7155837d7 | ||
![]() |
f76c020dd7 | ||
![]() |
722fba7805 | ||
![]() |
86551909fc | ||
![]() |
588e94c11d | ||
![]() |
31e003bda5 | ||
![]() |
490e4d3180 | ||
![]() |
dc9f69bab0 | ||
![]() |
fdf04f77f2 | ||
![]() |
9e66310c28 | ||
![]() |
93c422dce6 | ||
![]() |
7d6eebdae3 | ||
![]() |
f11bb609c9 | ||
![]() |
5e87483f34 | ||
![]() |
f7aa451591 | ||
![]() |
321d11c2c6 | ||
![]() |
b910a92731 | ||
![]() |
ee447bc4ce | ||
![]() |
31153e4366 | ||
![]() |
7693024c29 | ||
![]() |
9628700a2f | ||
![]() |
38576173cb | ||
![]() |
19a769c12e | ||
![]() |
3c1db7d2f7 | ||
![]() |
626507093a | ||
![]() |
ee7d297ca8 | ||
![]() |
a70c0174e1 | ||
![]() |
da707afa3f | ||
![]() |
a41597431c | ||
![]() |
d0b817381e | ||
![]() |
60a2e9b5dc | ||
![]() |
df3a37b0a3 | ||
![]() |
5f4718cd13 | ||
![]() |
3cc5cb3123 | ||
![]() |
8a2872afa4 | ||
![]() |
85941c4729 | ||
![]() |
588b3d14a3 | ||
![]() |
815efa7791 | ||
![]() |
97a691ce2f | ||
![]() |
82eeefb544 | ||
![]() |
9d948f2c2b | ||
![]() |
f6061ba00e | ||
![]() |
9e3afcfe7a | ||
![]() |
0b87108174 | ||
![]() |
7fc7809cfc | ||
![]() |
c30be20e49 | ||
![]() |
25c64db0a1 | ||
![]() |
676e9c6593 | ||
![]() |
d459859361 | ||
![]() |
2be0cef446 | ||
![]() |
294db93fde | ||
![]() |
21f2f86cb8 | ||
![]() |
04576ca828 | ||
![]() |
067cb0cd9d | ||
![]() |
7f971f7173 | ||
![]() |
5c7b59524d | ||
![]() |
5133e5910e | ||
![]() |
1512c350df | ||
![]() |
a5fc7891a6 | ||
![]() |
3eb9633231 | ||
![]() |
ac67b48247 | ||
![]() |
81b65ea646 | ||
![]() |
45c1f6bc27 | ||
![]() |
0d31e5c8b1 | ||
![]() |
6378abf454 | ||
![]() |
f8fcaadb5b | ||
![]() |
0b5fd3ee76 | ||
![]() |
d010cb7e42 | ||
![]() |
71136d7347 | ||
![]() |
a18c552ddf | ||
![]() |
17fb8f2298 | ||
![]() |
fbfc4e72ca | ||
![]() |
d2e171eabc | ||
![]() |
e50094af80 | ||
![]() |
93edf72993 | ||
![]() |
a230d63cf9 | ||
![]() |
9656878ef3 | ||
![]() |
7ded7de39a | ||
![]() |
0f74e89b44 | ||
![]() |
953c40b083 | ||
![]() |
2bb39bee2f | ||
![]() |
ce2ca5446a | ||
![]() |
8a014ff786 | ||
![]() |
271b0287d8 | ||
![]() |
96a8a2a8b8 | ||
![]() |
dc09ec7598 | ||
![]() |
27fb0474d5 | ||
![]() |
7f0a87742a | ||
![]() |
47e236788c | ||
![]() |
75306f658f | ||
![]() |
325d9a0b86 | ||
![]() |
236ad57608 | ||
![]() |
6d03798314 | ||
![]() |
c954a4f7bc | ||
![]() |
ba588d1097 | ||
![]() |
44f7c9a545 | ||
![]() |
b910db322b | ||
![]() |
c44a942fb7 | ||
![]() |
d713ad3499 | ||
![]() |
ddf40df649 | ||
![]() |
7c6d85221d | ||
![]() |
b66b82a6e9 | ||
![]() |
c44b85ea87 | ||
![]() |
a02493fbaa | ||
![]() |
9c27d691dd | ||
![]() |
fcbf56e93a | ||
![]() |
a539ffb188 | ||
![]() |
512f533a80 | ||
![]() |
96ef9cdbee | ||
![]() |
935bd01f59 | ||
![]() |
eeb5d669f6 | ||
![]() |
28fcbbcf7b | ||
![]() |
0f4326151f | ||
![]() |
e0e27774ad | ||
![]() |
1223b48b2c | ||
![]() |
d8338f0b48 | ||
![]() |
38019f7f42 | ||
![]() |
78daa2eb62 | ||
![]() |
40eda05a30 | ||
![]() |
9f9de8c43b | ||
![]() |
23978ef4d2 | ||
![]() |
3b4cb23112 | ||
![]() |
974cb1167f | ||
![]() |
6ccbc272c6 | ||
![]() |
0eb28c3265 | ||
![]() |
2daa131fb2 | ||
![]() |
51247d36c5 | ||
![]() |
a910c8ccd8 | ||
![]() |
43bda2d4a4 | ||
![]() |
c7033dd757 | ||
![]() |
5673a9bace | ||
![]() |
34ff764515 | ||
![]() |
1b3a009da7 | ||
![]() |
a49002bb2c | ||
![]() |
7342fc2307 | ||
![]() |
9867a3bd60 | ||
![]() |
5ffb9eaa5b | ||
![]() |
37fa227fb5 | ||
![]() |
9dd272b357 | ||
![]() |
277298feae | ||
![]() |
ff24bc0b68 | ||
![]() |
b05b688267 | ||
![]() |
f3d7f85063 | ||
![]() |
de969a9dab | ||
![]() |
700c51f95c | ||
![]() |
659914afbe | ||
![]() |
ee06aed94b | ||
![]() |
af1f5d5ab2 | ||
![]() |
4292ddd0ae | ||
![]() |
4a68fd65b6 | ||
![]() |
0e33632e79 | ||
![]() |
a9b20dae33 | ||
![]() |
e595937740 | ||
![]() |
72eb584e65 | ||
![]() |
8999a57f06 | ||
![]() |
8024089bde | ||
![]() |
5e01f785ae | ||
![]() |
d35d1b8860 | ||
![]() |
88027f2151 | ||
![]() |
cd41e7108b | ||
![]() |
6da566faff | ||
![]() |
df7a866617 | ||
![]() |
1cc8f13d54 | ||
![]() |
086ce63c6c | ||
![]() |
f1dcecc6cf | ||
![]() |
fe1ce08a6c | ||
![]() |
1d64ddb7f5 | ||
![]() |
823b121cc7 | ||
![]() |
149d35c687 | ||
![]() |
3a18e68751 | ||
![]() |
6afcc83955 | ||
![]() |
277d8773f2 | ||
![]() |
f161cf8b0a | ||
![]() |
dc62ae95a6 | ||
![]() |
f4ecc315d0 | ||
![]() |
cb2a1e57fe | ||
![]() |
1396faf433 | ||
![]() |
dc8d2ae683 | ||
![]() |
191c7c50b6 | ||
![]() |
c6725b0518 | ||
![]() |
4820a6e01c | ||
![]() |
57a9b5bc0c | ||
![]() |
8c224da5d5 | ||
![]() |
14e49f3c80 | ||
![]() |
cc8f1adca3 | ||
![]() |
122e2f7a8e | ||
![]() |
b4e1585e2b | ||
![]() |
a5830599c4 |
5
.gitattributes
vendored
5
.gitattributes
vendored
@@ -17,3 +17,8 @@ tools/** binary
|
||||
*.apk binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ttf binary
|
||||
|
||||
# Help GitHub detect languages
|
||||
native/jni/external/** linguist-vendored
|
||||
native/jni/systemproperties/** linguist-language=C++
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,8 +2,8 @@ out
|
||||
*.zip
|
||||
*.jks
|
||||
*.apk
|
||||
config.prop
|
||||
update.sh
|
||||
/config.prop
|
||||
/update.sh
|
||||
|
||||
# Built binaries
|
||||
native/out
|
||||
|
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -22,6 +22,9 @@
|
||||
[submodule "mincrypt"]
|
||||
path = native/jni/external/mincrypt
|
||||
url = https://github.com/topjohnwu/mincrypt.git
|
||||
[submodule "pcre"]
|
||||
path = native/jni/external/pcre
|
||||
url = https://android.googlesource.com/platform/external/pcre
|
||||
[submodule "termux-elf-cleaner"]
|
||||
path = tools/termux-elf-cleaner
|
||||
url = https://github.com/termux/termux-elf-cleaner.git
|
||||
|
98
README.MD
98
README.MD
@@ -1,63 +1,77 @@
|
||||
# Magisk
|
||||

|
||||
|
||||
[Downloads](https://github.com/topjohnwu/Magisk/releases) \| [Documentation](https://topjohnwu.github.io/Magisk/) \| [XDA Thread](https://forum.xda-developers.com/apps/magisk/official-magisk-v7-universal-systemless-t3473445)
|
||||

|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
Magisk is a suite of open source tools for customizing Android, supporting devices higher than Android 4.2 (API 17). It covers the fundamental parts for Android customization: root, boot scripts, SELinux patches, AVB2.0 / dm-verity / forceencrypt removals etc.
|
||||
Magisk is a suite of open source tools for customizing Android, supporting devices higher than Android 4.2. It covers fundamental parts of Android customization: root, boot scripts, SELinux patches, AVB2.0 / dm-verity / forceencrypt removals etc.
|
||||
|
||||
Furthermore, Magisk provides a **Systemless Interface** to alter the system (or vendor) arbitrarily while the actual partitions stay completely intact. With its systemless nature along with several other hacks, Magisk can hide modifications from nearly any system integrity verifications used in banking apps, corporation monitoring apps, game cheat detections, and most importantly [Google's SafetyNet API](https://developer.android.com/training/safetynet/index.html).
|
||||
Here are some feature highlights:
|
||||
|
||||
- **MagiskSU**: Provide root access to your device
|
||||
- **Magisk Modules**: Modify read-only partitions by installing modules
|
||||
- **MagiskHide**: Hide Magisk from root detections / system integrity checks
|
||||
|
||||
## Downloads
|
||||
|
||||
[](https://github.com/topjohnwu/Magisk/releases/download/manager-v8.0.2/MagiskManager-v8.0.2.apk)
|
||||
[](https://raw.githubusercontent.com/topjohnwu/magisk_files/canary/app-debug.apk)
|
||||
<br>
|
||||
[](https://github.com/topjohnwu/Magisk/releases/tag/v20.4)
|
||||
[](https://github.com/topjohnwu/Magisk/releases/tag/v21.0)
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [Installation Instruction](https://topjohnwu.github.io/Magisk/install.html)
|
||||
- [Frequently Asked Questions](https://topjohnwu.github.io/Magisk/faq.html)
|
||||
- [Magisk Documentation](https://topjohnwu.github.io/Magisk/)
|
||||
- [Magisk Troubleshoot Wiki](https://www.didgeridoohan.com/magisk/HomePage) (by [@Didgeridoohan](https://github.com/Didgeridoohan))
|
||||
|
||||
## Android Version Support
|
||||
|
||||
- Android 4.2+: MagiskSU and Magisk Modules Only
|
||||
- Android 4.4+: All core features available
|
||||
- Android 6.0+: Guaranteed MagiskHide support
|
||||
- Android 7.0+: Full MagiskHide protection
|
||||
- Android 9.0+: Magisk Manager stealth mode
|
||||
|
||||
## Bug Reports
|
||||
|
||||
**Make sure to install the latest [Canary Build](https://forum.xda-developers.com/apps/magisk/dev-magisk-canary-channel-bleeding-edge-t3839337) before reporting any bugs!** **DO NOT** report bugs that are already fixed upstream. Follow the instructions in the [Canary Channel XDA Thread](https://forum.xda-developers.com/apps/magisk/dev-magisk-canary-channel-bleeding-edge-t3839337), and report a bug either by [opening an issue on GitHub](https://github.com/topjohnwu/Magisk/issues) or directly in the thread.
|
||||
Canary Channels are cutting edge builds for those adventurous. To access canary builds, install the Canary Magisk Manager, switch to the Canary Channel in settings and upgrade.
|
||||
|
||||
## Building Environment Requirements
|
||||
**Only bug reports from Canary builds will be accepted.**
|
||||
|
||||
- Python 3: run `build.py` script
|
||||
- Java Development Kit (JDK) 8: Compile Magisk Manager and sign zips
|
||||
- Latest Android SDK: set `ANDROID_HOME` environment variable to the path to Android SDK
|
||||
- Android NDK: Install NDK along with SDK (`$ANDROID_HOME/ndk-bundle`), or optionally specify a custom path `ANDROID_NDK_HOME`
|
||||
- (Windows Only) Python package Colorama: Install with `pip install colorama`, used for ANSI color codes
|
||||
For installation issues, upload both boot image and install logs.<br>
|
||||
For Magisk issues, upload boot logcat or dmesg.<br>
|
||||
For Magisk Manager crashes, record and upload the logcat when the crash occurs.
|
||||
|
||||
## Building Notes and Instructions
|
||||
## Building and Development
|
||||
|
||||
- Clone sources with submodules: `git clone --recurse-submodules https://github.com/topjohnwu/Magisk.git`
|
||||
- Building is supported on macOS, Linux, and Windows. Official releases are built and tested with [FrankeNDK](https://github.com/topjohnwu/FrankeNDK); point `ANDROID_NDK_HOME` to FrankeNDK if you want to use it for compiling.
|
||||
- Set configurations in `config.prop`. A sample file `config.prop.sample` is provided as an example.
|
||||
- Run `build.py` with argument `-h` to see the built-in help message. The `-h` option also works for each supported actions, e.g. `./build.py binary -h`
|
||||
- By default, `build.py` build binaries and Magisk Manager in debug mode. If you want to build Magisk Manager in release mode (via the `-r, --release` flag), you need a Java Keystore file `release-key.jks` (only `JKS` format is supported) to sign APKs and zips. For more information, check out [Google's Official Documentation](https://developer.android.com/studio/publish/app-signing.html#signing-manually).
|
||||
- Magisk builds on any OS Android Studio supports. Install Android Studio and do the initial setups.
|
||||
- Clone sources: `git clone --recurse-submodules https://github.com/topjohnwu/Magisk.git`
|
||||
- Install Python 3.6+ \
|
||||
(Windows only: select **'Add Python to PATH'** in installer, and run `pip install colorama` after install)
|
||||
- Configure to use the JDK bundled in Android Studio:
|
||||
- macOS: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jre/jdk/Contents/Home"`
|
||||
- Linux: `export PATH="/path/to/androidstudio/jre/bin:$PATH"`
|
||||
- Windows: Add `C:\Path\To\Android Studio\jre\bin` to environment variable `PATH`
|
||||
- Set environment variable `ANDROID_SDK_ROOT` to the Android SDK folder (can be found in Android Studio settings)
|
||||
- Run `./build.py ndk` to let the script download and install NDK for you
|
||||
- Set configurations in `config.prop`. A sample `config.prop.sample` is provided.
|
||||
- To start building, run `build.py` to see your options. \
|
||||
For each action, use `-h` to access help (e.g. `./build.py all -h`)
|
||||
- To start development, open the project in Android Studio. Both app (Kotlin/Java) and native (C++/C) source code can be properly developed using the IDE, but *always* use `build.py` for building.
|
||||
- `build.py` builds in debug mode by default. If you want release builds (with `-r, --release`), you need a Java Keystore to sign APKs and zips. For more information, check [Google's Documentation](https://developer.android.com/studio/publish/app-signing.html#generate-key).
|
||||
|
||||
## Translations
|
||||
## Translation Contributions
|
||||
|
||||
Default string resources for Magisk Manager are scattered throughout
|
||||
Default string resources for Magisk Manager and its stub APK are located here:
|
||||
|
||||
- `app/src/main/res/values/strings.xml`
|
||||
- `stub/src/main/res/values/strings.xml`
|
||||
- `shared/src/main/res/values/strings.xml`
|
||||
|
||||
Translate each and place them in the respective locations (`<module>/src/main/res/values-<lang>/strings.xml`).
|
||||
|
||||
## Signature Verification
|
||||
|
||||
Official release zips and APKs are signed with my personal private key. You can verify the key certificate to make sure the binaries you downloaded are not manipulated in anyway.
|
||||
|
||||
``` bash
|
||||
# Use the keytool command from JDK to print certificates
|
||||
keytool -printcert -jarfile <APK or Magisk zip>
|
||||
|
||||
# The output should contain the following signature
|
||||
Owner: CN=John Wu, L=Taipei, C=TW
|
||||
Issuer: CN=John Wu, L=Taipei, C=TW
|
||||
Serial number: 50514879
|
||||
Valid from: Sun Aug 14 13:23:44 EDT 2016 until: Tue Jul 21 13:23:44 EDT 2116
|
||||
Certificate fingerprints:
|
||||
MD5: CE:DA:68:C1:E1:74:71:0A:EF:58:89:7D:AE:6E:AB:4F
|
||||
SHA1: DC:0F:2B:61:CB:D7:E9:D3:DB:BE:06:0B:2B:87:0D:46:BB:06:02:11
|
||||
SHA256: B4:CB:83:B4:DA:D9:9F:99:7D:BE:87:2F:01:3A:A1:6C:14:EE:C4:1D:16:70:21:F3:71:F7:E1:33:0F:27:3E:E6
|
||||
Signature algorithm name: SHA256withRSA
|
||||
Version: 3
|
||||
```
|
||||
Translate each and place them in the respective locations (`[module]/src/main/res/values-[lang]/strings.xml`).
|
||||
|
||||
## License
|
||||
|
||||
|
133
app/build.gradle
133
app/build.gradle
@@ -1,133 +0,0 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
useBuildCache = true
|
||||
mapDiagnosticLocations = true
|
||||
javacOptions {
|
||||
option("-Xmaxerrs", 1000)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId 'com.topjohnwu.magisk'
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
multiDexEnabled true
|
||||
versionName props['appVersion']
|
||||
versionCode props['appVersionCode'] as Integer
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
|
||||
'proguard-rules.pro', 'proguard-kotlin.pro'
|
||||
}
|
||||
}
|
||||
|
||||
dataBinding {
|
||||
enabled = true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude '/META-INF/*.version'
|
||||
exclude '/META-INF/*.kotlin_module'
|
||||
exclude '/META-INF/rxkotlin.properties'
|
||||
exclude '/androidsupportmultidexversion.txt'
|
||||
exclude '/org/bouncycastle/**'
|
||||
exclude '/kotlin/**'
|
||||
exclude '/kotlinx/**'
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
androidExtensions {
|
||||
experimental = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation project(':shared')
|
||||
implementation project(':signing')
|
||||
|
||||
implementation 'com.github.topjohnwu:jtar:1.0.0'
|
||||
implementation 'com.jakewharton.timber:timber:4.7.1'
|
||||
implementation 'com.ncapdevi:frag-nav:3.2.0'
|
||||
implementation 'com.github.pwittchen:reactivenetwork-rx2:3.0.6'
|
||||
|
||||
implementation 'io.reactivex.rxjava2:rxjava:2.2.13'
|
||||
implementation 'io.reactivex.rxjava2:rxkotlin:2.4.0'
|
||||
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:${vKotlin}"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${vKotlin}"
|
||||
|
||||
def vBAdapt = '3.1.1'
|
||||
def bindingAdapter = 'me.tatarka.bindingcollectionadapter2:bindingcollectionadapter'
|
||||
implementation "${bindingAdapter}:${vBAdapt}"
|
||||
implementation "${bindingAdapter}-recyclerview:${vBAdapt}"
|
||||
|
||||
def vMarkwon = '4.1.1'
|
||||
implementation "io.noties.markwon:core:${vMarkwon}"
|
||||
implementation "io.noties.markwon:html:${vMarkwon}"
|
||||
implementation "io.noties.markwon:image:${vMarkwon}"
|
||||
implementation 'com.caverock:androidsvg:1.4'
|
||||
|
||||
def vLibsu = '2.5.1'
|
||||
implementation "com.github.topjohnwu.libsu:core:${vLibsu}"
|
||||
implementation "com.github.topjohnwu.libsu:io:${vLibsu}"
|
||||
|
||||
def vKoin = "2.0.1"
|
||||
implementation "org.koin:koin-core:${vKoin}"
|
||||
implementation "org.koin:koin-android:${vKoin}"
|
||||
implementation "org.koin:koin-androidx-viewmodel:${vKoin}"
|
||||
|
||||
def vRetrofit = '2.6.2'
|
||||
implementation "com.squareup.retrofit2:retrofit:${vRetrofit}"
|
||||
implementation "com.squareup.retrofit2:converter-moshi:${vRetrofit}"
|
||||
implementation "com.squareup.retrofit2:converter-scalars:${vRetrofit}"
|
||||
implementation "com.squareup.retrofit2:adapter-rxjava2:${vRetrofit}"
|
||||
|
||||
def vOkHttp = '3.12.6'
|
||||
implementation "com.squareup.okhttp3:okhttp:${vOkHttp}"
|
||||
implementation "com.squareup.okhttp3:logging-interceptor:${vOkHttp}"
|
||||
|
||||
def vMoshi = "1.8.0"
|
||||
implementation "com.squareup.moshi:moshi:${vMoshi}"
|
||||
|
||||
def vKotshi = "2.0.1"
|
||||
implementation "se.ansman.kotshi:api:${vKotshi}"
|
||||
kapt "se.ansman.kotshi:compiler:${vKotshi}"
|
||||
|
||||
modules {
|
||||
module('androidx.room:room-runtime') {
|
||||
replacedBy('com.github.topjohnwu:room-runtime')
|
||||
}
|
||||
}
|
||||
def vRoom = "2.2.0"
|
||||
implementation "com.github.topjohnwu:room-runtime:${vRoom}"
|
||||
kapt "androidx.room:room-compiler:${vRoom}"
|
||||
|
||||
def vNav = "2.1.0"
|
||||
implementation "androidx.navigation:navigation-fragment-ktx:$vNav"
|
||||
implementation "androidx.navigation:navigation-ui-ktx:$vNav"
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0-alpha03'
|
||||
implementation 'androidx.preference:preference:1.1.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.1.0-beta05'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.work:work-runtime:2.2.0'
|
||||
implementation 'androidx.transition:transition:1.2.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.core:core-ktx:1.1.0'
|
||||
implementation 'com.google.android.material:material:1.1.0-beta01'
|
||||
}
|
149
app/build.gradle.kts
Normal file
149
app/build.gradle.kts
Normal file
@@ -0,0 +1,149 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
kotlin("android.extensions")
|
||||
kotlin("kapt")
|
||||
id("androidx.navigation.safeargs.kotlin")
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
useBuildCache = true
|
||||
mapDiagnosticLocations = true
|
||||
javacOptions {
|
||||
option("-Xmaxerrs", 1000)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId = "com.topjohnwu.magisk"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
multiDexEnabled = true
|
||||
versionName = Config["appVersion"]
|
||||
versionCode = Config["appVersionCode"]?.toInt()
|
||||
buildConfigField("int", "LATEST_MAGISK", Config["versionCode"] ?: "Integer.MAX_VALUE")
|
||||
|
||||
javaCompileOptions.annotationProcessorOptions.arguments(
|
||||
mapOf("room.incremental" to "true")
|
||||
)
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
dataBinding = true
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
exclude("/META-INF/**")
|
||||
exclude("/org/bouncycastle/**")
|
||||
exclude("/kotlin/**")
|
||||
exclude("/kotlinx/**")
|
||||
exclude("/okhttp3/**")
|
||||
exclude("/*.txt")
|
||||
exclude("/*.bin")
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
androidExtensions {
|
||||
isExperimental = true
|
||||
}
|
||||
|
||||
val copyUtils = tasks.register("copyUtils", Copy::class) {
|
||||
from(rootProject.file("scripts/util_functions.sh"))
|
||||
into("src/main/res/raw")
|
||||
}
|
||||
|
||||
tasks["preBuild"]?.dependsOn(copyUtils)
|
||||
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation(project(":app:shared"))
|
||||
implementation(project(":app:signing"))
|
||||
|
||||
implementation("com.github.topjohnwu:jtar:1.0.0")
|
||||
implementation("com.github.topjohnwu:indeterminate-checkbox:1.0.7")
|
||||
implementation("com.github.topjohnwu:lz4-java:1.7.1")
|
||||
implementation("com.jakewharton.timber:timber:4.7.1")
|
||||
|
||||
val vBAdapt = "4.0.0"
|
||||
val bindingAdapter = "me.tatarka.bindingcollectionadapter2:bindingcollectionadapter"
|
||||
implementation("${bindingAdapter}:${vBAdapt}")
|
||||
implementation("${bindingAdapter}-recyclerview:${vBAdapt}")
|
||||
|
||||
val vMarkwon = "4.6.0"
|
||||
implementation("io.noties.markwon:core:${vMarkwon}")
|
||||
implementation("io.noties.markwon:html:${vMarkwon}")
|
||||
implementation("io.noties.markwon:image:${vMarkwon}")
|
||||
implementation("com.caverock:androidsvg:1.4")
|
||||
|
||||
val vLibsu = "3.0.2"
|
||||
implementation("com.github.topjohnwu.libsu:core:${vLibsu}")
|
||||
implementation("com.github.topjohnwu.libsu:io:${vLibsu}")
|
||||
|
||||
val vKoin = "2.1.6"
|
||||
implementation("org.koin:koin-core:${vKoin}")
|
||||
implementation("org.koin:koin-android:${vKoin}")
|
||||
implementation("org.koin:koin-androidx-viewmodel:${vKoin}")
|
||||
|
||||
val vRetrofit = "2.9.0"
|
||||
implementation("com.squareup.retrofit2:retrofit:${vRetrofit}")
|
||||
implementation("com.squareup.retrofit2:converter-moshi:${vRetrofit}")
|
||||
implementation("com.squareup.retrofit2:converter-scalars:${vRetrofit}")
|
||||
|
||||
val vOkHttp = "3.12.12"
|
||||
implementation("com.squareup.okhttp3:okhttp") {
|
||||
version {
|
||||
strictly(vOkHttp)
|
||||
}
|
||||
}
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:${vOkHttp}")
|
||||
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:${vOkHttp}")
|
||||
|
||||
val vMoshi = "1.11.0"
|
||||
implementation("com.squareup.moshi:moshi:${vMoshi}")
|
||||
kapt("com.squareup.moshi:moshi-kotlin-codegen:${vMoshi}")
|
||||
|
||||
val vRoom = "2.3.0-alpha03"
|
||||
implementation("androidx.room:room-runtime:${vRoom}")
|
||||
implementation("androidx.room:room-ktx:${vRoom}")
|
||||
kapt("androidx.room:room-compiler:${vRoom}")
|
||||
|
||||
val vNav: String by rootProject.extra
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:${vNav}")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:${vNav}")
|
||||
|
||||
implementation("androidx.biometric:biometric:1.0.1")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.0.4")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("androidx.browser:browser:1.2.0")
|
||||
implementation("androidx.preference:preference:1.1.1")
|
||||
implementation("androidx.recyclerview:recyclerview:1.1.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.2.5")
|
||||
implementation("androidx.work:work-runtime-ktx:2.4.0")
|
||||
implementation("androidx.transition:transition:1.3.1")
|
||||
implementation("androidx.multidex:multidex:2.0.1")
|
||||
implementation("androidx.core:core-ktx:1.3.2")
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.0.0")
|
||||
implementation("com.google.android.material:material:1.2.1")
|
||||
}
|
@@ -1,20 +0,0 @@
|
||||
## So every class is case insensitive to avoid some bizare problems
|
||||
-dontusemixedcaseclassnames
|
||||
|
||||
## If reflection issues come up uncomment this, that should temporarily fix it
|
||||
#-keep class kotlin.** { *; }
|
||||
#-keep class kotlin.Metadata { *; }
|
||||
#-keepclassmembers class kotlin.Metadata {
|
||||
# public <methods>;
|
||||
#}
|
||||
|
||||
## Never warn about Kotlin, it should work as-is
|
||||
-dontwarn kotlin.**
|
||||
|
||||
## Removes runtime null checks - doesn't really matter if it crashes on kotlin or java NPE
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
|
||||
}
|
||||
|
||||
## Useless option for dex
|
||||
-dontpreverify
|
38
app/proguard-rules.pro
vendored
38
app/proguard-rules.pro
vendored
@@ -16,29 +16,33 @@
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Kotlin
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
public static void checkExpressionValueIsNotNull(...);
|
||||
public static void checkNotNullExpressionValue(...);
|
||||
public static void checkReturnedValueIsNotNull(...);
|
||||
public static void checkFieldIsNotNull(...);
|
||||
public static void checkParameterIsNotNull(...);
|
||||
}
|
||||
|
||||
# Stubs
|
||||
-keep class a.* { *; }
|
||||
|
||||
# Snet
|
||||
-keepclassmembers class com.topjohnwu.magisk.utils.SafetyNetHelper { *; }
|
||||
-keep,allowobfuscation interface com.topjohnwu.magisk.utils.SafetyNetHelper$Callback
|
||||
-keepclassmembers class * implements com.topjohnwu.magisk.utils.SafetyNetHelper$Callback {
|
||||
void onResponse(int);
|
||||
-keepclassmembers class com.topjohnwu.magisk.ui.safetynet.SafetyNetHelper { *; }
|
||||
-keep,allowobfuscation interface com.topjohnwu.magisk.ui.safetynet.SafetyNetHelper$Callback
|
||||
-keepclassmembers class * implements com.topjohnwu.magisk.ui.safetynet.SafetyNetHelper$Callback {
|
||||
void onResponse(org.json.JSONObject);
|
||||
}
|
||||
|
||||
# Keep all fragment constructors
|
||||
-keepclassmembers class * extends androidx.fragment.app.Fragment {
|
||||
public <init>(...);
|
||||
# Strip Timber verbose and debug logging
|
||||
-assumenosideeffects class timber.log.Timber.Tree {
|
||||
public void v(**);
|
||||
public void d(**);
|
||||
}
|
||||
|
||||
# DelegateWorker
|
||||
-keep,allowobfuscation class * extends com.topjohnwu.magisk.base.DelegateWorker
|
||||
|
||||
# BootSigner
|
||||
-keepclassmembers class com.topjohnwu.signing.BootSigner { *; }
|
||||
|
||||
# Strip logging
|
||||
-assumenosideeffects class timber.log.Timber.Tree { *; }
|
||||
|
||||
# Excessive obfuscation
|
||||
-repackageclasses 'a'
|
||||
-repackageclasses
|
||||
-allowaccessmodification
|
||||
|
||||
# QOL
|
||||
|
14
app/shared/build.gradle.kts
Normal file
14
app/shared/build.gradle.kts
Normal file
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
consumerProguardFiles("proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
|
||||
}
|
@@ -20,6 +20,6 @@
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keepclassmembers class * implements javax.net.ssl.SSLSocketFactory {
|
||||
-keepclassmembers class * extends javax.net.ssl.SSLSocketFactory {
|
||||
** delegate;
|
||||
}
|
17
app/shared/src/main/AndroidManifest.xml
Normal file
17
app/shared/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.topjohnwu.shared">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="Magisk Manager"
|
||||
android:installLocation="internalOnly"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"
|
||||
tools:ignore="UnusedAttribute">
|
||||
</application>
|
||||
|
||||
</manifest>
|
68
app/shared/src/main/java/com/topjohnwu/magisk/DynAPK.java
Normal file
68
app/shared/src/main/java/com/topjohnwu/magisk/DynAPK.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.topjohnwu.magisk;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.AssetManager;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
import static android.os.Build.VERSION.SDK_INT;
|
||||
|
||||
public class DynAPK {
|
||||
|
||||
// Indices of the object array
|
||||
private static final int STUB_VERSION_ENTRY = 0;
|
||||
private static final int CLASS_COMPONENT_MAP = 1;
|
||||
|
||||
private static File dynDir;
|
||||
private static Method addAssetPath;
|
||||
|
||||
private static File getDynDir(Context c) {
|
||||
if (dynDir == null) {
|
||||
if (SDK_INT >= 24) {
|
||||
// Use protected context to allow directBootAware
|
||||
c = c.createDeviceProtectedStorageContext();
|
||||
}
|
||||
dynDir = new File(c.getFilesDir().getParent(), "dyn");
|
||||
dynDir.mkdir();
|
||||
}
|
||||
return dynDir;
|
||||
}
|
||||
|
||||
public static File current(Context c) {
|
||||
return new File(getDynDir(c), "current.apk");
|
||||
}
|
||||
|
||||
public static File update(Context c) {
|
||||
return new File(getDynDir(c), "update.apk");
|
||||
}
|
||||
|
||||
public static Data load(Object o) {
|
||||
Object[] arr = (Object[]) o;
|
||||
Data data = new Data();
|
||||
data.version = (int) arr[STUB_VERSION_ENTRY];
|
||||
data.classToComponent = (Map<String, String>) arr[CLASS_COMPONENT_MAP];
|
||||
return data;
|
||||
}
|
||||
|
||||
public static Object pack(Data data) {
|
||||
Object[] arr = new Object[2];
|
||||
arr[STUB_VERSION_ENTRY] = data.version;
|
||||
arr[CLASS_COMPONENT_MAP] = data.classToComponent;
|
||||
return arr;
|
||||
}
|
||||
|
||||
public static void addAssetPath(AssetManager asset, String path) {
|
||||
try {
|
||||
if (addAssetPath == null)
|
||||
addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
|
||||
addAssetPath.invoke(asset, path);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
public static class Data {
|
||||
public int version;
|
||||
public Map<String, String> classToComponent;
|
||||
}
|
||||
}
|
@@ -1,11 +1,9 @@
|
||||
package com.topjohnwu.magisk.utils;
|
||||
package com.topjohnwu.magisk;
|
||||
|
||||
import android.content.ContentProvider;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ProviderInfo;
|
||||
import android.content.res.XmlResourceParser;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.net.Uri;
|
||||
@@ -16,17 +14,12 @@ import android.provider.OpenableColumns;
|
||||
import android.text.TextUtils;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import org.xmlpull.v1.XmlPullParserException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.xmlpull.v1.XmlPullParser.END_DOCUMENT;
|
||||
import static org.xmlpull.v1.XmlPullParser.START_TAG;
|
||||
|
||||
/**
|
||||
* Modified from androidx.core.content.FileProvider
|
||||
*/
|
||||
@@ -34,33 +27,17 @@ public class FileProvider extends ContentProvider {
|
||||
private static final String[] COLUMNS = {
|
||||
OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
||||
|
||||
private static final String
|
||||
META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS";
|
||||
|
||||
private static final String TAG_ROOT_PATH = "root-path";
|
||||
private static final String TAG_FILES_PATH = "files-path";
|
||||
private static final String TAG_CACHE_PATH = "cache-path";
|
||||
private static final String TAG_EXTERNAL = "external-path";
|
||||
private static final String TAG_EXTERNAL_FILES = "external-files-path";
|
||||
private static final String TAG_EXTERNAL_CACHE = "external-cache-path";
|
||||
private static final String TAG_EXTERNAL_MEDIA = "external-media-path";
|
||||
|
||||
private static final String ATTR_NAME = "name";
|
||||
private static final String ATTR_PATH = "path";
|
||||
|
||||
private static final File DEVICE_ROOT = new File("/");
|
||||
|
||||
private static HashMap<String, PathStrategy> sCache = new HashMap<>();
|
||||
|
||||
private PathStrategy mStrategy;
|
||||
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void attachInfo(Context context, ProviderInfo info) {
|
||||
super.attachInfo(context, info);
|
||||
@@ -83,7 +60,6 @@ public class FileProvider extends ContentProvider {
|
||||
return strategy.getUriForFile(file);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Cursor query(Uri uri, String[] projection, String selection,
|
||||
String[] selectionArgs,
|
||||
@@ -116,7 +92,6 @@ public class FileProvider extends ContentProvider {
|
||||
return cursor;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String getType(Uri uri) {
|
||||
|
||||
@@ -134,20 +109,17 @@ public class FileProvider extends ContentProvider {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Uri insert(Uri uri, ContentValues values) {
|
||||
throw new UnsupportedOperationException("No external inserts");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int update(Uri uri, ContentValues values, String selection,
|
||||
String[] selectionArgs) {
|
||||
throw new UnsupportedOperationException("No external updates");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public int delete(Uri uri, String selection,
|
||||
String[] selectionArgs) {
|
||||
@@ -156,7 +128,6 @@ public class FileProvider extends ContentProvider {
|
||||
return file.delete() ? 1 : 0;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public ParcelFileDescriptor openFile(Uri uri, String mode)
|
||||
throws FileNotFoundException {
|
||||
@@ -166,95 +137,54 @@ public class FileProvider extends ContentProvider {
|
||||
return ParcelFileDescriptor.open(file, fileMode);
|
||||
}
|
||||
|
||||
|
||||
private static PathStrategy getPathStrategy(Context context, String authority) {
|
||||
PathStrategy strat;
|
||||
synchronized (sCache) {
|
||||
strat = sCache.get(authority);
|
||||
if (strat == null) {
|
||||
try {
|
||||
strat = parsePathStrategy(context, authority);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
|
||||
} catch (XmlPullParserException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Failed to parse " + META_DATA_FILE_PROVIDER_PATHS + " meta-data", e);
|
||||
}
|
||||
strat = createPathStrategy(context, authority);
|
||||
sCache.put(authority, strat);
|
||||
}
|
||||
}
|
||||
return strat;
|
||||
}
|
||||
|
||||
|
||||
private static PathStrategy parsePathStrategy(Context context, String authority)
|
||||
throws IOException, XmlPullParserException {
|
||||
private static PathStrategy createPathStrategy(Context context, String authority) {
|
||||
final SimplePathStrategy strat = new SimplePathStrategy(authority);
|
||||
|
||||
final ProviderInfo info = context.getPackageManager()
|
||||
.resolveContentProvider(authority, PackageManager.GET_META_DATA);
|
||||
final XmlResourceParser in = info.loadXmlMetaData(
|
||||
context.getPackageManager(), META_DATA_FILE_PROVIDER_PATHS);
|
||||
if (in == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Missing " + META_DATA_FILE_PROVIDER_PATHS + " meta-data");
|
||||
strat.addRoot("root_files", buildPath(DEVICE_ROOT, "."));
|
||||
strat.addRoot("internal_files", buildPath(context.getFilesDir(), "."));
|
||||
strat.addRoot("cache_files", buildPath(context.getCacheDir(), "."));
|
||||
strat.addRoot("external_files", buildPath(Environment.getExternalStorageDirectory(), "."));
|
||||
{
|
||||
File[] externalFilesDirs = getExternalFilesDirs(context, null);
|
||||
if (externalFilesDirs.length > 0) {
|
||||
strat.addRoot("external_file_files", buildPath(externalFilesDirs[0], "."));
|
||||
}
|
||||
}
|
||||
|
||||
int type;
|
||||
while ((type = in.next()) != END_DOCUMENT) {
|
||||
if (type == START_TAG) {
|
||||
final String tag = in.getName();
|
||||
|
||||
final String name = in.getAttributeValue(null, ATTR_NAME);
|
||||
String path = in.getAttributeValue(null, ATTR_PATH);
|
||||
|
||||
File target = null;
|
||||
if (TAG_ROOT_PATH.equals(tag)) {
|
||||
target = DEVICE_ROOT;
|
||||
} else if (TAG_FILES_PATH.equals(tag)) {
|
||||
target = context.getFilesDir();
|
||||
} else if (TAG_CACHE_PATH.equals(tag)) {
|
||||
target = context.getCacheDir();
|
||||
} else if (TAG_EXTERNAL.equals(tag)) {
|
||||
target = Environment.getExternalStorageDirectory();
|
||||
} else if (TAG_EXTERNAL_FILES.equals(tag)) {
|
||||
File[] externalFilesDirs = getExternalFilesDirs(context, null);
|
||||
if (externalFilesDirs.length > 0) {
|
||||
target = externalFilesDirs[0];
|
||||
}
|
||||
} else if (TAG_EXTERNAL_CACHE.equals(tag)) {
|
||||
File[] externalCacheDirs = getExternalCacheDirs(context);
|
||||
if (externalCacheDirs.length > 0) {
|
||||
target = externalCacheDirs[0];
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
|
||||
&& TAG_EXTERNAL_MEDIA.equals(tag)) {
|
||||
File[] externalMediaDirs = context.getExternalMediaDirs();
|
||||
if (externalMediaDirs.length > 0) {
|
||||
target = externalMediaDirs[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (target != null) {
|
||||
strat.addRoot(name, buildPath(target, path));
|
||||
}
|
||||
{
|
||||
File[] externalCacheDirs = getExternalCacheDirs(context);
|
||||
if (externalCacheDirs.length > 0) {
|
||||
strat.addRoot("external_cache_files", buildPath(externalCacheDirs[0], "."));
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
File[] externalMediaDirs = context.getExternalMediaDirs();
|
||||
if (externalMediaDirs.length > 0) {
|
||||
strat.addRoot("external_media_files", buildPath(externalMediaDirs[0], "."));
|
||||
}
|
||||
}
|
||||
|
||||
return strat;
|
||||
}
|
||||
|
||||
|
||||
interface PathStrategy {
|
||||
|
||||
Uri getUriForFile(File file);
|
||||
|
||||
|
||||
File getFileForUri(Uri uri);
|
||||
}
|
||||
|
||||
|
||||
static class SimplePathStrategy implements PathStrategy {
|
||||
private final String mAuthority;
|
||||
private final HashMap<String, File> mRoots = new HashMap<>();
|
||||
@@ -263,7 +193,6 @@ public class FileProvider extends ContentProvider {
|
||||
mAuthority = authority;
|
||||
}
|
||||
|
||||
|
||||
void addRoot(String name, File root) {
|
||||
if (TextUtils.isEmpty(name)) {
|
||||
throw new IllegalArgumentException("Name must not be empty");
|
@@ -47,10 +47,9 @@ public class Networking {
|
||||
} catch (Exception e) {
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
// Failed to update SSL provider, use NoSSLv3SocketFactory on SDK < 21
|
||||
// and return false to notify potential issues
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(new NoSSLv3SocketFactory());
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
@@ -5,6 +5,8 @@ import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
||||
import com.topjohnwu.magisk.FileProvider;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public class APKInstall {
|
@@ -0,0 +1,35 @@
|
||||
package com.topjohnwu.magisk.utils;
|
||||
|
||||
import java.util.Enumeration;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
public class CompoundEnumeration<E> implements Enumeration<E> {
|
||||
private Enumeration<E>[] enums;
|
||||
private int index = 0;
|
||||
|
||||
@SafeVarargs
|
||||
public CompoundEnumeration(Enumeration<E> ...enums) {
|
||||
this.enums = enums;
|
||||
}
|
||||
|
||||
private boolean next() {
|
||||
while (index < enums.length) {
|
||||
if (enums[index] != null && enums[index].hasMoreElements()) {
|
||||
return true;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean hasMoreElements() {
|
||||
return next();
|
||||
}
|
||||
|
||||
public E nextElement() {
|
||||
if (!next()) {
|
||||
throw new NoSuchElementException();
|
||||
}
|
||||
return enums[index].nextElement();
|
||||
}
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
package com.topjohnwu.magisk.utils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.util.Enumeration;
|
||||
|
||||
import dalvik.system.DexClassLoader;
|
||||
|
||||
public class DynamicClassLoader extends DexClassLoader {
|
||||
|
||||
private ClassLoader base = Object.class.getClassLoader();
|
||||
|
||||
public DynamicClassLoader(File apk, ClassLoader parent) {
|
||||
super(apk.getPath(), apk.getParent(), null, parent);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
|
||||
// First check if already loaded
|
||||
Class cls = findLoadedClass(name);
|
||||
if (cls != null)
|
||||
return cls;
|
||||
|
||||
try {
|
||||
// Then check boot classpath
|
||||
return base.loadClass(name);
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
try {
|
||||
// Next try current dex
|
||||
return findClass(name);
|
||||
} catch (ClassNotFoundException fromSuper) {
|
||||
try {
|
||||
// Finally try parent
|
||||
return getParent().loadClass(name);
|
||||
} catch (ClassNotFoundException e) {
|
||||
throw fromSuper;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public URL getResource(String name) {
|
||||
URL resource = base.getResource(name);
|
||||
if (resource != null)
|
||||
return resource;
|
||||
resource = findResource(name);
|
||||
if (resource != null)
|
||||
return resource;
|
||||
resource = getParent().getResource(name);
|
||||
return resource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<URL> getResources(String name) throws IOException {
|
||||
return new CompoundEnumeration<>(base.getResources(name),
|
||||
findResources(name), getParent().getResources(name));
|
||||
}
|
||||
}
|
35
app/signing/build.gradle.kts
Normal file
35
app/signing/build.gradle.kts
Normal file
@@ -0,0 +1,35 @@
|
||||
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
|
||||
|
||||
plugins {
|
||||
id("java-library")
|
||||
id("java")
|
||||
id("com.github.johnrengelman.shadow") version "6.0.0"
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
val jar by tasks.getting(Jar::class) {
|
||||
manifest {
|
||||
attributes["Main-Class"] = "com.topjohnwu.signing.ZipSigner"
|
||||
}
|
||||
}
|
||||
|
||||
val shadowJar by tasks.getting(ShadowJar::class) {
|
||||
archiveBaseName.set("zipsigner")
|
||||
archiveClassifier.set(null as String?)
|
||||
archiveVersion.set("4.0")
|
||||
}
|
||||
|
||||
repositories {
|
||||
jcenter()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
|
||||
|
||||
api("org.bouncycastle:bcprov-jdk15on:1.67")
|
||||
api("org.bouncycastle:bcpkix-jdk15on:1.67")
|
||||
}
|
772
app/signing/src/main/java/com/topjohnwu/signing/ApkSignerV2.java
Normal file
772
app/signing/src/main/java/com/topjohnwu/signing/ApkSignerV2.java
Normal file
@@ -0,0 +1,772 @@
|
||||
package com.topjohnwu.signing;
|
||||
|
||||
import java.nio.BufferUnderflowException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.security.DigestException;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.spec.AlgorithmParameterSpec;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* APK Signature Scheme v2 signer.
|
||||
*
|
||||
* <p>APK Signature Scheme v2 is a whole-file signature scheme which aims to protect every single
|
||||
* bit of the APK, as opposed to the JAR Signature Scheme which protects only the names and
|
||||
* uncompressed contents of ZIP entries.
|
||||
*/
|
||||
public abstract class ApkSignerV2 {
|
||||
/*
|
||||
* The two main goals of APK Signature Scheme v2 are:
|
||||
* 1. Detect any unauthorized modifications to the APK. This is achieved by making the signature
|
||||
* cover every byte of the APK being signed.
|
||||
* 2. Enable much faster signature and integrity verification. This is achieved by requiring
|
||||
* only a minimal amount of APK parsing before the signature is verified, thus completely
|
||||
* bypassing ZIP entry decompression and by making integrity verification parallelizable by
|
||||
* employing a hash tree.
|
||||
*
|
||||
* The generated signature block is wrapped into an APK Signing Block and inserted into the
|
||||
* original APK immediately before the start of ZIP Central Directory. This is to ensure that
|
||||
* JAR and ZIP parsers continue to work on the signed APK. The APK Signing Block is designed for
|
||||
* extensibility. For example, a future signature scheme could insert its signatures there as
|
||||
* well. The contract of the APK Signing Block is that all contents outside of the block must be
|
||||
* protected by signatures inside the block.
|
||||
*/
|
||||
|
||||
public static final int SIGNATURE_RSA_PSS_WITH_SHA256 = 0x0101;
|
||||
public static final int SIGNATURE_RSA_PSS_WITH_SHA512 = 0x0102;
|
||||
public static final int SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256 = 0x0103;
|
||||
public static final int SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512 = 0x0104;
|
||||
public static final int SIGNATURE_ECDSA_WITH_SHA256 = 0x0201;
|
||||
public static final int SIGNATURE_ECDSA_WITH_SHA512 = 0x0202;
|
||||
public static final int SIGNATURE_DSA_WITH_SHA256 = 0x0301;
|
||||
public static final int SIGNATURE_DSA_WITH_SHA512 = 0x0302;
|
||||
|
||||
/**
|
||||
* {@code .SF} file header section attribute indicating that the APK is signed not just with
|
||||
* JAR signature scheme but also with APK Signature Scheme v2 or newer. This attribute
|
||||
* facilitates v2 signature stripping detection.
|
||||
*
|
||||
* <p>The attribute contains a comma-separated set of signature scheme IDs.
|
||||
*/
|
||||
public static final String SF_ATTRIBUTE_ANDROID_APK_SIGNED_NAME = "X-Android-APK-Signed";
|
||||
public static final String SF_ATTRIBUTE_ANDROID_APK_SIGNED_VALUE = "2";
|
||||
|
||||
private static final int CONTENT_DIGEST_CHUNKED_SHA256 = 0;
|
||||
private static final int CONTENT_DIGEST_CHUNKED_SHA512 = 1;
|
||||
|
||||
private static final int CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES = 1024 * 1024;
|
||||
|
||||
private static final byte[] APK_SIGNING_BLOCK_MAGIC =
|
||||
new byte[] {
|
||||
0x41, 0x50, 0x4b, 0x20, 0x53, 0x69, 0x67, 0x20,
|
||||
0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x20, 0x34, 0x32,
|
||||
};
|
||||
private static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a;
|
||||
|
||||
private ApkSignerV2() {}
|
||||
|
||||
/**
|
||||
* Signer configuration.
|
||||
*/
|
||||
public static final class SignerConfig {
|
||||
/** Private key. */
|
||||
public PrivateKey privateKey;
|
||||
|
||||
/**
|
||||
* Certificates, with the first certificate containing the public key corresponding to
|
||||
* {@link #privateKey}.
|
||||
*/
|
||||
public List<X509Certificate> certificates;
|
||||
|
||||
/**
|
||||
* List of signature algorithms with which to sign (see {@code SIGNATURE_...} constants).
|
||||
*/
|
||||
public List<Integer> signatureAlgorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the provided APK using APK Signature Scheme v2 and returns the signed APK as a list of
|
||||
* consecutive chunks.
|
||||
*
|
||||
* <p>NOTE: To enable APK signature verifier to detect v2 signature stripping, header sections
|
||||
* of META-INF/*.SF files of APK being signed must contain the
|
||||
* {@code X-Android-APK-Signed: true} attribute.
|
||||
*
|
||||
* @param inputApk contents of the APK to be signed. The APK starts at the current position
|
||||
* of the buffer and ends at the limit of the buffer.
|
||||
* @param signerConfigs signer configurations, one for each signer.
|
||||
*
|
||||
* @throws ApkParseException if the APK cannot be parsed.
|
||||
* @throws InvalidKeyException if a signing key is not suitable for this signature scheme or
|
||||
* cannot be used in general.
|
||||
* @throws SignatureException if an error occurs when computing digests of generating
|
||||
* signatures.
|
||||
*/
|
||||
public static ByteBuffer[] sign(
|
||||
ByteBuffer inputApk,
|
||||
List<SignerConfig> signerConfigs)
|
||||
throws ApkParseException, InvalidKeyException, SignatureException {
|
||||
// Slice/create a view in the inputApk to make sure that:
|
||||
// 1. inputApk is what's between position and limit of the original inputApk, and
|
||||
// 2. changes to position, limit, and byte order are not reflected in the original.
|
||||
ByteBuffer originalInputApk = inputApk;
|
||||
inputApk = originalInputApk.slice();
|
||||
inputApk.order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
// Locate ZIP End of Central Directory (EoCD), Central Directory, and check that Central
|
||||
// Directory is immediately followed by the ZIP End of Central Directory.
|
||||
int eocdOffset = ZipUtils.findZipEndOfCentralDirectoryRecord(inputApk);
|
||||
if (eocdOffset == -1) {
|
||||
throw new ApkParseException("Failed to locate ZIP End of Central Directory");
|
||||
}
|
||||
if (ZipUtils.isZip64EndOfCentralDirectoryLocatorPresent(inputApk, eocdOffset)) {
|
||||
throw new ApkParseException("ZIP64 format not supported");
|
||||
}
|
||||
inputApk.position(eocdOffset);
|
||||
long centralDirSizeLong = ZipUtils.getZipEocdCentralDirectorySizeBytes(inputApk);
|
||||
if (centralDirSizeLong > Integer.MAX_VALUE) {
|
||||
throw new ApkParseException(
|
||||
"ZIP Central Directory size out of range: " + centralDirSizeLong);
|
||||
}
|
||||
int centralDirSize = (int) centralDirSizeLong;
|
||||
long centralDirOffsetLong = ZipUtils.getZipEocdCentralDirectoryOffset(inputApk);
|
||||
if (centralDirOffsetLong > Integer.MAX_VALUE) {
|
||||
throw new ApkParseException(
|
||||
"ZIP Central Directory offset in file out of range: " + centralDirOffsetLong);
|
||||
}
|
||||
int centralDirOffset = (int) centralDirOffsetLong;
|
||||
int expectedEocdOffset = centralDirOffset + centralDirSize;
|
||||
if (expectedEocdOffset < centralDirOffset) {
|
||||
throw new ApkParseException(
|
||||
"ZIP Central Directory extent too large. Offset: " + centralDirOffset
|
||||
+ ", size: " + centralDirSize);
|
||||
}
|
||||
if (eocdOffset != expectedEocdOffset) {
|
||||
throw new ApkParseException(
|
||||
"ZIP Central Directory not immeiately followed by ZIP End of"
|
||||
+ " Central Directory. CD end: " + expectedEocdOffset
|
||||
+ ", EoCD start: " + eocdOffset);
|
||||
}
|
||||
|
||||
// Create ByteBuffers holding the contents of everything before ZIP Central Directory,
|
||||
// ZIP Central Directory, and ZIP End of Central Directory.
|
||||
inputApk.clear();
|
||||
ByteBuffer beforeCentralDir = getByteBuffer(inputApk, centralDirOffset);
|
||||
ByteBuffer centralDir = getByteBuffer(inputApk, eocdOffset - centralDirOffset);
|
||||
// Create a copy of End of Central Directory because we'll need modify its contents later.
|
||||
byte[] eocdBytes = new byte[inputApk.remaining()];
|
||||
inputApk.get(eocdBytes);
|
||||
ByteBuffer eocd = ByteBuffer.wrap(eocdBytes);
|
||||
eocd.order(inputApk.order());
|
||||
|
||||
// Figure which which digests to use for APK contents.
|
||||
Set<Integer> contentDigestAlgorithms = new HashSet<>();
|
||||
for (SignerConfig signerConfig : signerConfigs) {
|
||||
for (int signatureAlgorithm : signerConfig.signatureAlgorithms) {
|
||||
contentDigestAlgorithms.add(
|
||||
getSignatureAlgorithmContentDigestAlgorithm(signatureAlgorithm));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute digests of APK contents.
|
||||
Map<Integer, byte[]> contentDigests; // digest algorithm ID -> digest
|
||||
try {
|
||||
contentDigests =
|
||||
computeContentDigests(
|
||||
contentDigestAlgorithms,
|
||||
new ByteBuffer[] {beforeCentralDir, centralDir, eocd});
|
||||
} catch (DigestException e) {
|
||||
throw new SignatureException("Failed to compute digests of APK", e);
|
||||
}
|
||||
|
||||
// Sign the digests and wrap the signatures and signer info into an APK Signing Block.
|
||||
ByteBuffer apkSigningBlock =
|
||||
ByteBuffer.wrap(generateApkSigningBlock(signerConfigs, contentDigests));
|
||||
|
||||
// Update Central Directory Offset in End of Central Directory Record. Central Directory
|
||||
// follows the APK Signing Block and thus is shifted by the size of the APK Signing Block.
|
||||
centralDirOffset += apkSigningBlock.remaining();
|
||||
eocd.clear();
|
||||
ZipUtils.setZipEocdCentralDirectoryOffset(eocd, centralDirOffset);
|
||||
|
||||
// Follow the Java NIO pattern for ByteBuffer whose contents have been consumed.
|
||||
originalInputApk.position(originalInputApk.limit());
|
||||
|
||||
// Reset positions (to 0) and limits (to capacity) in the ByteBuffers below to follow the
|
||||
// Java NIO pattern for ByteBuffers which are ready for their contents to be read by caller.
|
||||
// Contrary to the name, this does not clear the contents of these ByteBuffer.
|
||||
beforeCentralDir.clear();
|
||||
centralDir.clear();
|
||||
eocd.clear();
|
||||
|
||||
// Insert APK Signing Block immediately before the ZIP Central Directory.
|
||||
return new ByteBuffer[] {
|
||||
beforeCentralDir,
|
||||
apkSigningBlock,
|
||||
centralDir,
|
||||
eocd,
|
||||
};
|
||||
}
|
||||
|
||||
private static Map<Integer, byte[]> computeContentDigests(
|
||||
Set<Integer> digestAlgorithms,
|
||||
ByteBuffer[] contents) throws DigestException {
|
||||
// For each digest algorithm the result is computed as follows:
|
||||
// 1. Each segment of contents is split into consecutive chunks of 1 MB in size.
|
||||
// The final chunk will be shorter iff the length of segment is not a multiple of 1 MB.
|
||||
// No chunks are produced for empty (zero length) segments.
|
||||
// 2. The digest of each chunk is computed over the concatenation of byte 0xa5, the chunk's
|
||||
// length in bytes (uint32 little-endian) and the chunk's contents.
|
||||
// 3. The output digest is computed over the concatenation of the byte 0x5a, the number of
|
||||
// chunks (uint32 little-endian) and the concatenation of digests of chunks of all
|
||||
// segments in-order.
|
||||
|
||||
int chunkCount = 0;
|
||||
for (ByteBuffer input : contents) {
|
||||
chunkCount += getChunkCount(input.remaining(), CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES);
|
||||
}
|
||||
|
||||
final Map<Integer, byte[]> digestsOfChunks = new HashMap<>(digestAlgorithms.size());
|
||||
for (int digestAlgorithm : digestAlgorithms) {
|
||||
int digestOutputSizeBytes = getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm);
|
||||
byte[] concatenationOfChunkCountAndChunkDigests =
|
||||
new byte[5 + chunkCount * digestOutputSizeBytes];
|
||||
concatenationOfChunkCountAndChunkDigests[0] = 0x5a;
|
||||
setUnsignedInt32LittleEngian(
|
||||
chunkCount, concatenationOfChunkCountAndChunkDigests, 1);
|
||||
digestsOfChunks.put(digestAlgorithm, concatenationOfChunkCountAndChunkDigests);
|
||||
}
|
||||
|
||||
int chunkIndex = 0;
|
||||
byte[] chunkContentPrefix = new byte[5];
|
||||
chunkContentPrefix[0] = (byte) 0xa5;
|
||||
// Optimization opportunity: digests of chunks can be computed in parallel.
|
||||
for (ByteBuffer input : contents) {
|
||||
while (input.hasRemaining()) {
|
||||
int chunkSize =
|
||||
Math.min(input.remaining(), CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES);
|
||||
final ByteBuffer chunk = getByteBuffer(input, chunkSize);
|
||||
for (int digestAlgorithm : digestAlgorithms) {
|
||||
String jcaAlgorithmName =
|
||||
getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithm);
|
||||
MessageDigest md;
|
||||
try {
|
||||
md = MessageDigest.getInstance(jcaAlgorithmName);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new DigestException(
|
||||
jcaAlgorithmName + " MessageDigest not supported", e);
|
||||
}
|
||||
// Reset position to 0 and limit to capacity. Position would've been modified
|
||||
// by the preceding iteration of this loop. NOTE: Contrary to the method name,
|
||||
// this does not modify the contents of the chunk.
|
||||
chunk.clear();
|
||||
setUnsignedInt32LittleEngian(chunk.remaining(), chunkContentPrefix, 1);
|
||||
md.update(chunkContentPrefix);
|
||||
md.update(chunk);
|
||||
byte[] concatenationOfChunkCountAndChunkDigests =
|
||||
digestsOfChunks.get(digestAlgorithm);
|
||||
int expectedDigestSizeBytes =
|
||||
getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm);
|
||||
int actualDigestSizeBytes =
|
||||
md.digest(
|
||||
concatenationOfChunkCountAndChunkDigests,
|
||||
5 + chunkIndex * expectedDigestSizeBytes,
|
||||
expectedDigestSizeBytes);
|
||||
if (actualDigestSizeBytes != expectedDigestSizeBytes) {
|
||||
throw new DigestException(
|
||||
"Unexpected output size of " + md.getAlgorithm()
|
||||
+ " digest: " + actualDigestSizeBytes);
|
||||
}
|
||||
}
|
||||
chunkIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
Map<Integer, byte[]> result = new HashMap<>(digestAlgorithms.size());
|
||||
for (Map.Entry<Integer, byte[]> entry : digestsOfChunks.entrySet()) {
|
||||
int digestAlgorithm = entry.getKey();
|
||||
byte[] concatenationOfChunkCountAndChunkDigests = entry.getValue();
|
||||
String jcaAlgorithmName = getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithm);
|
||||
MessageDigest md;
|
||||
try {
|
||||
md = MessageDigest.getInstance(jcaAlgorithmName);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new DigestException(jcaAlgorithmName + " MessageDigest not supported", e);
|
||||
}
|
||||
result.put(digestAlgorithm, md.digest(concatenationOfChunkCountAndChunkDigests));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int getChunkCount(int inputSize, int chunkSize) {
|
||||
return (inputSize + chunkSize - 1) / chunkSize;
|
||||
}
|
||||
|
||||
private static void setUnsignedInt32LittleEngian(int value, byte[] result, int offset) {
|
||||
result[offset] = (byte) (value & 0xff);
|
||||
result[offset + 1] = (byte) ((value >> 8) & 0xff);
|
||||
result[offset + 2] = (byte) ((value >> 16) & 0xff);
|
||||
result[offset + 3] = (byte) ((value >> 24) & 0xff);
|
||||
}
|
||||
|
||||
private static byte[] generateApkSigningBlock(
|
||||
List<SignerConfig> signerConfigs,
|
||||
Map<Integer, byte[]> contentDigests) throws InvalidKeyException, SignatureException {
|
||||
byte[] apkSignatureSchemeV2Block =
|
||||
generateApkSignatureSchemeV2Block(signerConfigs, contentDigests);
|
||||
return generateApkSigningBlock(apkSignatureSchemeV2Block);
|
||||
}
|
||||
|
||||
private static byte[] generateApkSigningBlock(byte[] apkSignatureSchemeV2Block) {
|
||||
// FORMAT:
|
||||
// uint64: size (excluding this field)
|
||||
// repeated ID-value pairs:
|
||||
// uint64: size (excluding this field)
|
||||
// uint32: ID
|
||||
// (size - 4) bytes: value
|
||||
// uint64: size (same as the one above)
|
||||
// uint128: magic
|
||||
|
||||
int resultSize =
|
||||
8 // size
|
||||
+ 8 + 4 + apkSignatureSchemeV2Block.length // v2Block as ID-value pair
|
||||
+ 8 // size
|
||||
+ 16 // magic
|
||||
;
|
||||
ByteBuffer result = ByteBuffer.allocate(resultSize);
|
||||
result.order(ByteOrder.LITTLE_ENDIAN);
|
||||
long blockSizeFieldValue = resultSize - 8;
|
||||
result.putLong(blockSizeFieldValue);
|
||||
|
||||
long pairSizeFieldValue = 4 + apkSignatureSchemeV2Block.length;
|
||||
result.putLong(pairSizeFieldValue);
|
||||
result.putInt(APK_SIGNATURE_SCHEME_V2_BLOCK_ID);
|
||||
result.put(apkSignatureSchemeV2Block);
|
||||
|
||||
result.putLong(blockSizeFieldValue);
|
||||
result.put(APK_SIGNING_BLOCK_MAGIC);
|
||||
|
||||
return result.array();
|
||||
}
|
||||
|
||||
private static byte[] generateApkSignatureSchemeV2Block(
|
||||
List<SignerConfig> signerConfigs,
|
||||
Map<Integer, byte[]> contentDigests) throws InvalidKeyException, SignatureException {
|
||||
// FORMAT:
|
||||
// * length-prefixed sequence of length-prefixed signer blocks.
|
||||
|
||||
List<byte[]> signerBlocks = new ArrayList<>(signerConfigs.size());
|
||||
int signerNumber = 0;
|
||||
for (SignerConfig signerConfig : signerConfigs) {
|
||||
signerNumber++;
|
||||
byte[] signerBlock;
|
||||
try {
|
||||
signerBlock = generateSignerBlock(signerConfig, contentDigests);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new InvalidKeyException("Signer #" + signerNumber + " failed", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new SignatureException("Signer #" + signerNumber + " failed", e);
|
||||
}
|
||||
signerBlocks.add(signerBlock);
|
||||
}
|
||||
|
||||
return encodeAsSequenceOfLengthPrefixedElements(
|
||||
new byte[][] {
|
||||
encodeAsSequenceOfLengthPrefixedElements(signerBlocks),
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] generateSignerBlock(
|
||||
SignerConfig signerConfig,
|
||||
Map<Integer, byte[]> contentDigests) throws InvalidKeyException, SignatureException {
|
||||
if (signerConfig.certificates.isEmpty()) {
|
||||
throw new SignatureException("No certificates configured for signer");
|
||||
}
|
||||
PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey();
|
||||
|
||||
byte[] encodedPublicKey = encodePublicKey(publicKey);
|
||||
|
||||
V2SignatureSchemeBlock.SignedData signedData = new V2SignatureSchemeBlock.SignedData();
|
||||
try {
|
||||
signedData.certificates = encodeCertificates(signerConfig.certificates);
|
||||
} catch (CertificateEncodingException e) {
|
||||
throw new SignatureException("Failed to encode certificates", e);
|
||||
}
|
||||
|
||||
List<Pair<Integer, byte[]>> digests =
|
||||
new ArrayList<>(signerConfig.signatureAlgorithms.size());
|
||||
for (int signatureAlgorithm : signerConfig.signatureAlgorithms) {
|
||||
int contentDigestAlgorithm =
|
||||
getSignatureAlgorithmContentDigestAlgorithm(signatureAlgorithm);
|
||||
byte[] contentDigest = contentDigests.get(contentDigestAlgorithm);
|
||||
if (contentDigest == null) {
|
||||
throw new RuntimeException(
|
||||
getContentDigestAlgorithmJcaDigestAlgorithm(contentDigestAlgorithm)
|
||||
+ " content digest for "
|
||||
+ getSignatureAlgorithmJcaSignatureAlgorithm(signatureAlgorithm)
|
||||
+ " not computed");
|
||||
}
|
||||
digests.add(Pair.create(signatureAlgorithm, contentDigest));
|
||||
}
|
||||
signedData.digests = digests;
|
||||
|
||||
V2SignatureSchemeBlock.Signer signer = new V2SignatureSchemeBlock.Signer();
|
||||
// FORMAT:
|
||||
// * length-prefixed sequence of length-prefixed digests:
|
||||
// * uint32: signature algorithm ID
|
||||
// * length-prefixed bytes: digest of contents
|
||||
// * length-prefixed sequence of certificates:
|
||||
// * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded).
|
||||
// * length-prefixed sequence of length-prefixed additional attributes:
|
||||
// * uint32: ID
|
||||
// * (length - 4) bytes: value
|
||||
signer.signedData = encodeAsSequenceOfLengthPrefixedElements(new byte[][] {
|
||||
encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(signedData.digests),
|
||||
encodeAsSequenceOfLengthPrefixedElements(signedData.certificates),
|
||||
// additional attributes
|
||||
new byte[0],
|
||||
});
|
||||
signer.publicKey = encodedPublicKey;
|
||||
signer.signatures = new ArrayList<>();
|
||||
for (int signatureAlgorithm : signerConfig.signatureAlgorithms) {
|
||||
Pair<String, ? extends AlgorithmParameterSpec> signatureParams =
|
||||
getSignatureAlgorithmJcaSignatureAlgorithm(signatureAlgorithm);
|
||||
String jcaSignatureAlgorithm = signatureParams.getFirst();
|
||||
AlgorithmParameterSpec jcaSignatureAlgorithmParams = signatureParams.getSecond();
|
||||
byte[] signatureBytes;
|
||||
try {
|
||||
Signature signature = Signature.getInstance(jcaSignatureAlgorithm);
|
||||
signature.initSign(signerConfig.privateKey);
|
||||
if (jcaSignatureAlgorithmParams != null) {
|
||||
signature.setParameter(jcaSignatureAlgorithmParams);
|
||||
}
|
||||
signature.update(signer.signedData);
|
||||
signatureBytes = signature.sign();
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new InvalidKeyException("Failed sign using " + jcaSignatureAlgorithm, e);
|
||||
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException
|
||||
| SignatureException e) {
|
||||
throw new SignatureException("Failed sign using " + jcaSignatureAlgorithm, e);
|
||||
}
|
||||
|
||||
try {
|
||||
Signature signature = Signature.getInstance(jcaSignatureAlgorithm);
|
||||
signature.initVerify(publicKey);
|
||||
if (jcaSignatureAlgorithmParams != null) {
|
||||
signature.setParameter(jcaSignatureAlgorithmParams);
|
||||
}
|
||||
signature.update(signer.signedData);
|
||||
if (!signature.verify(signatureBytes)) {
|
||||
throw new SignatureException("Signature did not verify");
|
||||
}
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new InvalidKeyException("Failed to verify generated " + jcaSignatureAlgorithm
|
||||
+ " signature using public key from certificate", e);
|
||||
} catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException
|
||||
| SignatureException e) {
|
||||
throw new SignatureException("Failed to verify generated " + jcaSignatureAlgorithm
|
||||
+ " signature using public key from certificate", e);
|
||||
}
|
||||
|
||||
signer.signatures.add(Pair.create(signatureAlgorithm, signatureBytes));
|
||||
}
|
||||
|
||||
// FORMAT:
|
||||
// * length-prefixed signed data
|
||||
// * length-prefixed sequence of length-prefixed signatures:
|
||||
// * uint32: signature algorithm ID
|
||||
// * length-prefixed bytes: signature of signed data
|
||||
// * length-prefixed bytes: public key (X.509 SubjectPublicKeyInfo, ASN.1 DER encoded)
|
||||
return encodeAsSequenceOfLengthPrefixedElements(
|
||||
new byte[][] {
|
||||
signer.signedData,
|
||||
encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(
|
||||
signer.signatures),
|
||||
signer.publicKey,
|
||||
});
|
||||
}
|
||||
|
||||
private static final class V2SignatureSchemeBlock {
|
||||
private static final class Signer {
|
||||
public byte[] signedData;
|
||||
public List<Pair<Integer, byte[]>> signatures;
|
||||
public byte[] publicKey;
|
||||
}
|
||||
|
||||
private static final class SignedData {
|
||||
public List<Pair<Integer, byte[]>> digests;
|
||||
public List<byte[]> certificates;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encodePublicKey(PublicKey publicKey) throws InvalidKeyException {
|
||||
byte[] encodedPublicKey = null;
|
||||
if ("X.509".equals(publicKey.getFormat())) {
|
||||
encodedPublicKey = publicKey.getEncoded();
|
||||
}
|
||||
if (encodedPublicKey == null) {
|
||||
try {
|
||||
encodedPublicKey =
|
||||
KeyFactory.getInstance(publicKey.getAlgorithm())
|
||||
.getKeySpec(publicKey, X509EncodedKeySpec.class)
|
||||
.getEncoded();
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new InvalidKeyException(
|
||||
"Failed to obtain X.509 encoded form of public key " + publicKey
|
||||
+ " of class " + publicKey.getClass().getName(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
if ((encodedPublicKey == null) || (encodedPublicKey.length == 0)) {
|
||||
throw new InvalidKeyException(
|
||||
"Failed to obtain X.509 encoded form of public key " + publicKey
|
||||
+ " of class " + publicKey.getClass().getName());
|
||||
}
|
||||
return encodedPublicKey;
|
||||
}
|
||||
|
||||
public static List<byte[]> encodeCertificates(List<X509Certificate> certificates)
|
||||
throws CertificateEncodingException {
|
||||
List<byte[]> result = new ArrayList<>();
|
||||
for (X509Certificate certificate : certificates) {
|
||||
result.add(certificate.getEncoded());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static byte[] encodeAsSequenceOfLengthPrefixedElements(List<byte[]> sequence) {
|
||||
return encodeAsSequenceOfLengthPrefixedElements(
|
||||
sequence.toArray(new byte[sequence.size()][]));
|
||||
}
|
||||
|
||||
private static byte[] encodeAsSequenceOfLengthPrefixedElements(byte[][] sequence) {
|
||||
int payloadSize = 0;
|
||||
for (byte[] element : sequence) {
|
||||
payloadSize += 4 + element.length;
|
||||
}
|
||||
ByteBuffer result = ByteBuffer.allocate(payloadSize);
|
||||
result.order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (byte[] element : sequence) {
|
||||
result.putInt(element.length);
|
||||
result.put(element);
|
||||
}
|
||||
return result.array();
|
||||
}
|
||||
|
||||
private static byte[] encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(
|
||||
List<Pair<Integer, byte[]>> sequence) {
|
||||
int resultSize = 0;
|
||||
for (Pair<Integer, byte[]> element : sequence) {
|
||||
resultSize += 12 + element.getSecond().length;
|
||||
}
|
||||
ByteBuffer result = ByteBuffer.allocate(resultSize);
|
||||
result.order(ByteOrder.LITTLE_ENDIAN);
|
||||
for (Pair<Integer, byte[]> element : sequence) {
|
||||
byte[] second = element.getSecond();
|
||||
result.putInt(8 + second.length);
|
||||
result.putInt(element.getFirst());
|
||||
result.putInt(second.length);
|
||||
result.put(second);
|
||||
}
|
||||
return result.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative <em>get</em> method for reading {@code size} number of bytes from the current
|
||||
* position of this buffer.
|
||||
*
|
||||
* <p>This method reads the next {@code size} bytes at this buffer's current position,
|
||||
* returning them as a {@code ByteBuffer} with start set to 0, limit and capacity set to
|
||||
* {@code size}, byte order set to this buffer's byte order; and then increments the position by
|
||||
* {@code size}.
|
||||
*/
|
||||
private static ByteBuffer getByteBuffer(ByteBuffer source, int size) {
|
||||
if (size < 0) {
|
||||
throw new IllegalArgumentException("size: " + size);
|
||||
}
|
||||
int originalLimit = source.limit();
|
||||
int position = source.position();
|
||||
int limit = position + size;
|
||||
if ((limit < position) || (limit > originalLimit)) {
|
||||
throw new BufferUnderflowException();
|
||||
}
|
||||
source.limit(limit);
|
||||
try {
|
||||
ByteBuffer result = source.slice();
|
||||
result.order(source.order());
|
||||
source.position(limit);
|
||||
return result;
|
||||
} finally {
|
||||
source.limit(originalLimit);
|
||||
}
|
||||
}
|
||||
|
||||
private static Pair<String, ? extends AlgorithmParameterSpec>
|
||||
getSignatureAlgorithmJcaSignatureAlgorithm(int sigAlgorithm) {
|
||||
switch (sigAlgorithm) {
|
||||
case SIGNATURE_RSA_PSS_WITH_SHA256:
|
||||
return Pair.create(
|
||||
"SHA256withRSA/PSS",
|
||||
new PSSParameterSpec(
|
||||
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 256 / 8, 1));
|
||||
case SIGNATURE_RSA_PSS_WITH_SHA512:
|
||||
return Pair.create(
|
||||
"SHA512withRSA/PSS",
|
||||
new PSSParameterSpec(
|
||||
"SHA-512", "MGF1", MGF1ParameterSpec.SHA512, 512 / 8, 1));
|
||||
case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256:
|
||||
return Pair.create("SHA256withRSA", null);
|
||||
case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512:
|
||||
return Pair.create("SHA512withRSA", null);
|
||||
case SIGNATURE_ECDSA_WITH_SHA256:
|
||||
return Pair.create("SHA256withECDSA", null);
|
||||
case SIGNATURE_ECDSA_WITH_SHA512:
|
||||
return Pair.create("SHA512withECDSA", null);
|
||||
case SIGNATURE_DSA_WITH_SHA256:
|
||||
return Pair.create("SHA256withDSA", null);
|
||||
case SIGNATURE_DSA_WITH_SHA512:
|
||||
return Pair.create("SHA512withDSA", null);
|
||||
default:
|
||||
throw new IllegalArgumentException(
|
||||
"Unknown signature algorithm: 0x"
|
||||
+ Long.toHexString(sigAlgorithm & 0xffffffff));
|
||||
}
|
||||
}
|
||||
|
||||
private static int getSignatureAlgorithmContentDigestAlgorithm(int sigAlgorithm) {
|
||||
switch (sigAlgorithm) {
|
||||
case SIGNATURE_RSA_PSS_WITH_SHA256:
|
||||
case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256:
|
||||
case SIGNATURE_ECDSA_WITH_SHA256:
|
||||
case SIGNATURE_DSA_WITH_SHA256:
|
||||
return CONTENT_DIGEST_CHUNKED_SHA256;
|
||||
case SIGNATURE_RSA_PSS_WITH_SHA512:
|
||||
case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512:
|
||||
case SIGNATURE_ECDSA_WITH_SHA512:
|
||||
case SIGNATURE_DSA_WITH_SHA512:
|
||||
return CONTENT_DIGEST_CHUNKED_SHA512;
|
||||
default:
|
||||
throw new IllegalArgumentException(
|
||||
"Unknown signature algorithm: 0x"
|
||||
+ Long.toHexString(sigAlgorithm & 0xffffffff));
|
||||
}
|
||||
}
|
||||
|
||||
private static String getContentDigestAlgorithmJcaDigestAlgorithm(int digestAlgorithm) {
|
||||
switch (digestAlgorithm) {
|
||||
case CONTENT_DIGEST_CHUNKED_SHA256:
|
||||
return "SHA-256";
|
||||
case CONTENT_DIGEST_CHUNKED_SHA512:
|
||||
return "SHA-512";
|
||||
default:
|
||||
throw new IllegalArgumentException(
|
||||
"Unknown content digest algorthm: " + digestAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
private static int getContentDigestAlgorithmOutputSizeBytes(int digestAlgorithm) {
|
||||
switch (digestAlgorithm) {
|
||||
case CONTENT_DIGEST_CHUNKED_SHA256:
|
||||
return 256 / 8;
|
||||
case CONTENT_DIGEST_CHUNKED_SHA512:
|
||||
return 512 / 8;
|
||||
default:
|
||||
throw new IllegalArgumentException(
|
||||
"Unknown content digest algorthm: " + digestAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that APK file could not be parsed.
|
||||
*/
|
||||
public static class ApkParseException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public ApkParseException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ApkParseException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair of two elements.
|
||||
*/
|
||||
private static class Pair<A, B> {
|
||||
private final A mFirst;
|
||||
private final B mSecond;
|
||||
|
||||
private Pair(A first, B second) {
|
||||
mFirst = first;
|
||||
mSecond = second;
|
||||
}
|
||||
|
||||
public static <A, B> Pair<A, B> create(A first, B second) {
|
||||
return new Pair<>(first, second);
|
||||
}
|
||||
|
||||
public A getFirst() {
|
||||
return mFirst;
|
||||
}
|
||||
|
||||
public B getSecond() {
|
||||
return mSecond;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
final int prime = 31;
|
||||
int result = 1;
|
||||
result = prime * result + ((mFirst == null) ? 0 : mFirst.hashCode());
|
||||
result = prime * result + ((mSecond == null) ? 0 : mSecond.hashCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null) {
|
||||
return false;
|
||||
}
|
||||
if (getClass() != obj.getClass()) {
|
||||
return false;
|
||||
}
|
||||
@SuppressWarnings("rawtypes")
|
||||
Pair other = (Pair) obj;
|
||||
if (mFirst == null) {
|
||||
if (other.mFirst != null) {
|
||||
return false;
|
||||
}
|
||||
} else if (!mFirst.equals(other.mFirst)) {
|
||||
return false;
|
||||
}
|
||||
if (mSecond == null) {
|
||||
return other.mSecond == null;
|
||||
} else return mSecond.equals(other.mSecond);
|
||||
}
|
||||
}
|
||||
}
|
@@ -24,7 +24,7 @@ import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
class CryptoUtils {
|
||||
public class CryptoUtils {
|
||||
|
||||
static final Map<String, String> ID_TO_ALG;
|
||||
static final Map<String, String> ALG_TO_ID;
|
||||
@@ -81,7 +81,7 @@ class CryptoUtils {
|
||||
return new AlgorithmIdentifier(new ASN1ObjectIdentifier(id));
|
||||
}
|
||||
|
||||
static X509Certificate readCertificate(InputStream input)
|
||||
public static X509Certificate readCertificate(InputStream input)
|
||||
throws IOException, GeneralSecurityException {
|
||||
try {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
@@ -92,7 +92,7 @@ class CryptoUtils {
|
||||
}
|
||||
|
||||
/** Read a PKCS#8 format private key. */
|
||||
static PrivateKey readPrivateKey(InputStream input)
|
||||
public static PrivateKey readPrivateKey(InputStream input)
|
||||
throws IOException, GeneralSecurityException {
|
||||
try {
|
||||
ByteArrayStream buf = new ByteArrayStream();
|
174
app/signing/src/main/java/com/topjohnwu/signing/JarMap.java
Normal file
174
app/signing/src/main/java/com/topjohnwu/signing/JarMap.java
Normal file
@@ -0,0 +1,174 @@
|
||||
package com.topjohnwu.signing;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.jar.JarInputStream;
|
||||
import java.util.jar.Manifest;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
public abstract class JarMap implements Closeable {
|
||||
|
||||
LinkedHashMap<String, JarEntry> entryMap;
|
||||
|
||||
public static JarMap open(String file) throws IOException {
|
||||
return new FileMap(new File(file), true, ZipFile.OPEN_READ);
|
||||
}
|
||||
|
||||
public static JarMap open(File file, boolean verify) throws IOException {
|
||||
return new FileMap(file, verify, ZipFile.OPEN_READ);
|
||||
}
|
||||
|
||||
public static JarMap open(String file, boolean verify) throws IOException {
|
||||
return new FileMap(new File(file), verify, ZipFile.OPEN_READ);
|
||||
}
|
||||
|
||||
public static JarMap open(InputStream is, boolean verify) throws IOException {
|
||||
return new StreamMap(is, verify);
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return null;
|
||||
}
|
||||
|
||||
public abstract Manifest getManifest() throws IOException;
|
||||
|
||||
public InputStream getInputStream(ZipEntry ze) throws IOException {
|
||||
JarMapEntry e = getMapEntry(ze.getName());
|
||||
return e != null ? e.data.getInputStream() : null;
|
||||
}
|
||||
|
||||
public OutputStream getOutputStream(ZipEntry ze) {
|
||||
if (entryMap == null)
|
||||
entryMap = new LinkedHashMap<>();
|
||||
JarMapEntry e = new JarMapEntry(ze.getName());
|
||||
entryMap.put(ze.getName(), e);
|
||||
return e.data;
|
||||
}
|
||||
|
||||
public byte[] getRawData(ZipEntry ze) throws IOException {
|
||||
JarMapEntry e = getMapEntry(ze.getName());
|
||||
return e != null ? e.data.toByteArray() : null;
|
||||
}
|
||||
|
||||
public abstract Enumeration<JarEntry> entries();
|
||||
|
||||
public final ZipEntry getEntry(String name) {
|
||||
return getJarEntry(name);
|
||||
}
|
||||
|
||||
public JarEntry getJarEntry(String name) {
|
||||
return getMapEntry(name);
|
||||
}
|
||||
|
||||
JarMapEntry getMapEntry(String name) {
|
||||
JarMapEntry e = null;
|
||||
if (entryMap != null)
|
||||
e = (JarMapEntry) entryMap.get(name);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static class FileMap extends JarMap {
|
||||
|
||||
private JarFile jarFile;
|
||||
|
||||
FileMap(File file, boolean verify, int mode) throws IOException {
|
||||
jarFile = new JarFile(file, verify, mode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getFile() {
|
||||
return new File(jarFile.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Manifest getManifest() throws IOException {
|
||||
return jarFile.getManifest();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream(ZipEntry ze) throws IOException {
|
||||
InputStream is = super.getInputStream(ze);
|
||||
return is != null ? is : jarFile.getInputStream(ze);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getRawData(ZipEntry ze) throws IOException {
|
||||
byte[] b = super.getRawData(ze);
|
||||
if (b != null)
|
||||
return b;
|
||||
ByteArrayStream bytes = new ByteArrayStream();
|
||||
bytes.readFrom(jarFile.getInputStream(ze));
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<JarEntry> entries() {
|
||||
return jarFile.entries();
|
||||
}
|
||||
|
||||
@Override
|
||||
public JarEntry getJarEntry(String name) {
|
||||
JarEntry e = getMapEntry(name);
|
||||
return e != null ? e : jarFile.getJarEntry(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
jarFile.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static class StreamMap extends JarMap {
|
||||
|
||||
private JarInputStream jis;
|
||||
|
||||
StreamMap(InputStream is, boolean verify) throws IOException {
|
||||
jis = new JarInputStream(is, verify);
|
||||
entryMap = new LinkedHashMap<>();
|
||||
JarEntry entry;
|
||||
while ((entry = jis.getNextJarEntry()) != null) {
|
||||
entryMap.put(entry.getName(), new JarMapEntry(entry, jis));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Manifest getManifest() {
|
||||
return jis.getManifest();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<JarEntry> entries() {
|
||||
return Collections.enumeration(entryMap.values());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
jis.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static class JarMapEntry extends JarEntry {
|
||||
|
||||
ByteArrayStream data;
|
||||
|
||||
JarMapEntry(JarEntry je, InputStream is) {
|
||||
super(je);
|
||||
data = new ByteArrayStream();
|
||||
data.readFrom(is);
|
||||
}
|
||||
|
||||
JarMapEntry(String s) {
|
||||
super(s);
|
||||
data = new ByteArrayStream();
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,9 +1,8 @@
|
||||
package com.topjohnwu.signing;
|
||||
|
||||
import org.bouncycastle.asn1.ASN1Encoding;
|
||||
import org.bouncycastle.asn1.ASN1InputStream;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.DEROutputStream;
|
||||
import org.bouncycastle.asn1.cms.CMSObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.ASN1OutputStream;
|
||||
import org.bouncycastle.cert.jcajce.JcaCertStore;
|
||||
import org.bouncycastle.cms.CMSException;
|
||||
import org.bouncycastle.cms.CMSProcessableByteArray;
|
||||
@@ -11,35 +10,38 @@ import org.bouncycastle.cms.CMSSignedData;
|
||||
import org.bouncycastle.cms.CMSSignedDataGenerator;
|
||||
import org.bouncycastle.cms.CMSTypedData;
|
||||
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
|
||||
import org.bouncycastle.util.encoders.Base64;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateEncodingException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TimeZone;
|
||||
import java.util.TreeMap;
|
||||
import java.util.jar.Attributes;
|
||||
import java.util.jar.JarEntry;
|
||||
@@ -49,87 +51,28 @@ import java.util.jar.Manifest;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/*
|
||||
* Modified from from AOSP(Marshmallow) SignAPK.java
|
||||
* */
|
||||
|
||||
public class SignAPK {
|
||||
* Modified from from AOSP
|
||||
* https://android.googlesource.com/platform/build/+/refs/tags/android-7.1.2_r39/tools/signapk/src/com/android/signapk/SignApk.java
|
||||
* */
|
||||
|
||||
public class SignApk {
|
||||
private static final String CERT_SF_NAME = "META-INF/CERT.SF";
|
||||
private static final String CERT_SIG_NAME = "META-INF/CERT.%s";
|
||||
private static final String CERT_SF_MULTI_NAME = "META-INF/CERT%d.SF";
|
||||
private static final String CERT_SIG_MULTI_NAME = "META-INF/CERT%d.%s";
|
||||
|
||||
// bitmasks for which hash algorithms we need the manifest to include.
|
||||
private static final int USE_SHA1 = 1;
|
||||
private static final int USE_SHA256 = 2;
|
||||
|
||||
public static void sign(JarMap input, OutputStream output) throws Exception {
|
||||
sign(SignAPK.class.getResourceAsStream("/keys/testkey.x509.pem"),
|
||||
SignAPK.class.getResourceAsStream("/keys/testkey.pk8"), input, output);
|
||||
}
|
||||
|
||||
public static void sign(InputStream certIs, InputStream keyIs,
|
||||
JarMap input, OutputStream output) throws Exception {
|
||||
X509Certificate cert = CryptoUtils.readCertificate(certIs);
|
||||
PrivateKey key = CryptoUtils.readPrivateKey(keyIs);
|
||||
sign(cert, key, input, output);
|
||||
}
|
||||
|
||||
public static void sign(InputStream jks, String keyStorePass, String alias, String keyPass,
|
||||
JarMap input, OutputStream output) throws Exception {
|
||||
KeyStore ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jks, keyStorePass.toCharArray());
|
||||
X509Certificate cert = (X509Certificate) ks.getCertificate(alias);
|
||||
PrivateKey key = (PrivateKey) ks.getKey(alias, keyPass.toCharArray());
|
||||
sign(cert, key, input, output);
|
||||
}
|
||||
|
||||
private static void sign(X509Certificate cert, PrivateKey key,
|
||||
JarMap input, OutputStream output) throws Exception {
|
||||
File temp1 = File.createTempFile("signAPK", null);
|
||||
File temp2 = File.createTempFile("signAPK", null);
|
||||
|
||||
try {
|
||||
try (OutputStream out = new BufferedOutputStream(new FileOutputStream(temp1))) {
|
||||
sign(cert, key, input, out, false);
|
||||
}
|
||||
|
||||
ZipAdjust.adjust(temp1, temp2);
|
||||
|
||||
try (JarMap map = new JarMap(temp2, false)) {
|
||||
sign(cert, key, map, output, true);
|
||||
}
|
||||
} finally {
|
||||
temp1.delete();
|
||||
temp2.delete();
|
||||
}
|
||||
}
|
||||
|
||||
private static void sign(X509Certificate cert, PrivateKey key,
|
||||
JarMap input, OutputStream output, boolean minSign) throws Exception {
|
||||
int hashes = 0;
|
||||
hashes |= getDigestAlgorithm(cert);
|
||||
|
||||
// Set the ZIP file timestamp to the starting valid time
|
||||
// of the 0th certificate plus one hour (to match what
|
||||
// we've historically done).
|
||||
long timestamp = cert.getNotBefore().getTime() + 3600L * 1000;
|
||||
|
||||
if (minSign) {
|
||||
signWholeFile(input.getFile(), cert, key, output);
|
||||
} else {
|
||||
JarOutputStream outputJar = new JarOutputStream(output);
|
||||
// For signing .apks, use the maximum compression to make
|
||||
// them as small as possible (since they live forever on
|
||||
// the system partition). For OTA packages, use the
|
||||
// default compression level, which is much much faster
|
||||
// and produces output that is only a tiny bit larger
|
||||
// (~0.1% on full OTA packages I tested).
|
||||
outputJar.setLevel(9);
|
||||
Manifest manifest = addDigestsToManifest(input, hashes);
|
||||
copyFiles(manifest, input, outputJar, timestamp, 4);
|
||||
signFile(manifest, input, cert, key, outputJar);
|
||||
outputJar.close();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Digest algorithm used when signing the APK using APK Signature Scheme v2.
|
||||
*/
|
||||
private static final String APK_SIG_SCHEME_V2_DIGEST_ALGORITHM = "SHA-256";
|
||||
// Files matching this pattern are not copied to the output.
|
||||
private static final Pattern stripPattern =
|
||||
Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA|EC)|com/android/otacert))|(" +
|
||||
Pattern.quote(JarFile.MANIFEST_NAME) + ")$");
|
||||
|
||||
/**
|
||||
* Return one of USE_SHA1 or USE_SHA256 according to the signature
|
||||
@@ -137,7 +80,7 @@ public class SignAPK {
|
||||
*/
|
||||
private static int getDigestAlgorithm(X509Certificate cert) {
|
||||
String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
|
||||
if (sigAlg.startsWith("SHA1WITHRSA") || sigAlg.startsWith("MD5WITHRSA")) {
|
||||
if ("SHA1WITHRSA".equals(sigAlg) || "MD5WITHRSA".equals(sigAlg)) {
|
||||
return USE_SHA1;
|
||||
} else if (sigAlg.startsWith("SHA256WITH")) {
|
||||
return USE_SHA256;
|
||||
@@ -146,9 +89,11 @@ public class SignAPK {
|
||||
"\" in cert [" + cert.getSubjectDN());
|
||||
}
|
||||
}
|
||||
/** Returns the expected signature algorithm for this key type. */
|
||||
|
||||
/**
|
||||
* Returns the expected signature algorithm for this key type.
|
||||
*/
|
||||
private static String getSignatureAlgorithm(X509Certificate cert) {
|
||||
String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US);
|
||||
String keyType = cert.getPublicKey().getAlgorithm().toUpperCase(Locale.US);
|
||||
if ("RSA".equalsIgnoreCase(keyType)) {
|
||||
if (getDigestAlgorithm(cert) == USE_SHA256) {
|
||||
@@ -162,10 +107,6 @@ public class SignAPK {
|
||||
throw new IllegalArgumentException("unsupported key type: " + keyType);
|
||||
}
|
||||
}
|
||||
// Files matching this pattern are not copied to the output.
|
||||
private static Pattern stripPattern =
|
||||
Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA|EC)|com/android/otacert))|(" +
|
||||
Pattern.quote(JarFile.MANIFEST_NAME) + ")$");
|
||||
|
||||
/**
|
||||
* Add the hash(es) of every file to the manifest, creating it if
|
||||
@@ -182,6 +123,7 @@ public class SignAPK {
|
||||
main.putValue("Manifest-Version", "1.0");
|
||||
main.putValue("Created-By", "1.0 (Android SignApk)");
|
||||
}
|
||||
|
||||
MessageDigest md_sha1 = null;
|
||||
MessageDigest md_sha256 = null;
|
||||
if ((hashes & USE_SHA1) != 0) {
|
||||
@@ -190,32 +132,51 @@ public class SignAPK {
|
||||
if ((hashes & USE_SHA256) != 0) {
|
||||
md_sha256 = MessageDigest.getInstance("SHA256");
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int num;
|
||||
|
||||
// We sort the input entries by name, and add them to the
|
||||
// output manifest in sorted order. We expect that the output
|
||||
// map will be deterministic.
|
||||
TreeMap<String, JarEntry> byName = new TreeMap<String, JarEntry>();
|
||||
|
||||
TreeMap<String, JarEntry> byName = new TreeMap<>();
|
||||
|
||||
for (Enumeration<JarEntry> e = jar.entries(); e.hasMoreElements(); ) {
|
||||
JarEntry entry = e.nextElement();
|
||||
byName.put(entry.getName(), entry);
|
||||
}
|
||||
for (JarEntry entry: byName.values()) {
|
||||
|
||||
for (JarEntry entry : byName.values()) {
|
||||
String name = entry.getName();
|
||||
if (!entry.isDirectory() &&
|
||||
(stripPattern == null || !stripPattern.matcher(name).matches())) {
|
||||
if (!entry.isDirectory() && !stripPattern.matcher(name).matches()) {
|
||||
InputStream data = jar.getInputStream(entry);
|
||||
while ((num = data.read(buffer)) > 0) {
|
||||
if (md_sha1 != null) md_sha1.update(buffer, 0, num);
|
||||
if (md_sha256 != null) md_sha256.update(buffer, 0, num);
|
||||
}
|
||||
|
||||
Attributes attr = null;
|
||||
if (input != null) attr = input.getAttributes(name);
|
||||
attr = attr != null ? new Attributes(attr) : new Attributes();
|
||||
// Remove any previously computed digests from this entry's attributes.
|
||||
for (Iterator<Object> i = attr.keySet().iterator(); i.hasNext(); ) {
|
||||
Object key = i.next();
|
||||
if (!(key instanceof Attributes.Name)) {
|
||||
continue;
|
||||
}
|
||||
String attributeNameLowerCase =
|
||||
key.toString().toLowerCase(Locale.US);
|
||||
if (attributeNameLowerCase.endsWith("-digest")) {
|
||||
i.remove();
|
||||
}
|
||||
}
|
||||
// Add SHA-1 digest if requested
|
||||
if (md_sha1 != null) {
|
||||
attr.putValue("SHA1-Digest",
|
||||
new String(Base64.encode(md_sha1.digest()), "ASCII"));
|
||||
}
|
||||
// Add SHA-256 digest if requested
|
||||
if (md_sha256 != null) {
|
||||
attr.putValue("SHA-256-Digest",
|
||||
new String(Base64.encode(md_sha256.digest()), "ASCII"));
|
||||
@@ -223,33 +184,13 @@ public class SignAPK {
|
||||
output.getEntries().put(name, attr);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Write to another stream and track how many bytes have been
|
||||
* written.
|
||||
/**
|
||||
* Write a .SF file with a digest of the specified manifest.
|
||||
*/
|
||||
private static class CountOutputStream extends FilterOutputStream {
|
||||
private int mCount;
|
||||
public CountOutputStream(OutputStream out) {
|
||||
super(out);
|
||||
mCount = 0;
|
||||
}
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
super.write(b);
|
||||
mCount++;
|
||||
}
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
super.write(b, off, len);
|
||||
mCount += len;
|
||||
}
|
||||
public int size() {
|
||||
return mCount;
|
||||
}
|
||||
}
|
||||
/** Write a .SF file with a digest of the specified manifest. */
|
||||
private static void writeSignatureFile(Manifest manifest, OutputStream out,
|
||||
int hash)
|
||||
throws IOException, GeneralSecurityException {
|
||||
@@ -257,16 +198,25 @@ public class SignAPK {
|
||||
Attributes main = sf.getMainAttributes();
|
||||
main.putValue("Signature-Version", "1.0");
|
||||
main.putValue("Created-By", "1.0 (Android SignApk)");
|
||||
MessageDigest md = MessageDigest.getInstance(
|
||||
hash == USE_SHA256 ? "SHA256" : "SHA1");
|
||||
PrintStream print = new PrintStream(
|
||||
new DigestOutputStream(new ByteArrayOutputStream(), md),
|
||||
// Add APK Signature Scheme v2 signature stripping protection.
|
||||
// This attribute indicates that this APK is supposed to have been signed using one or
|
||||
// more APK-specific signature schemes in addition to the standard JAR signature scheme
|
||||
// used by this code. APK signature verifier should reject the APK if it does not
|
||||
// contain a signature for the signature scheme the verifier prefers out of this set.
|
||||
main.putValue(
|
||||
ApkSignerV2.SF_ATTRIBUTE_ANDROID_APK_SIGNED_NAME,
|
||||
ApkSignerV2.SF_ATTRIBUTE_ANDROID_APK_SIGNED_VALUE);
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance(hash == USE_SHA256 ? "SHA256" : "SHA1");
|
||||
PrintStream print = new PrintStream(new DigestOutputStream(new ByteArrayOutputStream(), md),
|
||||
true, "UTF-8");
|
||||
|
||||
// Digest of the entire manifest
|
||||
manifest.write(print);
|
||||
print.flush();
|
||||
main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest" : "SHA1-Digest-Manifest",
|
||||
new String(Base64.encode(md.digest()), "ASCII"));
|
||||
|
||||
Map<String, Attributes> entries = manifest.getEntries();
|
||||
for (Map.Entry<String, Attributes> entry : entries.entrySet()) {
|
||||
// Digest of the manifest stanza for this entry.
|
||||
@@ -276,13 +226,16 @@ public class SignAPK {
|
||||
}
|
||||
print.print("\r\n");
|
||||
print.flush();
|
||||
|
||||
Attributes sfAttr = new Attributes();
|
||||
sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest" : "SHA1-Digest-Manifest",
|
||||
sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest" : "SHA1-Digest",
|
||||
new String(Base64.encode(md.digest()), "ASCII"));
|
||||
sf.getEntries().put(entry.getKey(), sfAttr);
|
||||
}
|
||||
|
||||
CountOutputStream cout = new CountOutputStream(out);
|
||||
sf.write(cout);
|
||||
|
||||
// A bug in the java.util.jar implementation of Android platforms
|
||||
// up to version 1.6 will cause a spurious IOException to be thrown
|
||||
// if the length of the signature file is a multiple of 1024 bytes.
|
||||
@@ -292,10 +245,12 @@ public class SignAPK {
|
||||
cout.write('\n');
|
||||
}
|
||||
}
|
||||
/** Sign data and write the digital signature to 'out'. */
|
||||
|
||||
/**
|
||||
* Sign data and write the digital signature to 'out'.
|
||||
*/
|
||||
private static void writeSignatureBlock(
|
||||
CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey,
|
||||
OutputStream out)
|
||||
CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, OutputStream out)
|
||||
throws IOException,
|
||||
CertificateEncodingException,
|
||||
OperatorCreationException,
|
||||
@@ -303,20 +258,24 @@ public class SignAPK {
|
||||
ArrayList<X509Certificate> certList = new ArrayList<>(1);
|
||||
certList.add(publicKey);
|
||||
JcaCertStore certs = new JcaCertStore(certList);
|
||||
|
||||
CMSSignedDataGenerator gen = new CMSSignedDataGenerator();
|
||||
ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(publicKey))
|
||||
.build(privateKey);
|
||||
gen.addSignerInfoGenerator(
|
||||
new JcaSignerInfoGeneratorBuilder(
|
||||
new JcaDigestCalculatorProviderBuilder().build())
|
||||
new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build())
|
||||
.setDirectSignature(true)
|
||||
.build(signer, publicKey));
|
||||
.build(signer, publicKey)
|
||||
);
|
||||
gen.addCertificates(certs);
|
||||
CMSSignedData sigData = gen.generate(data, false);
|
||||
ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded());
|
||||
DEROutputStream dos = new DEROutputStream(out);
|
||||
dos.writeObject(asn1.readObject());
|
||||
|
||||
try (ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded())) {
|
||||
ASN1OutputStream dos = ASN1OutputStream.create(out, ASN1Encoding.DER);
|
||||
dos.writeObject(asn1.readObject());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy all the files in a manifest from input to output. We set
|
||||
* the modification times in the output to a fixed time, so as to
|
||||
@@ -324,27 +283,37 @@ public class SignAPK {
|
||||
* more efficient.
|
||||
*/
|
||||
private static void copyFiles(Manifest manifest, JarMap in, JarOutputStream out,
|
||||
long timestamp, int alignment) throws IOException {
|
||||
long timestamp, int defaultAlignment) throws IOException {
|
||||
byte[] buffer = new byte[4096];
|
||||
int num;
|
||||
|
||||
Map<String, Attributes> entries = manifest.getEntries();
|
||||
ArrayList<String> names = new ArrayList<>(entries.keySet());
|
||||
Collections.sort(names);
|
||||
|
||||
boolean firstEntry = true;
|
||||
long offset = 0L;
|
||||
|
||||
// We do the copy in two passes -- first copying all the
|
||||
// entries that are STORED, then copying all the entries that
|
||||
// have any other compression flag (which in practice means
|
||||
// DEFLATED). This groups all the stored entries together at
|
||||
// the start of the file and makes it easier to do alignment
|
||||
// on them (since only stored entries are aligned).
|
||||
|
||||
for (String name : names) {
|
||||
JarEntry inEntry = in.getJarEntry(name);
|
||||
JarEntry outEntry = null;
|
||||
JarEntry outEntry;
|
||||
if (inEntry.getMethod() != JarEntry.STORED) continue;
|
||||
// Preserve the STORED method of the input entry.
|
||||
outEntry = new JarEntry(inEntry);
|
||||
outEntry.setTime(timestamp);
|
||||
// Discard comment and extra fields of this entry to
|
||||
// simplify alignment logic below and for consistency with
|
||||
// how compressed entries are handled later.
|
||||
outEntry.setComment(null);
|
||||
outEntry.setExtra(null);
|
||||
|
||||
// 'offset' is the offset into the file at which we expect
|
||||
// the file data to begin. This is the value we need to
|
||||
// make a multiple of 'alignement'.
|
||||
@@ -358,15 +327,18 @@ public class SignAPK {
|
||||
offset += 4;
|
||||
firstEntry = false;
|
||||
}
|
||||
int alignment = getStoredEntryDataAlignment(name, defaultAlignment);
|
||||
if (alignment > 0 && (offset % alignment != 0)) {
|
||||
// Set the "extra data" of the entry to between 1 and
|
||||
// alignment-1 bytes, to make the file data begin at
|
||||
// an aligned offset.
|
||||
int needed = alignment - (int)(offset % alignment);
|
||||
int needed = alignment - (int) (offset % alignment);
|
||||
outEntry.setExtra(new byte[needed]);
|
||||
offset += needed;
|
||||
}
|
||||
|
||||
out.putNextEntry(outEntry);
|
||||
|
||||
InputStream data = in.getInputStream(inEntry);
|
||||
while ((num = data.read(buffer)) > 0) {
|
||||
out.write(buffer, 0, num);
|
||||
@@ -374,17 +346,20 @@ public class SignAPK {
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
// Copy all the non-STORED entries. We don't attempt to
|
||||
// maintain the 'offset' variable past this point; we don't do
|
||||
// alignment on these entries.
|
||||
|
||||
for (String name : names) {
|
||||
JarEntry inEntry = in.getJarEntry(name);
|
||||
JarEntry outEntry = null;
|
||||
JarEntry outEntry;
|
||||
if (inEntry.getMethod() == JarEntry.STORED) continue;
|
||||
// Create a new entry so that the compressed len is recomputed.
|
||||
outEntry = new JarEntry(name);
|
||||
outEntry.setTime(timestamp);
|
||||
out.putNextEntry(outEntry);
|
||||
|
||||
InputStream data = in.getInputStream(inEntry);
|
||||
while ((num = data.read(buffer)) > 0) {
|
||||
out.write(buffer, 0, num);
|
||||
@@ -393,134 +368,203 @@ public class SignAPK {
|
||||
}
|
||||
}
|
||||
|
||||
// This class is to provide a file's content, but trimming out the last two bytes
|
||||
// Used for signWholeFile
|
||||
private static class CMSProcessableFile implements CMSTypedData {
|
||||
|
||||
private ASN1ObjectIdentifier type;
|
||||
private RandomAccessFile file;
|
||||
|
||||
CMSProcessableFile(File file) throws FileNotFoundException {
|
||||
this.file = new RandomAccessFile(file, "r");
|
||||
type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId());
|
||||
/**
|
||||
* Returns the multiple (in bytes) at which the provided {@code STORED} entry's data must start
|
||||
* relative to start of file or {@code 0} if alignment of this entry's data is not important.
|
||||
*/
|
||||
private static int getStoredEntryDataAlignment(String entryName, int defaultAlignment) {
|
||||
if (defaultAlignment <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ASN1ObjectIdentifier getContentType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(OutputStream out) throws IOException, CMSException {
|
||||
file.seek(0);
|
||||
int read;
|
||||
byte buffer[] = new byte[4096];
|
||||
int len = (int) file.length() - 2;
|
||||
while ((read = file.read(buffer, 0, len < buffer.length ? len : buffer.length)) > 0) {
|
||||
out.write(buffer, 0, read);
|
||||
len -= read;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getContent() {
|
||||
return file;
|
||||
}
|
||||
|
||||
byte[] getTail() throws IOException {
|
||||
byte tail[] = new byte[22];
|
||||
file.seek(file.length() - 22);
|
||||
file.readFully(tail);
|
||||
return tail;
|
||||
if (entryName.endsWith(".so")) {
|
||||
// Align .so contents to memory page boundary to enable memory-mapped
|
||||
// execution.
|
||||
return 4096;
|
||||
} else {
|
||||
return defaultAlignment;
|
||||
}
|
||||
}
|
||||
|
||||
private static void signWholeFile(File input, X509Certificate publicKey,
|
||||
PrivateKey privateKey, OutputStream outputStream)
|
||||
throws Exception {
|
||||
ByteArrayOutputStream temp = new ByteArrayOutputStream();
|
||||
// put a readable message and a null char at the start of the
|
||||
// archive comment, so that tools that display the comment
|
||||
// (hopefully) show something sensible.
|
||||
// TODO: anything more useful we can put in this message?
|
||||
byte[] message = "signed by SignApk".getBytes("UTF-8");
|
||||
temp.write(message);
|
||||
temp.write(0);
|
||||
|
||||
CMSProcessableFile cmsFile = new CMSProcessableFile(input);
|
||||
writeSignatureBlock(cmsFile, publicKey, privateKey, temp);
|
||||
|
||||
// For a zip with no archive comment, the
|
||||
// end-of-central-directory record will be 22 bytes long, so
|
||||
// we expect to find the EOCD marker 22 bytes from the end.
|
||||
byte[] zipData = cmsFile.getTail();
|
||||
if (zipData[zipData.length-22] != 0x50 ||
|
||||
zipData[zipData.length-21] != 0x4b ||
|
||||
zipData[zipData.length-20] != 0x05 ||
|
||||
zipData[zipData.length-19] != 0x06) {
|
||||
throw new IllegalArgumentException("zip data already has an archive comment");
|
||||
}
|
||||
int total_size = temp.size() + 6;
|
||||
if (total_size > 0xffff) {
|
||||
throw new IllegalArgumentException("signature is too big for ZIP file comment");
|
||||
}
|
||||
// signature starts this many bytes from the end of the file
|
||||
int signature_start = total_size - message.length - 1;
|
||||
temp.write(signature_start & 0xff);
|
||||
temp.write((signature_start >> 8) & 0xff);
|
||||
// Why the 0xff bytes? In a zip file with no archive comment,
|
||||
// bytes [-6:-2] of the file are the little-endian offset from
|
||||
// the start of the file to the central directory. So for the
|
||||
// two high bytes to be 0xff 0xff, the archive would have to
|
||||
// be nearly 4GB in size. So it's unlikely that a real
|
||||
// commentless archive would have 0xffs here, and lets us tell
|
||||
// an old signed archive from a new one.
|
||||
temp.write(0xff);
|
||||
temp.write(0xff);
|
||||
temp.write(total_size & 0xff);
|
||||
temp.write((total_size >> 8) & 0xff);
|
||||
temp.flush();
|
||||
// Signature verification checks that the EOCD header is the
|
||||
// last such sequence in the file (to avoid minzip finding a
|
||||
// fake EOCD appended after the signature in its scan). The
|
||||
// odds of producing this sequence by chance are very low, but
|
||||
// let's catch it here if it does.
|
||||
byte[] b = temp.toByteArray();
|
||||
for (int i = 0; i < b.length-3; ++i) {
|
||||
if (b[i] == 0x50 && b[i+1] == 0x4b && b[i+2] == 0x05 && b[i+3] == 0x06) {
|
||||
throw new IllegalArgumentException("found spurious EOCD header at " + i);
|
||||
}
|
||||
}
|
||||
cmsFile.write(outputStream);
|
||||
outputStream.write(total_size & 0xff);
|
||||
outputStream.write((total_size >> 8) & 0xff);
|
||||
temp.writeTo(outputStream);
|
||||
outputStream.close();
|
||||
}
|
||||
private static void signFile(Manifest manifest, JarMap inputJar,
|
||||
X509Certificate publicKey, PrivateKey privateKey,
|
||||
JarOutputStream outputJar)
|
||||
throws Exception {
|
||||
// Assume the certificate is valid for at least an hour.
|
||||
long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000;
|
||||
private static void signFile(Manifest manifest,
|
||||
X509Certificate[] publicKey, PrivateKey[] privateKey,
|
||||
long timestamp, JarOutputStream outputJar) throws Exception {
|
||||
// MANIFEST.MF
|
||||
JarEntry je = new JarEntry(JarFile.MANIFEST_NAME);
|
||||
je.setTime(timestamp);
|
||||
outputJar.putNextEntry(je);
|
||||
manifest.write(outputJar);
|
||||
je = new JarEntry(CERT_SF_NAME);
|
||||
je.setTime(timestamp);
|
||||
outputJar.putNextEntry(je);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey));
|
||||
byte[] signedData = baos.toByteArray();
|
||||
outputJar.write(signedData);
|
||||
// CERT.{EC,RSA} / CERT#.{EC,RSA}
|
||||
final String keyType = publicKey.getPublicKey().getAlgorithm();
|
||||
je = new JarEntry(String.format(CERT_SIG_NAME, keyType));
|
||||
je.setTime(timestamp);
|
||||
outputJar.putNextEntry(je);
|
||||
writeSignatureBlock(new CMSProcessableByteArray(signedData),
|
||||
publicKey, privateKey, outputJar);
|
||||
|
||||
int numKeys = publicKey.length;
|
||||
for (int k = 0; k < numKeys; ++k) {
|
||||
// CERT.SF / CERT#.SF
|
||||
je = new JarEntry(numKeys == 1 ? CERT_SF_NAME :
|
||||
(String.format(Locale.US, CERT_SF_MULTI_NAME, k)));
|
||||
je.setTime(timestamp);
|
||||
outputJar.putNextEntry(je);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey[k]));
|
||||
byte[] signedData = baos.toByteArray();
|
||||
outputJar.write(signedData);
|
||||
|
||||
// CERT.{EC,RSA} / CERT#.{EC,RSA}
|
||||
final String keyType = publicKey[k].getPublicKey().getAlgorithm();
|
||||
je = new JarEntry(numKeys == 1 ? (String.format(CERT_SIG_NAME, keyType)) :
|
||||
(String.format(Locale.US, CERT_SIG_MULTI_NAME, k, keyType)));
|
||||
je.setTime(timestamp);
|
||||
outputJar.putNextEntry(je);
|
||||
writeSignatureBlock(new CMSProcessableByteArray(signedData),
|
||||
publicKey[k], privateKey[k], outputJar);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the provided lists of private keys, their X.509 certificates, and digest algorithms
|
||||
* into a list of APK Signature Scheme v2 {@code SignerConfig} instances.
|
||||
*/
|
||||
private static List<ApkSignerV2.SignerConfig> createV2SignerConfigs(
|
||||
PrivateKey[] privateKeys, X509Certificate[] certificates, String[] digestAlgorithms)
|
||||
throws InvalidKeyException {
|
||||
if (privateKeys.length != certificates.length) {
|
||||
throw new IllegalArgumentException(
|
||||
"The number of private keys must match the number of certificates: "
|
||||
+ privateKeys.length + " vs" + certificates.length);
|
||||
}
|
||||
List<ApkSignerV2.SignerConfig> result = new ArrayList<>(privateKeys.length);
|
||||
for (int i = 0; i < privateKeys.length; i++) {
|
||||
PrivateKey privateKey = privateKeys[i];
|
||||
X509Certificate certificate = certificates[i];
|
||||
PublicKey publicKey = certificate.getPublicKey();
|
||||
String keyAlgorithm = privateKey.getAlgorithm();
|
||||
if (!keyAlgorithm.equalsIgnoreCase(publicKey.getAlgorithm())) {
|
||||
throw new InvalidKeyException(
|
||||
"Key algorithm of private key #" + (i + 1) + " does not match key"
|
||||
+ " algorithm of public key #" + (i + 1) + ": " + keyAlgorithm
|
||||
+ " vs " + publicKey.getAlgorithm());
|
||||
}
|
||||
ApkSignerV2.SignerConfig signerConfig = new ApkSignerV2.SignerConfig();
|
||||
signerConfig.privateKey = privateKey;
|
||||
signerConfig.certificates = Collections.singletonList(certificate);
|
||||
List<Integer> signatureAlgorithms = new ArrayList<>(digestAlgorithms.length);
|
||||
for (String digestAlgorithm : digestAlgorithms) {
|
||||
try {
|
||||
signatureAlgorithms.add(getV2SignatureAlgorithm(keyAlgorithm, digestAlgorithm));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new InvalidKeyException(
|
||||
"Unsupported key and digest algorithm combination for signer #"
|
||||
+ (i + 1), e);
|
||||
}
|
||||
}
|
||||
signerConfig.signatureAlgorithms = signatureAlgorithms;
|
||||
result.add(signerConfig);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int getV2SignatureAlgorithm(String keyAlgorithm, String digestAlgorithm) {
|
||||
if ("SHA-256".equalsIgnoreCase(digestAlgorithm)) {
|
||||
if ("RSA".equalsIgnoreCase(keyAlgorithm)) {
|
||||
// Use RSASSA-PKCS1-v1_5 signature scheme instead of RSASSA-PSS to guarantee
|
||||
// deterministic signatures which make life easier for OTA updates (fewer files
|
||||
// changed when deterministic signature schemes are used).
|
||||
return ApkSignerV2.SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256;
|
||||
} else if ("EC".equalsIgnoreCase(keyAlgorithm)) {
|
||||
return ApkSignerV2.SIGNATURE_ECDSA_WITH_SHA256;
|
||||
} else if ("DSA".equalsIgnoreCase(keyAlgorithm)) {
|
||||
return ApkSignerV2.SIGNATURE_DSA_WITH_SHA256;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm);
|
||||
}
|
||||
} else if ("SHA-512".equalsIgnoreCase(digestAlgorithm)) {
|
||||
if ("RSA".equalsIgnoreCase(keyAlgorithm)) {
|
||||
// Use RSASSA-PKCS1-v1_5 signature scheme instead of RSASSA-PSS to guarantee
|
||||
// deterministic signatures which make life easier for OTA updates (fewer files
|
||||
// changed when deterministic signature schemes are used).
|
||||
return ApkSignerV2.SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512;
|
||||
} else if ("EC".equalsIgnoreCase(keyAlgorithm)) {
|
||||
return ApkSignerV2.SIGNATURE_ECDSA_WITH_SHA512;
|
||||
} else if ("DSA".equalsIgnoreCase(keyAlgorithm)) {
|
||||
return ApkSignerV2.SIGNATURE_DSA_WITH_SHA512;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm);
|
||||
}
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported digest algorithm: " + digestAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
public static void sign(X509Certificate cert, PrivateKey key,
|
||||
JarMap inputJar, FileOutputStream outputFile) throws Exception {
|
||||
int alignment = 4;
|
||||
int hashes = 0;
|
||||
|
||||
X509Certificate[] publicKey = new X509Certificate[1];
|
||||
publicKey[0] = cert;
|
||||
hashes |= getDigestAlgorithm(publicKey[0]);
|
||||
|
||||
// Set all ZIP file timestamps to Jan 1 2009 00:00:00.
|
||||
long timestamp = 1230768000000L;
|
||||
// The Java ZipEntry API we're using converts milliseconds since epoch into MS-DOS
|
||||
// timestamp using the current timezone. We thus adjust the milliseconds since epoch
|
||||
// value to end up with MS-DOS timestamp of Jan 1 2009 00:00:00.
|
||||
timestamp -= TimeZone.getDefault().getOffset(timestamp);
|
||||
|
||||
PrivateKey[] privateKey = new PrivateKey[1];
|
||||
privateKey[0] = key;
|
||||
|
||||
// Generate, in memory, an APK signed using standard JAR Signature Scheme.
|
||||
ByteArrayOutputStream v1SignedApkBuf = new ByteArrayOutputStream();
|
||||
JarOutputStream outputJar = new JarOutputStream(v1SignedApkBuf);
|
||||
// Use maximum compression for compressed entries because the APK lives forever on
|
||||
// the system partition.
|
||||
outputJar.setLevel(9);
|
||||
Manifest manifest = addDigestsToManifest(inputJar, hashes);
|
||||
copyFiles(manifest, inputJar, outputJar, timestamp, alignment);
|
||||
signFile(manifest, publicKey, privateKey, timestamp, outputJar);
|
||||
outputJar.close();
|
||||
ByteBuffer v1SignedApk = ByteBuffer.wrap(v1SignedApkBuf.toByteArray());
|
||||
v1SignedApkBuf.reset();
|
||||
|
||||
ByteBuffer[] outputChunks;
|
||||
List<ApkSignerV2.SignerConfig> signerConfigs = createV2SignerConfigs(privateKey, publicKey,
|
||||
new String[]{APK_SIG_SCHEME_V2_DIGEST_ALGORITHM});
|
||||
outputChunks = ApkSignerV2.sign(v1SignedApk, signerConfigs);
|
||||
|
||||
// This assumes outputChunks are array-backed. To avoid this assumption, the
|
||||
// code could be rewritten to use FileChannel.
|
||||
for (ByteBuffer outputChunk : outputChunks) {
|
||||
outputFile.write(outputChunk.array(),
|
||||
outputChunk.arrayOffset() + outputChunk.position(), outputChunk.remaining());
|
||||
outputChunk.position(outputChunk.limit());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to another stream and track how many bytes have been
|
||||
* written.
|
||||
*/
|
||||
private static class CountOutputStream extends FilterOutputStream {
|
||||
private int mCount;
|
||||
|
||||
public CountOutputStream(OutputStream out) {
|
||||
super(out);
|
||||
mCount = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
super.write(b);
|
||||
mCount++;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
super.write(b, off, len);
|
||||
mCount += len;
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return mCount;
|
||||
}
|
||||
}
|
||||
}
|
@@ -33,6 +33,12 @@ public class SignBoot {
|
||||
private static final int BOOT_IMAGE_HEADER_V1_RECOVERY_DTBO_SIZE_OFFSET = 1632;
|
||||
private static final int BOOT_IMAGE_HEADER_V2_DTB_SIZE_OFFSET = 1648;
|
||||
|
||||
// Arbitrary maximum header version value; when greater assume the field is dt/extra size
|
||||
private static final int BOOT_IMAGE_HEADER_VERSION_MAXIMUM = 8;
|
||||
|
||||
// Maximum header size byte value to read (currently the bootimg minimum page size)
|
||||
private static final int BOOT_IMAGE_HEADER_SIZE_MAXIMUM = 2048;
|
||||
|
||||
private static class PushBackRWStream extends FilterInputStream {
|
||||
private OutputStream out;
|
||||
private int pos = 0;
|
||||
@@ -82,7 +88,7 @@ public class SignBoot {
|
||||
InputStream cert, InputStream key) {
|
||||
try {
|
||||
PushBackRWStream in = new PushBackRWStream(imgIn, imgOut);
|
||||
byte[] hdr = new byte[1024];
|
||||
byte[] hdr = new byte[BOOT_IMAGE_HEADER_SIZE_MAXIMUM];
|
||||
// First read the header
|
||||
in.read(hdr);
|
||||
int signableSize = getSignableImageSize(hdr);
|
||||
@@ -113,22 +119,27 @@ public class SignBoot {
|
||||
public static boolean verifySignature(InputStream imgIn, InputStream certIn) {
|
||||
try {
|
||||
// Read the header for size
|
||||
byte[] hdr = new byte[1024];
|
||||
if (imgIn.read(hdr) != hdr.length)
|
||||
byte[] hdr = new byte[BOOT_IMAGE_HEADER_SIZE_MAXIMUM];
|
||||
if (imgIn.read(hdr) != hdr.length) {
|
||||
System.err.println("Unable to read image header");
|
||||
return false;
|
||||
}
|
||||
int signableSize = getSignableImageSize(hdr);
|
||||
|
||||
// Read the rest of the image
|
||||
byte[] rawImg = Arrays.copyOf(hdr, signableSize);
|
||||
int remain = signableSize - hdr.length;
|
||||
if (imgIn.read(rawImg, hdr.length, remain) != remain) {
|
||||
System.err.println("Invalid image: not signed");
|
||||
System.err.println("Unable to read image");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read footer, which contains the signature
|
||||
byte[] signature = new byte[4096];
|
||||
imgIn.read(signature);
|
||||
if (imgIn.read(signature) == -1 || Arrays.equals(signature, new byte [signature.length])) {
|
||||
System.err.println("Invalid image: not signed");
|
||||
return false;
|
||||
}
|
||||
|
||||
BootSignature bootsig = new BootSignature(signature);
|
||||
if (certIn != null) {
|
||||
@@ -141,7 +152,8 @@ public class SignBoot {
|
||||
System.err.println("Signature is INVALID");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("Invalid image: not signed");
|
||||
e.printStackTrace();
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -165,8 +177,8 @@ public class SignBoot {
|
||||
+ ((kernelSize + pageSize - 1) / pageSize) * pageSize
|
||||
+ ((ramdskSize + pageSize - 1) / pageSize) * pageSize
|
||||
+ ((secondSize + pageSize - 1) / pageSize) * pageSize;
|
||||
int headerVersion = image.getInt(); // boot image header version or extra size
|
||||
if (headerVersion > 0 && headerVersion < 4) {
|
||||
int headerVersion = image.getInt(); // boot image header version or dt/extra size
|
||||
if (headerVersion > 0 && headerVersion < BOOT_IMAGE_HEADER_VERSION_MAXIMUM) {
|
||||
image.position(BOOT_IMAGE_HEADER_V1_RECOVERY_DTBO_SIZE_OFFSET);
|
||||
int recoveryDtboLength = image.getInt();
|
||||
length += ((recoveryDtboLength + pageSize - 1) / pageSize) * pageSize;
|
||||
@@ -183,7 +195,7 @@ public class SignBoot {
|
||||
"Invalid image header: invalid header length");
|
||||
}
|
||||
} else {
|
||||
// headerVersion is 0 or actually extra size in this case
|
||||
// headerVersion is 0 or actually dt/extra size in this case
|
||||
length += ((headerVersion + pageSize - 1) / pageSize) * pageSize;
|
||||
}
|
||||
length = ((length + pageSize - 1) / pageSize) * pageSize;
|
@@ -0,0 +1,81 @@
|
||||
package com.topjohnwu.signing;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Security;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
public class ZipSigner {
|
||||
|
||||
private static void usage() {
|
||||
System.err.println("ZipSigner usage:");
|
||||
System.err.println(" zipsigner.jar input.jar output.jar");
|
||||
System.err.println(" sign jar with AOSP test keys");
|
||||
System.err.println(" zipsigner.jar x509.pem pk8 input.jar output.jar");
|
||||
System.err.println(" sign jar with certificate / private key pair");
|
||||
System.err.println(" zipsigner.jar keyStore keyStorePass alias keyPass input.jar output.jar");
|
||||
System.err.println(" sign jar with Java KeyStore");
|
||||
System.exit(2);
|
||||
}
|
||||
|
||||
private static void sign(JarMap input, FileOutputStream output) throws Exception {
|
||||
sign(SignApk.class.getResourceAsStream("/keys/testkey.x509.pem"),
|
||||
SignApk.class.getResourceAsStream("/keys/testkey.pk8"), input, output);
|
||||
}
|
||||
|
||||
private static void sign(InputStream certIs, InputStream keyIs,
|
||||
JarMap input, FileOutputStream output) throws Exception {
|
||||
X509Certificate cert = CryptoUtils.readCertificate(certIs);
|
||||
PrivateKey key = CryptoUtils.readPrivateKey(keyIs);
|
||||
SignApk.sign(cert, key, input, output);
|
||||
}
|
||||
|
||||
private static void sign(String keyStore, String keyStorePass, String alias, String keyPass,
|
||||
JarMap in, FileOutputStream out) throws Exception {
|
||||
KeyStore ks;
|
||||
try {
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
try (InputStream is = new FileInputStream(keyStore)) {
|
||||
ks.load(is, keyStorePass.toCharArray());
|
||||
}
|
||||
} catch (KeyStoreException|IOException|CertificateException|NoSuchAlgorithmException e) {
|
||||
ks = KeyStore.getInstance("PKCS12");
|
||||
try (InputStream is = new FileInputStream(keyStore)) {
|
||||
ks.load(is, keyStorePass.toCharArray());
|
||||
}
|
||||
}
|
||||
X509Certificate cert = (X509Certificate) ks.getCertificate(alias);
|
||||
PrivateKey key = (PrivateKey) ks.getKey(alias, keyPass.toCharArray());
|
||||
SignApk.sign(cert, key, in, out);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length != 2 && args.length != 4 && args.length != 6)
|
||||
usage();
|
||||
|
||||
Security.insertProviderAt(new BouncyCastleProvider(), 1);
|
||||
|
||||
try (JarMap in = JarMap.open(args[args.length - 2], false);
|
||||
FileOutputStream out = new FileOutputStream(args[args.length - 1])) {
|
||||
if (args.length == 2) {
|
||||
sign(in, out);
|
||||
} else if (args.length == 4) {
|
||||
try (InputStream cert = new FileInputStream(args[0]);
|
||||
InputStream key = new FileInputStream(args[1])) {
|
||||
sign(cert, key, in, out);
|
||||
}
|
||||
} else if (args.length == 6) {
|
||||
sign(args[0], args[1], args[2], args[3], in, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
136
app/signing/src/main/java/com/topjohnwu/signing/ZipUtils.java
Normal file
136
app/signing/src/main/java/com/topjohnwu/signing/ZipUtils.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package com.topjohnwu.signing;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/**
|
||||
* Assorted ZIP format helpers.
|
||||
*
|
||||
* <p>NOTE: Most helper methods operating on {@code ByteBuffer} instances expect that the byte
|
||||
* order of these buffers is little-endian.
|
||||
*/
|
||||
public abstract class ZipUtils {
|
||||
|
||||
private static final int ZIP_EOCD_REC_MIN_SIZE = 22;
|
||||
private static final int ZIP_EOCD_REC_SIG = 0x06054b50;
|
||||
private static final int ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET = 12;
|
||||
private static final int ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET = 16;
|
||||
private static final int ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET = 20;
|
||||
|
||||
private static final int ZIP64_EOCD_LOCATOR_SIZE = 20;
|
||||
private static final int ZIP64_EOCD_LOCATOR_SIG = 0x07064b50;
|
||||
|
||||
private static final int UINT16_MAX_VALUE = 0xffff;
|
||||
|
||||
private ZipUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position at which ZIP End of Central Directory record starts in the provided
|
||||
* buffer or {@code -1} if the record is not present.
|
||||
*
|
||||
* <p>NOTE: Byte order of {@code zipContents} must be little-endian.
|
||||
*/
|
||||
public static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents) {
|
||||
assertByteOrderLittleEndian(zipContents);
|
||||
|
||||
// ZIP End of Central Directory (EOCD) record is located at the very end of the ZIP archive.
|
||||
// The record can be identified by its 4-byte signature/magic which is located at the very
|
||||
// beginning of the record. A complication is that the record is variable-length because of
|
||||
// the comment field.
|
||||
// The algorithm for locating the ZIP EOCD record is as follows. We search backwards from
|
||||
// end of the buffer for the EOCD record signature. Whenever we find a signature, we check
|
||||
// the candidate record's comment length is such that the remainder of the record takes up
|
||||
// exactly the remaining bytes in the buffer. The search is bounded because the maximum
|
||||
// size of the comment field is 65535 bytes because the field is an unsigned 16-bit number.
|
||||
|
||||
int archiveSize = zipContents.capacity();
|
||||
if (archiveSize < ZIP_EOCD_REC_MIN_SIZE) {
|
||||
return -1;
|
||||
}
|
||||
int maxCommentLength = Math.min(archiveSize - ZIP_EOCD_REC_MIN_SIZE, UINT16_MAX_VALUE);
|
||||
int eocdWithEmptyCommentStartPosition = archiveSize - ZIP_EOCD_REC_MIN_SIZE;
|
||||
for (int expectedCommentLength = 0; expectedCommentLength < maxCommentLength; expectedCommentLength++) {
|
||||
int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength;
|
||||
if (zipContents.getInt(eocdStartPos) == ZIP_EOCD_REC_SIG) {
|
||||
int actualCommentLength = getUnsignedInt16(zipContents, eocdStartPos + ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET);
|
||||
if (actualCommentLength == expectedCommentLength) {
|
||||
return eocdStartPos;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the provided buffer contains a ZIP64 End of Central Directory
|
||||
* Locator.
|
||||
*
|
||||
* <p>NOTE: Byte order of {@code zipContents} must be little-endian.
|
||||
*/
|
||||
public static boolean isZip64EndOfCentralDirectoryLocatorPresent(ByteBuffer zipContents, int zipEndOfCentralDirectoryPosition) {
|
||||
assertByteOrderLittleEndian(zipContents);
|
||||
|
||||
// ZIP64 End of Central Directory Locator immediately precedes the ZIP End of Central
|
||||
// Directory Record.
|
||||
|
||||
int locatorPosition = zipEndOfCentralDirectoryPosition - ZIP64_EOCD_LOCATOR_SIZE;
|
||||
if (locatorPosition < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return zipContents.getInt(locatorPosition) == ZIP64_EOCD_LOCATOR_SIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the offset of the start of the ZIP Central Directory in the archive.
|
||||
*
|
||||
* <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian.
|
||||
*/
|
||||
public static long getZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory) {
|
||||
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
|
||||
return getUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the offset of the start of the ZIP Central Directory in the archive.
|
||||
*
|
||||
* <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian.
|
||||
*/
|
||||
public static void setZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory, long offset) {
|
||||
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
|
||||
setUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET, offset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the size (in bytes) of the ZIP Central Directory.
|
||||
*
|
||||
* <p>NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian.
|
||||
*/
|
||||
public static long getZipEocdCentralDirectorySizeBytes(ByteBuffer zipEndOfCentralDirectory) {
|
||||
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
|
||||
return getUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET);
|
||||
}
|
||||
|
||||
private static void assertByteOrderLittleEndian(ByteBuffer buffer) {
|
||||
if (buffer.order() != ByteOrder.LITTLE_ENDIAN) {
|
||||
throw new IllegalArgumentException("ByteBuffer byte order must be little endian");
|
||||
}
|
||||
}
|
||||
|
||||
private static int getUnsignedInt16(ByteBuffer buffer, int offset) {
|
||||
return buffer.getShort(offset) & 0xffff;
|
||||
}
|
||||
|
||||
private static long getUnsignedInt32(ByteBuffer buffer, int offset) {
|
||||
return buffer.getInt(offset) & 0xffffffffL;
|
||||
}
|
||||
|
||||
private static void setUnsignedInt32(ByteBuffer buffer, int offset, long value) {
|
||||
if ((value < 0) || (value > 0xffffffffL)) {
|
||||
throw new IllegalArgumentException("uint32 value of out range: " + value);
|
||||
}
|
||||
buffer.putInt(buffer.position() + offset, (int) value);
|
||||
}
|
||||
}
|
@@ -4,56 +4,53 @@
|
||||
package="com.topjohnwu.magisk">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<application
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:name="a.e"
|
||||
android:allowBackup="true"
|
||||
android:theme="@style/MagiskTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="UnusedAttribute,GoogleAppIndexingWarning">
|
||||
|
||||
<!-- Activities -->
|
||||
|
||||
<activity
|
||||
android:name="a.b"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:exported="true" />
|
||||
<!-- Splash -->
|
||||
<activity
|
||||
android:name="a.c"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:exported="true"
|
||||
android:theme="@style/SplashTheme">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="a.f"
|
||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||
android:screenOrientation="nosensor"
|
||||
android:theme="@style/MagiskTheme.Flashing" />
|
||||
|
||||
<!-- Main -->
|
||||
<activity android:name="a.b" />
|
||||
|
||||
<!-- Superuser -->
|
||||
|
||||
<activity
|
||||
android:name="a.m"
|
||||
android:directBootAware="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:theme="@style/MagiskTheme.SU" />
|
||||
tools:ignore="AppLinkUrlError">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Receiver -->
|
||||
|
||||
<receiver
|
||||
android:name="a.h"
|
||||
android:directBootAware="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.REBOOT" />
|
||||
<action android:name="android.intent.action.LOCALE_CHANGED" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
@@ -64,16 +61,39 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Service -->
|
||||
<!-- DownloadService -->
|
||||
<service android:name="a.j" />
|
||||
|
||||
<service android:name="a.j"
|
||||
android:exported="false" />
|
||||
<!-- FileProvider -->
|
||||
<provider
|
||||
android:name="a.p"
|
||||
android:authorities="${applicationId}.provider"
|
||||
android:directBootAware="true"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
</provider>
|
||||
|
||||
<!-- Hardcode GMS version -->
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
android:value="12451000" />
|
||||
|
||||
<!-- Initialize WorkManager on-demand -->
|
||||
<provider
|
||||
android:name="androidx.work.impl.WorkManagerInitializer"
|
||||
android:authorities="${applicationId}.workmanager-init"
|
||||
tools:node="remove" />
|
||||
|
||||
<!-- We don't invalidate Room -->
|
||||
<service
|
||||
android:name="androidx.room.MultiInstanceInvalidationService"
|
||||
tools:node="remove"/>
|
||||
|
||||
<!-- We don't use Device Credentials -->
|
||||
<activity
|
||||
android:name="androidx.biometric.DeviceCredentialHandlerActivity"
|
||||
tools:node="remove" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
@@ -1,13 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.utils.PatchAPK;
|
||||
import com.topjohnwu.signing.BootSigner;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
|
||||
@Keep
|
||||
public class a extends BootSigner {
|
||||
public static boolean patchAPK(String in, String out, String pkg) {
|
||||
return PatchAPK.patch(in, out, pkg);
|
||||
}
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.ui.MainActivity;
|
||||
|
||||
public class b extends MainActivity {
|
||||
/* stub */
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.ui.SplashActivity;
|
||||
|
||||
public class c extends SplashActivity {
|
||||
/* stub */
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.App;
|
||||
|
||||
public class e extends App {
|
||||
/* stub */
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.ui.flash.FlashActivity;
|
||||
|
||||
public class f extends FlashActivity {
|
||||
/* stub */
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
package a;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import com.topjohnwu.magisk.model.update.UpdateCheckService;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
public class g extends w<UpdateCheckService> {
|
||||
/* Stub */
|
||||
public g(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.model.receiver.GeneralReceiver;
|
||||
|
||||
public class h extends GeneralReceiver {
|
||||
/* stub */
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.model.download.DownloadService;
|
||||
|
||||
public class j extends DownloadService {
|
||||
/* stub */
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
package a;
|
||||
|
||||
import com.topjohnwu.magisk.ui.surequest.SuRequestActivity;
|
||||
|
||||
public class m extends SuRequestActivity {
|
||||
/* stub */
|
||||
}
|
32
app/src/main/java/a/stubs.kt
Normal file
32
app/src/main/java/a/stubs.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
@file:JvmName("a")
|
||||
package a
|
||||
|
||||
import com.topjohnwu.magisk.core.App
|
||||
import com.topjohnwu.magisk.core.Provider
|
||||
import com.topjohnwu.magisk.core.Receiver
|
||||
import com.topjohnwu.magisk.core.SplashActivity
|
||||
import com.topjohnwu.magisk.core.download.DownloadService
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.ui.surequest.SuRequestActivity
|
||||
import com.topjohnwu.signing.BootSigner
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
BootSigner.main(args)
|
||||
}
|
||||
|
||||
class b : MainActivity()
|
||||
|
||||
class c : SplashActivity()
|
||||
|
||||
class e : App {
|
||||
constructor() : super()
|
||||
constructor(o: Any) : super(o)
|
||||
}
|
||||
|
||||
class h : Receiver()
|
||||
|
||||
class j : DownloadService()
|
||||
|
||||
class m : SuRequestActivity()
|
||||
|
||||
class p : Provider()
|
@@ -1,42 +0,0 @@
|
||||
package a;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import com.topjohnwu.magisk.base.DelegateWorker;
|
||||
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
|
||||
public abstract class w<T extends DelegateWorker> extends Worker {
|
||||
|
||||
/* Wrapper class to workaround Proguard -keep class * extends Worker */
|
||||
|
||||
private T base;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
w(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
try {
|
||||
base = ((Class<T>) ((ParameterizedType) getClass().getGenericSuperclass())
|
||||
.getActualTypeArguments()[0]).newInstance();
|
||||
base.attachWorker(this);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
if (base == null)
|
||||
return Result.failure();
|
||||
return base.doWork();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStopped() {
|
||||
if (base != null)
|
||||
base.onStopped();
|
||||
}
|
||||
}
|
@@ -1,59 +0,0 @@
|
||||
package com.topjohnwu.magisk
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.multidex.MultiDex
|
||||
import androidx.room.Room
|
||||
import androidx.work.impl.WorkDatabase
|
||||
import androidx.work.impl.WorkDatabase_Impl
|
||||
import com.topjohnwu.magisk.data.database.RepoDatabase
|
||||
import com.topjohnwu.magisk.data.database.RepoDatabase_Impl
|
||||
import com.topjohnwu.magisk.di.ActivityTracker
|
||||
import com.topjohnwu.magisk.di.koinModules
|
||||
import com.topjohnwu.magisk.extensions.get
|
||||
import com.topjohnwu.magisk.utils.LocaleManager
|
||||
import com.topjohnwu.magisk.utils.RootUtils
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import timber.log.Timber
|
||||
|
||||
open class App : Application() {
|
||||
|
||||
init {
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
Shell.Config.setFlags(Shell.FLAG_MOUNT_MASTER or Shell.FLAG_USE_MAGISK_BUSYBOX)
|
||||
Shell.Config.verboseLogging(BuildConfig.DEBUG)
|
||||
Shell.Config.addInitializers(RootUtils::class.java)
|
||||
Shell.Config.setTimeout(2)
|
||||
Room.setFactory {
|
||||
when (it) {
|
||||
WorkDatabase::class.java -> WorkDatabase_Impl()
|
||||
RepoDatabase::class.java -> RepoDatabase_Impl()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
if (BuildConfig.DEBUG)
|
||||
MultiDex.install(base)
|
||||
Timber.plant(Timber.DebugTree())
|
||||
|
||||
startKoin {
|
||||
androidContext(this@App)
|
||||
modules(koinModules)
|
||||
}
|
||||
|
||||
registerActivityLifecycleCallbacks(get<ActivityTracker>())
|
||||
LocaleManager.setLocale(this)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
LocaleManager.setLocale(this)
|
||||
}
|
||||
}
|
@@ -1,26 +0,0 @@
|
||||
package com.topjohnwu.magisk
|
||||
|
||||
import com.topjohnwu.magisk.model.download.DownloadService
|
||||
import com.topjohnwu.magisk.model.receiver.GeneralReceiver
|
||||
import com.topjohnwu.magisk.model.update.UpdateCheckService
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.ui.SplashActivity
|
||||
import com.topjohnwu.magisk.ui.flash.FlashActivity
|
||||
import com.topjohnwu.magisk.ui.surequest.SuRequestActivity
|
||||
|
||||
object ClassMap {
|
||||
private val map = mapOf(
|
||||
App::class.java to a.e::class.java,
|
||||
MainActivity::class.java to a.b::class.java,
|
||||
SplashActivity::class.java to a.c::class.java,
|
||||
FlashActivity::class.java to a.f::class.java,
|
||||
UpdateCheckService::class.java to a.g::class.java,
|
||||
GeneralReceiver::class.java to a.h::class.java,
|
||||
DownloadService::class.java to a.j::class.java,
|
||||
SuRequestActivity::class.java to a.m::class.java
|
||||
)
|
||||
|
||||
operator fun <T : Class<*>>get(c: Class<*>): T {
|
||||
return map.getOrElse(c) { throw IllegalArgumentException() } as T
|
||||
}
|
||||
}
|
@@ -1,78 +0,0 @@
|
||||
package com.topjohnwu.magisk
|
||||
|
||||
import android.os.Process
|
||||
import java.io.File
|
||||
|
||||
object Const {
|
||||
|
||||
// Paths
|
||||
const val MAGISK_PATH = "/sbin/.magisk/img"
|
||||
var MAGISK_DISABLE_FILE = File("xxx")
|
||||
const val TMP_FOLDER_PATH = "/dev/tmp"
|
||||
const val MAGISK_LOG = "/cache/magisk.log"
|
||||
|
||||
// Versions
|
||||
const val SNET_EXT_VER = 13
|
||||
const val SNET_REVISION = "a6c47f86f10b310358afa9dbe837037dd5d561df"
|
||||
const val BOOTCTL_REVISION = "a6c47f86f10b310358afa9dbe837037dd5d561df"
|
||||
|
||||
// Misc
|
||||
const val ANDROID_MANIFEST = "AndroidManifest.xml"
|
||||
const val MAGISK_INSTALL_LOG_FILENAME = "magisk_install_log_%s.log"
|
||||
const val MANAGER_CONFIGS = ".tmp.magisk.config"
|
||||
val USER_ID = Process.myUid() / 100000
|
||||
|
||||
object MagiskVersion {
|
||||
const val MIN_SUPPORT = 18000
|
||||
}
|
||||
|
||||
object ID {
|
||||
const val FETCH_ZIP = 2
|
||||
const val SELECT_BOOT = 3
|
||||
|
||||
// notifications
|
||||
const val MAGISK_UPDATE_NOTIFICATION_ID = 4
|
||||
const val APK_UPDATE_NOTIFICATION_ID = 5
|
||||
const val DTBO_NOTIFICATION_ID = 7
|
||||
const val HIDE_MANAGER_NOTIFICATION_ID = 8
|
||||
const val UPDATE_NOTIFICATION_CHANNEL = "update"
|
||||
const val PROGRESS_NOTIFICATION_CHANNEL = "progress"
|
||||
const val CHECK_MAGISK_UPDATE_WORKER_ID = "magisk_update"
|
||||
}
|
||||
|
||||
object Url {
|
||||
const val ZIP_URL = "https://github.com/Magisk-Modules-Repo/%s/archive/master.zip"
|
||||
const val PAYPAL_URL = "https://www.paypal.me/topjohnwu"
|
||||
const val PATREON_URL = "https://www.patreon.com/topjohnwu"
|
||||
const val TWITTER_URL = "https://twitter.com/topjohnwu"
|
||||
const val XDA_THREAD = "http://forum.xda-developers.com/showthread.php?t=3432382"
|
||||
const val SOURCE_CODE_URL = "https://github.com/topjohnwu/Magisk"
|
||||
|
||||
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com/"
|
||||
const val GITHUB_API_URL = "https://api.github.com/users/Magisk-Modules-Repo/"
|
||||
}
|
||||
|
||||
object Key {
|
||||
// others
|
||||
const val LINK_KEY = "Link"
|
||||
const val IF_NONE_MATCH = "If-None-Match"
|
||||
const val ETAG_KEY = "ETag"
|
||||
// intents
|
||||
const val OPEN_SECTION = "section"
|
||||
const val INTENT_SET_APP = "app_json"
|
||||
const val FLASH_ACTION = "action"
|
||||
const val FLASH_DATA = "additional_data"
|
||||
const val DISMISS_ID = "dismiss_id"
|
||||
const val BROADCAST_MANAGER_UPDATE = "manager_update"
|
||||
const val BROADCAST_REBOOT = "reboot"
|
||||
}
|
||||
|
||||
object Value {
|
||||
const val FLASH_ZIP = "flash"
|
||||
const val PATCH_FILE = "patch"
|
||||
const val FLASH_MAGISK = "magisk"
|
||||
const val FLASH_INACTIVE_SLOT = "slot"
|
||||
const val UNINSTALL = "uninstall"
|
||||
}
|
||||
|
||||
}
|
@@ -1,26 +0,0 @@
|
||||
package com.topjohnwu.magisk
|
||||
|
||||
import com.topjohnwu.magisk.model.entity.UpdateInfo
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ShellUtils
|
||||
|
||||
object Info {
|
||||
|
||||
var magiskVersionCode = -1
|
||||
|
||||
var magiskVersionString = ""
|
||||
|
||||
var remote = UpdateInfo()
|
||||
|
||||
var keepVerity = false
|
||||
var keepEnc = false
|
||||
var recovery = false
|
||||
|
||||
fun loadMagiskInfo() {
|
||||
runCatching {
|
||||
magiskVersionString = ShellUtils.fastCmd("magisk -v").split(":".toRegex())[0]
|
||||
magiskVersionCode = ShellUtils.fastCmd("magisk -V").toInt()
|
||||
Config.magiskHide = Shell.su("magiskhide --status").exec().isSuccess
|
||||
}
|
||||
}
|
||||
}
|
111
app/src/main/java/com/topjohnwu/magisk/arch/BaseUIActivity.kt
Normal file
111
app/src/main/java/com/topjohnwu/magisk/arch/BaseUIActivity.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.res.use
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDirections
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.core.Config
|
||||
import com.topjohnwu.magisk.core.base.BaseActivity
|
||||
import com.topjohnwu.magisk.ui.theme.Theme
|
||||
|
||||
abstract class BaseUIActivity<VM : BaseViewModel, Binding : ViewDataBinding> :
|
||||
BaseActivity(), BaseUIComponent<VM> {
|
||||
|
||||
protected lateinit var binding: Binding
|
||||
protected abstract val layoutRes: Int
|
||||
protected open val themeRes: Int = Theme.selected.themeRes
|
||||
|
||||
private val navHostFragment by lazy {
|
||||
supportFragmentManager.findFragmentById(navHost) as? NavHostFragment
|
||||
}
|
||||
private val topFragment get() = navHostFragment?.childFragmentManager?.fragments?.getOrNull(0)
|
||||
protected val currentFragment get() = topFragment as? BaseUIFragment<*, *>
|
||||
|
||||
override val viewRoot: View get() = binding.root
|
||||
open val navigation: NavController? get() = navHostFragment?.navController
|
||||
|
||||
open val navHost: Int = 0
|
||||
open val snackbarView get() = binding.root
|
||||
|
||||
init {
|
||||
val theme = Config.darkTheme
|
||||
AppCompatDelegate.setDefaultNightMode(theme)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(themeRes)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
startObserveEvents()
|
||||
|
||||
// We need to set the window background explicitly since for whatever reason it's not
|
||||
// propagated upstream
|
||||
obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground))
|
||||
.use { it.getDrawable(0) }
|
||||
.also { window.setBackgroundDrawable(it) }
|
||||
|
||||
directionsDispatcher.observe(this) {
|
||||
it?.navigate()
|
||||
// we don't want the directions to be re-dispatched, so we preemptively set them to null
|
||||
if (it != null) {
|
||||
directionsDispatcher.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setContentView() {
|
||||
binding = DataBindingUtil.setContentView<Binding>(this, layoutRes).also {
|
||||
it.setVariable(BR.viewModel, viewModel)
|
||||
it.lifecycleOwner = this
|
||||
}
|
||||
|
||||
ensureInsets()
|
||||
}
|
||||
|
||||
fun setAccessibilityDelegate(delegate: View.AccessibilityDelegate?) {
|
||||
viewRoot.rootView.accessibilityDelegate = delegate
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.requestRefresh()
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
return currentFragment?.onKeyEvent(event) == true || super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
override fun onEventDispatched(event: ViewEvent) = when (event) {
|
||||
is ContextExecutor -> event(this)
|
||||
is ActivityExecutor -> event(this)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (navigation == null || currentFragment?.onBackPressed()?.not() == true) {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
||||
fun NavDirections.navigate() {
|
||||
navigation?.navigate(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val directionsDispatcher = MutableLiveData<NavDirections?>()
|
||||
|
||||
fun postDirections(navDirections: NavDirections) =
|
||||
directionsDispatcher.postValue(navDirections)
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,63 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.view.View
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
interface BaseUIComponent<VM : BaseViewModel>: LifecycleOwner {
|
||||
|
||||
val viewRoot: View
|
||||
val viewModel: VM
|
||||
|
||||
fun startObserveEvents() {
|
||||
viewModel.viewEvents.observe(this) {
|
||||
onEventDispatched(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun consumeSystemWindowInsets(insets: Insets): Insets? = null
|
||||
|
||||
/**
|
||||
* Called for all [ViewEvent]s published by associated viewModel.
|
||||
*/
|
||||
fun onEventDispatched(event: ViewEvent) {}
|
||||
|
||||
fun ensureInsets() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(viewRoot) { _, insets ->
|
||||
insets.asInsets()
|
||||
.also { viewModel.insets = it }
|
||||
.let { consumeSystemWindowInsets(it) }
|
||||
?.subtractBy(insets) ?: insets
|
||||
}
|
||||
if (ViewCompat.isAttachedToWindow(viewRoot)) {
|
||||
ViewCompat.requestApplyInsets(viewRoot)
|
||||
} else {
|
||||
viewRoot.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {
|
||||
override fun onViewDetachedFromWindow(v: View) = Unit
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
ViewCompat.requestApplyInsets(v)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun WindowInsetsCompat.asInsets() = Insets.of(
|
||||
systemWindowInsetLeft,
|
||||
systemWindowInsetTop,
|
||||
systemWindowInsetRight,
|
||||
systemWindowInsetBottom
|
||||
)
|
||||
|
||||
private fun Insets.subtractBy(insets: WindowInsetsCompat) =
|
||||
WindowInsetsCompat.Builder(insets).setSystemWindowInsets(
|
||||
Insets.of(
|
||||
insets.systemWindowInsetLeft - left,
|
||||
insets.systemWindowInsetTop - top,
|
||||
insets.systemWindowInsetRight - right,
|
||||
insets.systemWindowInsetBottom - bottom
|
||||
)
|
||||
).build()
|
||||
|
||||
}
|
@@ -0,0 +1,90 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.OnRebindCallback
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.NavDirections
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.ktx.startAnimations
|
||||
|
||||
abstract class BaseUIFragment<VM : BaseViewModel, Binding : ViewDataBinding> :
|
||||
Fragment(), BaseUIComponent<VM> {
|
||||
|
||||
val activity get() = requireActivity() as BaseUIActivity<*, *>
|
||||
protected lateinit var binding: Binding
|
||||
protected abstract val layoutRes: Int
|
||||
|
||||
override val viewRoot: View get() = binding.root
|
||||
private val navigation get() = activity.navigation
|
||||
|
||||
override fun consumeSystemWindowInsets(insets: Insets) = insets
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startObserveEvents()
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
binding = DataBindingUtil.inflate<Binding>(inflater, layoutRes, container, false).also {
|
||||
it.setVariable(BR.viewModel, viewModel)
|
||||
it.lifecycleOwner = this
|
||||
}
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onEventDispatched(event: ViewEvent) = when(event) {
|
||||
is ContextExecutor -> event(requireContext())
|
||||
is ActivityExecutor -> event(activity)
|
||||
is FragmentExecutor -> event(this)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
open fun onKeyEvent(event: KeyEvent): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
open fun onBackPressed(): Boolean = false
|
||||
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.addOnRebindCallback(object : OnRebindCallback<Binding>() {
|
||||
override fun onPreBind(binding: Binding): Boolean {
|
||||
this@BaseUIFragment.onPreBind(binding)
|
||||
return true
|
||||
}
|
||||
})
|
||||
ensureInsets()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.requestRefresh()
|
||||
}
|
||||
|
||||
protected open fun onPreBind(binding: Binding) {
|
||||
(binding.root as? ViewGroup)?.startAnimations()
|
||||
}
|
||||
|
||||
fun NavDirections.navigate() {
|
||||
navigation?.navigate(this)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface ReselectionTarget {
|
||||
|
||||
fun onReselected()
|
||||
|
||||
}
|
114
app/src/main/java/com/topjohnwu/magisk/arch/BaseViewModel.kt
Normal file
114
app/src/main/java/com/topjohnwu/magisk/arch/BaseViewModel.kt
Normal file
@@ -0,0 +1,114 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.databinding.Observable
|
||||
import androidx.databinding.PropertyChangeRegistry
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavDirections
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.base.BaseActivity
|
||||
import com.topjohnwu.magisk.events.*
|
||||
import com.topjohnwu.magisk.utils.ObservableHost
|
||||
import com.topjohnwu.magisk.utils.set
|
||||
import kotlinx.coroutines.Job
|
||||
import org.koin.core.KoinComponent
|
||||
|
||||
abstract class BaseViewModel(
|
||||
initialState: State = State.LOADING
|
||||
) : ViewModel(), ObservableHost, KoinComponent {
|
||||
|
||||
override var callbacks: PropertyChangeRegistry? = null
|
||||
|
||||
enum class State {
|
||||
LOADED, LOADING, LOADING_FAILED
|
||||
}
|
||||
|
||||
@get:Bindable
|
||||
val loading get() = state == State.LOADING
|
||||
@get:Bindable
|
||||
val loaded get() = state == State.LOADED
|
||||
@get:Bindable
|
||||
val loadFailed get() = state == State.LOADING_FAILED
|
||||
|
||||
val isConnected get() = Info.isConnected
|
||||
val viewEvents: LiveData<ViewEvent> get() = _viewEvents
|
||||
|
||||
@get:Bindable
|
||||
var insets = Insets.NONE
|
||||
set(value) = set(value, field, { field = it }, BR.insets)
|
||||
|
||||
var state= initialState
|
||||
set(value) = set(value, field, { field = it }, BR.loading, BR.loaded, BR.loadFailed)
|
||||
|
||||
private val _viewEvents = MutableLiveData<ViewEvent>()
|
||||
private var runningJob: Job? = null
|
||||
private val refreshCallback = object : Observable.OnPropertyChangedCallback() {
|
||||
override fun onPropertyChanged(sender: Observable?, propertyId: Int) {
|
||||
requestRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
isConnected.addOnPropertyChangedCallback(refreshCallback)
|
||||
}
|
||||
|
||||
/** This should probably never be called manually, it's called manually via delegate. */
|
||||
@Synchronized
|
||||
fun requestRefresh() {
|
||||
if (runningJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
runningJob = refresh()
|
||||
}
|
||||
|
||||
protected open fun refresh(): Job? = null
|
||||
|
||||
@CallSuper
|
||||
override fun onCleared() {
|
||||
isConnected.removeOnPropertyChangedCallback(refreshCallback)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun withView(action: BaseActivity.() -> Unit) {
|
||||
ViewActionEvent(action).publish()
|
||||
}
|
||||
|
||||
fun withPermission(permission: String, callback: (Boolean) -> Unit) {
|
||||
PermissionEvent(permission, callback).publish()
|
||||
}
|
||||
|
||||
fun withExternalRW(callback: () -> Unit) {
|
||||
withPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) {
|
||||
if (!it) {
|
||||
SnackbarEvent(R.string.external_rw_permission_denied).publish()
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun back() = BackPressEvent().publish()
|
||||
|
||||
fun <Event : ViewEvent> Event.publish() {
|
||||
_viewEvents.postValue(this)
|
||||
}
|
||||
|
||||
fun <Event : ViewEventWithScope> Event.publish() {
|
||||
scope = viewModelScope
|
||||
_viewEvents.postValue(this)
|
||||
}
|
||||
|
||||
fun NavDirections.publish() {
|
||||
_viewEvents.postValue(NavigationEvent(this))
|
||||
}
|
||||
|
||||
}
|
48
app/src/main/java/com/topjohnwu/magisk/arch/Helpers.kt
Normal file
48
app/src/main/java/com/topjohnwu/magisk/arch/Helpers.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import com.topjohnwu.magisk.databinding.ComparableRvItem
|
||||
import com.topjohnwu.magisk.databinding.RvItem
|
||||
import com.topjohnwu.magisk.utils.DiffObservableList
|
||||
import com.topjohnwu.magisk.utils.FilterableDiffObservableList
|
||||
import me.tatarka.bindingcollectionadapter2.BindingRecyclerViewAdapter
|
||||
import me.tatarka.bindingcollectionadapter2.ItemBinding
|
||||
import me.tatarka.bindingcollectionadapter2.OnItemBind
|
||||
|
||||
fun <T : ComparableRvItem<*>> diffListOf(
|
||||
vararg newItems: T
|
||||
) = diffListOf(newItems.toList())
|
||||
|
||||
fun <T : ComparableRvItem<*>> diffListOf(
|
||||
newItems: List<T>
|
||||
) = DiffObservableList(object : DiffObservableList.Callback<T> {
|
||||
override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem.genericItemSameAs(newItem)
|
||||
override fun areContentsTheSame(oldItem: T, newItem: T) = oldItem.genericContentSameAs(newItem)
|
||||
}).also { it.update(newItems) }
|
||||
|
||||
fun <T : ComparableRvItem<*>> filterableListOf(
|
||||
vararg newItems: T
|
||||
) = FilterableDiffObservableList(object : DiffObservableList.Callback<T> {
|
||||
override fun areItemsTheSame(oldItem: T, newItem: T) = oldItem.genericItemSameAs(newItem)
|
||||
override fun areContentsTheSame(oldItem: T, newItem: T) = oldItem.genericContentSameAs(newItem)
|
||||
}).also { it.update(newItems.toList()) }
|
||||
|
||||
fun <T : RvItem> adapterOf() = object : BindingRecyclerViewAdapter<T>() {
|
||||
override fun onBindBinding(
|
||||
binding: ViewDataBinding,
|
||||
variableId: Int,
|
||||
layoutRes: Int,
|
||||
position: Int,
|
||||
item: T
|
||||
) {
|
||||
super.onBindBinding(binding, variableId, layoutRes, position, item)
|
||||
item.onBindingBound(binding)
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T : RvItem> itemBindingOf(
|
||||
crossinline body: (ItemBinding<*>) -> Unit = {}
|
||||
) = OnItemBind<T> { itemBinding, _, item ->
|
||||
item.bind(itemBinding)
|
||||
body(itemBinding)
|
||||
}
|
17
app/src/main/java/com/topjohnwu/magisk/arch/Queryable.kt
Normal file
17
app/src/main/java/com/topjohnwu/magisk/arch/Queryable.kt
Normal file
@@ -0,0 +1,17 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.os.Handler
|
||||
import androidx.core.os.postDelayed
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
||||
|
||||
interface Queryable {
|
||||
|
||||
val queryDelay: Long
|
||||
val queryHandler: Handler get() = UiThreadHandler.handler
|
||||
|
||||
fun submitQuery() {
|
||||
queryHandler.postDelayed(queryDelay) { query() }
|
||||
}
|
||||
|
||||
fun query()
|
||||
}
|
26
app/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt
Normal file
26
app/src/main/java/com/topjohnwu/magisk/arch/ViewEvent.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.topjohnwu.magisk.arch
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* Class for passing events from ViewModels to Activities/Fragments
|
||||
* (see https://medium.com/google-developers/livedata-with-snackbar-navigation-and-other-events-the-singleliveevent-case-ac2622673150)
|
||||
*/
|
||||
abstract class ViewEvent
|
||||
|
||||
abstract class ViewEventWithScope: ViewEvent() {
|
||||
lateinit var scope: CoroutineScope
|
||||
}
|
||||
|
||||
interface ContextExecutor {
|
||||
operator fun invoke(context: Context)
|
||||
}
|
||||
|
||||
interface ActivityExecutor {
|
||||
operator fun invoke(activity: BaseUIActivity<*, *>)
|
||||
}
|
||||
|
||||
interface FragmentExecutor {
|
||||
operator fun invoke(fragment: BaseUIFragment<*, *>)
|
||||
}
|
@@ -1,123 +0,0 @@
|
||||
package com.topjohnwu.magisk.base
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.collection.SparseArrayCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.Config
|
||||
import com.topjohnwu.magisk.base.viewmodel.BaseViewModel
|
||||
import com.topjohnwu.magisk.extensions.set
|
||||
import com.topjohnwu.magisk.model.events.EventHandler
|
||||
import com.topjohnwu.magisk.model.permissions.PermissionRequestBuilder
|
||||
import com.topjohnwu.magisk.utils.LocaleManager
|
||||
import com.topjohnwu.magisk.utils.currentLocale
|
||||
import kotlin.random.Random
|
||||
|
||||
typealias RequestCallback = BaseActivity<*, *>.(Int, Intent?) -> Unit
|
||||
|
||||
abstract class BaseActivity<ViewModel : BaseViewModel, Binding : ViewDataBinding> :
|
||||
AppCompatActivity(), EventHandler {
|
||||
|
||||
protected lateinit var binding: Binding
|
||||
protected abstract val layoutRes: Int
|
||||
protected abstract val viewModel: ViewModel
|
||||
protected open val snackbarView get() = binding.root
|
||||
protected open val navHostId: Int = 0
|
||||
protected open val defaultPosition: Int = 0
|
||||
|
||||
private val resultCallbacks by lazy { SparseArrayCompat<RequestCallback>() }
|
||||
|
||||
init {
|
||||
val theme = if (Config.darkTheme) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES
|
||||
} else {
|
||||
AppCompatDelegate.MODE_NIGHT_NO
|
||||
}
|
||||
AppCompatDelegate.setDefaultNightMode(theme)
|
||||
}
|
||||
|
||||
override fun applyOverrideConfiguration(config: Configuration?) {
|
||||
// Force applying our preferred local
|
||||
config?.setLocale(currentLocale)
|
||||
super.applyOverrideConfiguration(config)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(LocaleManager.getLocaleContext(base))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
viewModel.viewEvents.observe(this, viewEventObserver)
|
||||
|
||||
binding = DataBindingUtil.setContentView<Binding>(this, layoutRes).apply {
|
||||
setVariable(BR.viewModel, viewModel)
|
||||
lifecycleOwner = this@BaseActivity
|
||||
}
|
||||
}
|
||||
|
||||
fun withPermissions(vararg permissions: String, builder: PermissionRequestBuilder.() -> Unit) {
|
||||
val request = PermissionRequestBuilder().apply(builder).build()
|
||||
val ungranted = permissions.filter {
|
||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (ungranted.isEmpty()) {
|
||||
request.onSuccess()
|
||||
} else {
|
||||
val requestCode = Random.nextInt(256, 512)
|
||||
resultCallbacks[requestCode] = { result, _ ->
|
||||
if (result > 0)
|
||||
request.onSuccess()
|
||||
else
|
||||
request.onFailure()
|
||||
}
|
||||
ActivityCompat.requestPermissions(this, ungranted.toTypedArray(), requestCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun withExternalRW(builder: PermissionRequestBuilder.() -> Unit) {
|
||||
withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, builder = builder)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
var success = true
|
||||
for (res in grantResults) {
|
||||
if (res != PackageManager.PERMISSION_GRANTED) {
|
||||
success = false
|
||||
break
|
||||
}
|
||||
}
|
||||
resultCallbacks[requestCode]?.apply {
|
||||
resultCallbacks.remove(requestCode)
|
||||
invoke(this@BaseActivity, if (success) 1 else -1, null)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
resultCallbacks[requestCode]?.apply {
|
||||
resultCallbacks.remove(requestCode)
|
||||
invoke(this@BaseActivity, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
fun startActivityForResult(intent: Intent, requestCode: Int, listener: RequestCallback) {
|
||||
resultCallbacks[requestCode] = listener
|
||||
startActivityForResult(intent, requestCode)
|
||||
}
|
||||
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
package com.topjohnwu.magisk.base
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.databinding.DataBindingUtil
|
||||
import androidx.databinding.ViewDataBinding
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.topjohnwu.magisk.BR
|
||||
import com.topjohnwu.magisk.base.viewmodel.BaseViewModel
|
||||
import com.topjohnwu.magisk.model.events.EventHandler
|
||||
import com.topjohnwu.magisk.model.events.ViewEvent
|
||||
|
||||
abstract class BaseFragment<ViewModel : BaseViewModel, Binding : ViewDataBinding> :
|
||||
Fragment(), EventHandler {
|
||||
|
||||
protected val activity get() = requireActivity() as BaseActivity<*, *>
|
||||
protected lateinit var binding: Binding
|
||||
protected abstract val layoutRes: Int
|
||||
protected abstract val viewModel: ViewModel
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
viewModel.viewEvents.observe(this, viewEventObserver)
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
binding = DataBindingUtil.inflate<Binding>(inflater, layoutRes, container, false).apply {
|
||||
setVariable(BR.viewModel, viewModel)
|
||||
lifecycleOwner = this@BaseFragment
|
||||
}
|
||||
|
||||
return binding.root
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onEventDispatched(event: ViewEvent) {
|
||||
super.onEventDispatched(event)
|
||||
activity.onEventDispatched(event)
|
||||
}
|
||||
|
||||
open fun onBackPressed(): Boolean = false
|
||||
|
||||
}
|
@@ -1,56 +0,0 @@
|
||||
package com.topjohnwu.magisk.base
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.preference.*
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
abstract class BasePreferenceFragment : PreferenceFragmentCompat(),
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
protected val prefs: SharedPreferences by inject()
|
||||
protected val activity get() = requireActivity() as BaseActivity<*, *>
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View? {
|
||||
val v = super.onCreateView(inflater, container, savedInstanceState)
|
||||
prefs.registerOnSharedPreferenceChangeListener(this)
|
||||
return v
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun setAllPreferencesToAvoidHavingExtraSpace(preference: Preference) {
|
||||
preference.isIconSpaceReserved = false
|
||||
if (preference is PreferenceGroup)
|
||||
for (i in 0 until preference.preferenceCount)
|
||||
setAllPreferencesToAvoidHavingExtraSpace(preference.getPreference(i))
|
||||
}
|
||||
|
||||
override fun setPreferenceScreen(preferenceScreen: PreferenceScreen?) {
|
||||
if (preferenceScreen != null)
|
||||
setAllPreferencesToAvoidHavingExtraSpace(preferenceScreen)
|
||||
super.setPreferenceScreen(preferenceScreen)
|
||||
}
|
||||
|
||||
override fun onCreateAdapter(preferenceScreen: PreferenceScreen?): RecyclerView.Adapter<*> =
|
||||
object : PreferenceGroupAdapter(preferenceScreen) {
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onPreferenceHierarchyChange(preference: Preference?) {
|
||||
if (preference != null)
|
||||
setAllPreferencesToAvoidHavingExtraSpace(preference)
|
||||
super.onPreferenceHierarchyChange(preference)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,39 +0,0 @@
|
||||
package com.topjohnwu.magisk.base.viewmodel
|
||||
|
||||
import android.app.Activity
|
||||
import com.github.pwittchen.reactivenetwork.library.rx2.ReactiveNetwork
|
||||
import com.topjohnwu.magisk.extensions.doOnSubscribeUi
|
||||
import com.topjohnwu.magisk.extensions.get
|
||||
import com.topjohnwu.magisk.extensions.subscribeK
|
||||
import com.topjohnwu.magisk.model.events.BackPressEvent
|
||||
import com.topjohnwu.magisk.model.events.PermissionEvent
|
||||
import com.topjohnwu.magisk.model.events.ViewActionEvent
|
||||
import com.topjohnwu.magisk.utils.KObservableField
|
||||
import io.reactivex.Observable
|
||||
import io.reactivex.subjects.PublishSubject
|
||||
|
||||
|
||||
abstract class BaseViewModel(
|
||||
initialState: State = State.LOADING
|
||||
) : LoadingViewModel(initialState) {
|
||||
|
||||
val isConnected = KObservableField(false)
|
||||
|
||||
init {
|
||||
ReactiveNetwork.observeNetworkConnectivity(get())
|
||||
.subscribeK { isConnected.value = it.available() }
|
||||
.add()
|
||||
}
|
||||
|
||||
fun withView(action: Activity.() -> Unit) {
|
||||
ViewActionEvent(action).publish()
|
||||
}
|
||||
|
||||
fun withPermissions(vararg permissions: String): Observable<Boolean> {
|
||||
val subject = PublishSubject.create<Boolean>()
|
||||
return subject.doOnSubscribeUi { PermissionEvent(permissions.toList(), subject).publish() }
|
||||
}
|
||||
|
||||
fun back() = BackPressEvent().publish()
|
||||
|
||||
}
|
@@ -1,78 +0,0 @@
|
||||
package com.topjohnwu.magisk.base.viewmodel
|
||||
|
||||
import androidx.databinding.Bindable
|
||||
import com.topjohnwu.magisk.BR
|
||||
import io.reactivex.*
|
||||
|
||||
abstract class LoadingViewModel(defaultState: State = State.LOADING) :
|
||||
StatefulViewModel<LoadingViewModel.State>(defaultState) {
|
||||
|
||||
val loading @Bindable get() = state == State.LOADING
|
||||
val loaded @Bindable get() = state == State.LOADED
|
||||
val loadingFailed @Bindable get() = state == State.LOADING_FAILED
|
||||
|
||||
@Deprecated(
|
||||
"Direct access is recommended since 0.2. This access method will be removed in 1.0",
|
||||
ReplaceWith("state = State.LOADING", "com.topjohnwu.magisk.base.viewmodel.LoadingViewModel.State"),
|
||||
DeprecationLevel.WARNING
|
||||
)
|
||||
fun setLoading() {
|
||||
state = State.LOADING
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"Direct access is recommended since 0.2. This access method will be removed in 1.0",
|
||||
ReplaceWith("state = State.LOADED", "com.topjohnwu.magisk.base.viewmodel.LoadingViewModel.State"),
|
||||
DeprecationLevel.WARNING
|
||||
)
|
||||
fun setLoaded() {
|
||||
state = State.LOADED
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"Direct access is recommended since 0.2. This access method will be removed in 1.0",
|
||||
ReplaceWith("state = State.LOADING_FAILED", "com.topjohnwu.magisk.base.viewmodel.LoadingViewModel.State"),
|
||||
DeprecationLevel.WARNING
|
||||
)
|
||||
fun setLoadingFailed() {
|
||||
state = State.LOADING_FAILED
|
||||
}
|
||||
|
||||
override fun notifyStateChanged() {
|
||||
notifyPropertyChanged(BR.loading)
|
||||
notifyPropertyChanged(BR.loaded)
|
||||
notifyPropertyChanged(BR.loadingFailed)
|
||||
}
|
||||
|
||||
enum class State {
|
||||
LOADED, LOADING, LOADING_FAILED
|
||||
}
|
||||
|
||||
//region Rx
|
||||
protected fun <T> Observable<T>.applyViewModel(viewModel: LoadingViewModel, allowFinishing: Boolean = true) =
|
||||
doOnSubscribe { viewModel.state = State.LOADING }
|
||||
.doOnError { viewModel.state = State.LOADING_FAILED }
|
||||
.doOnNext { if (allowFinishing) viewModel.state = State.LOADED }
|
||||
|
||||
protected fun <T> Single<T>.applyViewModel(viewModel: LoadingViewModel, allowFinishing: Boolean = true) =
|
||||
doOnSubscribe { viewModel.state = State.LOADING }
|
||||
.doOnError { viewModel.state = State.LOADING_FAILED }
|
||||
.doOnSuccess { if (allowFinishing) viewModel.state = State.LOADED }
|
||||
|
||||
protected fun <T> Maybe<T>.applyViewModel(viewModel: LoadingViewModel, allowFinishing: Boolean = true) =
|
||||
doOnSubscribe { viewModel.state = State.LOADING }
|
||||
.doOnError { viewModel.state = State.LOADING_FAILED }
|
||||
.doOnComplete { if (allowFinishing) viewModel.state = State.LOADED }
|
||||
.doOnSuccess { if (allowFinishing) viewModel.state = State.LOADED }
|
||||
|
||||
protected fun <T> Flowable<T>.applyViewModel(viewModel: LoadingViewModel, allowFinishing: Boolean = true) =
|
||||
doOnSubscribe { viewModel.state = State.LOADING }
|
||||
.doOnError { viewModel.state = State.LOADING_FAILED }
|
||||
.doOnNext { if (allowFinishing) viewModel.state = State.LOADED }
|
||||
|
||||
protected fun Completable.applyViewModel(viewModel: LoadingViewModel, allowFinishing: Boolean = true) =
|
||||
doOnSubscribe { viewModel.state = State.LOADING }
|
||||
.doOnError { viewModel.state = State.LOADING_FAILED }
|
||||
.doOnComplete { if (allowFinishing) viewModel.state = State.LOADED }
|
||||
//endregion
|
||||
}
|
@@ -1,46 +0,0 @@
|
||||
package com.topjohnwu.magisk.base.viewmodel
|
||||
|
||||
import androidx.databinding.Observable
|
||||
import androidx.databinding.PropertyChangeRegistry
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
/**
|
||||
* Copy of [android.databinding.BaseObservable] which extends [ViewModel]
|
||||
*/
|
||||
abstract class ObservableViewModel : TeanityViewModel(), Observable {
|
||||
|
||||
@Transient
|
||||
private var callbacks: PropertyChangeRegistry? = null
|
||||
|
||||
@Synchronized
|
||||
override fun addOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) {
|
||||
if (callbacks == null) {
|
||||
callbacks = PropertyChangeRegistry()
|
||||
}
|
||||
callbacks?.add(callback)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun removeOnPropertyChangedCallback(callback: Observable.OnPropertyChangedCallback) {
|
||||
callbacks?.remove(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies listeners that all properties of this instance have changed.
|
||||
*/
|
||||
@Synchronized
|
||||
fun notifyChange() {
|
||||
callbacks?.notifyCallbacks(this, 0, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies listeners that a specific property has changed. The getter for the property
|
||||
* that changes should be marked with [android.databinding.Bindable] to generate a field in
|
||||
* `BR` to be used as `fieldId`.
|
||||
*
|
||||
* @param fieldId The generated BR id for the Bindable field.
|
||||
*/
|
||||
fun notifyPropertyChanged(fieldId: Int) {
|
||||
callbacks?.notifyCallbacks(this, fieldId, null)
|
||||
}
|
||||
}
|
@@ -1,15 +0,0 @@
|
||||
package com.topjohnwu.magisk.base.viewmodel
|
||||
|
||||
abstract class StatefulViewModel<State : Enum<*>>(
|
||||
val defaultState: State
|
||||
) : ObservableViewModel() {
|
||||
|
||||
var state: State = defaultState
|
||||
set(value) {
|
||||
field = value
|
||||
notifyStateChanged()
|
||||
}
|
||||
|
||||
open fun notifyStateChanged() = Unit
|
||||
|
||||
}
|
@@ -1,33 +0,0 @@
|
||||
package com.topjohnwu.magisk.base.viewmodel
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.topjohnwu.magisk.model.events.SimpleViewEvent
|
||||
import com.topjohnwu.magisk.model.events.ViewEvent
|
||||
import io.reactivex.disposables.CompositeDisposable
|
||||
import io.reactivex.disposables.Disposable
|
||||
|
||||
abstract class TeanityViewModel : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val _viewEvents = MutableLiveData<ViewEvent>()
|
||||
val viewEvents: LiveData<ViewEvent> get() = _viewEvents
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun <Event : ViewEvent> Event.publish() {
|
||||
_viewEvents.value = this
|
||||
}
|
||||
|
||||
fun Int.publish() {
|
||||
_viewEvents.value = SimpleViewEvent(this)
|
||||
}
|
||||
|
||||
fun Disposable.add() {
|
||||
disposables.add(this)
|
||||
}
|
||||
}
|
106
app/src/main/java/com/topjohnwu/magisk/core/App.kt
Normal file
106
app/src/main/java/com/topjohnwu/magisk/core/App.kt
Normal file
@@ -0,0 +1,106 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.multidex.MultiDex
|
||||
import androidx.work.WorkManager
|
||||
import com.topjohnwu.magisk.BuildConfig
|
||||
import com.topjohnwu.magisk.DynAPK
|
||||
import com.topjohnwu.magisk.core.utils.IODispatcherExecutor
|
||||
import com.topjohnwu.magisk.core.utils.RootInit
|
||||
import com.topjohnwu.magisk.core.utils.updateConfig
|
||||
import com.topjohnwu.magisk.di.koinModules
|
||||
import com.topjohnwu.magisk.ktx.unwrap
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import timber.log.Timber
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
open class App() : Application() {
|
||||
|
||||
constructor(o: Any) : this() {
|
||||
Info.stub = DynAPK.load(o)
|
||||
}
|
||||
|
||||
init {
|
||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
||||
Shell.setDefaultBuilder(Shell.Builder.create()
|
||||
.setFlags(Shell.FLAG_MOUNT_MASTER)
|
||||
.setInitializers(RootInit::class.java)
|
||||
.setTimeout(2))
|
||||
Shell.EXECUTOR = IODispatcherExecutor()
|
||||
|
||||
// Always log full stack trace with Timber
|
||||
Timber.plant(Timber.DebugTree())
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, e ->
|
||||
Timber.e(e)
|
||||
exitProcess(1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
// Basic setup
|
||||
if (BuildConfig.DEBUG)
|
||||
MultiDex.install(base)
|
||||
|
||||
// Some context magic
|
||||
val app: Application
|
||||
val impl: Context
|
||||
if (base is Application) {
|
||||
app = base
|
||||
impl = base.baseContext
|
||||
} else {
|
||||
app = this
|
||||
impl = base
|
||||
}
|
||||
val wrapped = impl.wrap()
|
||||
super.attachBaseContext(wrapped)
|
||||
|
||||
// Normal startup
|
||||
startKoin {
|
||||
androidContext(wrapped)
|
||||
modules(koinModules)
|
||||
}
|
||||
ResMgr.init(impl)
|
||||
app.registerActivityLifecycleCallbacks(ForegroundTracker)
|
||||
WorkManager.initialize(impl.wrapJob(), androidx.work.Configuration.Builder().build())
|
||||
}
|
||||
|
||||
// This is required as some platforms expect ContextImpl
|
||||
override fun getBaseContext(): Context {
|
||||
return super.getBaseContext().unwrap()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
resources.updateConfig(newConfig)
|
||||
if (!isRunningAsStub)
|
||||
super.onConfigurationChanged(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
object ForegroundTracker : Application.ActivityLifecycleCallbacks {
|
||||
|
||||
@Volatile
|
||||
var foreground: Activity? = null
|
||||
|
||||
val hasForeground get() = foreground != null
|
||||
|
||||
override fun onActivityResumed(activity: Activity) {
|
||||
foreground = activity
|
||||
}
|
||||
|
||||
override fun onActivityPaused(activity: Activity) {
|
||||
foreground = null
|
||||
}
|
||||
|
||||
override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
|
||||
override fun onActivityStarted(activity: Activity) {}
|
||||
override fun onActivityStopped(activity: Activity) {}
|
||||
override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {}
|
||||
override fun onActivityDestroyed(activity: Activity) {}
|
||||
}
|
@@ -1,25 +1,25 @@
|
||||
package com.topjohnwu.magisk
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Environment
|
||||
import android.util.Xml
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.content.edit
|
||||
import com.topjohnwu.magisk.data.database.SettingsDao
|
||||
import com.topjohnwu.magisk.data.database.StringDao
|
||||
import com.topjohnwu.magisk.BuildConfig
|
||||
import com.topjohnwu.magisk.core.magiskdb.SettingsDao
|
||||
import com.topjohnwu.magisk.core.magiskdb.StringDao
|
||||
import com.topjohnwu.magisk.core.utils.BiometricHelper
|
||||
import com.topjohnwu.magisk.core.utils.refreshLocale
|
||||
import com.topjohnwu.magisk.data.preference.PreferenceModel
|
||||
import com.topjohnwu.magisk.data.repository.DBConfig
|
||||
import com.topjohnwu.magisk.di.Protected
|
||||
import com.topjohnwu.magisk.extensions.get
|
||||
import com.topjohnwu.magisk.extensions.inject
|
||||
import com.topjohnwu.magisk.extensions.packageName
|
||||
import com.topjohnwu.magisk.model.preference.PreferenceModel
|
||||
import com.topjohnwu.magisk.utils.FingerprintHelper
|
||||
import com.topjohnwu.magisk.utils.Utils
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.io.SuFile
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream
|
||||
import com.topjohnwu.magisk.ktx.inject
|
||||
import com.topjohnwu.magisk.ui.theme.Theme
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
|
||||
object Config : PreferenceModel, DBConfig {
|
||||
|
||||
@@ -27,31 +27,46 @@ object Config : PreferenceModel, DBConfig {
|
||||
override val settingsDao: SettingsDao by inject()
|
||||
override val context: Context by inject(Protected)
|
||||
|
||||
@get:SuppressLint("ApplySharedPref")
|
||||
val prefsFile: File get() {
|
||||
// Flush prefs to disk
|
||||
prefs.edit().apply {
|
||||
remove(Key.ASKED_HOME)
|
||||
}.commit()
|
||||
return File("${context.filesDir.parent}/shared_prefs", "${fileName}.xml")
|
||||
}
|
||||
|
||||
object Key {
|
||||
// db configs
|
||||
const val ROOT_ACCESS = "root_access"
|
||||
const val SU_MULTIUSER_MODE = "multiuser_mode"
|
||||
const val SU_MNT_NS = "mnt_ns"
|
||||
const val SU_BIOMETRIC = "su_biometric"
|
||||
const val SU_MANAGER = "requester"
|
||||
const val SU_FINGERPRINT = "su_fingerprint"
|
||||
const val KEYSTORE = "keystore"
|
||||
|
||||
// prefs
|
||||
const val SU_REQUEST_TIMEOUT = "su_request_timeout"
|
||||
const val SU_AUTO_RESPONSE = "su_auto_response"
|
||||
const val SU_NOTIFICATION = "su_notification"
|
||||
const val SU_REAUTH = "su_reauth"
|
||||
const val SU_TAPJACK = "su_tapjack"
|
||||
const val CHECK_UPDATES = "check_update"
|
||||
const val UPDATE_CHANNEL = "update_channel"
|
||||
const val CUSTOM_CHANNEL = "custom_channel"
|
||||
const val LOCALE = "locale"
|
||||
const val DARK_THEME = "dark_theme"
|
||||
const val DARK_THEME = "dark_theme_extended"
|
||||
const val REPO_ORDER = "repo_order"
|
||||
const val SHOW_SYSTEM_APP = "show_system"
|
||||
const val DOWNLOAD_PATH = "download_path"
|
||||
const val DOWNLOAD_DIR = "download_dir"
|
||||
const val SAFETY = "safety_notice"
|
||||
const val THEME_ORDINAL = "theme_ordinal"
|
||||
const val BOOT_ID = "boot_id"
|
||||
const val ASKED_HOME = "asked_home"
|
||||
const val DOH = "doh"
|
||||
|
||||
// system state
|
||||
const val MAGISKHIDE = "magiskhide"
|
||||
const val COREONLY = "disable"
|
||||
}
|
||||
|
||||
object Value {
|
||||
@@ -61,7 +76,6 @@ object Config : PreferenceModel, DBConfig {
|
||||
const val BETA_CHANNEL = 1
|
||||
const val CUSTOM_CHANNEL = 2
|
||||
const val CANARY_CHANNEL = 3
|
||||
const val CANARY_DEBUG_CHANNEL = 4
|
||||
|
||||
// root access mode
|
||||
const val ROOT_ACCESS_DISABLED = 0
|
||||
@@ -97,57 +111,83 @@ object Config : PreferenceModel, DBConfig {
|
||||
}
|
||||
|
||||
private val defaultChannel =
|
||||
if (Utils.isCanary) Value.CANARY_DEBUG_CHANNEL
|
||||
else Value.DEFAULT_CHANNEL
|
||||
if (BuildConfig.DEBUG)
|
||||
Value.CANARY_CHANNEL
|
||||
else
|
||||
Value.DEFAULT_CHANNEL
|
||||
|
||||
var downloadPath by preference(Key.DOWNLOAD_PATH, Environment.DIRECTORY_DOWNLOADS)
|
||||
@JvmStatic var keepVerity = false
|
||||
@JvmStatic var keepEnc = false
|
||||
@JvmStatic var recovery = false
|
||||
|
||||
var bootId by preference(Key.BOOT_ID, "")
|
||||
var askedHome by preference(Key.ASKED_HOME, false)
|
||||
|
||||
var downloadDir by preference(Key.DOWNLOAD_DIR, "")
|
||||
var repoOrder by preference(Key.REPO_ORDER, Value.ORDER_DATE)
|
||||
|
||||
var suDefaultTimeout by preferenceStrInt(Key.SU_REQUEST_TIMEOUT, 10)
|
||||
var suAutoReponse by preferenceStrInt(Key.SU_AUTO_RESPONSE, Value.SU_PROMPT)
|
||||
var suAutoResponse by preferenceStrInt(Key.SU_AUTO_RESPONSE, Value.SU_PROMPT)
|
||||
var suNotification by preferenceStrInt(Key.SU_NOTIFICATION, Value.NOTIFICATION_TOAST)
|
||||
var updateChannel by preferenceStrInt(Key.UPDATE_CHANNEL, defaultChannel)
|
||||
|
||||
var darkTheme by preference(Key.DARK_THEME, true)
|
||||
var safetyNotice by preference(Key.SAFETY, true)
|
||||
var darkTheme by preference(Key.DARK_THEME, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
var themeOrdinal by preference(Key.THEME_ORDINAL, Theme.Piplup.ordinal)
|
||||
var suReAuth by preference(Key.SU_REAUTH, false)
|
||||
var suTapjack by preference(Key.SU_TAPJACK, true)
|
||||
var checkUpdate by preference(Key.CHECK_UPDATES, true)
|
||||
var doh by preference(Key.DOH, false)
|
||||
var magiskHide by preference(Key.MAGISKHIDE, true)
|
||||
var coreOnly by preference(Key.COREONLY, false)
|
||||
var showSystemApp by preference(Key.SHOW_SYSTEM_APP, false)
|
||||
|
||||
var customChannelUrl by preference(Key.CUSTOM_CHANNEL, "")
|
||||
var locale by preference(Key.LOCALE, "")
|
||||
private var localePrefs by preference(Key.LOCALE, "")
|
||||
var locale
|
||||
get() = localePrefs
|
||||
set(value) {
|
||||
localePrefs = value
|
||||
refreshLocale()
|
||||
}
|
||||
|
||||
var rootMode by dbSettings(Key.ROOT_ACCESS, Value.ROOT_ACCESS_APPS_AND_ADB)
|
||||
var suMntNamespaceMode by dbSettings(Key.SU_MNT_NS, Value.NAMESPACE_MODE_REQUESTER)
|
||||
var suMultiuserMode by dbSettings(Key.SU_MULTIUSER_MODE, Value.MULTIUSER_MODE_OWNER_ONLY)
|
||||
var suFingerprint by dbSettings(Key.SU_FINGERPRINT, false)
|
||||
var suBiometric by dbSettings(Key.SU_BIOMETRIC, false)
|
||||
var suManager by dbStrings(Key.SU_MANAGER, "", true)
|
||||
var keyStoreRaw by dbStrings(Key.KEYSTORE, "", true)
|
||||
|
||||
// Always return a path in external storage where we can write
|
||||
val downloadDirectory get() =
|
||||
Utils.ensureDownloadPath(downloadPath) ?: get<Context>().getExternalFilesDir(null)!!
|
||||
private const val SU_FINGERPRINT = "su_fingerprint"
|
||||
|
||||
fun initialize() = prefs.edit {
|
||||
parsePrefs(this)
|
||||
fun load(pkg: String) {
|
||||
try {
|
||||
context.contentResolver.openInputStream(Provider.PREFS_URI(pkg))?.use {
|
||||
prefs.edit { parsePrefs(it) }
|
||||
}
|
||||
} catch (e: IOException) {}
|
||||
|
||||
if (!prefs.contains(Key.UPDATE_CHANNEL))
|
||||
putString(Key.UPDATE_CHANNEL, defaultChannel.toString())
|
||||
prefs.edit {
|
||||
// Settings migration
|
||||
if (prefs.getBoolean(SU_FINGERPRINT, false))
|
||||
suBiometric = true
|
||||
remove(SU_FINGERPRINT)
|
||||
prefs.getString(Key.UPDATE_CHANNEL, null).also {
|
||||
if (it == null)
|
||||
putString(Key.UPDATE_CHANNEL, defaultChannel.toString())
|
||||
else if (it.toInt() > Value.CANARY_CHANNEL)
|
||||
putString(Key.UPDATE_CHANNEL, Value.CANARY_CHANNEL.toString())
|
||||
}
|
||||
|
||||
// Get actual state
|
||||
putBoolean(Key.COREONLY, Const.MAGISK_DISABLE_FILE.exists())
|
||||
|
||||
// Write database configs
|
||||
putString(Key.ROOT_ACCESS, rootMode.toString())
|
||||
putString(Key.SU_MNT_NS, suMntNamespaceMode.toString())
|
||||
putString(Key.SU_MULTIUSER_MODE, suMultiuserMode.toString())
|
||||
putBoolean(Key.SU_FINGERPRINT, FingerprintHelper.useFingerprint())
|
||||
// Write database configs
|
||||
putString(Key.ROOT_ACCESS, rootMode.toString())
|
||||
putString(Key.SU_MNT_NS, suMntNamespaceMode.toString())
|
||||
putString(Key.SU_MULTIUSER_MODE, suMultiuserMode.toString())
|
||||
putBoolean(Key.SU_BIOMETRIC, BiometricHelper.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parsePrefs(editor: SharedPreferences.Editor) = editor.apply {
|
||||
val config = SuFile.open("/data/adb", Const.MANAGER_CONFIGS)
|
||||
if (config.exists()) runCatching {
|
||||
val input = SuFileInputStream(config).buffered()
|
||||
private fun SharedPreferences.Editor.parsePrefs(input: InputStream) {
|
||||
runCatching {
|
||||
val parser = Xml.newPullParser()
|
||||
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
|
||||
parser.setInput(input, "UTF-8")
|
||||
@@ -191,18 +231,6 @@ object Config : PreferenceModel, DBConfig {
|
||||
else -> parser.next()
|
||||
}
|
||||
}
|
||||
config.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun export() {
|
||||
// Flush prefs to disk
|
||||
prefs.edit().commit()
|
||||
val xml = File(
|
||||
"${get<Context>(Protected).filesDir.parent}/shared_prefs",
|
||||
"${packageName}_preferences.xml"
|
||||
)
|
||||
Shell.su("cat $xml > /data/adb/${Const.MANAGER_CONFIGS}").exec()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
79
app/src/main/java/com/topjohnwu/magisk/core/Const.kt
Normal file
79
app/src/main/java/com/topjohnwu/magisk/core/Const.kt
Normal file
@@ -0,0 +1,79 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.os.Process
|
||||
|
||||
object Const {
|
||||
|
||||
// Paths
|
||||
lateinit var MAGISKTMP: String
|
||||
val MAGISK_PATH get() = "$MAGISKTMP/modules"
|
||||
const val TMP_FOLDER_PATH = "/dev/tmp"
|
||||
const val MAGISK_LOG = "/cache/magisk.log"
|
||||
|
||||
// Versions
|
||||
const val SNET_EXT_VER = 15
|
||||
const val SNET_REVISION = "18ab78817087c337ae0edd1ecac38aec49217880"
|
||||
const val BOOTCTL_REVISION = "18ab78817087c337ae0edd1ecac38aec49217880"
|
||||
|
||||
// Misc
|
||||
const val ANDROID_MANIFEST = "AndroidManifest.xml"
|
||||
const val MAGISK_INSTALL_LOG_FILENAME = "magisk_install_log_%s.log"
|
||||
val USER_ID = Process.myUid() / 100000
|
||||
|
||||
object Version {
|
||||
const val MIN_VERSION = "v19.0"
|
||||
const val MIN_VERCODE = 19000
|
||||
|
||||
fun atLeast_20_2() = Info.env.magiskVersionCode >= 20200 || isCanary()
|
||||
fun atLeast_20_4() = Info.env.magiskVersionCode >= 20400 || isCanary()
|
||||
fun atLeast_21_0() = Info.env.magiskVersionCode >= 21000 || isCanary()
|
||||
fun isCanary() = Info.env.magiskVersionCode % 100 != 0
|
||||
}
|
||||
|
||||
object ID {
|
||||
const val FETCH_ZIP = 2
|
||||
const val SELECT_FILE = 3
|
||||
const val MAX_ACTIVITY_RESULT = 10
|
||||
|
||||
// notifications
|
||||
const val MAGISK_UPDATE_NOTIFICATION_ID = 4
|
||||
const val APK_UPDATE_NOTIFICATION_ID = 5
|
||||
const val HIDE_MANAGER_NOTIFICATION_ID = 8
|
||||
const val UPDATE_NOTIFICATION_CHANNEL = "update"
|
||||
const val PROGRESS_NOTIFICATION_CHANNEL = "progress"
|
||||
const val CHECK_MAGISK_UPDATE_WORKER_ID = "magisk_update"
|
||||
}
|
||||
|
||||
object Url {
|
||||
const val PATREON_URL = "https://www.patreon.com/topjohnwu"
|
||||
const val SOURCE_CODE_URL = "https://github.com/topjohnwu/Magisk"
|
||||
|
||||
const val GITHUB_RAW_URL = "https://raw.githubusercontent.com/"
|
||||
const val GITHUB_API_URL = "https://api.github.com/"
|
||||
const val GITHUB_PAGE_URL = "https://topjohnwu.github.io/magisk_files/"
|
||||
const val JS_DELIVR_URL = "https://cdn.jsdelivr.net/gh/"
|
||||
const val OFFICIAL_REPO = "https://magisk-modules-repo.github.io/submission/modules.json"
|
||||
}
|
||||
|
||||
object Key {
|
||||
// intents
|
||||
const val OPEN_SECTION = "section"
|
||||
const val HIDDEN_PKG = "hidden_pkg"
|
||||
}
|
||||
|
||||
object Value {
|
||||
const val FLASH_ZIP = "flash"
|
||||
const val PATCH_FILE = "patch"
|
||||
const val FLASH_MAGISK = "magisk"
|
||||
const val FLASH_INACTIVE_SLOT = "slot"
|
||||
const val UNINSTALL = "uninstall"
|
||||
}
|
||||
|
||||
object Nav {
|
||||
const val HOME = "home"
|
||||
const val SETTINGS = "settings"
|
||||
const val HIDE = "hide"
|
||||
const val MODULES = "modules"
|
||||
const val SUPERUSER = "superuser"
|
||||
}
|
||||
}
|
173
app/src/main/java/com/topjohnwu/magisk/core/Hacks.kt
Normal file
173
app/src/main/java/com/topjohnwu/magisk/core/Hacks.kt
Normal file
@@ -0,0 +1,173 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.job.JobInfo
|
||||
import android.app.job.JobScheduler
|
||||
import android.app.job.JobWorkItem
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import android.content.res.AssetManager
|
||||
import android.content.res.Configuration
|
||||
import android.content.res.Resources
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.topjohnwu.magisk.DynAPK
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.download.DownloadService
|
||||
import com.topjohnwu.magisk.core.utils.refreshLocale
|
||||
import com.topjohnwu.magisk.core.utils.updateConfig
|
||||
import com.topjohnwu.magisk.ktx.forceGetDeclaredField
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.ui.surequest.SuRequestActivity
|
||||
|
||||
fun AssetManager.addAssetPath(path: String) {
|
||||
DynAPK.addAssetPath(this, path)
|
||||
}
|
||||
|
||||
fun Context.wrap(global: Boolean = true): Context =
|
||||
if (global) GlobalResContext(this) else ResContext(this)
|
||||
|
||||
fun Context.wrapJob(): Context = object : GlobalResContext(this) {
|
||||
|
||||
override fun getApplicationContext(): Context {
|
||||
return this
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi")
|
||||
override fun getSystemService(name: String): Any? {
|
||||
return if (!isRunningAsStub) super.getSystemService(name) else
|
||||
when (name) {
|
||||
Context.JOB_SCHEDULER_SERVICE ->
|
||||
JobSchedulerWrapper(super.getSystemService(name) as JobScheduler)
|
||||
else -> super.getSystemService(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Class<*>.cmp(pkg: String): ComponentName {
|
||||
val name = ClassMap[this].name
|
||||
return ComponentName(pkg, Info.stub?.classToComponent?.get(name) ?: name)
|
||||
}
|
||||
|
||||
inline fun <reified T> Activity.redirect() = Intent(intent)
|
||||
.setComponent(T::class.java.cmp(packageName))
|
||||
.setFlags(0)
|
||||
|
||||
inline fun <reified T> Context.intent() = Intent().setComponent(T::class.java.cmp(packageName))
|
||||
|
||||
private open class GlobalResContext(base: Context) : ContextWrapper(base) {
|
||||
open val mRes: Resources get() = ResMgr.resource
|
||||
|
||||
override fun getResources(): Resources {
|
||||
return mRes
|
||||
}
|
||||
|
||||
override fun getClassLoader(): ClassLoader {
|
||||
return javaClass.classLoader!!
|
||||
}
|
||||
|
||||
override fun createConfigurationContext(config: Configuration): Context {
|
||||
return ResContext(super.createConfigurationContext(config))
|
||||
}
|
||||
}
|
||||
|
||||
private class ResContext(base: Context) : GlobalResContext(base) {
|
||||
override val mRes by lazy { base.resources.patch() }
|
||||
|
||||
private fun Resources.patch(): Resources {
|
||||
updateConfig()
|
||||
if (isRunningAsStub)
|
||||
assets.addAssetPath(ResMgr.apk)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
object ResMgr {
|
||||
|
||||
lateinit var resource: Resources
|
||||
lateinit var apk: String
|
||||
|
||||
fun init(context: Context) {
|
||||
resource = context.resources
|
||||
refreshLocale()
|
||||
if (isRunningAsStub) {
|
||||
apk = DynAPK.current(context).path
|
||||
resource.assets.addAssetPath(apk)
|
||||
} else {
|
||||
apk = context.packageResourcePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
private class JobSchedulerWrapper(private val base: JobScheduler) : JobScheduler() {
|
||||
|
||||
override fun schedule(job: JobInfo): Int {
|
||||
return base.schedule(job.patch())
|
||||
}
|
||||
|
||||
override fun enqueue(job: JobInfo, work: JobWorkItem): Int {
|
||||
return base.enqueue(job.patch(), work)
|
||||
}
|
||||
|
||||
override fun cancel(jobId: Int) {
|
||||
base.cancel(jobId)
|
||||
}
|
||||
|
||||
override fun cancelAll() {
|
||||
base.cancelAll()
|
||||
}
|
||||
|
||||
override fun getAllPendingJobs(): List<JobInfo> {
|
||||
return base.allPendingJobs
|
||||
}
|
||||
|
||||
override fun getPendingJob(jobId: Int): JobInfo? {
|
||||
return base.getPendingJob(jobId)
|
||||
}
|
||||
|
||||
private fun JobInfo.patch(): JobInfo {
|
||||
// We need to swap out the service of JobInfo
|
||||
val name = service.className
|
||||
val component = ComponentName(
|
||||
service.packageName,
|
||||
Info.stubChk.classToComponent[name] ?: name
|
||||
)
|
||||
|
||||
javaClass.forceGetDeclaredField("service")?.set(this, component)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
private object ClassMap {
|
||||
|
||||
private val map = mapOf(
|
||||
App::class.java to a.e::class.java,
|
||||
MainActivity::class.java to a.b::class.java,
|
||||
SplashActivity::class.java to a.c::class.java,
|
||||
Receiver::class.java to a.h::class.java,
|
||||
DownloadService::class.java to a.j::class.java,
|
||||
SuRequestActivity::class.java to a.m::class.java
|
||||
)
|
||||
|
||||
operator fun get(c: Class<*>) = map.getOrElse(c) { c }
|
||||
}
|
||||
|
||||
// Keep a reference to these resources to prevent it from
|
||||
// being removed when running "remove unused resources"
|
||||
val shouldKeepResources = listOf(
|
||||
R.string.no_info_provided,
|
||||
R.string.release_notes,
|
||||
R.string.invalid_update_channel,
|
||||
R.string.update_available,
|
||||
R.string.safetynet_api_error,
|
||||
R.raw.changelog,
|
||||
R.drawable.ic_device,
|
||||
R.drawable.ic_hide_select_md2,
|
||||
R.drawable.ic_more,
|
||||
R.drawable.ic_magisk_delete
|
||||
)
|
87
app/src/main/java/com/topjohnwu/magisk/core/Info.kt
Normal file
87
app/src/main/java/com/topjohnwu/magisk/core/Info.kt
Normal file
@@ -0,0 +1,87 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import androidx.databinding.ObservableBoolean
|
||||
import com.topjohnwu.magisk.DynAPK
|
||||
import com.topjohnwu.magisk.core.model.UpdateInfo
|
||||
import com.topjohnwu.magisk.core.utils.net.NetworkObserver
|
||||
import com.topjohnwu.magisk.ktx.get
|
||||
import com.topjohnwu.magisk.utils.CachedValue
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.ShellUtils.fastCmd
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
|
||||
val isRunningAsStub get() = Info.stub != null
|
||||
|
||||
object Info {
|
||||
|
||||
val envRef = CachedValue { loadState() }
|
||||
|
||||
@JvmStatic val env by envRef
|
||||
|
||||
var stub: DynAPK.Data? = null
|
||||
val stubChk: DynAPK.Data
|
||||
get() = stub as DynAPK.Data
|
||||
|
||||
var remote = UpdateInfo()
|
||||
|
||||
// Device state
|
||||
var crypto = ""
|
||||
@JvmStatic var isSAR = false
|
||||
@JvmStatic var isAB = false
|
||||
@JvmStatic val isFDE get() = crypto == "block"
|
||||
@JvmStatic var ramdisk = false
|
||||
@JvmStatic var hasGMS = true
|
||||
@JvmStatic var isPixel = false
|
||||
@JvmStatic val cryptoText get() = crypto.capitalize(Locale.US)
|
||||
|
||||
val isConnected by lazy {
|
||||
ObservableBoolean(false).also { field ->
|
||||
NetworkObserver.observe(get()) {
|
||||
UiThreadHandler.run { field.set(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isNewReboot by lazy {
|
||||
try {
|
||||
FileInputStream("/proc/sys/kernel/random/boot_id").bufferedReader().use {
|
||||
val id = it.readLine()
|
||||
if (id != Config.bootId) {
|
||||
Config.bootId = id
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadState() = Env(
|
||||
fastCmd("magisk -v").split(":".toRegex())[0],
|
||||
runCatching { fastCmd("magisk -V").toInt() }.getOrDefault(-1),
|
||||
Shell.su("magiskhide --status").exec().isSuccess
|
||||
)
|
||||
|
||||
class Env(
|
||||
val magiskVersionString: String = "",
|
||||
code: Int = -1,
|
||||
hide: Boolean = false
|
||||
) {
|
||||
val magiskHide get() = Config.magiskHide
|
||||
val magiskVersionCode = when (code) {
|
||||
in Int.MIN_VALUE..Const.Version.MIN_VERCODE -> -1
|
||||
else -> if (Shell.rootAccess()) code else -1
|
||||
}
|
||||
val isUnsupported = code > 0 && code < Const.Version.MIN_VERCODE
|
||||
val isActive = magiskVersionCode >= 0
|
||||
|
||||
init {
|
||||
Config.magiskHide = hide
|
||||
}
|
||||
}
|
||||
}
|
39
app/src/main/java/com/topjohnwu/magisk/core/Provider.kt
Normal file
39
app/src/main/java/com/topjohnwu/magisk/core/Provider.kt
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ProviderInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.ParcelFileDescriptor.MODE_READ_ONLY
|
||||
import com.topjohnwu.magisk.FileProvider
|
||||
import com.topjohnwu.magisk.core.su.SuCallbackHandler
|
||||
import java.io.File
|
||||
|
||||
open class Provider : FileProvider() {
|
||||
|
||||
override fun attachInfo(context: Context, info: ProviderInfo?) {
|
||||
super.attachInfo(context.wrap(), info)
|
||||
}
|
||||
|
||||
override fun call(method: String, arg: String?, extras: Bundle?): Bundle? {
|
||||
SuCallbackHandler(context!!, method, extras)
|
||||
return Bundle.EMPTY
|
||||
}
|
||||
|
||||
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||
return when (uri.encodedPath ?: return null) {
|
||||
"/apk_file" -> ParcelFileDescriptor.open(File(context!!.packageCodePath), MODE_READ_ONLY)
|
||||
"/prefs_file" -> ParcelFileDescriptor.open(Config.prefsFile, MODE_READ_ONLY)
|
||||
else -> super.openFile(uri, mode)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun APK_URI(pkg: String) =
|
||||
Uri.Builder().scheme("content").authority("$pkg.provider").path("apk_file").build()
|
||||
|
||||
fun PREFS_URI(pkg: String) =
|
||||
Uri.Builder().scheme("content").authority("$pkg.provider").path("prefs_file").build()
|
||||
}
|
||||
}
|
46
app/src/main/java/com/topjohnwu/magisk/core/Receiver.kt
Normal file
46
app/src/main/java/com/topjohnwu/magisk/core/Receiver.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import com.topjohnwu.magisk.core.base.BaseReceiver
|
||||
import com.topjohnwu.magisk.core.magiskdb.PolicyDao
|
||||
import com.topjohnwu.magisk.core.su.SuCallbackHandler
|
||||
import com.topjohnwu.magisk.view.Shortcuts
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.inject
|
||||
|
||||
open class Receiver : BaseReceiver() {
|
||||
|
||||
private val policyDB: PolicyDao by inject()
|
||||
|
||||
private fun getPkg(intent: Intent): String {
|
||||
return intent.data?.encodedSchemeSpecificPart.orEmpty()
|
||||
}
|
||||
|
||||
override fun onReceive(context: ContextWrapper, intent: Intent?) {
|
||||
intent ?: return
|
||||
|
||||
fun rmPolicy(pkg: String) = GlobalScope.launch {
|
||||
policyDB.delete(pkg)
|
||||
}
|
||||
|
||||
when (intent.action ?: return) {
|
||||
Intent.ACTION_REBOOT -> {
|
||||
SuCallbackHandler(context, intent.getStringExtra("action"), intent.extras)
|
||||
}
|
||||
Intent.ACTION_PACKAGE_REPLACED -> {
|
||||
// This will only work pre-O
|
||||
if (Config.suReAuth)
|
||||
rmPolicy(getPkg(intent))
|
||||
}
|
||||
Intent.ACTION_PACKAGE_FULLY_REMOVED -> {
|
||||
val pkg = getPkg(intent)
|
||||
rmPolicy(pkg)
|
||||
Shell.su("magiskhide --rm $pkg").submit()
|
||||
}
|
||||
Intent.ACTION_LOCALE_CHANGED -> Shortcuts.setupDynamic(context)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,71 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import com.topjohnwu.magisk.BuildConfig.APPLICATION_ID
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.data.repository.NetworkService
|
||||
import com.topjohnwu.magisk.ktx.get
|
||||
import com.topjohnwu.magisk.ui.MainActivity
|
||||
import com.topjohnwu.magisk.view.Notifications
|
||||
import com.topjohnwu.magisk.view.Shortcuts
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
open class SplashActivity : Activity() {
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base.wrap())
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setTheme(R.style.SplashTheme)
|
||||
super.onCreate(savedInstanceState)
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
initAndStart()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRepackage(pkg: String?) {
|
||||
if (packageName != APPLICATION_ID) {
|
||||
runCatching {
|
||||
// Hidden, remove com.topjohnwu.magisk if exist as it could be malware
|
||||
packageManager.getApplicationInfo(APPLICATION_ID, 0)
|
||||
Shell.su("(pm uninstall $APPLICATION_ID)& >/dev/null 2>&1").exec()
|
||||
}
|
||||
} else {
|
||||
if (Config.suManager.isNotEmpty())
|
||||
Config.suManager = ""
|
||||
pkg ?: return
|
||||
Shell.su("(pm uninstall $pkg)& >/dev/null 2>&1").exec()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initAndStart() {
|
||||
// Pre-initialize root shell
|
||||
Shell.getShell()
|
||||
|
||||
val hiddenPackage = intent.getStringExtra(Const.Key.HIDDEN_PKG)
|
||||
|
||||
Config.load(hiddenPackage ?: APPLICATION_ID)
|
||||
handleRepackage(hiddenPackage)
|
||||
Notifications.setup(this)
|
||||
UpdateCheckService.schedule(this)
|
||||
Shortcuts.setupDynamic(this)
|
||||
|
||||
// Pre-fetch network services
|
||||
get<NetworkService>()
|
||||
|
||||
DONE = true
|
||||
|
||||
redirect<MainActivity>().also { startActivity(it) }
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
var DONE = false
|
||||
}
|
||||
}
|
@@ -0,0 +1,53 @@
|
||||
package com.topjohnwu.magisk.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.*
|
||||
import com.topjohnwu.magisk.BuildConfig
|
||||
import com.topjohnwu.magisk.data.repository.NetworkService
|
||||
import com.topjohnwu.magisk.view.Notifications
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.KoinComponent
|
||||
import org.koin.core.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class UpdateCheckService(context: Context, workerParams: WorkerParameters)
|
||||
: CoroutineWorker(context, workerParams), KoinComponent {
|
||||
|
||||
private val svc: NetworkService by inject()
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
// Make sure shell initializer was ran
|
||||
withContext(Dispatchers.IO) {
|
||||
Shell.getShell()
|
||||
}
|
||||
return svc.fetchUpdate()?.let {
|
||||
if (BuildConfig.VERSION_CODE < it.app.versionCode)
|
||||
Notifications.managerUpdate(applicationContext)
|
||||
else if (Info.env.isActive && Info.env.magiskVersionCode < it.magisk.versionCode)
|
||||
Notifications.magiskUpdate(applicationContext)
|
||||
Result.success()
|
||||
} ?: Result.failure()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun schedule(context: Context) {
|
||||
if (Config.checkUpdate) {
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresDeviceIdle(true)
|
||||
.build()
|
||||
val request = PeriodicWorkRequestBuilder<UpdateCheckService>(12, TimeUnit.HOURS)
|
||||
.setConstraints(constraints)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
Const.ID.CHECK_MAGISK_UPDATE_WORKER_ID,
|
||||
ExistingPeriodicWorkPolicy.REPLACE, request)
|
||||
} else {
|
||||
WorkManager.getInstance(context)
|
||||
.cancelUniqueWork(Const.ID.CHECK_MAGISK_UPDATE_WORKER_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
109
app/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt
Normal file
109
app/src/main/java/com/topjohnwu/magisk/core/base/BaseActivity.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package com.topjohnwu.magisk.core.base
|
||||
|
||||
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.collection.SparseArrayCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.utils.currentLocale
|
||||
import com.topjohnwu.magisk.core.wrap
|
||||
import com.topjohnwu.magisk.ktx.set
|
||||
import com.topjohnwu.magisk.utils.Utils
|
||||
import kotlin.random.Random
|
||||
|
||||
typealias ActivityResultCallback = BaseActivity.(Int, Intent?) -> Unit
|
||||
|
||||
abstract class BaseActivity : AppCompatActivity() {
|
||||
|
||||
private val resultCallbacks by lazy { SparseArrayCompat<ActivityResultCallback>() }
|
||||
|
||||
override fun applyOverrideConfiguration(config: Configuration?) {
|
||||
// Force applying our preferred local
|
||||
config?.setLocale(currentLocale)
|
||||
super.applyOverrideConfiguration(config)
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base.wrap(false))
|
||||
}
|
||||
|
||||
fun withPermission(permission: String, builder: PermissionRequestBuilder.() -> Unit) {
|
||||
val request = PermissionRequestBuilder().apply(builder).build()
|
||||
|
||||
if (permission == WRITE_EXTERNAL_STORAGE && Build.VERSION.SDK_INT >= 30) {
|
||||
// We do not need external rw on 30+
|
||||
request.onSuccess()
|
||||
return
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED) {
|
||||
request.onSuccess()
|
||||
} else {
|
||||
var requestCode: Int
|
||||
do {
|
||||
requestCode = Random.nextInt(Const.ID.MAX_ACTIVITY_RESULT + 1, 1 shl 15)
|
||||
} while (resultCallbacks.containsKey(requestCode))
|
||||
resultCallbacks[requestCode] = { result, _ ->
|
||||
if (result > 0)
|
||||
request.onSuccess()
|
||||
else
|
||||
request.onFailure()
|
||||
}
|
||||
ActivityCompat.requestPermissions(this, arrayOf(permission), requestCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun withExternalRW(builder: PermissionRequestBuilder.() -> Unit) {
|
||||
withPermission(WRITE_EXTERNAL_STORAGE, builder = builder)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
var success = true
|
||||
for (res in grantResults) {
|
||||
if (res != PackageManager.PERMISSION_GRANTED) {
|
||||
success = false
|
||||
break
|
||||
}
|
||||
}
|
||||
resultCallbacks[requestCode]?.also {
|
||||
resultCallbacks.remove(requestCode)
|
||||
it(this, if (success) 1 else -1, null)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
resultCallbacks[requestCode]?.also { callback ->
|
||||
resultCallbacks.remove(requestCode)
|
||||
callback(this, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
fun startActivityForResult(intent: Intent, requestCode: Int, callback: ActivityResultCallback) {
|
||||
resultCallbacks[requestCode] = callback
|
||||
try {
|
||||
startActivityForResult(intent, requestCode)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Utils.toast(R.string.app_not_found, Toast.LENGTH_SHORT)
|
||||
}
|
||||
}
|
||||
|
||||
override fun recreate() {
|
||||
startActivity(Intent().setComponent(intent.component))
|
||||
finish()
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,17 @@
|
||||
package com.topjohnwu.magisk.core.base
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.Intent
|
||||
import com.topjohnwu.magisk.core.wrap
|
||||
import org.koin.core.KoinComponent
|
||||
|
||||
abstract class BaseReceiver : BroadcastReceiver(), KoinComponent {
|
||||
|
||||
final override fun onReceive(context: Context, intent: Intent?) {
|
||||
onReceive(context.wrap() as ContextWrapper, intent)
|
||||
}
|
||||
|
||||
abstract fun onReceive(context: ContextWrapper, intent: Intent?)
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
package com.topjohnwu.magisk.core.base
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import com.topjohnwu.magisk.core.wrap
|
||||
import org.koin.core.KoinComponent
|
||||
|
||||
abstract class BaseService : Service(), KoinComponent {
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base.wrap())
|
||||
}
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
package com.topjohnwu.magisk.base
|
||||
package com.topjohnwu.magisk.core.base
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Network
|
||||
@@ -10,7 +10,7 @@ import androidx.work.ListenableWorker
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import java.util.*
|
||||
|
||||
abstract class DelegateWorker {
|
||||
abstract class BaseWorkerWrapper {
|
||||
|
||||
private lateinit var worker: ListenableWorker
|
||||
|
@@ -1,4 +1,4 @@
|
||||
package com.topjohnwu.magisk.model.permissions
|
||||
package com.topjohnwu.magisk.core.base
|
||||
|
||||
typealias SimpleCallback = () -> Unit
|
||||
typealias PermissionRationaleCallback = (List<String>) -> Unit
|
||||
@@ -37,4 +37,4 @@ class PermissionRequest(
|
||||
fun onFailure() = onFailureCallback()
|
||||
fun onShowRationale(permissions: List<String>) = onShowRationaleCallback(permissions)
|
||||
|
||||
}
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
package com.topjohnwu.magisk.core.download
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
sealed class Action : Parcelable {
|
||||
|
||||
sealed class Flash : Action() {
|
||||
|
||||
@Parcelize
|
||||
object Primary : Flash()
|
||||
|
||||
@Parcelize
|
||||
object Secondary : Flash()
|
||||
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
object Download : Action()
|
||||
|
||||
@Parcelize
|
||||
object Uninstall : Action()
|
||||
|
||||
@Parcelize
|
||||
object EnvFix : Action()
|
||||
|
||||
@Parcelize
|
||||
data class Patch(val fileUri: Uri) : Action()
|
||||
|
||||
}
|
@@ -0,0 +1,204 @@
|
||||
package com.topjohnwu.magisk.core.download
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.ForegroundTracker
|
||||
import com.topjohnwu.magisk.core.base.BaseService
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.checkSum
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
|
||||
import com.topjohnwu.magisk.core.utils.ProgressInputStream
|
||||
import com.topjohnwu.magisk.data.repository.NetworkService
|
||||
import com.topjohnwu.magisk.ktx.withStreams
|
||||
import com.topjohnwu.magisk.view.Notifications
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.ResponseBody
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.core.KoinComponent
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
import kotlin.random.Random.Default.nextInt
|
||||
|
||||
abstract class BaseDownloader : BaseService(), KoinComponent {
|
||||
|
||||
private val hasNotifications get() = notifications.isNotEmpty()
|
||||
private val notifications = Collections.synchronizedMap(HashMap<Int, Notification.Builder>())
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
val service: NetworkService by inject()
|
||||
|
||||
// -- Service overrides
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
intent.getParcelableExtra<Subject>(ACTION_KEY)?.let { subject ->
|
||||
update(subject.notifyID())
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
subject.startDownload()
|
||||
} catch (e: IOException) {
|
||||
Timber.e(e)
|
||||
notifyFail(subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_REDELIVER_INTENT
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
notifications.forEach { cancel(it.key) }
|
||||
notifications.clear()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineScope.cancel()
|
||||
}
|
||||
|
||||
// -- Download logic
|
||||
|
||||
private suspend fun Subject.startDownload() {
|
||||
val skip = this is Subject.Magisk && file.checkSum("MD5", magisk.md5)
|
||||
if (!skip) {
|
||||
val stream = service.fetchFile(url).toProgressStream(this)
|
||||
when (this) {
|
||||
is Subject.Module -> // Download and process on-the-fly
|
||||
stream.toModule(file, service.fetchInstaller().byteStream())
|
||||
else -> {
|
||||
withStreams(stream, file.outputStream()) { it, out -> it.copyTo(out) }
|
||||
if (this is Subject.Manager)
|
||||
handleAPK(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
val newId = notifyFinish(this)
|
||||
if (ForegroundTracker.hasForeground)
|
||||
onFinish(this, newId)
|
||||
if (!hasNotifications)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun ResponseBody.toProgressStream(subject: Subject): InputStream {
|
||||
val max = contentLength()
|
||||
val total = max.toFloat() / 1048576
|
||||
val id = subject.notifyID()
|
||||
|
||||
update(id) { it.setContentTitle(subject.title) }
|
||||
|
||||
return ProgressInputStream(byteStream()) {
|
||||
val progress = it.toFloat() / 1048576
|
||||
update(id) { notification ->
|
||||
if (max > 0) {
|
||||
broadcast(progress / total, subject)
|
||||
notification
|
||||
.setProgress(max.toInt(), it.toInt(), false)
|
||||
.setContentText("%.2f / %.2f MB".format(progress, total))
|
||||
} else {
|
||||
broadcast(-1f, subject)
|
||||
notification.setContentText("%.2f MB / ??".format(progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification managements
|
||||
|
||||
fun Subject.notifyID() = hashCode()
|
||||
|
||||
private fun notifyFail(subject: Subject) = lastNotify(subject.notifyID()) {
|
||||
broadcast(-2f, subject)
|
||||
it.setContentText(getString(R.string.download_file_error))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setOngoing(false)
|
||||
}
|
||||
|
||||
private fun notifyFinish(subject: Subject) = lastNotify(subject.notifyID()) {
|
||||
broadcast(1f, subject)
|
||||
it.setIntent(subject)
|
||||
.setContentTitle(subject.title)
|
||||
.setContentText(getString(R.string.download_complete))
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setProgress(0, 0, false)
|
||||
.setOngoing(false)
|
||||
.setAutoCancel(true)
|
||||
}
|
||||
|
||||
private fun create() = Notifications.progress(this, "")
|
||||
|
||||
fun update(id: Int, editor: (Notification.Builder) -> Unit = {}) {
|
||||
val wasEmpty = !hasNotifications
|
||||
val notification = notifications.getOrPut(id, ::create).also(editor)
|
||||
if (wasEmpty)
|
||||
updateForeground()
|
||||
else
|
||||
notify(id, notification.build())
|
||||
}
|
||||
|
||||
private fun lastNotify(
|
||||
id: Int,
|
||||
editor: (Notification.Builder) -> Notification.Builder? = { null }
|
||||
) : Int {
|
||||
val notification = remove(id)?.run(editor) ?: return -1
|
||||
val newId: Int = nextInt()
|
||||
notify(newId, notification.build())
|
||||
return newId
|
||||
}
|
||||
|
||||
protected fun remove(id: Int) = notifications.remove(id)
|
||||
?.also { updateForeground(); cancel(id) }
|
||||
?: { cancel(id); null }()
|
||||
|
||||
private fun notify(id: Int, notification: Notification) {
|
||||
Notifications.mgr.notify(id, notification)
|
||||
}
|
||||
|
||||
private fun cancel(id: Int) {
|
||||
Notifications.mgr.cancel(id)
|
||||
}
|
||||
|
||||
private fun updateForeground() {
|
||||
if (hasNotifications) {
|
||||
val (id, notification) = notifications.entries.first()
|
||||
startForeground(id, notification.build())
|
||||
} else {
|
||||
stopForeground(false)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Implement custom logic
|
||||
|
||||
protected abstract suspend fun onFinish(subject: Subject, id: Int)
|
||||
|
||||
protected abstract fun Notification.Builder.setIntent(subject: Subject): Notification.Builder
|
||||
|
||||
// ---
|
||||
|
||||
companion object : KoinComponent {
|
||||
const val ACTION_KEY = "download_action"
|
||||
|
||||
private val progressBroadcast = MutableLiveData<Pair<Float, Subject>>()
|
||||
|
||||
fun observeProgress(owner: LifecycleOwner, callback: (Float, Subject) -> Unit) {
|
||||
progressBroadcast.value = null
|
||||
progressBroadcast.observe(owner) {
|
||||
val (progress, subject) = it ?: return@observe
|
||||
callback(progress, subject)
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcast(progress: Float, subject: Subject) {
|
||||
progressBroadcast.postValue(progress to subject)
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,110 @@
|
||||
package com.topjohnwu.magisk.core.download
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.net.toFile
|
||||
import com.topjohnwu.magisk.core.download.Action.*
|
||||
import com.topjohnwu.magisk.core.download.Action.Flash.Secondary
|
||||
import com.topjohnwu.magisk.core.download.Subject.*
|
||||
import com.topjohnwu.magisk.core.intent
|
||||
import com.topjohnwu.magisk.core.tasks.EnvFixTask
|
||||
import com.topjohnwu.magisk.ui.flash.FlashFragment
|
||||
import com.topjohnwu.magisk.utils.APKInstall
|
||||
import kotlin.random.Random.Default.nextInt
|
||||
|
||||
@SuppressLint("Registered")
|
||||
open class DownloadService : BaseDownloader() {
|
||||
|
||||
private val context get() = this
|
||||
|
||||
override suspend fun onFinish(subject: Subject, id: Int) = when (subject) {
|
||||
is Magisk -> subject.onFinish(id)
|
||||
is Module -> subject.onFinish(id)
|
||||
is Manager -> subject.onFinish(id)
|
||||
}
|
||||
|
||||
private suspend fun Magisk.onFinish(id: Int) = when (val action = action) {
|
||||
Uninstall -> FlashFragment.uninstall(file, id)
|
||||
EnvFix -> {
|
||||
remove(id)
|
||||
EnvFixTask(file).exec()
|
||||
Unit
|
||||
}
|
||||
is Patch -> FlashFragment.patch(file, action.fileUri, id)
|
||||
is Flash -> FlashFragment.flash(file, action is Secondary, id)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
private fun Module.onFinish(id: Int) = when (action) {
|
||||
is Flash -> FlashFragment.install(file, id)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
private fun Manager.onFinish(id: Int) {
|
||||
remove(id)
|
||||
APKInstall.install(context, file.toFile())
|
||||
}
|
||||
|
||||
// --- Customize finish notification
|
||||
|
||||
override fun Notification.Builder.setIntent(subject: Subject)
|
||||
= when (subject) {
|
||||
is Magisk -> setIntent(subject)
|
||||
is Module -> setIntent(subject)
|
||||
is Manager -> setIntent(subject)
|
||||
}
|
||||
|
||||
private fun Notification.Builder.setIntent(subject: Magisk)
|
||||
= when (val action = subject.action) {
|
||||
Uninstall -> setContentIntent(FlashFragment.uninstallIntent(context, subject.file))
|
||||
is Flash -> setContentIntent(FlashFragment.flashIntent(context, subject.file, action is Secondary))
|
||||
is Patch -> setContentIntent(FlashFragment.patchIntent(context, subject.file, action.fileUri))
|
||||
else -> setContentIntent(Intent())
|
||||
}
|
||||
|
||||
private fun Notification.Builder.setIntent(subject: Module)
|
||||
= when (subject.action) {
|
||||
is Flash -> setContentIntent(FlashFragment.installIntent(context, subject.file))
|
||||
else -> setContentIntent(Intent())
|
||||
}
|
||||
|
||||
private fun Notification.Builder.setIntent(subject: Manager)
|
||||
= setContentIntent(APKInstall.installIntent(context, subject.file.toFile()))
|
||||
|
||||
private fun Notification.Builder.setContentIntent(intent: Intent) =
|
||||
setContentIntent(
|
||||
PendingIntent.getActivity(context, nextInt(), intent, PendingIntent.FLAG_ONE_SHOT)
|
||||
)
|
||||
|
||||
// ---
|
||||
|
||||
companion object {
|
||||
|
||||
private fun intent(context: Context, subject: Subject) =
|
||||
context.intent<DownloadService>().putExtra(ACTION_KEY, subject)
|
||||
|
||||
fun pendingIntent(context: Context, subject: Subject): PendingIntent {
|
||||
return if (Build.VERSION.SDK_INT >= 26) {
|
||||
PendingIntent.getForegroundService(context, nextInt(),
|
||||
intent(context, subject), PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
} else {
|
||||
PendingIntent.getService(context, nextInt(),
|
||||
intent(context, subject), PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
}
|
||||
|
||||
fun start(context: Context, subject: Subject) {
|
||||
val app = context.applicationContext
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
app.startForegroundService(intent(app, subject))
|
||||
} else {
|
||||
app.startService(intent(app, subject))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,51 @@
|
||||
package com.topjohnwu.magisk.core.download
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toFile
|
||||
import com.topjohnwu.magisk.BuildConfig
|
||||
import com.topjohnwu.magisk.DynAPK
|
||||
import com.topjohnwu.magisk.R
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.isRunningAsStub
|
||||
import com.topjohnwu.magisk.core.tasks.HideAPK
|
||||
import com.topjohnwu.magisk.ktx.relaunchApp
|
||||
import com.topjohnwu.magisk.ktx.writeTo
|
||||
import java.io.File
|
||||
|
||||
private fun Context.patch(apk: File) {
|
||||
val patched = File(apk.parent, "patched.apk")
|
||||
HideAPK.patch(this, apk.path, patched.path, packageName, applicationInfo.nonLocalizedLabel)
|
||||
apk.delete()
|
||||
patched.renameTo(apk)
|
||||
}
|
||||
|
||||
private fun BaseDownloader.notifyHide(id: Int) {
|
||||
update(id) {
|
||||
it.setProgress(0, 0, true)
|
||||
.setContentTitle(getString(R.string.hide_manager_title))
|
||||
.setContentText("")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun BaseDownloader.handleAPK(subject: Subject.Manager) {
|
||||
val apk = subject.file.toFile()
|
||||
val id = subject.notifyID()
|
||||
if (isRunningAsStub) {
|
||||
// Move to upgrade location
|
||||
apk.copyTo(DynAPK.update(this), overwrite = true)
|
||||
apk.delete()
|
||||
if (Info.stubChk.version < subject.stub.versionCode) {
|
||||
notifyHide(id)
|
||||
// Also upgrade stub
|
||||
service.fetchFile(subject.stub.link).byteStream().use { it.writeTo(apk) }
|
||||
patch(apk)
|
||||
} else {
|
||||
// Simply relaunch the app
|
||||
stopSelf()
|
||||
relaunchApp(this)
|
||||
}
|
||||
} else if (packageName != BuildConfig.APPLICATION_ID) {
|
||||
notifyHide(id)
|
||||
patch(apk)
|
||||
}
|
||||
}
|
@@ -1,13 +1,14 @@
|
||||
package com.topjohnwu.magisk.model.download
|
||||
package com.topjohnwu.magisk.core.download
|
||||
|
||||
import com.topjohnwu.magisk.extensions.withStreams
|
||||
import java.io.File
|
||||
import android.net.Uri
|
||||
import com.topjohnwu.magisk.ktx.withStreams
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils.outputStream
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
fun InputStream.toModule(file: File, installer: InputStream) {
|
||||
fun InputStream.toModule(file: Uri, installer: InputStream) {
|
||||
|
||||
val input = ZipInputStream(buffered())
|
||||
val output = ZipOutputStream(file.outputStream().buffered())
|
||||
@@ -41,4 +42,4 @@ fun InputStream.toModule(file: File, installer: InputStream) {
|
||||
entry = zin.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
110
app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt
Normal file
110
app/src/main/java/com/topjohnwu/magisk/core/download/Subject.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.topjohnwu.magisk.core.download
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.core.net.toUri
|
||||
import com.topjohnwu.magisk.core.Info
|
||||
import com.topjohnwu.magisk.core.model.MagiskJson
|
||||
import com.topjohnwu.magisk.core.model.ManagerJson
|
||||
import com.topjohnwu.magisk.core.model.StubJson
|
||||
import com.topjohnwu.magisk.core.model.module.OnlineModule
|
||||
import com.topjohnwu.magisk.core.utils.MediaStoreUtils
|
||||
import com.topjohnwu.magisk.ktx.cachedFile
|
||||
import com.topjohnwu.magisk.ktx.get
|
||||
import kotlinx.android.parcel.IgnoredOnParcel
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
private fun cachedFile(name: String) = get<Context>().cachedFile(name).apply { delete() }.toUri()
|
||||
|
||||
sealed class Subject : Parcelable {
|
||||
|
||||
abstract val url: String
|
||||
abstract val file: Uri
|
||||
abstract val action: Action
|
||||
abstract val title: String
|
||||
|
||||
@Parcelize
|
||||
class Module(
|
||||
val module: OnlineModule,
|
||||
override val action: Action
|
||||
) : Subject() {
|
||||
override val url: String get() = module.zip_url
|
||||
override val title: String get() = module.downloadFilename
|
||||
|
||||
@IgnoredOnParcel
|
||||
override val file by lazy {
|
||||
MediaStoreUtils.getFile(title).uri
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class Manager(
|
||||
private val app: ManagerJson = Info.remote.app,
|
||||
val stub: StubJson = Info.remote.stub
|
||||
) : Subject() {
|
||||
override val action get() = Action.Download
|
||||
override val title: String get() = "MagiskManager-${app.version}(${app.versionCode})"
|
||||
override val url: String get() = app.link
|
||||
|
||||
@IgnoredOnParcel
|
||||
override val file by lazy {
|
||||
cachedFile("manager.apk")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
abstract class Magisk : Subject() {
|
||||
|
||||
val magisk: MagiskJson = Info.remote.magisk
|
||||
|
||||
@Parcelize
|
||||
private class Internal(
|
||||
override val action: Action
|
||||
) : Magisk() {
|
||||
override val url: String get() = magisk.link
|
||||
override val title: String get() = "Magisk-${magisk.version}(${magisk.versionCode})"
|
||||
|
||||
@IgnoredOnParcel
|
||||
override val file by lazy {
|
||||
cachedFile("magisk.zip")
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private class Uninstall : Magisk() {
|
||||
override val action get() = Action.Uninstall
|
||||
override val url: String get() = Info.remote.uninstaller.link
|
||||
override val title: String get() = "uninstall.zip"
|
||||
|
||||
@IgnoredOnParcel
|
||||
override val file by lazy {
|
||||
cachedFile(title)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private class Download : Magisk() {
|
||||
override val action get() = Action.Download
|
||||
override val url: String get() = magisk.link
|
||||
override val title: String get() = "Magisk-${magisk.version}(${magisk.versionCode}).zip"
|
||||
|
||||
@IgnoredOnParcel
|
||||
override val file by lazy {
|
||||
MediaStoreUtils.getFile(title).uri
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
operator fun invoke(config: Action) = when (config) {
|
||||
Action.Download -> Download()
|
||||
Action.Uninstall -> Uninstall()
|
||||
Action.EnvFix, is Action.Flash, is Action.Patch -> Internal(config)
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,28 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
|
||||
abstract class BaseDao {
|
||||
|
||||
object Table {
|
||||
const val POLICY = "policies"
|
||||
const val LOG = "logs"
|
||||
const val SETTINGS = "settings"
|
||||
const val STRINGS = "strings"
|
||||
}
|
||||
|
||||
@StringDef(Table.POLICY, Table.LOG, Table.SETTINGS, Table.STRINGS)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class TableStrict
|
||||
|
||||
@TableStrict
|
||||
abstract val table: String
|
||||
|
||||
inline fun <reified Builder : Query.Builder> buildQuery(builder: Builder.() -> Unit = {}) =
|
||||
Builder::class.java.newInstance()
|
||||
.apply { table = this@BaseDao.table }
|
||||
.apply(builder)
|
||||
.toString()
|
||||
.let { Query(it) }
|
||||
|
||||
}
|
@@ -0,0 +1,77 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy
|
||||
import com.topjohnwu.magisk.core.model.su.toMap
|
||||
import com.topjohnwu.magisk.core.model.su.toPolicy
|
||||
import com.topjohnwu.magisk.ktx.now
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
class PolicyDao(
|
||||
private val context: Context
|
||||
) : BaseDao() {
|
||||
|
||||
override val table: String = Table.POLICY
|
||||
|
||||
suspend fun deleteOutdated() = buildQuery<Delete> {
|
||||
condition {
|
||||
greaterThan("until", "0")
|
||||
and {
|
||||
lessThan("until", TimeUnit.MILLISECONDS.toSeconds(now).toString())
|
||||
}
|
||||
or {
|
||||
lessThan("until", "0")
|
||||
}
|
||||
}
|
||||
}.commit()
|
||||
|
||||
suspend fun delete(packageName: String) = buildQuery<Delete> {
|
||||
condition {
|
||||
equals("package_name", packageName)
|
||||
}
|
||||
}.commit()
|
||||
|
||||
suspend fun delete(uid: Int) = buildQuery<Delete> {
|
||||
condition {
|
||||
equals("uid", uid)
|
||||
}
|
||||
}.commit()
|
||||
|
||||
suspend fun fetch(uid: Int) = buildQuery<Select> {
|
||||
condition {
|
||||
equals("uid", uid)
|
||||
}
|
||||
}.query().first().toPolicyOrNull()
|
||||
|
||||
suspend fun update(policy: SuPolicy) = buildQuery<Replace> {
|
||||
values(policy.toMap())
|
||||
}.commit()
|
||||
|
||||
suspend fun <R: Any> fetchAll(mapper: (SuPolicy) -> R) = buildQuery<Select> {
|
||||
condition {
|
||||
equals("uid/100000", Const.USER_ID)
|
||||
}
|
||||
}.query {
|
||||
it.toPolicyOrNull()?.let(mapper)
|
||||
}
|
||||
|
||||
private fun Map<String, String>.toPolicyOrNull(): SuPolicy? {
|
||||
return runCatching { toPolicy(context.packageManager) }.getOrElse {
|
||||
Timber.e(it)
|
||||
if (it is PackageManager.NameNotFoundException) {
|
||||
val uid = getOrElse("uid") { null } ?: return null
|
||||
GlobalScope.launch {
|
||||
delete(uid.toInt())
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@@ -1,27 +1,41 @@
|
||||
package com.topjohnwu.magisk.data.database.base
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
import androidx.annotation.StringDef
|
||||
import com.topjohnwu.magisk.data.database.base.Order.Companion.ASC
|
||||
import com.topjohnwu.magisk.data.database.base.Order.Companion.DESC
|
||||
import com.topjohnwu.magisk.ktx.await
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface MagiskQueryBuilder {
|
||||
class Query(private val _query: String) {
|
||||
val query get() = "magisk --sqlite '$_query'"
|
||||
|
||||
val requestType: String
|
||||
var table: String
|
||||
|
||||
companion object {
|
||||
inline operator fun <reified Builder : MagiskQueryBuilder> invoke(builder: Builder.() -> Unit): MagiskQuery =
|
||||
Builder::class.java.newInstance()
|
||||
.apply(builder)
|
||||
.toString()
|
||||
.let {
|
||||
MagiskQuery(it)
|
||||
}
|
||||
interface Builder {
|
||||
val requestType: String
|
||||
var table: String
|
||||
}
|
||||
|
||||
suspend inline fun <R : Any> query(crossinline mapper: (Map<String, String>) -> R?): List<R> =
|
||||
withContext(Dispatchers.Default) {
|
||||
Shell.su(query).await().out.map { line ->
|
||||
async {
|
||||
line.split("\\|".toRegex())
|
||||
.map { it.split("=", limit = 2) }
|
||||
.filter { it.size == 2 }
|
||||
.map { it[0] to it[1] }
|
||||
.toMap()
|
||||
.let(mapper)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
suspend inline fun query() = query { it }
|
||||
|
||||
suspend inline fun commit() = Shell.su(query).to(null).await()
|
||||
}
|
||||
|
||||
class Delete : MagiskQueryBuilder {
|
||||
class Delete : Query.Builder {
|
||||
override val requestType: String = "DELETE FROM"
|
||||
override var table = ""
|
||||
|
||||
@@ -36,7 +50,7 @@ class Delete : MagiskQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
class Select : MagiskQueryBuilder {
|
||||
class Select : Query.Builder {
|
||||
override val requestType: String get() = "SELECT $fields FROM"
|
||||
override lateinit var table: String
|
||||
|
||||
@@ -69,7 +83,7 @@ class Replace : Insert() {
|
||||
override val requestType: String = "REPLACE INTO"
|
||||
}
|
||||
|
||||
open class Insert : MagiskQueryBuilder {
|
||||
open class Insert : Query.Builder {
|
||||
override val requestType: String = "INSERT INTO"
|
||||
override lateinit var table: String
|
||||
|
||||
@@ -137,19 +151,11 @@ class Condition {
|
||||
}
|
||||
}
|
||||
|
||||
class Order {
|
||||
|
||||
@set:OrderStrict
|
||||
var order = DESC
|
||||
var field = ""
|
||||
|
||||
companion object {
|
||||
const val ASC = "ASC"
|
||||
const val DESC = "DESC"
|
||||
}
|
||||
|
||||
object Order {
|
||||
const val ASC = "ASC"
|
||||
const val DESC = "DESC"
|
||||
}
|
||||
|
||||
@StringDef(ASC, DESC)
|
||||
@StringDef(Order.ASC, Order.DESC)
|
||||
@Retention(AnnotationRetention.SOURCE)
|
||||
annotation class OrderStrict
|
||||
annotation class OrderStrict
|
@@ -0,0 +1,22 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
class SettingsDao : BaseDao() {
|
||||
|
||||
override val table = Table.SETTINGS
|
||||
|
||||
suspend fun delete(key: String) = buildQuery<Delete> {
|
||||
condition { equals("key", key) }
|
||||
}.commit()
|
||||
|
||||
suspend fun put(key: String, value: Int) = buildQuery<Replace> {
|
||||
values("key" to key, "value" to value)
|
||||
}.commit()
|
||||
|
||||
suspend fun fetch(key: String, default: Int = -1) = buildQuery<Select> {
|
||||
fields("value")
|
||||
condition { equals("key", key) }
|
||||
}.query {
|
||||
it["value"]?.toIntOrNull()
|
||||
}.firstOrNull() ?: default
|
||||
|
||||
}
|
@@ -0,0 +1,22 @@
|
||||
package com.topjohnwu.magisk.core.magiskdb
|
||||
|
||||
class StringDao : BaseDao() {
|
||||
|
||||
override val table = Table.STRINGS
|
||||
|
||||
suspend fun delete(key: String) = buildQuery<Delete> {
|
||||
condition { equals("key", key) }
|
||||
}.commit()
|
||||
|
||||
suspend fun put(key: String, value: String) = buildQuery<Replace> {
|
||||
values("key" to key, "value" to value)
|
||||
}.commit()
|
||||
|
||||
suspend fun fetch(key: String, default: String = "") = buildQuery<Select> {
|
||||
fields("value")
|
||||
condition { equals("key", key) }
|
||||
}.query {
|
||||
it["value"]
|
||||
}.firstOrNull() ?: default
|
||||
|
||||
}
|
@@ -0,0 +1,69 @@
|
||||
package com.topjohnwu.magisk.core.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import com.squareup.moshi.JsonClass
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UpdateInfo(
|
||||
val app: ManagerJson = ManagerJson(),
|
||||
val uninstaller: UninstallerJson = UninstallerJson(),
|
||||
val magisk: MagiskJson = MagiskJson(),
|
||||
val stub: StubJson = StubJson()
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class UninstallerJson(
|
||||
val link: String = ""
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class MagiskJson(
|
||||
val version: String = "",
|
||||
val versionCode: Int = -1,
|
||||
val link: String = "",
|
||||
val note: String = "",
|
||||
val md5: String = ""
|
||||
)
|
||||
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ManagerJson(
|
||||
val version: String = "",
|
||||
val versionCode: Int = -1,
|
||||
val link: String = "",
|
||||
val note: String = ""
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class StubJson(
|
||||
val versionCode: Int = -1,
|
||||
val link: String = ""
|
||||
) : Parcelable
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ModuleJson(
|
||||
val id: String,
|
||||
val last_update: Long,
|
||||
val prop_url: String,
|
||||
val zip_url: String,
|
||||
val notes_url: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class RepoJson(
|
||||
val name: String,
|
||||
val last_update: Long,
|
||||
val modules: List<ModuleJson>
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class CommitInfo(
|
||||
val sha: String
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class BranchInfo(
|
||||
val commit: CommitInfo
|
||||
)
|
@@ -0,0 +1,89 @@
|
||||
package com.topjohnwu.magisk.core.model.module
|
||||
|
||||
import com.topjohnwu.magisk.core.Const
|
||||
import com.topjohnwu.superuser.Shell
|
||||
import com.topjohnwu.superuser.io.SuFile
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class LocalModule(path: String) : Module() {
|
||||
override var id: String = ""
|
||||
override var name: String = ""
|
||||
override var author: String = ""
|
||||
override var version: String = ""
|
||||
override var versionCode: Int = -1
|
||||
override var description: String = ""
|
||||
|
||||
private val removeFile = SuFile(path, "remove")
|
||||
private val disableFile = SuFile(path, "disable")
|
||||
private val updateFile = SuFile(path, "update")
|
||||
private val ruleFile = SuFile(path, "sepolicy.rule")
|
||||
|
||||
val updated: Boolean get() = updateFile.exists()
|
||||
|
||||
var enable: Boolean
|
||||
get() = !disableFile.exists()
|
||||
set(enable) {
|
||||
val dir = "$PERSIST/$id"
|
||||
if (enable) {
|
||||
disableFile.delete()
|
||||
if (Const.Version.isCanary())
|
||||
Shell.su("copy_sepolicy_rules").submit()
|
||||
else
|
||||
Shell.su("mkdir -p $dir", "cp -af $ruleFile $dir").submit()
|
||||
} else {
|
||||
!disableFile.createNewFile()
|
||||
if (Const.Version.isCanary())
|
||||
Shell.su("copy_sepolicy_rules").submit()
|
||||
else
|
||||
Shell.su("rm -rf $dir").submit()
|
||||
}
|
||||
}
|
||||
|
||||
var remove: Boolean
|
||||
get() = removeFile.exists()
|
||||
set(remove) {
|
||||
if (remove) {
|
||||
removeFile.createNewFile()
|
||||
if (Const.Version.isCanary())
|
||||
Shell.su("copy_sepolicy_rules").submit()
|
||||
else
|
||||
Shell.su("rm -rf $PERSIST/$id").submit()
|
||||
} else {
|
||||
!removeFile.delete()
|
||||
if (Const.Version.isCanary())
|
||||
Shell.su("copy_sepolicy_rules").submit()
|
||||
else
|
||||
Shell.su("cp -af $ruleFile $PERSIST/$id").submit()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
runCatching {
|
||||
parseProps(Shell.su("dos2unix < $path/module.prop").exec().out)
|
||||
}
|
||||
|
||||
if (id.isEmpty()) {
|
||||
val sep = path.lastIndexOf('/')
|
||||
id = path.substring(sep + 1)
|
||||
}
|
||||
|
||||
if (name.isEmpty()) {
|
||||
name = id
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val PERSIST get() = "${Const.MAGISKTMP}/mirror/persist/magisk"
|
||||
|
||||
suspend fun installed() = withContext(Dispatchers.IO) {
|
||||
SuFile(Const.MAGISK_PATH)
|
||||
.listFiles { _, name -> name != "lost+found" && name != ".core" }
|
||||
.orEmpty()
|
||||
.filter { !it.isFile }
|
||||
.map { LocalModule("${Const.MAGISK_PATH}/${it.name}") }
|
||||
.sortedBy { it.name.toLowerCase() }
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
package com.topjohnwu.magisk.model.entity.module
|
||||
package com.topjohnwu.magisk.core.model.module
|
||||
|
||||
abstract class BaseModule : Comparable<BaseModule> {
|
||||
abstract class Module : Comparable<Module> {
|
||||
abstract var id: String
|
||||
protected set
|
||||
abstract var name: String
|
||||
@@ -37,5 +37,5 @@ abstract class BaseModule : Comparable<BaseModule> {
|
||||
}
|
||||
}
|
||||
|
||||
override operator fun compareTo(other: BaseModule) = name.compareTo(other.name, true)
|
||||
override operator fun compareTo(other: Module) = name.compareTo(other.name, true)
|
||||
}
|
@@ -0,0 +1,66 @@
|
||||
package com.topjohnwu.magisk.core.model.module
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import com.topjohnwu.magisk.core.model.ModuleJson
|
||||
import com.topjohnwu.magisk.data.repository.NetworkService
|
||||
import com.topjohnwu.magisk.ktx.get
|
||||
import com.topjohnwu.magisk.ktx.legalFilename
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
@Entity(tableName = "modules")
|
||||
@Parcelize
|
||||
data class OnlineModule(
|
||||
@PrimaryKey override var id: String,
|
||||
override var name: String = "",
|
||||
override var author: String = "",
|
||||
override var version: String = "",
|
||||
override var versionCode: Int = -1,
|
||||
override var description: String = "",
|
||||
val last_update: Long,
|
||||
val prop_url: String,
|
||||
val zip_url: String,
|
||||
val notes_url: String
|
||||
) : Module(), Parcelable {
|
||||
|
||||
private val svc: NetworkService get() = get()
|
||||
|
||||
constructor(info: ModuleJson) : this(
|
||||
id = info.id,
|
||||
last_update = info.last_update,
|
||||
prop_url = info.prop_url,
|
||||
zip_url = info.zip_url,
|
||||
notes_url = info.notes_url
|
||||
)
|
||||
|
||||
val lastUpdate get() = Date(last_update)
|
||||
val lastUpdateString get() = DATE_FORMAT.format(lastUpdate)
|
||||
val downloadFilename get() = "$name-$version($versionCode).zip".legalFilename()
|
||||
|
||||
suspend fun notes() = svc.fetchString(notes_url)
|
||||
|
||||
@Throws(IllegalRepoException::class)
|
||||
suspend fun load() {
|
||||
try {
|
||||
val rawProps = svc.fetchString(prop_url)
|
||||
val props = rawProps.split("\\n".toRegex()).dropLastWhile { it.isEmpty() }
|
||||
parseProps(props)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalRepoException("Repo [$id] parse error:", e)
|
||||
}
|
||||
|
||||
if (versionCode < 0) {
|
||||
throw IllegalRepoException("Repo [$id] does not contain versionCode")
|
||||
}
|
||||
}
|
||||
|
||||
class IllegalRepoException(msg: String, cause: Throwable? = null) : Exception(msg, cause)
|
||||
|
||||
companion object {
|
||||
private val DATE_FORMAT = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
package com.topjohnwu.magisk.core.model.su
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Ignore
|
||||
import androidx.room.PrimaryKey
|
||||
import com.topjohnwu.magisk.core.model.su.SuPolicy.Companion.ALLOW
|
||||
import com.topjohnwu.magisk.ktx.now
|
||||
import com.topjohnwu.magisk.ktx.timeFormatTime
|
||||
import com.topjohnwu.magisk.ktx.toTime
|
||||
|
||||
@Entity(tableName = "logs")
|
||||
data class SuLog(
|
||||
val fromUid: Int,
|
||||
val toUid: Int,
|
||||
val fromPid: Int,
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val command: String,
|
||||
val action: Boolean,
|
||||
val time: Long = -1
|
||||
) {
|
||||
@PrimaryKey(autoGenerate = true) var id: Int = 0
|
||||
@Ignore val timeString = time.toTime(timeFormatTime)
|
||||
}
|
||||
|
||||
fun SuPolicy.toLog(
|
||||
toUid: Int,
|
||||
fromPid: Int,
|
||||
command: String
|
||||
) = SuLog(uid, toUid, fromPid, packageName, appName, command, policy == ALLOW, now)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user