Compare commits
509 Commits
Author | SHA1 | Date |
---|---|---|
androidacy-user | 253ccb9127 | 1 year ago |
Weblate | 2e1ff92e63 | 1 year ago |
androidacy-user | c47d9dddf4 | 1 year ago |
androidacy-user | fd8f746a22 | 1 year ago |
androidacy-user | eae09bf812 | 1 year ago |
Weblate | b9986b30c7 | 1 year ago |
Weblate | f75da22155 | 1 year ago |
androidacy-user | 7987aea230 | 1 year ago |
androidacy-user | 3434c2202b | 1 year ago |
androidacy-user | c46b50e1ef | 1 year ago |
androidacy-user | 4111b91fe2 | 1 year ago |
androidacy-user | ec9da3adf8 | 1 year ago |
androidacy-user | d53eeff6d4 | 1 year ago |
androidacy-user | 6c54d19bf7 | 1 year ago |
androidacy-user | f579aabde5 | 1 year ago |
androidacy-user | cb277f70ca | 1 year ago |
androidacy-user | 9e818241a1 | 1 year ago |
androidacy-user | 6f0b8ac689 | 1 year ago |
Androidacy Service Account | 5a38e9ad71 | 1 year ago |
Androidacy Service Account | f64995055e | 1 year ago |
androidacy-user | 7706527a3a | 1 year ago |
Weblate | 67df7e89af | 1 year ago |
androidacy-user | d3f1a83dbd | 1 year ago |
androidacy-user | 6cf58542f7 | 1 year ago |
Weblate | 2b64adc3db | 1 year ago |
Fox2Code | 10e11efbf2 | 1 year ago |
Weblate | 434f338402 | 1 year ago |
androidacy-user | 3769acaebb | 1 year ago |
androidacy-user | ec6bfd36e3 | 1 year ago |
androidacy-user | 9cf494ac13 | 1 year ago |
androidacy-user | c6a2963695 | 1 year ago |
Weblate | 3333b0eb57 | 1 year ago |
androidacy-user | b9b4b6d0d7 | 1 year ago |
androidacy-user | 2925b6a8c6 | 1 year ago |
androidacy-user | 767a1fa7cd | 1 year ago |
androidacy-user | 9f203d6e54 | 1 year ago |
androidacy-user | bfe3c698c0 | 1 year ago |
androidacy-user | 128b8c51c7 | 1 year ago |
androidacy-user | 6d0ac44950 | 1 year ago |
androidacy-user | 702048e6c8 | 1 year ago |
androidacy-user | 777ab751c9 | 1 year ago |
androidacy-user | ea1f44dfd1 | 1 year ago |
androidacy-user | 5980b20939 | 1 year ago |
androidacy-user | 647f53231e | 1 year ago |
Weblate | 6015e8d6e8 | 1 year ago |
Weblate | 962e9fe248 | 1 year ago |
androidacy-user | 788589c315 | 1 year ago |
androidacy-user | 3faf9ee1cb | 1 year ago |
Weblate | f442750d4b | 1 year ago |
androidacy-user | d2dc8f64ac | 1 year ago |
androidacy-user | 6ba7ac5e90 | 1 year ago |
androidacy-user | cbc39eadc6 | 1 year ago |
androidacy-user | 898dcdbfa1 | 1 year ago |
androidacy-user | 018c6fbb22 | 1 year ago |
Weblate | 16f7711a37 | 1 year ago |
androidacy-user | 5a77f76bcb | 1 year ago |
androidacy-user | 0aa4abca53 | 1 year ago |
Weblate | 37c22ee35b | 1 year ago |
androidacy-user | 56f55990f7 | 1 year ago |
androidacy-user | fb81d0faa4 | 1 year ago |
androidacy-user | 7e1212aac2 | 1 year ago |
androidacy-user | 6e95acbba4 | 1 year ago |
androidacy-user | 231cc99cb6 | 1 year ago |
androidacy-user | f36f77214f | 1 year ago |
androidacy-user | f673f04f91 | 1 year ago |
androidacy-user | dd9823ad5b | 1 year ago |
androidacy-user | 0a6fd7316a | 1 year ago |
Weblate | d80e49a216 | 1 year ago |
Weblate | c3d6954013 | 1 year ago |
androidacy-user | 6587d40542 | 1 year ago |
androidacy-user | 0b68956a86 | 1 year ago |
androidacy-user | 7b4d7b579c | 1 year ago |
androidacy-user | 54b42dfa8c | 1 year ago |
Androidacy Service Account | d0ad060039 | 1 year ago |
androidacy-user | e6a4b6d2b5 | 1 year ago |
androidacy-user | 6a76a6403f | 1 year ago |
androidacy-user | aaed979932 | 1 year ago |
androidacy-user | 100ee6c043 | 1 year ago |
androidacy-user | 94edc3474d | 1 year ago |
androidacy-user | b71691c98c | 1 year ago |
Androidacy Service Account | 4bede75904 | 1 year ago |
Androidacy Service Account | f824e3a915 | 1 year ago |
Androidacy Service Account | 6b8ee7c8d1 | 1 year ago |
Androidacy Service Account | 68dc58476c | 1 year ago |
Androidacy Service Account | 05adafc6db | 1 year ago |
Androidacy Service Account | 162d888684 | 1 year ago |
Androidacy Service Account | fa765b2dc3 | 1 year ago |
Androidacy Service Account | 9bcc1f6303 | 1 year ago |
androidacy-user | 10d2790ae8 | 1 year ago |
Weblate | 9328cd8acd | 1 year ago |
androidacy-user | 27fefbf3cf | 1 year ago |
androidacy-user | 173422e9e1 | 1 year ago |
androidacy-user | a268013b0d | 1 year ago |
androidacy-user | d4218e5a9d | 1 year ago |
androidacy-user | 208ad83920 | 1 year ago |
androidacy-user | a4764fddeb | 1 year ago |
androidacy-user | e04d0f3c1d | 1 year ago |
androidacy-user | ff1bf975a2 | 1 year ago |
androidacy-user | b3e261d3d8 | 1 year ago |
androidacy-user | 2d201e2b64 | 1 year ago |
Weblate | 5319af4e07 | 1 year ago |
androidacy-user | 6a706b08d5 | 1 year ago |
androidacy-user | 5b60ae6a58 | 1 year ago |
androidacy-user | 8cb2ebbc7c | 1 year ago |
androidacy-user | 47eff4e5c4 | 1 year ago |
Weblate | ba070d5d4a | 1 year ago |
androidacy-user | 3b115044b7 | 1 year ago |
androidacy-user | f2d36547c9 | 1 year ago |
androidacy-user | cbd40572bc | 1 year ago |
Androidacy Service Account | 8a94fda8c2 | 1 year ago |
androidacy-user | 216d4ea4ad | 1 year ago |
Rom | f7f0bdde41 | 1 year ago |
Androidacy Service Account | d55a75bcec | 1 year ago |
Androidacy Service Account | 2bb0410264 | 1 year ago |
Androidacy Service Account | 83ed8aca41 | 1 year ago |
androidacy-user | 36b583932f | 1 year ago |
androidacy-user | 7effbaac2f | 1 year ago |
Weblate | 39bb0c119f | 1 year ago |
androidacy-user | af1c06985e | 1 year ago |
androidacy-user | 61117ed065 | 1 year ago |
androidacy-user | b0e00085a1 | 1 year ago |
androidacy-user | 85f41657dd | 1 year ago |
Weblate | 003d33a1c1 | 1 year ago |
androidacy-user | 63b407cb15 | 1 year ago |
Rom | c12de1dd45 | 1 year ago |
androidacy-user | 12a5c02669 | 1 year ago |
androidacy-user | ac845a0c0c | 1 year ago |
Weblate | 67167352f9 | 1 year ago |
androidacy-user | c4f1869331 | 1 year ago |
androidacy-user | c79f081879 | 1 year ago |
Weblate | 6a64aaa14f | 1 year ago |
Weblate | 31b1cb2fff | 1 year ago |
androidacy-user | 8ba2d2d98e | 1 year ago |
androidacy-user | a96cee7e2e | 1 year ago |
Weblate | 48b50a3bac | 1 year ago |
androidacy-user | 633a128742 | 1 year ago |
androidacy-user | 77cd9346f0 | 1 year ago |
androidacy-user | 3d92c5e0c5 | 1 year ago |
androidacy-user | 6ff3b23adc | 1 year ago |
androidacy-user | d8995d7f20 | 1 year ago |
androidacy-user | eb46a3a56f | 1 year ago |
Weblate | 5cbf0dbd61 | 1 year ago |
androidacy-user | acfb2caf7d | 1 year ago |
Weblate | b000ed778f | 1 year ago |
Weblate | 5daf67837c | 1 year ago |
androidacy-user | 5ea6840cee | 1 year ago |
androidacy-user | 99c3cd2ede | 1 year ago |
androidacy-user | 1349327a29 | 1 year ago |
androidacy-user | db94cf2a48 | 1 year ago |
Weblate | dd1e47c6de | 1 year ago |
androidacy-user | c9aab40670 | 1 year ago |
androidacy-user | babc002422 | 1 year ago |
Weblate | 10f5657e74 | 1 year ago |
Weblate | 92a39144b2 | 1 year ago |
androidacy-user | 0c1517460b | 1 year ago |
Weblate | 07b67cf75b | 1 year ago |
androidacy-user | c7cc320028 | 1 year ago |
androidacy-user | 0ac2779bb0 | 1 year ago |
Androidacy Service Account | 9e76087d94 | 1 year ago |
androidacy-user | 92a0d50443 | 1 year ago |
Weblate | 1369043f8b | 1 year ago |
androidacy-user | 1df1dc13c4 | 1 year ago |
androidacy-user | cb4922c2a4 | 1 year ago |
Weblate | f2b3496f28 | 1 year ago |
Weblate | 8cf288b25b | 1 year ago |
androidacy-user | 9ac56c371b | 1 year ago |
androidacy-user | 3217eaddee | 1 year ago |
androidacy-user | 888b624ff6 | 1 year ago |
androidacy-user | c4a4ec5287 | 1 year ago |
androidacy-user | cc13635f49 | 1 year ago |
Weblate | 66b698e6a7 | 1 year ago |
androidacy-user | dca6212925 | 1 year ago |
Weblate | 7fdc7232cc | 1 year ago |
Weblate | 92234a502c | 1 year ago |
Weblate | 945db7f167 | 1 year ago |
Weblate | 4b70ee6c1d | 1 year ago |
Androidacy Service Account | 08d3ffd1f2 | 1 year ago |
androidacy-user | b9a62f9988 | 1 year ago |
androidacy-user | 611b8a0cbe | 1 year ago |
androidacy-user | f8b95e663d | 1 year ago |
Weblate | 245b92d6a7 | 1 year ago |
Weblate | 82363538b4 | 1 year ago |
Androidacy Service Account | 00f09d0552 | 1 year ago |
RadiatedExodus | a72f5389f9 | 1 year ago |
RadiatedExodus | 29993f1843 | 1 year ago |
RadiatedExodus | 2eff62f97f | 1 year ago |
RadiatedExodus | 3f07c7fd3f | 1 year ago |
androidacy-user | 71722c89b0 | 1 year ago |
RadiatedExodus | 09026470e4 | 1 year ago |
Rom | 8fd7c35be6 | 1 year ago |
Weblate | 4271794823 | 1 year ago |
androidacy-user | 8884dbeb8e | 1 year ago |
androidacy-user | d4ccb5a056 | 1 year ago |
androidacy-user | dbc4797c80 | 1 year ago |
androidacy-user | a3b3c8b547 | 1 year ago |
Androidacy Service Account | df46a7c93b | 1 year ago |
androidacy-user | 3bc3584715 | 1 year ago |
Weblate | 344a233a94 | 1 year ago |
androidacy-user | cf671df86b | 1 year ago |
Weblate | 368b86984c | 1 year ago |
Rom | 3104ca667c | 1 year ago |
Androidacy Service Account | b00d69cf91 | 1 year ago |
androidacy-user | dac0c5ddde | 1 year ago |
androidacy-user | 37c158b3b9 | 1 year ago |
Weblate | a247d801c5 | 1 year ago |
Weblate | a9521eb6cc | 1 year ago |
androidacy-user | 5029486d4f | 1 year ago |
androidacy-user | 034753d6c4 | 1 year ago |
Weblate | 17cf8baee9 | 1 year ago |
Rom | 9e556142d0 | 1 year ago |
Weblate | 150b9f77e0 | 1 year ago |
androidacy-user | b95a842131 | 1 year ago |
Weblate | 87de510adb | 1 year ago |
androidacy-user | 2dd1257f37 | 1 year ago |
Androidacy Service Account | cc12fe60fa | 1 year ago |
androidacy-user | 6d0dec6ead | 1 year ago |
androidacy-user | 1f38a197de | 1 year ago |
Androidacy Service Account | 2b89049921 | 1 year ago |
Androidacy Service Account | e6f9fbe1cb | 1 year ago |
androidacy-user | 17ab83acd0 | 1 year ago |
Weblate | 3bfe30290b | 1 year ago |
androidacy-user | f1a6c6a2bf | 1 year ago |
androidacy-user | 40b1f4cb4c | 1 year ago |
androidacy-user | eb838c4147 | 1 year ago |
androidacy-user | 565cc1660f | 1 year ago |
androidacy-user | 45383a53d8 | 1 year ago |
androidacy-user | e2661e6436 | 1 year ago |
androidacy-user | c80833b2c0 | 1 year ago |
androidacy-user | 704771d5e2 | 1 year ago |
androidacy-user | a8e71e1bed | 1 year ago |
Weblate | d27fc70cd9 | 1 year ago |
Weblate | b630bde6c4 | 1 year ago |
androidacy-user | fcb26c213c | 1 year ago |
androidacy-user | 9228c156c8 | 1 year ago |
androidacy-user | a468d9cdd8 | 1 year ago |
androidacy-user | 6e7ce449c7 | 1 year ago |
androidacy-user | 4fc7a94f78 | 1 year ago |
androidacy-user | a277a7e18e | 1 year ago |
androidacy-user | 68bf2636c3 | 1 year ago |
androidacy-user | df41a04c15 | 1 year ago |
androidacy-user | cb562c7aa1 | 1 year ago |
androidacy-user | d763b4b85b | 1 year ago |
androidacy-user | 376ab671ce | 1 year ago |
androidacy-user | ead2d3d30e | 1 year ago |
androidacy-user | fb11b16eaf | 1 year ago |
androidacy-user | 6dd56592ad | 1 year ago |
androidacy-user | c17db63594 | 1 year ago |
Weblate | d0f5d4c650 | 1 year ago |
androidacy-user | d9ebb2a2c4 | 1 year ago |
androidacy-user | a75e68a27c | 1 year ago |
androidacy-user | 9e7a38ed0a | 1 year ago |
Weblate | 350737b4af | 1 year ago |
Weblate | b1478f2f4c | 1 year ago |
androidacy-user | 08e78d9577 | 1 year ago |
androidacy-user | 68a4c54ef8 | 1 year ago |
androidacy-user | 252a44f7e0 | 1 year ago |
androidacy-user | b5389d597c | 1 year ago |
Weblate | 937c7c5457 | 1 year ago |
androidacy-user | 837cd46ce3 | 1 year ago |
androidacy-user | 572731e288 | 1 year ago |
androidacy-user | c26c17e7ae | 1 year ago |
androidacy-user | b95559cd9c | 1 year ago |
androidacy-user | a54ecbc6d8 | 1 year ago |
Weblate | 39d1604215 | 1 year ago |
Androidacy Service Account | 5b3bf70f83 | 1 year ago |
androidacy-user | 3e1c14f2e2 | 1 year ago |
androidacy-user | f13ed32a22 | 1 year ago |
Rom | c3a2a145fe | 1 year ago |
Androidacy Service Account | ef0fbb1735 | 1 year ago |
androidacy-user | 8898d0674c | 1 year ago |
androidacy-user | 24ec7f6cc6 | 1 year ago |
androidacy-user | 8619c66624 | 1 year ago |
androidacy-user | e551ddc0c3 | 1 year ago |
Weblate | 8404ebf6ec | 1 year ago |
Weblate | 9383b4964d | 1 year ago |
androidacy-user | 21da75c3fc | 1 year ago |
Weblate | 2a45502d91 | 1 year ago |
androidacy-user | f9bb004721 | 1 year ago |
androidacy-user | 28a0e78acd | 1 year ago |
Weblate | 2d902a0ee1 | 1 year ago |
Weblate | bd3eecbf5d | 1 year ago |
Rom | e9c9cf79bf | 1 year ago |
androidacy-user | cdd4092bbf | 1 year ago |
androidacy-user | e3734e15d6 | 1 year ago |
androidacy-user | 5499ab0b43 | 1 year ago |
androidacy-user | 7865b62255 | 1 year ago |
androidacy-user | 606ff7d778 | 1 year ago |
androidacy-user | 2a243e485d | 1 year ago |
Androidacy Service Account | 8f095974c7 | 1 year ago |
androidacy-user | c92f26d65f | 1 year ago |
Rom | bb9642435d | 1 year ago |
Rom | 89b184b5b5 | 1 year ago |
Weblate | ccf8bc9eb9 | 1 year ago |
Weblate | 59b5f026a5 | 1 year ago |
Weblate | 8cf7cd8aa4 | 1 year ago |
Weblate | 6f78d204f7 | 1 year ago |
Weblate | 45a9ef6b93 | 1 year ago |
androidacy-user | d7f8c02302 | 1 year ago |
Fox2Code | e4fff6bb6e | 1 year ago |
Fox2Code | 0de68f3b06 | 1 year ago |
Fox2Code | 8eeabcf8f6 | 1 year ago |
androidacy-user | c8529ee12b | 1 year ago |
androidacy-user | 609a6c6b19 | 1 year ago |
Weblate | 3cc9d05481 | 1 year ago |
Weblate | 9f4a3938e8 | 1 year ago |
nift4 | b5bbc8a117 | 1 year ago |
Nick | 0c9c410f74 | 1 year ago |
androidacy-user | 75763c89e9 | 1 year ago |
androidacy-user | ec777e3d8e | 1 year ago |
androidacy-user | f39b3af4fa | 1 year ago |
androidacy-user | 5d844e0911 | 1 year ago |
androidacy-user | f63f433104 | 1 year ago |
androidacy-user | e357c6febc | 1 year ago |
androidacy-user | 15b257720e | 1 year ago |
nift4 | 9dc6e6806e | 1 year ago |
androidacy-user | fc3406ce08 | 1 year ago |
nift4 | e17e839f2d | 1 year ago |
nift4 | dddbf6d1c7 | 1 year ago |
Weblate | 55ec84a2de | 1 year ago |
Weblate | 6c73d49aee | 1 year ago |
Weblate | 121baad8e7 | 1 year ago |
Weblate | 7346c4c74e | 1 year ago |
Androidacy Service Account | fdfc7c45dd | 1 year ago |
Androidacy Service Account | 46b4e5e097 | 1 year ago |
androidacy-user | f75e78764d | 1 year ago |
androidacy-user | 4209653655 | 1 year ago |
androidacy-user | 6eac6e5e9d | 1 year ago |
androidacy-user | 0ee54a2224 | 1 year ago |
Androidacy Service Account | 487cf40bf7 | 1 year ago |
Rom | 624c2698f2 | 1 year ago |
Rom | 49d0234374 | 1 year ago |
Rom | 158cd7c582 | 1 year ago |
Rom | e8d52ecd6c | 1 year ago |
Rom | 93c07692ff | 1 year ago |
Rom | b17582e605 | 1 year ago |
Rom | 6163f20108 | 1 year ago |
Weblate | ea8a009207 | 1 year ago |
Rom | 521e0771e3 | 1 year ago |
Weblate | bd726a95fd | 1 year ago |
ender-zhao | fd566365e9 | 1 year ago |
ender-zhao | a4f1677a62 | 1 year ago |
ender-zhao | a1922a16f2 | 1 year ago |
ender-zhao | 92b06b7b35 | 1 year ago |
androidacy-user | 08989be2a3 | 1 year ago |
androidacy-user | efd5391ef8 | 1 year ago |
androidacy-user | cb20fb4a8f | 1 year ago |
androidacy-user | d656d1d142 | 1 year ago |
androidacy-user | 2573313f3a | 1 year ago |
androidacy-user | 5d941ae570 | 1 year ago |
Weblate | 16cf6d4501 | 1 year ago |
androidacy-user | daf0f63689 | 1 year ago |
androidacy-user | b956aaeef7 | 1 year ago |
androidacy-user | ad22378070 | 1 year ago |
androidacy-user | 46d66f31bf | 1 year ago |
androidacy-user | faf19b1146 | 1 year ago |
Weblate | 4dbd821732 | 1 year ago |
Weblate | 5d90198e25 | 1 year ago |
Weblate | 8910b811cc | 1 year ago |
Weblate | 0324508aa2 | 1 year ago |
androidacy-user | 01214cf7f2 | 1 year ago |
androidacy-user | 30d77941ac | 1 year ago |
androidacy-user | d675e6702a | 1 year ago |
androidacy-user | 9e9793ddcd | 1 year ago |
androidacy-user | c318911cfa | 1 year ago |
androidacy-user | 858c4e7eb6 | 1 year ago |
androidacy-user | 63aa102963 | 1 year ago |
androidacy-user | d2bfab8ea7 | 1 year ago |
Weblate | 730bb1f45b | 1 year ago |
Weblate | 18090b033b | 1 year ago |
Weblate | fed797c51f | 1 year ago |
androidacy-user | 3ba8f4bc37 | 1 year ago |
androidacy-user | 3c990bf8af | 1 year ago |
androidacy-user | 9a79029cfc | 1 year ago |
androidacy-user | 18d07d81b5 | 1 year ago |
Androidacy Service Account | 16731ac2c4 | 1 year ago |
Moondarker | ba8b5ee425 | 1 year ago |
androidacy-user | dbf0d6d35c | 1 year ago |
androidacy-user | e2e4b952f9 | 1 year ago |
Androidacy Service Account | 4f9fdad91a | 1 year ago |
Vladi69 | 86ee7ae12b | 1 year ago |
androidacy-user | ba1357e307 | 1 year ago |
androidacy-user | ee4ad76b43 | 1 year ago |
androidacy-user | 589eab5124 | 1 year ago |
Fox2Code | ce03a0b36a | 1 year ago |
Weblate | 93c4789107 | 1 year ago |
androidacy-user | a92da12849 | 1 year ago |
androidacy-user | 37b6e1659a | 1 year ago |
androidacy-user | 209f2711e3 | 1 year ago |
androidacy-user | 615f243485 | 1 year ago |
androidacy-user | e86c10dc28 | 1 year ago |
Androidacy Service Account | e473821ebc | 1 year ago |
Androidacy Service Account | 7892559bb3 | 1 year ago |
Androidacy Service Account | d153898197 | 1 year ago |
Androidacy Service Account | 69448b8bec | 1 year ago |
Androidacy Service Account | f7a176e9d2 | 1 year ago |
Androidacy Service Account | 38e3014335 | 1 year ago |
Joshua Birger | 0164a4e485 | 1 year ago |
androidacy-user | e378f604b6 | 1 year ago |
Daviteusz | d5e3147667 | 1 year ago |
Rom | e4b3968340 | 1 year ago |
Tullio | b5f2ccc3db | 1 year ago |
Tullio | c24be8b5aa | 1 year ago |
ender-zhao | eb4f050b63 | 1 year ago |
ender-zhao | db50325f41 | 1 year ago |
androidacy-user | 6747c11b5b | 1 year ago |
androidacy-user | a5cd1daaa7 | 1 year ago |
Androidacy Service Account | 482a4d2b90 | 1 year ago |
Weblate | 8dcccecc78 | 1 year ago |
Daviteusz | d5fdfc9abe | 1 year ago |
Weblate | bd7de0f474 | 1 year ago |
Fox2Code | e0ecd8f960 | 1 year ago |
Weblate | 876b7987ff | 1 year ago |
Androidacy Service Account | 8ac1d3bce9 | 1 year ago |
Androidacy Service Account | c5cca14495 | 1 year ago |
androidacy-user | f34ec443a1 | 1 year ago |
androidacy-user | a8877af824 | 1 year ago |
androidacy-user | 414caf3dbe | 1 year ago |
androidacy-user | 2c675577cb | 1 year ago |
androidacy-user | 20d51c5b9c | 1 year ago |
androidacy-user | 0721742c62 | 1 year ago |
androidacy-user | a61ca71221 | 1 year ago |
androidacy-user | edea473b09 | 1 year ago |
Androidacy Service Account | b5bb905b60 | 1 year ago |
Rom | 022cef982b | 1 year ago |
androidacy-user | 66cb0b1813 | 1 year ago |
Weblate | d62cd4f636 | 1 year ago |
androidacy-user | 86c46de069 | 1 year ago |
androidacy-user | 816d94aee4 | 1 year ago |
androidacy-user | 37b93367bf | 1 year ago |
androidacy-user | 26f6d4e657 | 2 years ago |
androidacy-user | 74dbd66ff7 | 2 years ago |
Androidacy Service Account | 926731bebc | 2 years ago |
Daviteusz | 1f9fafbf59 | 2 years ago |
androidacy-user | 1e45a3dd5f | 2 years ago |
androidacy-user | 76a4696423 | 2 years ago |
androidacy-user | 12f764a4a3 | 2 years ago |
androidacy-user | c30bc44698 | 2 years ago |
androidacy-user | e6039666bd | 2 years ago |
androidacy-user | 056d88955e | 2 years ago |
Androidacy Service Account | 3c98c9b6a1 | 2 years ago |
androidacy-user | 71e11600ef | 2 years ago |
Weblate | 19b061242f | 2 years ago |
Weblate | 30fbac837a | 2 years ago |
Daviteusz | 42d0125e85 | 2 years ago |
Daviteusz | 558cf31421 | 2 years ago |
Weblate | 41d68e4d12 | 2 years ago |
Androidacy Service Account | a1cc1a29c9 | 2 years ago |
Androidacy Service Account | 36307183a5 | 2 years ago |
ender-zhao | 8f10cbf122 | 2 years ago |
ender-zhao | e638efa560 | 2 years ago |
Androidacy Service Account | 92952e112e | 2 years ago |
androidacy-user | cdc4bcd51e | 2 years ago |
androidacy-user | 9f8703df56 | 2 years ago |
androidacy-user | 46a4bd2934 | 2 years ago |
androidacy-user | f3d31ed380 | 2 years ago |
androidacy-user | dfe53576bd | 2 years ago |
androidacy-user | f07627da59 | 2 years ago |
androidacy-user | 4fa978b78c | 2 years ago |
androidacy-user | 0339dd7525 | 2 years ago |
androidacy-user | ff1cb4fbb6 | 2 years ago |
androidacy-user | 35b00cfb61 | 2 years ago |
androidacy-user | 29e3d7e58e | 2 years ago |
androidacy-user | 39cfa8c52e | 2 years ago |
androidacy-user | 37b19f01b6 | 2 years ago |
androidacy-user | 55b2b5c040 | 2 years ago |
androidacy-user | 7c934e9987 | 2 years ago |
androidacy-user | b6077f2256 | 2 years ago |
Androidacy Service Account | 05a29b9a81 | 2 years ago |
Weblate | 494aab40a3 | 2 years ago |
Weblate | a916f6db31 | 2 years ago |
Androidacy Service Account | 596776524b | 2 years ago |
Weblate | d62612c73b | 2 years ago |
Weblate | 9dc99479b1 | 2 years ago |
Weblate | 6ce8a250b0 | 2 years ago |
Weblate | 8f5852a128 | 2 years ago |
Androidacy Service Account | b188ccc103 | 2 years ago |
Daviteusz | adf10122f0 | 2 years ago |
Androidacy Service Account | c88de9cb12 | 2 years ago |
Androidacy Service Account | 801c136765 | 2 years ago |
Androidacy Service Account | f20d95e2d3 | 2 years ago |
Weblate | 4024c6d661 | 2 years ago |
Weblate | 58c5b628a0 | 2 years ago |
Weblate | 1d4e85f3af | 2 years ago |
ender-zhao | 93ca7e06e9 | 2 years ago |
Fox2Code | 09671850f7 | 2 years ago |
Fox2Code | 8a59f75cc5 | 2 years ago |
Fox2Code | dc58ee63ba | 2 years ago |
Rom | 3871e387c6 | 2 years ago |
Weblate | ad83719c8d | 2 years ago |
Rom | 44eed04002 | 2 years ago |
androidacy-user | b6fc43783d | 2 years ago |
Fox2Code | 0b0079d91b | 2 years ago |
Fox2Code | 0f0f579d13 | 2 years ago |
androidacy-user | 154e5d715b | 2 years ago |
androidacy-user | 2171364db8 | 2 years ago |
Fox2Code | d4ab506798 | 2 years ago |
Fox2Code | 78a72ba36f | 2 years ago |
Fox2Code | 414bd5d340 | 2 years ago |
androidacy-user | 8b3d26a4d4 | 2 years ago |
androidacy-user | b6ce2a62f0 | 2 years ago |
Rom | 946847ef88 | 2 years ago |
Rom | ceac162ba3 | 2 years ago |
Fox2Code | e3a7420bea | 2 years ago |
Weblate | a082e1fbd4 | 2 years ago |
Fox2Code | a76040eb12 | 2 years ago |
Androidacy Service Account | 112d94b86a | 2 years ago |
androidacy-user | ff1afd0416 | 2 years ago |
Nick | adc7ddb731 | 2 years ago |
Weblate | a056a32099 | 2 years ago |
@ -1 +1,2 @@
|
||||
custom: ["https://www.paypal.com/paypalme/fox2code"]
|
||||
patreon: Androidacy
|
||||
custom: ["https://www.androidacy.com/membership-join/#utm_source=github&utm_medium=web&utm_campaign=ghsponsors"]
|
||||
|
@ -1,51 +1,94 @@
|
||||
#file: noinspection SpellCheckingInspection
|
||||
name: Generate APK Debug
|
||||
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for default and protected branches
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- debug
|
||||
- '*'
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- debug
|
||||
branches:
|
||||
- '*'
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up Java 19
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 19
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v2
|
||||
|
||||
- name: Set Up JDK
|
||||
uses: actions/setup-java@v1
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
with:
|
||||
java-version: 11
|
||||
gradle-home-cache-includes: |
|
||||
caches
|
||||
notifications
|
||||
jdks
|
||||
${{ github.workspace }}/.gradle/configuration-cache
|
||||
|
||||
- name: Change wrapper permissions
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Run tests
|
||||
run: ./gradlew test
|
||||
# temporary disabled
|
||||
# - name: Run tests
|
||||
# run: ./gradlew test
|
||||
|
||||
# Create APK Debug
|
||||
- name: Build apk debug
|
||||
run: ./gradlew app:assembleDefaultDebug
|
||||
|
||||
# will not upload, just build to check if it builds
|
||||
- name: Build apk fdroid-debug
|
||||
run: ./gradlew app:assembleFdroidDebug
|
||||
|
||||
# Upload Artifact Build
|
||||
# Noted For Output [module-name]/build/outputs/apk
|
||||
- name: Upload apk debug
|
||||
# UPLOAD ARTIFACT SECTION
|
||||
# Will be shorter, when https://github.com/actions/upload-artifact/pull/354 will be merged
|
||||
# FoxMMM-default-debug
|
||||
- name: Upload FoxMMM-default-arm64-v8a-debug
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: FoxMMM-default-arm64-v8a-debug
|
||||
path: app/build/outputs/apk/default/debug/*-default-arm64-v8a-debug.apk
|
||||
|
||||
- name: Upload FoxMMM-default-armeabi-v7a-debug
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: FoxMMM-default-armeabi-v7a-debug
|
||||
path: app/build/outputs/apk/default/debug/*-default-armeabi-v7a-debug.apk
|
||||
|
||||
- name: Upload FoxMMM-default-universal-debug
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: FoxMMM-default-universal-debug
|
||||
path: app/build/outputs/apk/default/debug/*-default-universal-debug.apk
|
||||
|
||||
- name: Upload FoxMMM-default-x86-debug
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: FoxMmm-debug
|
||||
path: app/build/outputs/apk/default/debug/app-default-debug.apk
|
||||
name: FoxMMM-default-x86-debug
|
||||
path: app/build/outputs/apk/default/debug/*-default-x86-debug.apk
|
||||
|
||||
- name: Upload apk fdroid-debug
|
||||
- name: Upload FoxMMM-default-x86_64-debug
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: FoxMmm-fdroid-debug
|
||||
path: app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk
|
||||
name: FoxMMM-default-x86_64-debug
|
||||
path: app/build/outputs/apk/default/debug/*-default-x86_64-debug.apk
|
||||
|
@ -0,0 +1,39 @@
|
||||
name: Dependencies
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master # run the action on your projects default branch
|
||||
pull_request:
|
||||
branches:
|
||||
- master # run the action on your projects default branch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions: # The Dependency Submission API requires write permission
|
||||
contents: write
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up JDK 19
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
java-version: 19
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v2
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Change wrapper permissions
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Run snapshot action
|
||||
uses: mikepenz/gradle-dependency-submission@v1
|
||||
with:
|
||||
gradle-project-path: "."
|
||||
gradle-build-module: ":app"
|
@ -1,203 +0,0 @@
|
||||
plugins {
|
||||
id "io.sentry.android.gradle" version "3.1.5"
|
||||
id 'com.android.application'
|
||||
id 'com.mikepenz.aboutlibraries.plugin'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.fox2code.mmm"
|
||||
compileSdk 33
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.fox2code.mmm"
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
versionCode 58
|
||||
versionName "0.6.6"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
debug {
|
||||
applicationIdSuffix '.debug'
|
||||
debuggable true
|
||||
// ONLY FOR TESTING SENTRY
|
||||
// minifyEnabled true
|
||||
// shrinkResources true
|
||||
// proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions "type"
|
||||
productFlavors {
|
||||
"default" {
|
||||
dimension "type"
|
||||
buildConfigField "boolean", "ENABLE_AUTO_UPDATER", "true"
|
||||
buildConfigField "boolean", "DEFAULT_ENABLE_CRASH_REPORTING", "true"
|
||||
buildConfigField("java.util.List<String>",
|
||||
"ENABLED_REPOS",
|
||||
"java.util.Arrays.asList(\"magisk_alt_repo\", \"androidacy_repo\")",)
|
||||
}
|
||||
|
||||
fdroid {
|
||||
dimension "type"
|
||||
applicationIdSuffix ".fdroid"
|
||||
|
||||
// Need to disable auto-updater for F-Droid flavor because their inclusion policy
|
||||
// forbids downloading blobs from third-party websites (and F-Droid APK isn't signed
|
||||
// with our keys, so the APK wouldn't install anyways).
|
||||
buildConfigField "boolean", "ENABLE_AUTO_UPDATER", "false"
|
||||
|
||||
// Disable crash reporting for F-Droid flavor by default
|
||||
buildConfigField "boolean", "DEFAULT_ENABLE_CRASH_REPORTING", "false"
|
||||
|
||||
// Repo with ads or tracking feature are disabled by default for the
|
||||
// F-Droid flavor.
|
||||
buildConfigField("java.util.List<String>",
|
||||
"ENABLED_REPOS",
|
||||
"java.util.Arrays.asList(\"magisk_alt_repo\")",)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
lint {
|
||||
disable 'MissingTranslation'
|
||||
disable 'TypographyEllipsis'
|
||||
}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
additionalLicenses = ["LGPL_3_0_only"]
|
||||
}
|
||||
|
||||
sentry {
|
||||
// Disable sentry on F-Droid flavor
|
||||
ignoredFlavors = [ "fdroid" ]
|
||||
|
||||
// Disables or enables the handling of Proguard mapping for Sentry.
|
||||
// If enabled the plugin will generate a UUID and will take care of
|
||||
// uploading the mapping to Sentry. If disabled, all the logic
|
||||
// related to proguard mapping will be excluded.
|
||||
// Default is enabled.
|
||||
includeProguardMapping = true
|
||||
|
||||
// Whether the plugin should attempt to auto-upload the mapping file to Sentry or not.
|
||||
// If disabled the plugin will run a dry-run and just generate a UUID.
|
||||
// The mapping file has to be uploaded manually via sentry-cli in this case.
|
||||
// Default is enabled.
|
||||
autoUploadProguardMapping = hasSentryConfig
|
||||
|
||||
// Experimental flag to turn on support for GuardSquare's tools integration (Dexguard and External Proguard).
|
||||
// If enabled, the plugin will try to consume and upload the mapping file produced by Dexguard and External Proguard.
|
||||
// Default is disabled.
|
||||
experimentalGuardsquareSupport = true
|
||||
|
||||
// Disables or enables the automatic configuration of Native Symbols
|
||||
// for Sentry. This executes sentry-cli automatically so
|
||||
// you don't need to do it manually.
|
||||
// Default is disabled.
|
||||
uploadNativeSymbols = hasSentryConfig
|
||||
|
||||
// Does or doesn't include the source code of native code for Sentry.
|
||||
// This executes sentry-cli with the --include-sources param. automatically so
|
||||
// you don't need to do it manually.
|
||||
// Default is disabled.
|
||||
includeNativeSources = true
|
||||
|
||||
// Enable or disable the tracing instrumentation.
|
||||
// Does auto instrumentation for specified features through bytecode manipulation.
|
||||
// Default is enabled.
|
||||
tracingInstrumentation {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
// Enable auto-installation of Sentry components (sentry-android SDK and okhttp, timber and fragment integrations).
|
||||
// Default is enabled.
|
||||
// Only available v3.1.0 and above.
|
||||
autoInstallation {
|
||||
enabled = true
|
||||
|
||||
// Specifies a version of the sentry-android SDK and fragment, timber and okhttp integrations.
|
||||
//
|
||||
// This is also useful, when you have the sentry-android SDK already included into a transitive dependency/module and want to
|
||||
// align integration versions with it (if it's a direct dependency, the version will be inferred).
|
||||
//
|
||||
// NOTE: if you have a higher version of the sentry-android SDK or integrations on the classpath, this setting will have no effect
|
||||
// as Gradle will resolve it to the latest version.
|
||||
//
|
||||
// Defaults to the latest published sentry version.
|
||||
sentryVersion = '6.4.1'
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
implementation.exclude group: 'org.jetbrains', module: 'annotations'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// UI
|
||||
implementation 'androidx.appcompat:appcompat:1.5.1'
|
||||
implementation 'androidx.emoji2:emoji2:1.2.0'
|
||||
implementation 'androidx.emoji2:emoji2-views-helper:1.2.0'
|
||||
implementation 'androidx.preference:preference:1.2.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||
implementation 'androidx.webkit:webkit:1.5.0'
|
||||
implementation 'com.google.android.material:material:1.6.1'
|
||||
implementation "com.mikepenz:aboutlibraries:${latestAboutLibsRelease}"
|
||||
implementation "dev.rikka.rikkax.layoutinflater:layoutinflater:1.2.0"
|
||||
implementation "dev.rikka.rikkax.insets:insets:1.3.0"
|
||||
implementation 'com.github.Dimezis:BlurView:version-2.0.2'
|
||||
implementation 'com.github.KieronQuinn:MonetCompat:0.4.1'
|
||||
implementation 'com.github.Fox2Code:FoxCompat:0.1.5'
|
||||
|
||||
// Utils
|
||||
implementation 'androidx.work:work-runtime:2.7.1'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.3'
|
||||
implementation 'com.squareup.okhttp3:okhttp-brotli:4.9.3'
|
||||
implementation 'com.github.topjohnwu.libsu:io:5.0.1'
|
||||
implementation 'com.github.Fox2Code:RosettaX:1.0.9'
|
||||
implementation 'com.github.Fox2Code:AndroidANSI:1.0.1'
|
||||
|
||||
// Error reporting
|
||||
defaultImplementation 'io.sentry:sentry-android:6.4.1'
|
||||
defaultImplementation 'io.sentry:sentry-android-fragment:6.4.1'
|
||||
defaultImplementation 'io.sentry:sentry-android-okhttp:6.4.1'
|
||||
defaultImplementation 'io.sentry:sentry-android-core:6.4.1'
|
||||
defaultImplementation 'io.sentry:sentry-android-ndk:6.4.1'
|
||||
|
||||
// Markdown
|
||||
implementation "io.noties.markwon:core:4.6.2"
|
||||
implementation "io.noties.markwon:html:4.6.2"
|
||||
implementation "io.noties.markwon:image:4.6.2"
|
||||
implementation "io.noties.markwon:syntax-highlight:4.6.2"
|
||||
annotationProcessor "io.noties:prism4j-bundler:2.0.0"
|
||||
implementation "com.caverock:androidsvg:1.4"
|
||||
|
||||
// Test
|
||||
testImplementation 'junit:junit:4.+'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
}
|
||||
|
||||
if (hasSentryConfig) {
|
||||
Properties properties = new Properties();
|
||||
try (FileInputStream fis = new FileInputStream(sentryConfigFile)) {
|
||||
properties.load(fis)
|
||||
}
|
||||
tasks.withType(Exec) {
|
||||
environment "SENTRY_PROJECT", properties.getProperty("defaults.project")
|
||||
environment "SENTRY_ORG", properties.getProperty("defaults.org")
|
||||
environment "SENTRY_URL", properties.getProperty("defaults.url")
|
||||
environment "SENTRY_AUTH_TOKEN", properties.getProperty("auth.token")
|
||||
}
|
||||
}
|
@ -0,0 +1,497 @@
|
||||
@file:Suppress("UnstableApiUsage", "SpellCheckingInspection")
|
||||
|
||||
import com.android.build.api.variant.FilterConfiguration.FilterType.ABI
|
||||
import io.sentry.android.gradle.extensions.InstrumentationFeature
|
||||
import io.sentry.android.gradle.instrumentation.logcat.LogcatLevel
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
// Gradle doesn't allow conditionally enabling/disabling plugins
|
||||
id("io.sentry.android.gradle")
|
||||
id("com.android.application")
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
kotlin("android")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
// apply realm-android
|
||||
apply(plugin = "realm-android")
|
||||
val hasSentryConfig = File(rootProject.projectDir, "sentry.properties").exists()
|
||||
android {
|
||||
// functions to get git info: gitCommitHash, gitBranch, gitRemote
|
||||
val gitCommitHash = providers.exec {
|
||||
commandLine("git", "rev-parse", "--short", "HEAD")
|
||||
}.standardOutput.asText.get().toString().trim()
|
||||
val gitBranch = providers.exec {
|
||||
commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
|
||||
}.standardOutput.asText.get().toString().trim()
|
||||
val gitRemote = providers.exec {
|
||||
commandLine("git", "config", "--get", "remote.origin.url")
|
||||
}.standardOutput.asText.get().toString().trim()
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
namespace = "com.fox2code.mmm"
|
||||
compileSdk = 33
|
||||
ndkVersion = "25.2.9519653"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.fox2code.mmm"
|
||||
minSdk = 24
|
||||
targetSdk = 33
|
||||
versionCode = 70
|
||||
versionName = "2.0.2"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
multiDexEnabled = true
|
||||
resourceConfigurations.addAll(listOf("ar", "bs", "cs", "de", "es-rMX", "fr", "hu", "id", "ja", "nl", "pl", "pt", "pt-rBR", "ro", "ru", "tr", "uk", "zh", "zh-rTW", "en"))
|
||||
}
|
||||
|
||||
splits {
|
||||
|
||||
// Configures multiple APKs based on ABI.
|
||||
abi {
|
||||
|
||||
// Enables building multiple APKs per ABI.
|
||||
isEnable = true
|
||||
|
||||
// By default all ABIs are included, so use reset()
|
||||
|
||||
// Resets the list of ABIs for Gradle to create APKs for to none.
|
||||
reset()
|
||||
|
||||
// Specifies a list of ABIs for Gradle to create APKs for.
|
||||
include("x86", "x86_64", "arm64-v8a", "armeabi-v7a")
|
||||
|
||||
// Specifies that you don't want to also generate a universal APK that includes all ABIs.
|
||||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
)
|
||||
renderscriptOptimLevel = 3
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
isDebuggable = true
|
||||
versionNameSuffix = "-debug"
|
||||
isJniDebuggable = true
|
||||
isRenderscriptDebuggable = true
|
||||
|
||||
// ONLY FOR TESTING SENTRY
|
||||
// minifyEnabled true
|
||||
// shrinkResources true
|
||||
// proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"),"proguard-rules.pro"
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions.add("type")
|
||||
productFlavors {
|
||||
create("default") {
|
||||
dimension = "type"
|
||||
// current timestamp of build
|
||||
buildConfigField("long", "BUILD_TIME", "$timestamp")
|
||||
// debug http requests. do not set this to true if you care about performance!!!!!
|
||||
buildConfigField("boolean", "DEBUG_HTTP", "false")
|
||||
// Latest commit hash as BuildConfig.COMMIT_HASH
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$gitCommitHash\"")
|
||||
// Get the current branch name as BuildConfig.BRANCH_NAME
|
||||
buildConfigField("String", "BRANCH_NAME", "\"$gitBranch\"")
|
||||
// Get remote url as BuildConfig.REMOTE_URL
|
||||
buildConfigField("String", "REMOTE_URL", "\"$gitRemote\"")
|
||||
buildConfigField("boolean", "ENABLE_AUTO_UPDATER", "true")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_CRASH_REPORTING", "true")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_CRASH_REPORTING_PII", "true")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_ANALYTICS", "true")
|
||||
val properties = Properties()
|
||||
if (project.rootProject.file("local.properties").exists()) {
|
||||
properties.load(project.rootProject.file("local.properties").reader())
|
||||
// grab matomo.url
|
||||
buildConfigField(
|
||||
"String", "ANALYTICS_ENDPOINT", "\"" + properties.getProperty(
|
||||
"matomo.url", "https://s-api.androidacy.com/matomo.php"
|
||||
) + "\""
|
||||
)
|
||||
} else {
|
||||
buildConfigField(
|
||||
"String", "ANALYTICS_ENDPOINT", "\"https://s-api.androidacy.com/matomo.php\""
|
||||
)
|
||||
}
|
||||
buildConfigField("boolean", "ENABLE_PROTECTION", "true")
|
||||
// Get the androidacy client ID from the androidacy.properties
|
||||
|
||||
val propertiesA = Properties()
|
||||
// If androidacy.properties doesn"t exist, use the default client ID which is heavily
|
||||
// rate limited to 30 requests per minute
|
||||
if (project.rootProject.file("androidacy.properties").exists()) {
|
||||
propertiesA.load(project.rootProject.file("androidacy.properties").reader())
|
||||
properties.setProperty(
|
||||
"client_id", "\"" + propertiesA.getProperty(
|
||||
"client_id",
|
||||
"5KYccdYxWB2RxMq5FTbkWisXi2dS6yFN9R7RVlFCG98FRdz6Mf5ojY2fyJCUlXJZ"
|
||||
) + "\""
|
||||
)
|
||||
} else {
|
||||
properties.setProperty(
|
||||
"client_id", "5KYccdYxWB2RxMq5FTbkWisXi2dS6yFN9R7RVlFCG98FRdz6Mf5ojY2fyJCUlXJZ"
|
||||
)
|
||||
}
|
||||
buildConfigField(
|
||||
"String", "ANDROIDACY_CLIENT_ID", "\"" + propertiesA.getProperty("client_id") + "\""
|
||||
)
|
||||
|
||||
buildConfigField(
|
||||
"java.util.List<String>",
|
||||
"ENABLED_REPOS",
|
||||
"java.util.Arrays.asList(\"androidacy_repo\")",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// play variant. pretty similiar to default, but with an empty inital online repo list, and use play_client_id instead of client_id
|
||||
create("play") {
|
||||
dimension = "type"
|
||||
applicationIdSuffix = ".play"
|
||||
// current timestamp of build
|
||||
buildConfigField("long", "BUILD_TIME", "$timestamp")
|
||||
// debug http requests. do not set this to true if you care about performance!!!!!
|
||||
buildConfigField("boolean", "DEBUG_HTTP", "false")
|
||||
// Latest commit hash as BuildConfig.COMMIT_HASH
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$gitCommitHash\"")
|
||||
// Get the current branch name as BuildConfig.BRANCH_NAME
|
||||
buildConfigField("String", "BRANCH_NAME", "\"$gitBranch\"")
|
||||
// Get remote url as BuildConfig.REMOTE_URL
|
||||
buildConfigField("String", "REMOTE_URL", "\"$gitRemote\"")
|
||||
buildConfigField("boolean", "ENABLE_AUTO_UPDATER", "false")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_CRASH_REPORTING", "true")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_CRASH_REPORTING_PII", "true")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_ANALYTICS", "true")
|
||||
val properties = Properties()
|
||||
if (project.rootProject.file("local.properties").exists()) {
|
||||
properties.load(project.rootProject.file("local.properties").reader())
|
||||
// grab matomo.url
|
||||
buildConfigField(
|
||||
"String", "ANALYTICS_ENDPOINT", "\"" + properties.getProperty(
|
||||
"matomo.url", "https://s-api.androidacy.com/matomo.php"
|
||||
) + "\""
|
||||
)
|
||||
} else {
|
||||
buildConfigField(
|
||||
"String", "ANALYTICS_ENDPOINT", "\"https://s-api.androidacy.com/matomo.php\""
|
||||
)
|
||||
}
|
||||
buildConfigField("boolean", "ENABLE_PROTECTION", "true")
|
||||
// Get the androidacy client ID from the androidacy.properties
|
||||
|
||||
val propertiesA = Properties()
|
||||
// If androidacy.properties doesn"t exist, use the default client ID which is heavily
|
||||
// rate limited to 30 requests per minute
|
||||
if (project.rootProject.file("androidacy.properties").exists()) {
|
||||
propertiesA.load(project.rootProject.file("androidacy.properties").reader())
|
||||
properties.setProperty(
|
||||
"client_id", "\"" + propertiesA.getProperty(
|
||||
"play_client_id",
|
||||
"5KYccdYxWB2RxMq5FTbkWisXi2dS6yFN9R7RVlFCG98FRdz6Mf5ojY2fyJCUlXJZ"
|
||||
) + "\""
|
||||
)
|
||||
} else {
|
||||
properties.setProperty(
|
||||
"client_id", "5KYccdYxWB2RxMq5FTbkWisXi2dS6yFN9R7RVlFCG98FRdz6Mf5ojY2fyJCUlXJZ"
|
||||
)
|
||||
}
|
||||
buildConfigField(
|
||||
"String", "ANDROIDACY_CLIENT_ID", "\"" + propertiesA.getProperty("client_id") + "\""
|
||||
)
|
||||
|
||||
buildConfigField(
|
||||
"java.util.List<String>",
|
||||
"ENABLED_REPOS",
|
||||
"java.util.Arrays.asList(\"\")",
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
create("fdroid") {
|
||||
dimension = "type"
|
||||
applicationIdSuffix = ".fdroid"
|
||||
// current timestamp of build
|
||||
buildConfigField("long", "BUILD_TIME", "$timestamp")
|
||||
// debug http requests. do not set this to true if you care about performance!!!!!
|
||||
buildConfigField("boolean", "DEBUG_HTTP", "false")
|
||||
|
||||
// Latest commit hash as BuildConfig.COMMIT_HASH
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$gitCommitHash\"")
|
||||
// Get the current branch name as BuildConfig.BRANCH_NAME
|
||||
buildConfigField("String", "BRANCH_NAME", "\"$gitBranch\"")
|
||||
// Get remote url as BuildConfig.REMOTE_URL
|
||||
buildConfigField("String", "REMOTE_URL", "\"$gitRemote\"")
|
||||
|
||||
// Need to disable auto-updater for F-Droid flavor because their inclusion policy
|
||||
// forbids downloading blobs from third-party websites (and F-Droid APK isn"t signed
|
||||
// with our keys, so the APK wouldn"t install anyways).
|
||||
buildConfigField("boolean", "ENABLE_AUTO_UPDATER", "false")
|
||||
|
||||
// Disable crash reporting for F-Droid flavor by default
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_CRASH_REPORTING", "false")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_CRASH_REPORTING_PII", "false")
|
||||
buildConfigField("boolean", "DEFAULT_ENABLE_ANALYTICS", "false")
|
||||
val properties = Properties()
|
||||
if (project.rootProject.file("local.properties").exists()) {
|
||||
properties.load(project.rootProject.file("local.properties").reader())
|
||||
// grab matomo.url
|
||||
buildConfigField(
|
||||
"String", "ANALYTICS_ENDPOINT", "\"" + properties.getProperty(
|
||||
"matomo.url", "https://s-api.androidacy.com/matomo.php"
|
||||
) + "\""
|
||||
)
|
||||
} else {
|
||||
buildConfigField(
|
||||
"String", "ANALYTICS_ENDPOINT", "\"https://s-api.androidacy.com/matomo.php\""
|
||||
)
|
||||
}
|
||||
buildConfigField("boolean", "ENABLE_PROTECTION", "true")
|
||||
|
||||
// Repo with ads or tracking feature are disabled by default for the
|
||||
// F-Droid flavor. at the same time, the alt repo isn"t particularly trustworthy
|
||||
buildConfigField(
|
||||
"java.util.List<String>",
|
||||
"ENABLED_REPOS",
|
||||
"java.util.Arrays.asList(\"\")",
|
||||
)
|
||||
|
||||
// Get the androidacy client ID from the androidacy.properties
|
||||
val propertiesA = Properties()
|
||||
// If androidacy.properties doesn"t exist, use the fdroid client ID which is limited
|
||||
// to 50 requests per minute
|
||||
if (project.rootProject.file("androidacy.properties").exists()) {
|
||||
propertiesA.load(project.rootProject.file("androidacy.properties").inputStream())
|
||||
} else {
|
||||
propertiesA.setProperty(
|
||||
"client_id", "dQ1p7X8bF14PVJ7wAU6ORVjPB2IeTinsuAZ8Uos6tQiyUdUyIjSyZSmN54QBbaTy"
|
||||
)
|
||||
}
|
||||
buildConfigField(
|
||||
"String", "ANDROIDACY_CLIENT_ID", "\"" + propertiesA.getProperty(
|
||||
"client_id", "dQ1p7X8bF14PVJ7wAU6ORVjPB2IeTinsuAZ8Uos6tQiyUdUyIjSyZSmN54QBbaTy"
|
||||
) + "\""
|
||||
)
|
||||
versionNameSuffix = "-froid"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = JavaVersion.VERSION_19
|
||||
targetCompatibility = JavaVersion.VERSION_19
|
||||
}
|
||||
|
||||
lint {
|
||||
disable.add("MissingTranslation")
|
||||
}
|
||||
}
|
||||
|
||||
sentry {
|
||||
|
||||
includeProguardMapping.set(true)
|
||||
|
||||
autoUploadProguardMapping.set(hasSentryConfig)
|
||||
|
||||
experimentalGuardsquareSupport.set(true)
|
||||
|
||||
uploadNativeSymbols.set(hasSentryConfig)
|
||||
|
||||
includeNativeSources.set(true)
|
||||
|
||||
tracingInstrumentation {
|
||||
enabled.set(true)
|
||||
|
||||
features.set(
|
||||
setOf(
|
||||
InstrumentationFeature.DATABASE,
|
||||
InstrumentationFeature.FILE_IO,
|
||||
InstrumentationFeature.OKHTTP,
|
||||
InstrumentationFeature.COMPOSE
|
||||
)
|
||||
)
|
||||
|
||||
logcat {
|
||||
enabled.set(true)
|
||||
|
||||
minLevel.set(LogcatLevel.WARNING)
|
||||
}
|
||||
}
|
||||
|
||||
autoInstallation {
|
||||
enabled.set(true)
|
||||
sentryVersion.set("6.18.1")
|
||||
}
|
||||
|
||||
includeDependenciesReport.set(true)
|
||||
}
|
||||
|
||||
val abiCodes = mapOf("armeabi-v7a" to 1, "x86" to 2, "x86_64" to 3)
|
||||
|
||||
// For per-density APKs, create a similar map:
|
||||
// val densityCodes = mapOf("mdpi" to 1, "hdpi" to 2, "xhdpi" to 3)
|
||||
|
||||
|
||||
// For each APK output variant, override versionCode with a combination of
|
||||
// abiCodes * 1000 + variant.versionCode. In this example, variant.versionCode
|
||||
// is equal to defaultConfig.versionCode. If you configure product flavors that
|
||||
// define their own versionCode, variant.versionCode uses that value instead.
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
|
||||
// Assigns a different version code for each output APK
|
||||
// other than the universal APK.
|
||||
variant.outputs.forEach { output ->
|
||||
val name = output.filters.find { it.filterType == ABI }?.identifier
|
||||
|
||||
// Stores the value of abiCodes that is associated with the ABI for this variant.
|
||||
val baseAbiCode = abiCodes[name]
|
||||
// Because abiCodes.get() returns null for ABIs that are not mapped by ext.abiCodes,
|
||||
// the following code doesn't override the version code for universal APKs.
|
||||
// However, because you want universal APKs to have the lowest version code,
|
||||
// this outcome is desirable.
|
||||
if (baseAbiCode != null) {
|
||||
// Assigns the new version code to output.versionCode, which changes the version code
|
||||
// for only the output APK, not for the variant itself.
|
||||
val versioCode = output.versionCode.get() as Int
|
||||
output.versionCode.set(baseAbiCode * 1000 + versioCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
// Specify the additional licenses
|
||||
additionalLicenses = arrayOf("LGPL_3_0_only", "Apache_2_0")
|
||||
}
|
||||
|
||||
configurations {
|
||||
// Access all imported libraries
|
||||
all {
|
||||
// Exclude all libraries with the following group and module
|
||||
exclude(group = "org.jetbrains", module = "annotations-java5")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// UI
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.activity:activity-ktx:1.7.1")
|
||||
implementation("androidx.emoji2:emoji2:1.3.0")
|
||||
implementation("androidx.emoji2:emoji2-views-helper:1.3.0")
|
||||
implementation("androidx.preference:preference-ktx:1.2.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("androidx.webkit:webkit:1.6.1")
|
||||
implementation("com.google.android.material:material:1.8.0")
|
||||
implementation("dev.rikka.rikkax.layoutinflater:layoutinflater:1.3.0")
|
||||
implementation("dev.rikka.rikkax.insets:insets:1.3.0")
|
||||
implementation("com.github.KieronQuinn:MonetCompat:0.4.1")
|
||||
implementation("com.github.Fox2Code:FoxCompat:0.2.0")
|
||||
implementation("com.mikepenz:aboutlibraries:10.6.2")
|
||||
|
||||
// Utils
|
||||
implementation("androidx.work:work-runtime:2.8.1")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.10")
|
||||
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:5.0.0-alpha.10")
|
||||
// logging interceptor
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.10")
|
||||
// Chromium cronet from androidacy
|
||||
implementation("org.chromium.net:cronet-embedded:108.5359.79")
|
||||
|
||||
// protobuf - fixes a crash on some devices
|
||||
// implementation("com.google.protobuf:protobuf-javalite:3.22.2")
|
||||
|
||||
// google guava, maybe fix a bug
|
||||
implementation("com.google.guava:guava:31.1-jre")
|
||||
|
||||
|
||||
val libsuVersion = "5.0.5"
|
||||
// The core module that provides APIs to a shell
|
||||
implementation("com.github.topjohnwu.libsu:core:${libsuVersion}")
|
||||
|
||||
// Optional: APIs for creating root services. Depends on ":core"
|
||||
implementation("com.github.topjohnwu.libsu:service:${libsuVersion}")
|
||||
|
||||
// Optional: Provides remote file system support
|
||||
implementation("com.github.topjohnwu.libsu:io:${libsuVersion}")
|
||||
|
||||
implementation("com.github.Fox2Code:RosettaX:1.0.9")
|
||||
implementation("com.github.Fox2Code:AndroidANSI:1.0.1")
|
||||
|
||||
// sentry
|
||||
implementation("io.sentry:sentry-android:6.18.1")
|
||||
implementation("io.sentry:sentry-android-timber:6.18.1")
|
||||
implementation("io.sentry:sentry-android-fragment:6.18.1")
|
||||
implementation("io.sentry:sentry-android-okhttp:6.18.1")
|
||||
implementation("io.sentry:sentry-kotlin-extensions:6.18.1")
|
||||
implementation("io.sentry:sentry-android-ndk:6.18.1")
|
||||
|
||||
// Markdown
|
||||
// TODO: switch to an updated implementation
|
||||
implementation("io.noties.markwon:core:4.6.2")
|
||||
implementation("io.noties.markwon:html:4.6.2")
|
||||
implementation("io.noties.markwon:image:4.6.2")
|
||||
implementation("io.noties.markwon:syntax-highlight:4.6.2")
|
||||
implementation("com.google.net.cronet:cronet-okhttp:0.1.0")
|
||||
implementation("com.caverock:androidsvg:1.4")
|
||||
|
||||
implementation("androidx.core:core-ktx:1.10.0")
|
||||
|
||||
// timber
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
|
||||
// encryption
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// some utils
|
||||
implementation("commons-io:commons-io:20030203.000550")
|
||||
implementation("org.apache.commons:commons-compress:1.23.0")
|
||||
|
||||
// analytics
|
||||
implementation("com.github.matomo-org:matomo-sdk-android:HEAD")
|
||||
|
||||
// annotations
|
||||
implementation("org.jetbrains:annotations-java5:24.0.1")
|
||||
|
||||
// debugging
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.10")
|
||||
|
||||
// desugaring
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
|
||||
}
|
||||
|
||||
android {
|
||||
ndkVersion = "25.2.9519653"
|
||||
dependenciesInfo {
|
||||
includeInApk = false
|
||||
includeInBundle = false
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
//noinspection GrDeprecatedAPIUsage
|
||||
buildToolsVersion = "34.0.0 rc3"
|
||||
@Suppress("DEPRECATION") packagingOptions {
|
||||
jniLibs {
|
||||
useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(19))
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
package com.fox2code.mmm;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
assertEquals("com.fox2code.mmm", appContext.getPackageName());
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="QueryAllPackagesPermission">
|
||||
|
||||
<application android:icon="@mipmap/ic_launcher">
|
||||
<meta-data android:name="io.sentry.auto-init" android:value="false" />
|
||||
<meta-data
|
||||
android:name="io.sentry.dsn"
|
||||
android:value="https://cdcdb0efca4a42a28df90e4b7f087347@sentry.androidacy.com/2" />
|
||||
<!-- Sane value, but feel free to lower it -->
|
||||
<meta-data
|
||||
android:name="io.sentry.traces.sample-rate"
|
||||
android:value="0.5" />
|
||||
<!-- Doesn't actually monitor anything, just used to get the activities the user went through -->
|
||||
<meta-data
|
||||
android:name="io.sentry.traces.user-interaction.enable"
|
||||
android:value="true" />
|
||||
<!-- Just a screenshot of ONLY the current activity at the time of the crash -->
|
||||
<meta-data
|
||||
android:name="io.sentry.attach-screenshot"
|
||||
android:value="true" />
|
||||
</application>
|
||||
</manifest>
|
@ -1,97 +0,0 @@
|
||||
package com.fox2code.mmm.sentry;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
|
||||
import io.sentry.JsonObjectWriter;
|
||||
import io.sentry.NoOpLogger;
|
||||
import io.sentry.Sentry;
|
||||
import io.sentry.TypeCheckHint;
|
||||
import io.sentry.android.core.SentryAndroid;
|
||||
import io.sentry.android.fragment.FragmentLifecycleIntegration;
|
||||
import io.sentry.hints.DiskFlushNotification;
|
||||
|
||||
public class SentryMain {
|
||||
public static final boolean IS_SENTRY_INSTALLED = true;
|
||||
private static final String TAG = "SentryMain";
|
||||
|
||||
public static void initialize(final MainApplication mainApplication) {
|
||||
SentryAndroid.init(mainApplication, options -> {
|
||||
// If crash reporting is disabled, stop here.
|
||||
if (!MainApplication.isCrashReportingEnabled()) {
|
||||
options.setDsn("");
|
||||
} else {
|
||||
options.addIntegration(new FragmentLifecycleIntegration(mainApplication, true, true));
|
||||
// Sentry sends ABSOLUTELY NO Personally Identifiable Information (PII) by default.
|
||||
// Already set to false by default, just set it again to make peoples feel safer.
|
||||
options.setSendDefaultPii(false);
|
||||
// It just tell if sentry should ping the sentry dsn to tell the app is running.
|
||||
// This is not needed at all for crash reporting purposes, so disable it.
|
||||
options.setEnableAutoSessionTracking(false);
|
||||
// A screenshot of the app itself is only sent if the app crashes, and it only shows the last activity
|
||||
// In addition, sentry is configured with a trusted third party other than sentry.io, and only trusted people have access to the sentry instance
|
||||
// Add a callback that will be used before the event is sent to Sentry.
|
||||
// With this callback, you can modify the event or, when returning null, also discard the event.
|
||||
options.setBeforeSend((event, hint) -> {
|
||||
if (BuildConfig.DEBUG) { // Debug sentry events for debug.
|
||||
StringBuilder stringBuilder = new StringBuilder("Sentry report debug: ");
|
||||
try {
|
||||
event.serialize(new JsonObjectWriter(new Writer() {
|
||||
@Override
|
||||
public void write(char[] cbuf) {
|
||||
stringBuilder.append(cbuf);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String str) {
|
||||
stringBuilder.append(str);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(char[] chars, int i, int i1) {
|
||||
stringBuilder.append(chars, i, i1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String str, int off, int len) {
|
||||
stringBuilder.append(str, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
}
|
||||
}, 4), NoOpLogger.getInstance());
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
Log.i(TAG, stringBuilder.toString());
|
||||
}
|
||||
if (MainApplication.isCrashReportingEnabled()) {
|
||||
return event;
|
||||
} else {
|
||||
// We need to do this to avoid crash delay on crash when the event is dropped
|
||||
DiskFlushNotification diskFlushNotification = hint.getAs(
|
||||
TypeCheckHint.SENTRY_TYPE_CHECK_HINT, DiskFlushNotification.class);
|
||||
if (diskFlushNotification != null) diskFlushNotification.markFlushed();
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public static void addSentryBreadcrumb(SentryBreadcrumb sentryBreadcrumb) {
|
||||
if (MainApplication.isCrashReportingEnabled()) {
|
||||
Sentry.addBreadcrumb(sentryBreadcrumb.breadcrumb);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
package com.fox2code.mmm.sentry;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class SentryBreadcrumb {
|
||||
public SentryBreadcrumb() {}
|
||||
|
||||
public void setType(@Nullable String type) {}
|
||||
|
||||
public void setData(@NotNull String key, @Nullable Object value) {
|
||||
Objects.requireNonNull(key);
|
||||
}
|
||||
|
||||
public void setCategory(@Nullable String category) {}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package com.fox2code.mmm.sentry;
|
||||
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
|
||||
public class SentryMain {
|
||||
public static final boolean IS_SENTRY_INSTALLED = false;
|
||||
|
||||
public static void initialize(MainApplication mainApplication) {}
|
||||
|
||||
public static void addSentryBreadcrumb(SentryBreadcrumb sentryBreadcrumb) {}
|
||||
}
|
@ -0,0 +1,172 @@
|
||||
package com.fox2code.mmm;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ClipboardManager;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Toast;
|
||||
|
||||
import com.fox2code.foxcompat.app.FoxActivity;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.textview.MaterialTextView;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
import io.sentry.Sentry;
|
||||
import io.sentry.UserFeedback;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class CrashHandler extends FoxActivity {
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
Timber.i("CrashHandler.onCreate(%s)", savedInstanceState);
|
||||
// log intent with extras
|
||||
Timber.d("CrashHandler.onCreate: intent=%s", getIntent());
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_crash_handler);
|
||||
// unlock webview
|
||||
// set crash_details MaterialTextView to the exception passed in the intent or unknown if null
|
||||
// convert stacktrace from array to string, and pretty print it (first line is the exception, the rest is the stacktrace, with each line indented by 4 spaces)
|
||||
MaterialTextView crashDetails = findViewById(R.id.crash_details);
|
||||
crashDetails.setText("");
|
||||
// get the exception from the intent
|
||||
Throwable exception = (Throwable) getIntent().getSerializableExtra("exception");
|
||||
// get the crashReportingEnabled from the intent
|
||||
boolean crashReportingEnabled = getIntent().getBooleanExtra("crashReportingEnabled", false);
|
||||
// if the exception is null, set the crash details to "Unknown"
|
||||
if (exception == null) {
|
||||
crashDetails.setText(R.string.crash_details);
|
||||
} else {
|
||||
// if the exception is not null, set the crash details to the exception and stacktrace
|
||||
// stacktrace is an StacktraceElement, so convert it to a string and replace the commas with newlines
|
||||
StringWriter stringWriter = new StringWriter();
|
||||
exception.printStackTrace(new PrintWriter(stringWriter));
|
||||
String stacktrace = stringWriter.toString();
|
||||
stacktrace = stacktrace.replace(",", "\n ");
|
||||
crashDetails.setText(getString(R.string.crash_full_stacktrace, stacktrace));
|
||||
}
|
||||
String lastEventId = getIntent().getStringExtra("lastEventId");
|
||||
Timber.d("CrashHandler.onCreate: lastEventId=%s, crashReportingEnabled=%s", lastEventId, crashReportingEnabled);
|
||||
if (lastEventId == null && crashReportingEnabled) {
|
||||
// if lastEventId is null, hide the feedback button
|
||||
findViewById(R.id.feedback).setVisibility(View.GONE);
|
||||
Timber.d("CrashHandler.onCreate: lastEventId is null but crash reporting is enabled. This may indicate a bug in the crash reporting system.");
|
||||
} else {
|
||||
// if lastEventId is not null, show the feedback button
|
||||
findViewById(R.id.feedback).setVisibility(View.VISIBLE);
|
||||
}
|
||||
// disable feedback if sentry is disabled
|
||||
//noinspection ConstantConditions
|
||||
if (crashReportingEnabled && lastEventId != null) {
|
||||
// get name, email, and message fields
|
||||
EditText name = findViewById(R.id.feedback_name);
|
||||
EditText email = findViewById(R.id.feedback_email);
|
||||
EditText description = findViewById(R.id.feedback_message);
|
||||
// get submit button
|
||||
findViewById(R.id.feedback_submit).setOnClickListener(v -> {
|
||||
// require the feedback_message, rest is optional
|
||||
if (description.getText().toString().equals("")) {
|
||||
Toast.makeText(this, R.string.sentry_dialogue_empty_message, Toast.LENGTH_LONG).show();
|
||||
return;
|
||||
}
|
||||
// if email or name is empty, use "Anonymous"
|
||||
final String[] nameString = {name.getText().toString().equals("") ? "Anonymous" : name.getText().toString()};
|
||||
final String[] emailString = {email.getText().toString().equals("") ? "Anonymous" : email.getText().toString()};
|
||||
// get sentryException passed in intent
|
||||
Throwable sentryException = (Throwable) getIntent().getSerializableExtra("sentryException");
|
||||
new Thread(() -> {
|
||||
try {
|
||||
UserFeedback userFeedback;
|
||||
if (sentryException != null) {
|
||||
userFeedback = new UserFeedback(Sentry.captureException(sentryException));
|
||||
// Setups the JSON body
|
||||
if (nameString[0].equals("")) nameString[0] = "Anonymous";
|
||||
if (emailString[0].equals("")) emailString[0] = "Anonymous";
|
||||
userFeedback.setName(nameString[0]);
|
||||
userFeedback.setEmail(emailString[0]);
|
||||
userFeedback.setComments(description.getText().toString());
|
||||
Sentry.captureUserFeedback(userFeedback);
|
||||
}
|
||||
Timber.i("Submitted user feedback: name %s email %s comment %s", nameString[0], emailString[0], description.getText().toString());
|
||||
runOnUiThread(() -> Toast.makeText(this, R.string.sentry_dialogue_success, Toast.LENGTH_LONG).show());
|
||||
// Close the activity
|
||||
finish();
|
||||
// start the main activity
|
||||
startActivity(getPackageManager().getLaunchIntentForPackage(getPackageName()));
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to submit user feedback");
|
||||
// Show a toast if the user feedback could not be submitted
|
||||
runOnUiThread(() -> Toast.makeText(this, R.string.sentry_dialogue_failed_toast, Toast.LENGTH_LONG).show());
|
||||
}
|
||||
}).start();
|
||||
});
|
||||
// get restart button
|
||||
findViewById(R.id.restart).setOnClickListener(v -> {
|
||||
// Restart the app
|
||||
finish();
|
||||
startActivity(getPackageManager().getLaunchIntentForPackage(getPackageName()));
|
||||
});
|
||||
} else {
|
||||
// disable feedback if sentry is disabled
|
||||
findViewById(R.id.feedback_name).setEnabled(false);
|
||||
findViewById(R.id.feedback_email).setEnabled(false);
|
||||
findViewById(R.id.feedback_message).setEnabled(false);
|
||||
// fade out all the fields
|
||||
findViewById(R.id.feedback_name).setAlpha(0.5f);
|
||||
findViewById(R.id.feedback_email).setAlpha(0.5f);
|
||||
findViewById(R.id.feedback_message).setAlpha(0.5f);
|
||||
// fade out the submit button
|
||||
findViewById(R.id.feedback_submit).setAlpha(0.5f);
|
||||
// set feedback_text to "Crash reporting is disabled"
|
||||
((MaterialTextView) findViewById(R.id.feedback_text)).setText(R.string.sentry_enable_nag);
|
||||
findViewById(R.id.feedback_submit).setOnClickListener(v -> Toast.makeText(this, R.string.sentry_dialogue_disabled, Toast.LENGTH_LONG).show());
|
||||
// handle restart button
|
||||
// we have to explicitly enable it because it's disabled by default
|
||||
findViewById(R.id.restart).setEnabled(true);
|
||||
findViewById(R.id.restart).setOnClickListener(v -> {
|
||||
// Restart the app
|
||||
finish();
|
||||
startActivity(getPackageManager().getLaunchIntentForPackage(getPackageName()));
|
||||
});
|
||||
}
|
||||
// handle reset button
|
||||
findViewById(R.id.reset).setOnClickListener(v -> {
|
||||
// show a confirmation material dialog
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
|
||||
builder.setTitle(R.string.reset_app);
|
||||
builder.setMessage(R.string.reset_app_confirmation);
|
||||
builder.setPositiveButton(R.string.reset, (dialog, which) -> {
|
||||
// reset the app
|
||||
MainApplication.getINSTANCE().resetApp();
|
||||
});
|
||||
builder.setNegativeButton(R.string.cancel, (dialog, which) -> {
|
||||
// do nothing
|
||||
});
|
||||
builder.show();
|
||||
});
|
||||
}
|
||||
|
||||
public void copyCrashDetails(View view) {
|
||||
// change view to a checkmark
|
||||
view.setBackgroundResource(R.drawable.baseline_check_24);
|
||||
// copy crash_details to clipboard
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
|
||||
String crashDetails = ((MaterialTextView) findViewById(R.id.crash_details)).getText().toString();
|
||||
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("crash_details", crashDetails));
|
||||
// show a toast
|
||||
Toast.makeText(this, R.string.crash_details_copied, Toast.LENGTH_LONG).show();
|
||||
// after 1 second, change the view back to a copy button
|
||||
new Thread(() -> {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
runOnUiThread(() -> view.setBackgroundResource(R.drawable.baseline_copy_all_24));
|
||||
}).start();
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,396 @@
|
||||
package com.fox2code.mmm;
|
||||
|
||||
import static com.fox2code.mmm.utils.IntentHelper.getActivity;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Resources;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.CookieManager;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import com.fox2code.foxcompat.app.FoxActivity;
|
||||
import com.fox2code.mmm.databinding.ActivitySetupBinding;
|
||||
import com.fox2code.mmm.repo.RepoManager;
|
||||
import com.fox2code.mmm.utils.realm.ReposList;
|
||||
import com.fox2code.rosettax.LanguageActivity;
|
||||
import com.fox2code.rosettax.LanguageSwitcher;
|
||||
import com.google.android.material.bottomnavigation.BottomNavigationItemView;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.materialswitch.MaterialSwitch;
|
||||
import com.topjohnwu.superuser.internal.UiThreadHandler;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.realm.Realm;
|
||||
import io.realm.RealmConfiguration;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class SetupActivity extends FoxActivity implements LanguageActivity {
|
||||
private int cachedTheme;
|
||||
private boolean realmDatabasesCreated;
|
||||
|
||||
@SuppressLint({"ApplySharedPref", "RestrictedApi"})
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
this.setTitle(R.string.setup_title);
|
||||
this.getWindow().setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION, 0);
|
||||
createFiles();
|
||||
disableUpdateActivityForFdroidFlavor();
|
||||
// Set theme
|
||||
SharedPreferences prefs = MainApplication.getSharedPreferences("mmm");
|
||||
switch (prefs.getString("theme", "system")) {
|
||||
case "light" -> setTheme(R.style.Theme_MagiskModuleManager_Monet_Light);
|
||||
case "dark" -> setTheme(R.style.Theme_MagiskModuleManager_Monet_Dark);
|
||||
case "system" -> setTheme(R.style.Theme_MagiskModuleManager_Monet);
|
||||
case "black" -> setTheme(R.style.Theme_MagiskModuleManager_Monet_Black);
|
||||
case "transparent_light" ->
|
||||
setTheme(R.style.Theme_MagiskModuleManager_Transparent_Light);
|
||||
}
|
||||
ActivitySetupBinding binding = ActivitySetupBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
View view = binding.getRoot();
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_background_update_check))).setChecked(BuildConfig.ENABLE_AUTO_UPDATER);
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting))).setChecked(BuildConfig.DEFAULT_ENABLE_CRASH_REPORTING);
|
||||
// pref_crash_reporting_pii
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting_pii))).setChecked(BuildConfig.DEFAULT_ENABLE_CRASH_REPORTING_PII);
|
||||
// pref_analytics_enabled
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_app_analytics))).setChecked(BuildConfig.DEFAULT_ENABLE_ANALYTICS);
|
||||
// assert that both switches match the build config on debug builds
|
||||
if (BuildConfig.DEBUG) {
|
||||
assert ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_background_update_check))).isChecked() == BuildConfig.ENABLE_AUTO_UPDATER;
|
||||
assert ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting))).isChecked() == BuildConfig.DEFAULT_ENABLE_CRASH_REPORTING;
|
||||
}
|
||||
// Repos are a little harder, as the enabled_repos build config is an arraylist
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_androidacy_repo))).setChecked(BuildConfig.ENABLED_REPOS.contains("androidacy_repo"));
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_magisk_alt_repo))).setChecked(BuildConfig.ENABLED_REPOS.contains("magisk_alt_repo"));
|
||||
// On debug builds, log when a switch is toggled
|
||||
if (BuildConfig.DEBUG) {
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_background_update_check))).setOnCheckedChangeListener((buttonView, isChecked) -> Timber.i("Automatic update Check: %s", isChecked));
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting))).setOnCheckedChangeListener((buttonView, isChecked) -> Timber.i("Crash Reporting: %s", isChecked));
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting_pii))).setOnCheckedChangeListener((buttonView, isChecked) -> Timber.i("Crash Reporting PII: %s", isChecked));
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_androidacy_repo))).setOnCheckedChangeListener((buttonView, isChecked) -> Timber.i("Androidacy Repo: %s", isChecked));
|
||||
((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_magisk_alt_repo))).setOnCheckedChangeListener((buttonView, isChecked) -> Timber.i("Magisk Alt Repo: %s", isChecked));
|
||||
}
|
||||
// Setup popup dialogue for the setup_theme_button
|
||||
MaterialButton themeButton = view.findViewById(R.id.setup_theme_button);
|
||||
themeButton.setOnClickListener(v -> {
|
||||
// Create a new dialog for the theme picker
|
||||
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
|
||||
builder.setTitle(R.string.setup_theme_title);
|
||||
// Create a new array of theme names (system, light, dark, black, transparent light)
|
||||
String[] themeNames = new String[]{getString(R.string.theme_system), getString(R.string.theme_light), getString(R.string.theme_dark), getString(R.string.theme_black), getString(R.string.theme_transparent_light)};
|
||||
// Create a new array of theme values (system, light, dark, black, transparent_light)
|
||||
String[] themeValues = new String[]{"system", "light", "dark", "black", "transparent_light"};
|
||||
// if pref_theme is set, check the relevant theme_* menu item, otherwise check the default (theme_system)
|
||||
String prefTheme = prefs.getString("pref_theme", "system");
|
||||
int checkedItem = 0;
|
||||
switch (prefTheme) {
|
||||
case "system":
|
||||
break;
|
||||
case "light":
|
||||
checkedItem = 1;
|
||||
break;
|
||||
case "dark":
|
||||
checkedItem = 2;
|
||||
break;
|
||||
case "black":
|
||||
checkedItem = 3;
|
||||
break;
|
||||
case "transparent_light":
|
||||
checkedItem = 4;
|
||||
break;
|
||||
}
|
||||
builder.setCancelable(true);
|
||||
// Create the dialog
|
||||
builder.setSingleChoiceItems(themeNames, checkedItem, (dialog, which) -> {
|
||||
// Set the theme
|
||||
prefs.edit().putString("pref_theme", themeValues[which]).commit();
|
||||
// Dismiss the dialog
|
||||
dialog.dismiss();
|
||||
// Set the theme
|
||||
UiThreadHandler.handler.postDelayed(() -> {
|
||||
switch (prefs.getString("pref_theme", "system")) {
|
||||
case "light" -> setTheme(R.style.Theme_MagiskModuleManager_Monet_Light);
|
||||
case "dark" -> setTheme(R.style.Theme_MagiskModuleManager_Monet_Dark);
|
||||
case "system" -> setTheme(R.style.Theme_MagiskModuleManager_Monet);
|
||||
case "black" -> setTheme(R.style.Theme_MagiskModuleManager_Monet_Black);
|
||||
case "transparent_light" ->
|
||||
setTheme(R.style.Theme_MagiskModuleManager_Transparent_Light);
|
||||
}
|
||||
// restart the activity because switching to transparent pisses the rendering engine off
|
||||
Intent intent = new Intent(this, SetupActivity.class);
|
||||
finish();
|
||||
// ensure intent originates from the same package
|
||||
intent.setPackage(getPackageName());
|
||||
startActivity(intent);
|
||||
}, 100);
|
||||
});
|
||||
builder.show();
|
||||
});
|
||||
// Setup language selector
|
||||
MaterialButton languageSelector = view.findViewById(R.id.setup_language_button);
|
||||
languageSelector.setOnClickListener(preference -> {
|
||||
LanguageSwitcher ls = new LanguageSwitcher(Objects.requireNonNull(getActivity(this)));
|
||||
ls.setSupportedStringLocales(MainApplication.supportedLocales);
|
||||
ls.showChangeLanguageDialog((FragmentActivity) getActivity(this));
|
||||
});
|
||||
// Set up the buttons
|
||||
// Setup button
|
||||
BottomNavigationItemView setupButton = view.findViewById(R.id.setup_finish);
|
||||
// on clicking setup_agree_eula, enable the setup button if it's checked, if it's not, disable it
|
||||
MaterialSwitch agreeEula = view.findViewById(R.id.setup_agree_eula);
|
||||
agreeEula.setOnCheckedChangeListener((buttonView, isChecked) -> setupButton.setEnabled(isChecked));
|
||||
setupButton.setOnClickListener(v -> {
|
||||
Timber.i("Setup button clicked");
|
||||
// get instance of editor
|
||||
Timber.d("Saving preferences");
|
||||
SharedPreferences.Editor editor = prefs.edit();
|
||||
Timber.d("Got editor: %s", editor);
|
||||
// Set the Automatic update check pref
|
||||
editor.putBoolean("pref_background_update_check", ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_background_update_check))).isChecked());
|
||||
// require wifi pref
|
||||
editor.putBoolean("pref_background_update_check_wifi", ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_background_update_check_require_wifi))).isChecked());
|
||||
// Set the crash reporting pref
|
||||
editor.putBoolean("pref_crash_reporting", ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting))).isChecked());
|
||||
// Set the crash reporting PII pref
|
||||
editor.putBoolean("pref_crash_reporting_pii", ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_crash_reporting_pii))).isChecked());
|
||||
editor.putBoolean("pref_analytics_enabled", ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_app_analytics))).isChecked());
|
||||
Timber.d("Saving preferences");
|
||||
// Set the repos in the ReposList realm db
|
||||
RealmConfiguration realmConfig = new RealmConfiguration.Builder().name("ReposList.realm").encryptionKey(MainApplication.getINSTANCE().getKey()).directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).build();
|
||||
boolean androidacyRepo = ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_androidacy_repo))).isChecked();
|
||||
boolean magiskAltRepo = ((MaterialSwitch) Objects.requireNonNull(view.findViewById(R.id.setup_magisk_alt_repo))).isChecked();
|
||||
Realm realm = Realm.getInstance(realmConfig);
|
||||
Timber.d("Realm instance: %s", realm);
|
||||
if (realm.isInTransaction()) {
|
||||
realm.commitTransaction();
|
||||
Timber.d("Committed last unfinished transaction");
|
||||
}
|
||||
// check if instance has been closed
|
||||
if (realm.isClosed()) {
|
||||
Timber.d("Realm instance was closed, reopening");
|
||||
realm = Realm.getInstance(realmConfig);
|
||||
}
|
||||
realm.executeTransactionAsync(r -> {
|
||||
Timber.d("Realm transaction started");
|
||||
Objects.requireNonNull(r.where(ReposList.class).equalTo("id", "androidacy_repo").findFirst()).setEnabled(androidacyRepo);
|
||||
Objects.requireNonNull(r.where(ReposList.class).equalTo("id", "magisk_alt_repo").findFirst()).setEnabled(magiskAltRepo);
|
||||
Timber.d("Realm transaction committing");
|
||||
// commit the changes
|
||||
r.commitTransaction();
|
||||
r.close();
|
||||
Timber.d("Realm transaction committed");
|
||||
});
|
||||
editor.putString("last_shown_setup", "v2");
|
||||
// Commit the changes
|
||||
editor.commit();
|
||||
// sleep to allow the realm transaction to finish
|
||||
try {
|
||||
Thread.sleep(250);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// Log the changes
|
||||
Timber.d("Setup finished. Preferences: %s", prefs.getAll());
|
||||
Timber.d("Androidacy repo: %s", androidacyRepo);
|
||||
Timber.d("Magisk Alt repo: %s", magiskAltRepo);
|
||||
// log last shown setup
|
||||
Timber.d("Last shown setup: %s", prefs.getString("last_shown_setup", "v0"));
|
||||
// Restart the activity
|
||||
MainActivity.doSetupRestarting = true;
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), PendingIntent.FLAG_IMMUTABLE);
|
||||
try {
|
||||
pendingIntent.send();
|
||||
} catch (PendingIntent.CanceledException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
android.os.Process.killProcess(android.os.Process.myPid());
|
||||
});
|
||||
// Cancel button
|
||||
BottomNavigationItemView cancelButton = view.findViewById(R.id.cancel_setup);
|
||||
// unselect the cancel button because it's selected by default
|
||||
cancelButton.setSelected(false);
|
||||
cancelButton.setOnClickListener(v -> {
|
||||
Timber.i("Cancel button clicked");
|
||||
// close the app
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resources.Theme getTheme() {
|
||||
Resources.Theme theme = super.getTheme();
|
||||
// try cached value
|
||||
if (cachedTheme != 0) {
|
||||
theme.applyStyle(cachedTheme, true);
|
||||
return theme;
|
||||
}
|
||||
// Set the theme
|
||||
SharedPreferences prefs = MainApplication.getSharedPreferences("mmm");
|
||||
String themePref = prefs.getString("pref_theme", "system");
|
||||
switch (themePref) {
|
||||
case "light" -> {
|
||||
theme.applyStyle(R.style.Theme_MagiskModuleManager_Monet_Light, true);
|
||||
cachedTheme = R.style.Theme_MagiskModuleManager_Monet_Light;
|
||||
}
|
||||
case "dark" -> {
|
||||
theme.applyStyle(R.style.Theme_MagiskModuleManager_Monet_Dark, true);
|
||||
cachedTheme = R.style.Theme_MagiskModuleManager_Monet_Dark;
|
||||
}
|
||||
case "system" -> {
|
||||
theme.applyStyle(R.style.Theme_MagiskModuleManager_Monet, true);
|
||||
cachedTheme = R.style.Theme_MagiskModuleManager_Monet;
|
||||
}
|
||||
case "black" -> {
|
||||
theme.applyStyle(R.style.Theme_MagiskModuleManager_Monet_Black, true);
|
||||
cachedTheme = R.style.Theme_MagiskModuleManager_Monet_Black;
|
||||
}
|
||||
case "transparent_light" -> {
|
||||
theme.applyStyle(R.style.Theme_MagiskModuleManager_Transparent_Light, true);
|
||||
cachedTheme = R.style.Theme_MagiskModuleManager_Transparent_Light;
|
||||
}
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressLint({"InlinedApi", "RestrictedApi"})
|
||||
public void refreshRosettaX() {
|
||||
// refresh app language
|
||||
runOnUiThread(() -> {
|
||||
// refresh activity
|
||||
Intent intent = new Intent(this, SetupActivity.class);
|
||||
finish();
|
||||
startActivity(intent);
|
||||
});
|
||||
}
|
||||
|
||||
// creates the realm database
|
||||
private void createRealmDatabase() {
|
||||
if (realmDatabasesCreated) {
|
||||
Timber.d("Realm databases already created");
|
||||
return;
|
||||
}
|
||||
Timber.d("Creating Realm databases");
|
||||
long startTime = System.currentTimeMillis();
|
||||
// create encryption key
|
||||
Timber.d("Creating encryption key");
|
||||
byte[] key = MainApplication.getINSTANCE().getKey();
|
||||
// create the realm database for ReposList
|
||||
// create the realm configuration
|
||||
RealmConfiguration config = new RealmConfiguration.Builder().name("ReposList.realm").directory(MainApplication.getINSTANCE().getDataDirWithPath("realms")).schemaVersion(1).encryptionKey(key).build();
|
||||
// get the instance
|
||||
Realm.getInstanceAsync(config, new Realm.Callback() {
|
||||
@Override
|
||||
public void onSuccess(@NonNull Realm realm) {
|
||||
Timber.d("Realm instance: %s", realm);
|
||||
realm.beginTransaction();
|
||||
// create the ReposList realm database
|
||||
Timber.d("Creating ReposList realm database");
|
||||
if (realm.where(ReposList.class).equalTo("id", "androidacy_repo").findFirst() == null) {
|
||||
Timber.d("Creating androidacy_repo");
|
||||
// create the androidacy_repo row
|
||||
// cant use createObject because it crashes because reasons. use copyToRealm instead
|
||||
ReposList androidacy_repo = realm.createObject(ReposList.class, "androidacy_repo");
|
||||
Timber.d("Created androidacy_repo object");
|
||||
androidacy_repo.setName("Androidacy Repo");
|
||||
Timber.d("Set androidacy_repo name");
|
||||
androidacy_repo.setDonate("https://www.androidacy.com/membership-account/membership-join/?utm_source=fox-app&utm_medium=app&utm_campaign=app");
|
||||
Timber.d("Set androidacy_repo donate");
|
||||
androidacy_repo.setSupport("https://t.me/androidacy_discussions");
|
||||
Timber.d("Set androidacy_repo support");
|
||||
androidacy_repo.setSubmitModule("https://www.androidacy.com/module-repository-applications/?utm_source=fox-app&utm_medium=app&utm_campaign=app");
|
||||
Timber.d("Set androidacy_repo submit module");
|
||||
androidacy_repo.setUrl(RepoManager.ANDROIDACY_MAGISK_REPO_ENDPOINT);
|
||||
Timber.d("Set androidacy_repo url");
|
||||
androidacy_repo.setEnabled(true);
|
||||
Timber.d("Set androidacy_repo enabled");
|
||||
androidacy_repo.setLastUpdate(0);
|
||||
Timber.d("Set androidacy_repo last update");
|
||||
androidacy_repo.setWebsite(RepoManager.ANDROIDACY_MAGISK_REPO_HOMEPAGE);
|
||||
Timber.d("Set androidacy_repo website");
|
||||
// now copy the data from the data class to the realm object using copyToRealmOrUpdate
|
||||
Timber.d("Copying data to realm object");
|
||||
realm.copyToRealmOrUpdate(androidacy_repo);
|
||||
Timber.d("Created androidacy_repo");
|
||||
}
|
||||
// create magisk_alt_repo
|
||||
if (realm.where(ReposList.class).equalTo("id", "magisk_alt_repo").findFirst() == null) {
|
||||
Timber.d("Creating magisk_alt_repo");
|
||||
ReposList magisk_alt_repo = realm.createObject(ReposList.class, "magisk_alt_repo");
|
||||
Timber.d("Created magisk_alt_repo object");
|
||||
magisk_alt_repo.setName("Magisk Alt Repo");
|
||||
magisk_alt_repo.setDonate(null);
|
||||
magisk_alt_repo.setWebsite(RepoManager.MAGISK_ALT_REPO_HOMEPAGE);
|
||||
magisk_alt_repo.setSupport(null);
|
||||
magisk_alt_repo.setEnabled(true);
|
||||
magisk_alt_repo.setUrl(RepoManager.MAGISK_ALT_REPO);
|
||||
magisk_alt_repo.setSubmitModule(RepoManager.MAGISK_ALT_REPO_HOMEPAGE + "/submission");
|
||||
magisk_alt_repo.setLastUpdate(0);
|
||||
// commit the changes
|
||||
Timber.d("Copying data to realm object");
|
||||
realm.copyToRealmOrUpdate(magisk_alt_repo);
|
||||
Timber.d("Created magisk_alt_repo");
|
||||
}
|
||||
realm.commitTransaction();
|
||||
realm.close();
|
||||
realmDatabasesCreated = true;
|
||||
Timber.d("Realm transaction finished");
|
||||
long endTime = System.currentTimeMillis();
|
||||
Timber.d("Realm databases created in %d ms", endTime - startTime);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void createFiles() {
|
||||
// use cookiemanager to create the cookie database
|
||||
try {
|
||||
CookieManager.getInstance();
|
||||
} catch (Exception e) {
|
||||
Timber.e(e);
|
||||
// show a toast
|
||||
runOnUiThread(() -> Toast.makeText(this, R.string.error_creating_cookie_database, Toast.LENGTH_LONG).show());
|
||||
}
|
||||
// we literally only use these to create the http cache folders
|
||||
try {
|
||||
FileUtils.forceMkdir(new File(MainApplication.getINSTANCE().getDataDir() + "/cache/cronet"));
|
||||
FileUtils.forceMkdir(new File(MainApplication.getINSTANCE().getDataDir() + "/cache/WebView/Default/HTTP Cache/Code Cache/wasm"));
|
||||
FileUtils.forceMkdir(new File(MainApplication.getINSTANCE().getDataDir() + "/cache/WebView/Default/HTTP Cache/Code Cache/js"));
|
||||
FileUtils.forceMkdir(new File(MainApplication.getINSTANCE().getDataDir() + "/repos/magisk_alt_repo"));
|
||||
} catch (IOException e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
createRealmDatabase();
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
public void disableUpdateActivityForFdroidFlavor() {
|
||||
if (BuildConfig.FLAVOR.equals("fdroid")) {
|
||||
// check if the update activity is enabled
|
||||
PackageManager pm = getPackageManager();
|
||||
ComponentName componentName = new ComponentName(this, UpdateActivity.class);
|
||||
int componentEnabledSetting = pm.getComponentEnabledSetting(componentName);
|
||||
if (componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
|
||||
Timber.d("Disabling update activity for fdroid flavor");
|
||||
// disable update activity through package manager
|
||||
pm.setComponentEnabledSetting(componentName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,372 @@
|
||||
package com.fox2code.mmm;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.fox2code.foxcompat.app.FoxActivity;
|
||||
import com.fox2code.mmm.utils.io.net.Http;
|
||||
import com.google.android.material.button.MaterialButton;
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator;
|
||||
import com.google.android.material.textview.MaterialTextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.matomo.sdk.extra.TrackHelper;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.noties.markwon.Markwon;
|
||||
import timber.log.Timber;
|
||||
|
||||
@SuppressWarnings("UnnecessaryReturnStatement")
|
||||
public class UpdateActivity extends FoxActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (MainApplication.getINSTANCE().isMatomoAllowed) {
|
||||
TrackHelper.track().screen(this).with(MainApplication.getINSTANCE().getTracker());
|
||||
}
|
||||
setContentView(R.layout.activity_update);
|
||||
// Get the progress bar and make it indeterminate for now
|
||||
LinearProgressIndicator progressIndicator = findViewById(R.id.update_progress);
|
||||
progressIndicator.setIndeterminate(true);
|
||||
// get update_cancel button
|
||||
MaterialButton updateCancel = findViewById(R.id.update_cancel_button);
|
||||
// get status text view
|
||||
MaterialTextView statusTextView = findViewById(R.id.update_progress_text);
|
||||
// set status text to please wait
|
||||
statusTextView.setText(R.string.please_wait);
|
||||
Thread updateThread = new Thread() {
|
||||
public void run() {
|
||||
// Now, parse the intent
|
||||
String extras = getIntent().getAction();
|
||||
// if extras is null, then we are in a bad state or user launched the activity manually
|
||||
if (extras == null) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.error_no_extras);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(0, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// get action
|
||||
ACTIONS action = ACTIONS.valueOf(extras);
|
||||
// if action is null, then we are in a bad state or user launched the activity manually
|
||||
if (Objects.isNull(action)) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.error_no_action);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(0, false);
|
||||
});
|
||||
// return
|
||||
return;
|
||||
}
|
||||
|
||||
// For check action, we need to check if there is an update using the AppUpdateManager.peekShouldUpdate()
|
||||
if (action == ACTIONS.CHECK) {
|
||||
checkForUpdate();
|
||||
} else if (action == ACTIONS.DOWNLOAD) {
|
||||
try {
|
||||
downloadUpdate();
|
||||
} catch (
|
||||
JSONException e) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.error_download_update);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
}
|
||||
} else if (action == ACTIONS.INSTALL) {
|
||||
// ensure path was passed and points to a file within our cache directory. replace .. and url encoded characters
|
||||
String path = getIntent().getStringExtra("path").trim().replaceAll("\\.\\.", "").replaceAll("%2e%2e", "");
|
||||
if (path.isEmpty()) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.no_file_found);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(0, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// check and sanitize file path
|
||||
// path must be in our cache directory
|
||||
if (!path.startsWith(getCacheDir().getAbsolutePath())) {
|
||||
throw new SecurityException("Path is not in cache directory: " + path);
|
||||
}
|
||||
File file = new File(path);
|
||||
File parentFile = file.getParentFile();
|
||||
try {
|
||||
if (parentFile == null || !parentFile.getCanonicalPath().startsWith(getCacheDir().getCanonicalPath())) {
|
||||
throw new SecurityException("Path is not in cache directory: " + path);
|
||||
}
|
||||
} catch (
|
||||
IOException e) {
|
||||
throw new SecurityException("Path is not in cache directory: " + path);
|
||||
}
|
||||
if (!file.exists()) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.no_file_found);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(0, false);
|
||||
});
|
||||
// return
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(file.getParentFile(), getCacheDir())) {
|
||||
// set status text to error
|
||||
runOnUiThread(() -> {
|
||||
statusTextView.setText(R.string.no_file_found);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(0, false);
|
||||
});
|
||||
// return
|
||||
return;
|
||||
}
|
||||
// set status text to installing
|
||||
statusTextView.setText(R.string.installing_update);
|
||||
// set progress bar to indeterminate
|
||||
progressIndicator.setIndeterminate(true);
|
||||
// install update
|
||||
installUpdate(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
// on click, finish the activity and anything running in it
|
||||
updateCancel.setOnClickListener(v -> {
|
||||
// end any download
|
||||
updateThread.interrupt();
|
||||
forceBackPressed();
|
||||
finish();
|
||||
});
|
||||
updateThread.start();
|
||||
}
|
||||
|
||||
public void checkForUpdate() {
|
||||
// get status text view
|
||||
MaterialTextView statusTextView = findViewById(R.id.update_progress_text);
|
||||
LinearProgressIndicator progressIndicator = findViewById(R.id.update_progress);
|
||||
runOnUiThread(() -> {
|
||||
progressIndicator.setIndeterminate(true);
|
||||
// set status text to checking for update
|
||||
statusTextView.setText(R.string.checking_for_update);
|
||||
// set progress bar to indeterminate
|
||||
progressIndicator.setIndeterminate(true);
|
||||
});
|
||||
// check for update
|
||||
boolean shouldUpdate = AppUpdateManager.getAppUpdateManager().peekShouldUpdate();
|
||||
// if shouldUpdate is true, then we have an update
|
||||
if (shouldUpdate) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to update available
|
||||
statusTextView.setText(R.string.update_available);
|
||||
// set button text to download
|
||||
MaterialButton button = findViewById(R.id.update_button);
|
||||
button.setText(R.string.download_update);
|
||||
});
|
||||
// return
|
||||
} else {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to no update available
|
||||
statusTextView.setText(R.string.no_update_available);
|
||||
});
|
||||
// set progress bar to error
|
||||
// return
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
public void downloadUpdate() throws JSONException {
|
||||
LinearProgressIndicator progressIndicator = findViewById(R.id.update_progress);
|
||||
runOnUiThread(() -> progressIndicator.setIndeterminate(true));
|
||||
// get status text view
|
||||
MaterialTextView statusTextView = findViewById(R.id.update_progress_text);
|
||||
byte[] lastestJSON = new byte[0];
|
||||
try {
|
||||
lastestJSON = Http.doHttpGet(AppUpdateManager.RELEASES_API_URL, false);
|
||||
} catch (
|
||||
Exception e) {
|
||||
// when logging, REMOVE the json from the log
|
||||
Timber.e(e, "Error downloading update info");
|
||||
runOnUiThread(() -> {
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
statusTextView.setText(R.string.error_download_update);
|
||||
});
|
||||
}
|
||||
// convert to JSON
|
||||
JSONObject latestJSON = new JSONObject(new String(lastestJSON));
|
||||
String changelog = latestJSON.getString("body");
|
||||
// set changelog text. changelog could be markdown, so we need to convert it to HTML
|
||||
MaterialTextView changelogTextView = findViewById(R.id.update_changelog);
|
||||
final Markwon markwon = Markwon.builder(this).build();
|
||||
runOnUiThread(() -> markwon.setMarkdown(changelogTextView, changelog));
|
||||
// we already know that there is an update, so we can get the latest version of our architecture. We're going to have to iterate through the assets to find the one we want
|
||||
JSONArray assets = latestJSON.getJSONArray("assets");
|
||||
// get the asset we want
|
||||
JSONObject asset = null;
|
||||
// iterate through assets until we find the one that contains Build.SUPPORTED_ABIS[0]
|
||||
while (Objects.isNull(asset)) {
|
||||
for (int i = 0; i < assets.length(); i++) {
|
||||
JSONObject asset1 = assets.getJSONObject(i);
|
||||
if (asset1.getString("name").contains(Build.SUPPORTED_ABIS[0])) {
|
||||
asset = asset1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// if asset is null, then we are in a bad state
|
||||
if (Objects.isNull(asset)) {
|
||||
// set status text to error
|
||||
runOnUiThread(() -> {
|
||||
statusTextView.setText(R.string.error_no_asset);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
// return
|
||||
return;
|
||||
}
|
||||
// get the download url
|
||||
String downloadUrl = Objects.requireNonNull(asset).getString("browser_download_url");
|
||||
// get the download size
|
||||
long downloadSize = asset.getLong("size");
|
||||
runOnUiThread(() -> {
|
||||
// set status text to downloading update
|
||||
statusTextView.setText(getString(R.string.downloading_update, 0));
|
||||
// set progress bar to 0
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(0, false);
|
||||
});
|
||||
// download the update
|
||||
byte[] update = new byte[0];
|
||||
try {
|
||||
update = Http.doHttpGet(downloadUrl, (downloaded, total, done) -> runOnUiThread(() -> {
|
||||
// update progress bar
|
||||
progressIndicator.setProgressCompat((int) (((float) downloaded / (float) total) * 100), true);
|
||||
// update status text
|
||||
statusTextView.setText(getString(R.string.downloading_update, (int) (((float) downloaded / (float) total) * 100)));
|
||||
}));
|
||||
} catch (
|
||||
Exception e) {
|
||||
runOnUiThread(() -> {
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
statusTextView.setText(R.string.error_download_update);
|
||||
});
|
||||
}
|
||||
// if update is null, then we are in a bad state
|
||||
if (Objects.isNull(update)) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.error_download_update);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
// return
|
||||
return;
|
||||
}
|
||||
// if update is not the same size as the download size, then we are in a bad state
|
||||
if (update.length != downloadSize) {
|
||||
runOnUiThread(() -> {
|
||||
// set status text to error
|
||||
statusTextView.setText(R.string.error_download_update);
|
||||
// set progress bar to error
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
// return
|
||||
return;
|
||||
}
|
||||
// set status text to installing update
|
||||
runOnUiThread(() -> {
|
||||
statusTextView.setText(R.string.installing_update);
|
||||
// set progress bar to 100
|
||||
progressIndicator.setIndeterminate(true);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
// save the update to the cache
|
||||
File updateFile = null;
|
||||
FileOutputStream fileOutputStream = null;
|
||||
try {
|
||||
updateFile = new File(getCacheDir(), "update.apk");
|
||||
fileOutputStream = new FileOutputStream(updateFile);
|
||||
fileOutputStream.write(update);
|
||||
} catch (
|
||||
IOException e) {
|
||||
runOnUiThread(() -> {
|
||||
progressIndicator.setIndeterminate(false);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
statusTextView.setText(R.string.error_download_update);
|
||||
});
|
||||
} finally {
|
||||
if (Objects.nonNull(updateFile)) {
|
||||
Objects.requireNonNull(updateFile).deleteOnExit();
|
||||
}
|
||||
try {
|
||||
Objects.requireNonNull(fileOutputStream).close();
|
||||
} catch (
|
||||
IOException ignored) {
|
||||
}
|
||||
}
|
||||
// install the update
|
||||
installUpdate(updateFile);
|
||||
// return
|
||||
return;
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
private void installUpdate(File updateFile) {
|
||||
// get status text view
|
||||
runOnUiThread(() -> {
|
||||
MaterialTextView statusTextView = findViewById(R.id.update_progress_text);
|
||||
// set status text to installing update
|
||||
statusTextView.setText(R.string.installing_update);
|
||||
// set progress bar to 100
|
||||
LinearProgressIndicator progressIndicator = findViewById(R.id.update_progress);
|
||||
progressIndicator.setIndeterminate(true);
|
||||
progressIndicator.setProgressCompat(100, false);
|
||||
});
|
||||
// request install permissions
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW);
|
||||
Context context = getApplicationContext();
|
||||
Uri uri = FileProvider.getUriForFile(context, context.getPackageName() + ".file-provider", updateFile);
|
||||
intent.setDataAndTypeAndNormalize(uri, "application/vnd.android.package-archive");
|
||||
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
// return
|
||||
return;
|
||||
}
|
||||
|
||||
public enum ACTIONS {
|
||||
// action can be CHECK, DOWNLOAD, INSTALL
|
||||
CHECK, DOWNLOAD, INSTALL
|
||||
}
|
||||
}
|
@ -1,123 +1,249 @@
|
||||
package com.fox2code.mmm.background;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.NotificationChannelGroup;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.Network;
|
||||
import android.net.NetworkCapabilities;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.app.NotificationChannelCompat;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import com.fox2code.mmm.AppUpdateManager;
|
||||
import com.fox2code.mmm.MainActivity;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.R;
|
||||
import com.fox2code.mmm.UpdateActivity;
|
||||
import com.fox2code.mmm.manager.LocalModuleInfo;
|
||||
import com.fox2code.mmm.manager.ModuleManager;
|
||||
import com.fox2code.mmm.repo.RepoManager;
|
||||
import com.fox2code.mmm.repo.RepoModule;
|
||||
import com.fox2code.mmm.utils.PropUtils;
|
||||
import com.fox2code.mmm.utils.io.PropUtils;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Random;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
@SuppressWarnings("SpellCheckingInspection")
|
||||
public class BackgroundUpdateChecker extends Worker {
|
||||
private static boolean easterEggActive = false;
|
||||
static final Object lock = new Object(); // Avoid concurrency issues
|
||||
public static final String NOTIFICATION_CHANNEL_ID = "background_update";
|
||||
public static final int NOTIFICATION_ID = 1;
|
||||
public static final String NOTFIICATION_GROUP = "updates";
|
||||
public static final String NOTIFICATION_CHANNEL_ID_APP = "background_update_app";
|
||||
static final Object lock = new Object(); // Avoid concurrency issuespublic static final String NOTIFICATION_CHANNEL_ID = "background_update";
|
||||
private static final int NOTIFICATION_ID_ONGOING = 2;
|
||||
private static final String NOTIFICATION_CHANNEL_ID_ONGOING = "mmm_background_update";
|
||||
private static final int NOTIFICATION_ID_APP = 3;
|
||||
|
||||
public BackgroundUpdateChecker(@NonNull Context context,
|
||||
@NonNull WorkerParameters workerParams) {
|
||||
public BackgroundUpdateChecker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
if (!NotificationManagerCompat.from(this.getApplicationContext()).areNotificationsEnabled()
|
||||
|| !MainApplication.isBackgroundUpdateCheckEnabled()) return Result.success();
|
||||
synchronized (lock) {
|
||||
doCheck(this.getApplicationContext());
|
||||
@SuppressLint("RestrictedApi")
|
||||
private static void postNotificationForAppUpdate(Context context) {
|
||||
// create the notification channel if not already created
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
||||
notificationManager.createNotificationChannel(new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID_APP, NotificationManagerCompat.IMPORTANCE_HIGH).setName(context.getString(R.string.notification_channel_category_app_update)).setDescription(context.getString(R.string.notification_channel_category_app_update_description)).setGroup(NOTFIICATION_GROUP).build());
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID_APP);
|
||||
builder.setSmallIcon(R.drawable.baseline_system_update_24);
|
||||
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
|
||||
builder.setCategory(NotificationCompat.CATEGORY_RECOMMENDATION);
|
||||
builder.setShowWhen(false);
|
||||
builder.setOnlyAlertOnce(true);
|
||||
builder.setOngoing(false);
|
||||
builder.setAutoCancel(true);
|
||||
builder.setGroup(NOTFIICATION_GROUP);
|
||||
// open app on click
|
||||
Intent intent = new Intent(context, UpdateActivity.class);
|
||||
// set action to ACTIONS.DOWNLOAD
|
||||
intent.setAction(String.valueOf(UpdateActivity.ACTIONS.DOWNLOAD));
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
builder.setContentIntent(android.app.PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE));
|
||||
// set summary to Found X
|
||||
builder.setContentTitle(context.getString(R.string.notification_channel_background_update_app));
|
||||
builder.setContentText(context.getString(R.string.notification_channel_background_update_app_description));
|
||||
if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
notificationManager.notify(NOTIFICATION_ID_APP, builder.build());
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
static void doCheck(Context context) {
|
||||
Thread.currentThread().setPriority(Thread.MIN_PRIORITY);
|
||||
ModuleManager.getINSTANCE().scanAsync();
|
||||
RepoManager.getINSTANCE().update(null);
|
||||
ModuleManager.getINSTANCE().runAfterScan(() -> {
|
||||
int moduleUpdateCount = 0;
|
||||
HashMap<String, RepoModule> repoModules =
|
||||
RepoManager.getINSTANCE().getModules();
|
||||
for (LocalModuleInfo localModuleInfo :
|
||||
ModuleManager.getINSTANCE().getModules().values()) {
|
||||
if ("twrp-keep".equals(localModuleInfo.id)) continue;
|
||||
RepoModule repoModule = repoModules.get(localModuleInfo.id);
|
||||
localModuleInfo.checkModuleUpdate();
|
||||
if (localModuleInfo.updateVersionCode > localModuleInfo.versionCode &&
|
||||
!PropUtils.isNullString(localModuleInfo.updateVersion)) {
|
||||
moduleUpdateCount++;
|
||||
} else if (repoModule != null &&
|
||||
repoModule.moduleInfo.versionCode > localModuleInfo.versionCode &&
|
||||
!PropUtils.isNullString(repoModule.moduleInfo.version)) {
|
||||
moduleUpdateCount++;
|
||||
// first, check if the user has enabled background update checking
|
||||
if (!MainApplication.getSharedPreferences("mmm").getBoolean("pref_background_update_check", false)) {
|
||||
return;
|
||||
}
|
||||
if (MainApplication.getINSTANCE().isInForeground()) {
|
||||
// don't check if app is in foreground, this is a background check
|
||||
return;
|
||||
}
|
||||
// next, check if user requires wifi
|
||||
if (MainApplication.getSharedPreferences("mmm").getBoolean("pref_background_update_check_wifi", true)) {
|
||||
// check if wifi is connected
|
||||
ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||
Network networkInfo = connectivityManager.getActiveNetwork();
|
||||
if (networkInfo == null || !Objects.requireNonNull(connectivityManager.getNetworkCapabilities(networkInfo)).hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)) {
|
||||
Timber.w("Background update check: wifi not connected but required");
|
||||
return;
|
||||
}
|
||||
}
|
||||
synchronized (lock) {
|
||||
// post checking notification if notifications are enabled
|
||||
if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
||||
notificationManager.createNotificationChannel(new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID_ONGOING, NotificationManagerCompat.IMPORTANCE_MIN).setName(context.getString(R.string.notification_channel_category_background_update)).setDescription(context.getString(R.string.notification_channel_category_background_update_description)).setGroup(NOTFIICATION_GROUP).build());
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID_ONGOING);
|
||||
builder.setSmallIcon(R.drawable.ic_baseline_update_24);
|
||||
builder.setPriority(NotificationCompat.PRIORITY_MIN);
|
||||
builder.setCategory(NotificationCompat.CATEGORY_SERVICE);
|
||||
builder.setShowWhen(false);
|
||||
builder.setOnlyAlertOnce(true);
|
||||
builder.setOngoing(true);
|
||||
builder.setAutoCancel(false);
|
||||
builder.setGroup("update_bg");
|
||||
builder.setContentTitle(context.getString(R.string.notification_channel_background_update));
|
||||
builder.setContentText(context.getString(R.string.notification_channel_background_update_description));
|
||||
notificationManager.notify(NOTIFICATION_ID_ONGOING, builder.build());
|
||||
} else {
|
||||
Timber.d("Not posting notification because of missing permission");
|
||||
}
|
||||
ModuleManager.getINSTANCE().scanAsync();
|
||||
RepoManager.getINSTANCE().update(null);
|
||||
ModuleManager.getINSTANCE().runAfterScan(() -> {
|
||||
int moduleUpdateCount = 0;
|
||||
HashMap<String, RepoModule> repoModules = RepoManager.getINSTANCE().getModules();
|
||||
// hashmap of updateable modules names
|
||||
HashMap<String, String> updateableModules = new HashMap<>();
|
||||
for (LocalModuleInfo localModuleInfo : ModuleManager.getINSTANCE().getModules().values()) {
|
||||
if ("twrp-keep".equals(localModuleInfo.id)) continue;
|
||||
// exclude all modules with id's stored in the pref pref_background_update_check_excludes
|
||||
try {
|
||||
if (Objects.requireNonNull(MainApplication.getSharedPreferences("mmm").getStringSet("pref_background_update_check_excludes", null)).contains(localModuleInfo.id))
|
||||
continue;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
RepoModule repoModule = repoModules.get(localModuleInfo.id);
|
||||
localModuleInfo.checkModuleUpdate();
|
||||
if (localModuleInfo.updateVersionCode > localModuleInfo.versionCode && !PropUtils.isNullString(localModuleInfo.updateVersion)) {
|
||||
moduleUpdateCount++;
|
||||
updateableModules.put(localModuleInfo.name, localModuleInfo.version);
|
||||
} else if (repoModule != null && repoModule.moduleInfo.versionCode > localModuleInfo.versionCode && !PropUtils.isNullString(repoModule.moduleInfo.version)) {
|
||||
moduleUpdateCount++;
|
||||
updateableModules.put(localModuleInfo.name, localModuleInfo.version);
|
||||
}
|
||||
}
|
||||
if (moduleUpdateCount != 0) {
|
||||
Timber.d("Found %d updates", moduleUpdateCount);
|
||||
postNotification(context, updateableModules, moduleUpdateCount, false);
|
||||
}
|
||||
});
|
||||
// check for app updates
|
||||
if (MainApplication.getSharedPreferences("mmm").getBoolean("pref_background_update_check_app", false)) {
|
||||
try {
|
||||
boolean shouldUpdate = AppUpdateManager.getAppUpdateManager().checkUpdate(true);
|
||||
if (shouldUpdate) {
|
||||
Timber.d("Found app update");
|
||||
postNotificationForAppUpdate(context);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (moduleUpdateCount != 0) {
|
||||
postNotification(context, moduleUpdateCount);
|
||||
// remove checking notification
|
||||
if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
Timber.d("Removing notification");
|
||||
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
|
||||
notificationManager.cancel(NOTIFICATION_ID_ONGOING);
|
||||
}
|
||||
});
|
||||
}
|
||||
// increment or create counter in shared preferences
|
||||
MainApplication.getSharedPreferences("mmm").edit().putInt("pref_background_update_counter", MainApplication.getSharedPreferences("mmm").getInt("pref_background_update_counter", 0) + 1).apply();
|
||||
}
|
||||
|
||||
public static void postNotification(Context context, int updateCount) {
|
||||
if (!easterEggActive) easterEggActive = new Random().nextInt(100) <= updateCount;
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(
|
||||
context, NOTIFICATION_CHANNEL_ID)
|
||||
.setContentTitle(context.getString(easterEggActive ?
|
||||
R.string.notification_update_title_easter_egg :
|
||||
R.string.notification_update_title)
|
||||
.replace("%i", String.valueOf(updateCount)))
|
||||
.setContentText(context.getString(R.string.notification_update_subtitle))
|
||||
.setSmallIcon(R.drawable.ic_baseline_extension_24)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0,
|
||||
new Intent(context, MainActivity.class).setFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK),
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ?
|
||||
PendingIntent.FLAG_IMMUTABLE : 0)).setAutoCancel(true);
|
||||
public static void postNotification(Context context, HashMap<String, String> updateable, int updateCount, boolean test) {
|
||||
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID);
|
||||
builder.setSmallIcon(R.drawable.baseline_system_update_24);
|
||||
builder.setPriority(NotificationCompat.PRIORITY_HIGH);
|
||||
builder.setCategory(NotificationCompat.CATEGORY_RECOMMENDATION);
|
||||
builder.setShowWhen(false);
|
||||
builder.setOnlyAlertOnce(true);
|
||||
builder.setOngoing(false);
|
||||
builder.setAutoCancel(true);
|
||||
builder.setGroup(NOTFIICATION_GROUP);
|
||||
// open app on click
|
||||
Intent intent = new Intent(context, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
builder.setContentIntent(android.app.PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE));
|
||||
// set summary to Found X updates: <module name> <module version> <module name> <module version> ...
|
||||
StringBuilder summary = new StringBuilder();
|
||||
summary.append(context.getString(R.string.notification_update_summary));
|
||||
// use notification_update_module_template string to set name and version
|
||||
for (Map.Entry<String, String> entry : updateable.entrySet()) {
|
||||
summary.append("\n").append(context.getString(R.string.notification_update_module_template, entry.getKey(), entry.getValue()));
|
||||
}
|
||||
builder.setContentTitle(context.getString(R.string.notification_update_title, updateCount));
|
||||
builder.setContentText(summary);
|
||||
// set long text to summary so it doesn't get cut off
|
||||
builder.setStyle(new NotificationCompat.BigTextStyle().bigText(summary));
|
||||
if (ContextCompat.checkSelfPermission(MainApplication.getINSTANCE(), Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
// check if app is in foreground. if so, don't show notification
|
||||
if (MainApplication.getINSTANCE().isInForeground() && !test) return;
|
||||
NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build());
|
||||
}
|
||||
|
||||
public static void onMainActivityCreate(Context context) {
|
||||
NotificationManagerCompat notificationManagerCompat =
|
||||
NotificationManagerCompat.from(context);
|
||||
notificationManagerCompat.createNotificationChannel(
|
||||
new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID,
|
||||
NotificationManagerCompat.IMPORTANCE_HIGH).setShowBadge(true)
|
||||
.setName(context.getString(R.string.notification_update_pref)).build());
|
||||
// Refuse to run if first_launch pref is not false
|
||||
if (!Objects.equals(MainApplication.getSharedPreferences("mmm").getString("last_shown_setup", null), "v2"))
|
||||
return;
|
||||
// create notification channel group
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
CharSequence groupName = context.getString(R.string.notification_group_updates);
|
||||
NotificationManager mNotificationManager = ContextCompat.getSystemService(context, NotificationManager.class);
|
||||
Objects.requireNonNull(mNotificationManager).createNotificationChannelGroup(new NotificationChannelGroup(NOTFIICATION_GROUP, groupName));
|
||||
}
|
||||
NotificationManagerCompat notificationManagerCompat = NotificationManagerCompat.from(context);
|
||||
notificationManagerCompat.createNotificationChannel(new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH).setShowBadge(true).setName(context.getString(R.string.notification_update_pref)).setDescription(context.getString(R.string.auto_updates_notifs)).setGroup(NOTFIICATION_GROUP).build());
|
||||
notificationManagerCompat.cancel(BackgroundUpdateChecker.NOTIFICATION_ID);
|
||||
BackgroundUpdateChecker.easterEggActive = false;
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork("background_checker",
|
||||
ExistingPeriodicWorkPolicy.REPLACE, new PeriodicWorkRequest.Builder(
|
||||
BackgroundUpdateChecker.class, 6, TimeUnit.HOURS)
|
||||
.setConstraints(new Constraints.Builder().setRequiresBatteryNotLow(true)
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED).build()).build());
|
||||
// now for the ongoing notification
|
||||
notificationManagerCompat.createNotificationChannel(new NotificationChannelCompat.Builder(NOTIFICATION_CHANNEL_ID_ONGOING, NotificationManagerCompat.IMPORTANCE_MIN).setShowBadge(true).setName(context.getString(R.string.notification_update_pref)).setDescription(context.getString(R.string.auto_updates_notifs)).setGroup(NOTFIICATION_GROUP).build());
|
||||
notificationManagerCompat.cancel(BackgroundUpdateChecker.NOTIFICATION_ID_ONGOING);
|
||||
// schedule periodic check for updates every 6 hours (6 * 60 * 60 = 21600)
|
||||
Timber.d("Scheduling periodic background check");
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork("background_checker", ExistingPeriodicWorkPolicy.UPDATE, new PeriodicWorkRequest.Builder(BackgroundUpdateChecker.class, 6, TimeUnit.HOURS).setConstraints(new Constraints.Builder().setRequiresBatteryNotLow(true).build()).build());
|
||||
}
|
||||
|
||||
public static void onMainActivityResume(Context context) {
|
||||
NotificationManagerCompat.from(context).cancel(
|
||||
BackgroundUpdateChecker.NOTIFICATION_ID);
|
||||
BackgroundUpdateChecker.easterEggActive = false;
|
||||
NotificationManagerCompat.from(context).cancel(BackgroundUpdateChecker.NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
if (!NotificationManagerCompat.from(this.getApplicationContext()).areNotificationsEnabled() || !MainApplication.isBackgroundUpdateCheckEnabled())
|
||||
return Result.success();
|
||||
synchronized (lock) {
|
||||
doCheck(this.getApplicationContext());
|
||||
}
|
||||
return Result.success();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,64 @@
|
||||
package com.fox2code.mmm.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.preference.Preference;
|
||||
import androidx.preference.PreferenceViewHolder;
|
||||
|
||||
public class LongClickablePreference extends Preference {
|
||||
private OnPreferenceLongClickListener onPreferenceLongClickListener;
|
||||
|
||||
public LongClickablePreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
}
|
||||
|
||||
public LongClickablePreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public LongClickablePreference(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
public LongClickablePreference(@NonNull Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull PreferenceViewHolder holder) {
|
||||
super.onBindViewHolder(holder);
|
||||
holder.itemView.setOnLongClickListener(v -> performLongClick());
|
||||
}
|
||||
|
||||
private boolean performLongClick() {
|
||||
if (!this.isEnabled() || !this.isSelectable()) {
|
||||
return false;
|
||||
}
|
||||
if (this.onPreferenceLongClickListener != null) {
|
||||
return this.onPreferenceLongClickListener.onPreferenceLongClick(this);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void setOnPreferenceLongClickListener(OnPreferenceLongClickListener onPreferenceLongClickListener) {
|
||||
this.onPreferenceLongClickListener = onPreferenceLongClickListener;
|
||||
}
|
||||
|
||||
public OnPreferenceLongClickListener getOnPreferenceLongClickListener() {
|
||||
return this.onPreferenceLongClickListener;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface OnPreferenceLongClickListener {
|
||||
/**
|
||||
* Called when a preference has been clicked.
|
||||
*
|
||||
* @param preference The preference that was clicked
|
||||
* @return {@code true} if the click was handled
|
||||
*/
|
||||
boolean onPreferenceLongClick(@NonNull Preference preference);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,89 @@
|
||||
package com.fox2code.mmm.settings;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.preference.PreferenceDataStore;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public class SharedPreferenceDataStore extends PreferenceDataStore {
|
||||
|
||||
private final SharedPreferences mSharedPreferences;
|
||||
|
||||
public SharedPreferenceDataStore(@NonNull SharedPreferences sharedPreferences) {
|
||||
Timber.d("SharedPreferenceDataStore: %s", sharedPreferences);
|
||||
mSharedPreferences = sharedPreferences;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public SharedPreferences getSharedPreferences() {
|
||||
Timber.d("getSharedPreferences: %s", mSharedPreferences);
|
||||
return mSharedPreferences;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putString(String key, @Nullable String value) {
|
||||
mSharedPreferences.edit().putString(key, value).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putStringSet(String key, @Nullable Set<String> values) {
|
||||
mSharedPreferences.edit().putStringSet(key, values).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putInt(String key, int value) {
|
||||
mSharedPreferences.edit().putInt(key, value).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putLong(String key, long value) {
|
||||
mSharedPreferences.edit().putLong(key, value).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putFloat(String key, float value) {
|
||||
mSharedPreferences.edit().putFloat(key, value).apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putBoolean(String key, boolean value) {
|
||||
mSharedPreferences.edit().putBoolean(key, value).apply();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getString(String key, @Nullable String defValue) {
|
||||
return mSharedPreferences.getString(key, defValue);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, @Nullable Set<String> defValues) {
|
||||
return mSharedPreferences.getStringSet(key, defValues);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
return mSharedPreferences.getInt(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
return mSharedPreferences.getLong(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
return mSharedPreferences.getFloat(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
return mSharedPreferences.getBoolean(key, defValue);
|
||||
}
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import eightbitlab.com.blurview.BlurAlgorithm;
|
||||
import eightbitlab.com.blurview.BlurView;
|
||||
import eightbitlab.com.blurview.RenderEffectBlur;
|
||||
import eightbitlab.com.blurview.RenderScriptBlur;
|
||||
|
||||
public class BlurUtils {
|
||||
public static void setupBlur(BlurView blurView, Activity activity, @IdRes int viewId) {
|
||||
setupBlur(blurView, activity, activity.findViewById(viewId));
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void setupBlur(BlurView blurView, Activity activity, ViewGroup rootView) {
|
||||
blurView.setupWith(rootView, new BlurAlgorithmWrapper(
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ?
|
||||
new RenderScriptBlur(blurView.getContext()) : new RenderEffectBlur()))
|
||||
.setFrameClearDrawable(activity.getWindow().getDecorView().getBackground())
|
||||
.setBlurRadius(4F).setBlurAutoUpdate(true);
|
||||
}
|
||||
|
||||
// Allow to have fancy blur, use more performance.
|
||||
private static final class BlurAlgorithmWrapper implements BlurAlgorithm {
|
||||
private final BlurAlgorithm algorithm;
|
||||
|
||||
private BlurAlgorithmWrapper(BlurAlgorithm algorithm) {
|
||||
this.algorithm = algorithm;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Bitmap blur(Bitmap bitmap, float blurRadius) {
|
||||
return this.algorithm.blur(bitmap, blurRadius * 6f);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroy() {
|
||||
this.algorithm.destroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canModifyBitmap() {
|
||||
return this.algorithm.canModifyBitmap();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Bitmap.Config getSupportedBitmapConfig() {
|
||||
return this.algorithm.getSupportedBitmapConfig();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float scaleFactor() {
|
||||
return 1f;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull Canvas canvas, @NonNull Bitmap bitmap) {
|
||||
this.algorithm.render(canvas, bitmap);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.LinearLayoutCompat;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
// ProgressDialog is deprecated because it's an bad UX pattern, but sometimes we have no other choice...
|
||||
public enum BudgetProgressDialog {
|
||||
;
|
||||
|
||||
public static AlertDialog build(Context context, String title, String message) {
|
||||
Resources r = context.getResources();
|
||||
int padding = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 20, r.getDisplayMetrics()));
|
||||
LinearLayoutCompat v = new LinearLayoutCompat(context);
|
||||
v.setOrientation(LinearLayoutCompat.HORIZONTAL);
|
||||
ProgressBar pb = new ProgressBar(context);
|
||||
v.addView(pb, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
|
||||
TextView t = new TextView(context);
|
||||
t.setGravity(Gravity.CENTER);
|
||||
v.addView(t, new LinearLayoutCompat.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT, 4));
|
||||
v.setPadding(padding, padding, padding, padding);
|
||||
|
||||
t.setText(message);
|
||||
return new MaterialAlertDialogBuilder(context)
|
||||
.setTitle(title)
|
||||
.setView(v)
|
||||
.setCancelable(false)
|
||||
.create();
|
||||
}
|
||||
|
||||
public static AlertDialog build(Context context, int title, String message) {
|
||||
return build(context, context.getString(title), message);
|
||||
}
|
||||
|
||||
public static AlertDialog build(Context context, int title, int message) {
|
||||
return build(context, title, context.getString(message));
|
||||
}
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream;
|
||||
import com.topjohnwu.superuser.io.SuFileOutputStream;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
public class Files {
|
||||
private static final boolean is64bit = Build.SUPPORTED_64_BIT_ABIS.length > 0;
|
||||
|
||||
public static void write(File file, byte[] bytes) throws IOException {
|
||||
try (OutputStream outputStream = new FileOutputStream(file)) {
|
||||
outputStream.write(bytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] read(File file) throws IOException {
|
||||
try (InputStream inputStream = new FileInputStream(file)) {
|
||||
return readAllBytes(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeSU(File file, byte[] bytes) throws IOException {
|
||||
try (OutputStream outputStream = SuFileOutputStream.open(file)) {
|
||||
outputStream.write(bytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] readSU(File file) throws IOException {
|
||||
if (file.isFile() && file.canRead()) {
|
||||
try { // Read as app if su not required
|
||||
return read(file);
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
try (InputStream inputStream = SuFileInputStream.open(file)) {
|
||||
return readAllBytes(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean existsSU(File file) {
|
||||
return file.exists() || new SuFile(file.getAbsolutePath()).exists();
|
||||
}
|
||||
|
||||
public static void copy(InputStream inputStream,OutputStream outputStream) throws IOException {
|
||||
int nRead;
|
||||
byte[] data = new byte[16384];
|
||||
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
|
||||
outputStream.write(data, 0, nRead);
|
||||
}
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
public static void closeSilently(Closeable closeable) {
|
||||
try {
|
||||
if (closeable != null) closeable.close();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
public static ByteArrayOutputStream makeBuffer(long capacity) {
|
||||
// Cap buffer to 1 Gib (or 512 Mib for 32bit) to avoid memory errors
|
||||
return Files.makeBuffer((int) Math.min(capacity, is64bit ? 0x40000000 : 0x20000000));
|
||||
}
|
||||
|
||||
public static ByteArrayOutputStream makeBuffer(int capacity) {
|
||||
return new ByteArrayOutputStream(Math.max(0x20, capacity)) {
|
||||
@NonNull
|
||||
@Override
|
||||
public byte[] toByteArray() {
|
||||
return this.buf.length == this.count ?
|
||||
this.buf : super.toByteArray();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static byte[] readAllBytes(InputStream inputStream) throws IOException {
|
||||
ByteArrayOutputStream buffer = Files.makeBuffer(inputStream.available());
|
||||
copy(inputStream, buffer);
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
public static void fixJavaZipHax(byte[] bytes) {
|
||||
if (bytes.length > 8 && bytes[0x6] == 0x0 && bytes[0x7] == 0x0 && bytes[0x8] == 0x8)
|
||||
bytes[0x7] = 0x8; // Known hax to prevent java zip file read
|
||||
}
|
||||
|
||||
public static void patchModuleSimple(byte[] bytes,OutputStream outputStream) throws IOException {
|
||||
fixJavaZipHax(bytes); patchModuleSimple(new ByteArrayInputStream(bytes), outputStream);
|
||||
}
|
||||
|
||||
public static void patchModuleSimple(InputStream inputStream,OutputStream outputStream) throws IOException {
|
||||
ZipInputStream zipInputStream = new ZipInputStream(inputStream);
|
||||
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream);
|
||||
int nRead;
|
||||
byte[] data = new byte[16384];
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zipInputStream.getNextEntry()) != null) {
|
||||
String name = zipEntry.getName();
|
||||
int i = name.indexOf('/', 1);
|
||||
if (i == -1) continue;
|
||||
String newName = name.substring(i + 1);
|
||||
if (newName.startsWith(".git")) continue; // Skip metadata
|
||||
zipOutputStream.putNextEntry(new ZipEntry(newName));
|
||||
while ((nRead = zipInputStream.read(data, 0, data.length)) != -1) {
|
||||
zipOutputStream.write(data, 0, nRead);
|
||||
}
|
||||
zipOutputStream.flush();
|
||||
zipOutputStream.closeEntry();
|
||||
zipInputStream.closeEntry();
|
||||
}
|
||||
zipOutputStream.finish();
|
||||
zipOutputStream.flush();
|
||||
zipOutputStream.close();
|
||||
zipInputStream.close();
|
||||
}
|
||||
}
|
@ -1,165 +0,0 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class Hashes {
|
||||
private static final String TAG = "Hashes";
|
||||
private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray();
|
||||
private static final Pattern nonAlphaNum = Pattern.compile("[^a-zA-Z0-9]");
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
|
||||
}
|
||||
return new String(hexChars);
|
||||
}
|
||||
|
||||
public static String hashMd5(byte[] input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
|
||||
return bytesToHex(md.digest(input));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String hashSha1(byte[] input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-1");
|
||||
|
||||
return bytesToHex(md.digest(input));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String hashSha256(byte[] input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
return bytesToHex(md.digest(input));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String hashSha512(byte[] input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-512");
|
||||
|
||||
return bytesToHex(md.digest(input));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the checksum match a file by picking the correct
|
||||
* hashing algorithm depending on the length of the checksum
|
||||
*/
|
||||
public static boolean checkSumMatch(byte[] data, String checksum) {
|
||||
String hash;
|
||||
if (checksum == null) return false;
|
||||
switch (checksum.length()) {
|
||||
case 0:
|
||||
return true; // No checksum
|
||||
case 32:
|
||||
hash = Hashes.hashMd5(data); break;
|
||||
case 40:
|
||||
hash = Hashes.hashSha1(data); break;
|
||||
case 64:
|
||||
hash = Hashes.hashSha256(data); break;
|
||||
case 128:
|
||||
hash = Hashes.hashSha512(data); break;
|
||||
default:
|
||||
Log.e(TAG, "No hash algorithm for " +
|
||||
checksum.length() * 8 + "bit checksums");
|
||||
return false;
|
||||
}
|
||||
Log.d(TAG, "Checksum result (data: " + hash+ ",expected: " + checksum + ")");
|
||||
return hash.equals(checksum.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the checksum match a file by picking the correct
|
||||
* hashing algorithm depending on the length of the checksum
|
||||
*/
|
||||
public static boolean checkSumMatch(InputStream data, String checksum) throws IOException {
|
||||
String hash;
|
||||
if (checksum == null) return false;
|
||||
String checksumAlgorithm = checkSumName(checksum);
|
||||
if (checksumAlgorithm == null) {
|
||||
Log.e(TAG, "No hash algorithm for " +
|
||||
checksum.length() * 8 + "bit checksums");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance(checksumAlgorithm);
|
||||
|
||||
byte[] bytes = new byte[2048];
|
||||
int nRead;
|
||||
while ((nRead = data.read(bytes)) > 0) {
|
||||
md.update(bytes, 0, nRead);
|
||||
}
|
||||
hash = bytesToHex(md.digest());
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
Log.d(TAG, "Checksum result (data: " + hash + ",expected: " + checksum + ")");
|
||||
return hash.equals(checksum.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
public static boolean checkSumValid(String checksum) {
|
||||
if (checksum == null) return false;
|
||||
switch (checksum.length()) {
|
||||
case 0:
|
||||
default:
|
||||
return false;
|
||||
case 32:
|
||||
case 40:
|
||||
case 64:
|
||||
case 128:
|
||||
final int len = checksum.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
char c = checksum.charAt(i);
|
||||
if (c < '0' || c > 'f') return false;
|
||||
if (c > '9' && // Easier working with bits
|
||||
(c & 0b01011111) < 'A') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static String checkSumName(String checksum) {
|
||||
if (checksum == null) return null;
|
||||
switch (checksum.length()) {
|
||||
case 0:
|
||||
default:
|
||||
return null;
|
||||
case 32:
|
||||
return "MD5";
|
||||
case 40:
|
||||
return "SHA-1";
|
||||
case 64:
|
||||
return "SHA-256";
|
||||
case 128:
|
||||
return "SHA-512";
|
||||
}
|
||||
}
|
||||
|
||||
public static String checkSumFormat(String checksum) {
|
||||
if (checksum == null) return null;
|
||||
// Remove all non-alphanumeric characters
|
||||
return nonAlphaNum.matcher(checksum.trim()).replaceAll("");
|
||||
}
|
||||
}
|
@ -1,600 +0,0 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Build;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.util.Log;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.WebSettings;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.androidacy.AndroidacyUtil;
|
||||
import com.fox2code.mmm.installer.InstallerInitializer;
|
||||
import com.fox2code.mmm.repo.RepoManager;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Proxy;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.sentry.android.okhttp.SentryOkHttpInterceptor;
|
||||
import okhttp3.Cache;
|
||||
import okhttp3.Cookie;
|
||||
import okhttp3.CookieJar;
|
||||
import okhttp3.Dns;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import okhttp3.brotli.BrotliInterceptor;
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps;
|
||||
import okio.BufferedSink;
|
||||
|
||||
public class Http {
|
||||
private static final String TAG = "Http";
|
||||
private static final OkHttpClient httpClient;
|
||||
private static final OkHttpClient httpClientDoH;
|
||||
private static final OkHttpClient httpClientWithCache;
|
||||
private static final OkHttpClient httpClientWithCacheDoH;
|
||||
private static final OkHttpClient httpClientNoRedirect;
|
||||
private static final OkHttpClient httpClientNoRedirectDoH;
|
||||
private static final FallBackDNS fallbackDNS;
|
||||
private static final CDNCookieJar cookieJar;
|
||||
private static final String androidacyUA;
|
||||
private static final boolean hasWebView;
|
||||
private static boolean doh;
|
||||
|
||||
static {
|
||||
MainApplication mainApplication = MainApplication.getINSTANCE();
|
||||
if (mainApplication == null) {
|
||||
Error error = new Error("Initialized Http too soon!");
|
||||
error.fillInStackTrace();
|
||||
Log.e(TAG, "Initialized Http too soon!", error);
|
||||
System.out.flush();
|
||||
System.err.flush();
|
||||
try {
|
||||
Os.kill(Os.getpid(), 9);
|
||||
} catch (ErrnoException e) {
|
||||
System.exit(9);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
CookieManager cookieManager;
|
||||
try {
|
||||
cookieManager = CookieManager.getInstance();
|
||||
cookieManager.setAcceptCookie(true);
|
||||
cookieManager.flush(); // Make sure the instance work
|
||||
} catch (Throwable t) {
|
||||
cookieManager = null;
|
||||
Log.e(TAG, "No WebView support!", t);
|
||||
}
|
||||
hasWebView = cookieManager != null;
|
||||
OkHttpClient.Builder httpclientBuilder = new OkHttpClient.Builder();
|
||||
// Default is 10, extend it a bit for slow mobile connections.
|
||||
httpclientBuilder.connectTimeout(15, TimeUnit.SECONDS);
|
||||
httpclientBuilder.writeTimeout(15, TimeUnit.SECONDS);
|
||||
httpclientBuilder.readTimeout(15, TimeUnit.SECONDS);
|
||||
httpclientBuilder.addInterceptor(BrotliInterceptor.INSTANCE);
|
||||
httpclientBuilder.proxy(Proxy.NO_PROXY); // Do not use system proxy
|
||||
Dns dns = Dns.SYSTEM;
|
||||
try {
|
||||
InetAddress[] cloudflareBootstrap = new InetAddress[] {
|
||||
InetAddress.getByName("162.159.36.1"),
|
||||
InetAddress.getByName("162.159.46.1"),
|
||||
InetAddress.getByName("1.1.1.1"),
|
||||
InetAddress.getByName("1.0.0.1"),
|
||||
InetAddress.getByName("162.159.132.53"),
|
||||
InetAddress.getByName("2606:4700:4700::1111"),
|
||||
InetAddress.getByName("2606:4700:4700::1001"),
|
||||
InetAddress.getByName("2606:4700:4700::0064"),
|
||||
InetAddress.getByName("2606:4700:4700::6400")
|
||||
};
|
||||
dns = s -> {
|
||||
if ("cloudflare-dns.com".equals(s)) {
|
||||
return Arrays.asList(cloudflareBootstrap);
|
||||
}
|
||||
return Dns.SYSTEM.lookup(s);
|
||||
};
|
||||
httpclientBuilder.dns(dns);
|
||||
httpclientBuilder.cookieJar(new CDNCookieJar());
|
||||
dns = new DnsOverHttps.Builder().client(httpclientBuilder.build()).url(
|
||||
Objects.requireNonNull(HttpUrl.parse("https://cloudflare-dns.com/dns-query")))
|
||||
.bootstrapDnsHosts(cloudflareBootstrap).resolvePrivateAddresses(true).build();
|
||||
} catch (UnknownHostException|RuntimeException e) {
|
||||
Log.e(TAG, "Failed to init DoH", e);
|
||||
}
|
||||
httpclientBuilder.cookieJar(CookieJar.NO_COOKIES);
|
||||
// User-Agent format was agreed on telegram
|
||||
if (hasWebView) {
|
||||
androidacyUA = WebSettings.getDefaultUserAgent(mainApplication).replace("; wv", "")
|
||||
+ " FoxMmm/" + BuildConfig.VERSION_CODE;
|
||||
} else {
|
||||
androidacyUA = "Mozilla/5.0 (Linux; Android " + Build.VERSION.RELEASE + "; " + Build.DEVICE +")" +
|
||||
" AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Mobile Safari/537.36"
|
||||
+ " FoxMmm/" + BuildConfig.VERSION_CODE;
|
||||
}
|
||||
httpclientBuilder.addInterceptor(chain -> {
|
||||
Request.Builder request = chain.request().newBuilder();
|
||||
request.header("Upgrade-Insecure-Requests", "1");
|
||||
String host = chain.request().url().host();
|
||||
if (host.endsWith(".androidacy.com")) {
|
||||
request.header("User-Agent", androidacyUA);
|
||||
} else if (!(host.equals("github.com") || host.endsWith(".github.com") ||
|
||||
host.endsWith(".jsdelivr.net") || host.endsWith(".githubusercontent.com"))) {
|
||||
if (InstallerInitializer.peekMagiskPath() != null) {
|
||||
request.header("User-Agent", // Declare Magisk version to the server
|
||||
"Magisk/" + InstallerInitializer.peekMagiskVersion());
|
||||
}
|
||||
}
|
||||
if (chain.request().header("Accept-Language") == null) {
|
||||
request.header("Accept-Language", // Send system language to the server
|
||||
mainApplication.getResources().getConfiguration().locale.toLanguageTag());
|
||||
}
|
||||
return chain.proceed(request.build());
|
||||
});
|
||||
// Fallback DNS cache responses in case request fail but already succeeded once in the past
|
||||
fallbackDNS = new FallBackDNS(mainApplication, dns,
|
||||
"github.com", "api.github.com", "raw.githubusercontent.com",
|
||||
"camo.githubusercontent.com", "user-images.githubusercontent.com",
|
||||
"cdn.jsdelivr.net", "img.shields.io", "magisk-modules-repo.github.io",
|
||||
"www.androidacy.com", "api.androidacy.com");
|
||||
httpclientBuilder.cookieJar(cookieJar = new CDNCookieJar(cookieManager));
|
||||
httpclientBuilder.dns(Dns.SYSTEM);
|
||||
httpClient = followRedirects(httpclientBuilder, true).build();
|
||||
httpClientNoRedirect = followRedirects(httpclientBuilder, false).build();
|
||||
httpclientBuilder.dns(fallbackDNS);
|
||||
httpClientDoH = followRedirects(httpclientBuilder, true).build();
|
||||
httpClientNoRedirectDoH = followRedirects(httpclientBuilder, false).build();
|
||||
httpclientBuilder.cache(new Cache(
|
||||
new File(mainApplication.getCacheDir(), "http_cache"),
|
||||
16L * 1024L * 1024L)); // 16Mib of cache
|
||||
httpclientBuilder.dns(Dns.SYSTEM);
|
||||
httpClientWithCache = followRedirects(httpclientBuilder, true).build();
|
||||
httpclientBuilder.dns(fallbackDNS);
|
||||
httpClientWithCacheDoH = followRedirects(httpclientBuilder, true).build();
|
||||
Log.i(TAG, "Initialized Http successfully!");
|
||||
doh = MainApplication.isDohEnabled();
|
||||
}
|
||||
|
||||
private static OkHttpClient.Builder followRedirects(
|
||||
OkHttpClient.Builder builder, boolean followRedirects) {
|
||||
return builder.followRedirects(followRedirects)
|
||||
.followSslRedirects(followRedirects);
|
||||
}
|
||||
|
||||
public static OkHttpClient getHttpClient() {
|
||||
return doh ? httpClientDoH : httpClient;
|
||||
}
|
||||
|
||||
public static OkHttpClient getHttpClientNoRedirect() {
|
||||
return doh ? httpClientNoRedirectDoH : httpClientNoRedirect;
|
||||
}
|
||||
|
||||
public static OkHttpClient getHttpClientWithCache() {
|
||||
return doh ? httpClientWithCacheDoH : httpClientWithCache;
|
||||
}
|
||||
|
||||
@SuppressWarnings("resource")
|
||||
public static byte[] doHttpGet(String url,boolean allowCache) throws IOException {
|
||||
if (!RepoManager.isAndroidacyRepoEnabled() &&
|
||||
AndroidacyUtil.isAndroidacyLink(url)) {
|
||||
throw new IOException("Androidacy repo is disabled, blocking url: " + url);
|
||||
}
|
||||
Response response = (allowCache ? getHttpClientWithCache() : getHttpClient()).newCall(
|
||||
new Request.Builder().url(url).get().build()
|
||||
).execute();
|
||||
// 200/204 == success, 304 == cache valid
|
||||
if (response.code() != 200 && response.code() != 204 &&
|
||||
(response.code() != 304 || !allowCache)) {
|
||||
throw new IOException("Received error code: "+ response.code());
|
||||
}
|
||||
ResponseBody responseBody = response.body();
|
||||
// Use cache api if used cached response
|
||||
if (responseBody == null && response.code() == 304) {
|
||||
response = response.cacheResponse();
|
||||
if (response != null)
|
||||
responseBody = response.body();
|
||||
}
|
||||
return responseBody == null ? new byte[0] : responseBody.bytes();
|
||||
}
|
||||
|
||||
public static byte[] doHttpPost(String url,String data,boolean allowCache) throws IOException {
|
||||
return (byte[]) doHttpPostRaw(url, data, allowCache, false);
|
||||
}
|
||||
|
||||
public static String doHttpPostRedirect(String url, String data, boolean allowCache) throws IOException {
|
||||
return (String) doHttpPostRaw(url, data, allowCache, true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("resource")
|
||||
private static Object doHttpPostRaw(String url,String data, boolean allowCache,
|
||||
boolean isRedirect) throws IOException {
|
||||
if (!RepoManager.isAndroidacyRepoEnabled() &&
|
||||
AndroidacyUtil.isAndroidacyLink(url)) {
|
||||
throw new IOException("Androidacy repo is disabled, blocking url: " + url);
|
||||
}
|
||||
Response response = (isRedirect ? getHttpClientNoRedirect() :
|
||||
allowCache ? getHttpClientWithCache() : getHttpClient()).newCall(
|
||||
new Request.Builder().url(url).post(JsonRequestBody.from(data))
|
||||
.header("Content-Type", "application/json").build()
|
||||
).execute();
|
||||
if (isRedirect && response.isRedirect()) {
|
||||
return response.request().url().uri().toString();
|
||||
}
|
||||
// 200/204 == success, 304 == cache valid
|
||||
if (response.code() != 200 && response.code() != 204 &&
|
||||
(response.code() != 304 || !allowCache)) {
|
||||
throw new IOException("Received error code: "+ response.code());
|
||||
}
|
||||
if (isRedirect) return url;
|
||||
ResponseBody responseBody = response.body();
|
||||
// Use cache api if used cached response
|
||||
if (responseBody == null && response.code() == 304) {
|
||||
response = response.cacheResponse();
|
||||
if (response != null)
|
||||
responseBody = response.body();
|
||||
}
|
||||
return responseBody == null ? new byte[0] : responseBody.bytes();
|
||||
}
|
||||
|
||||
public static byte[] doHttpGet(String url,ProgressListener progressListener) throws IOException {
|
||||
Log.d("Http", "Progress URL: " + url);
|
||||
if (!RepoManager.isAndroidacyRepoEnabled() &&
|
||||
AndroidacyUtil.isAndroidacyLink(url)) {
|
||||
throw new IOException("Androidacy repo is disabled, blocking url: " + url);
|
||||
}
|
||||
Response response = getHttpClient().newCall(
|
||||
new Request.Builder().url(url).get().build()).execute();
|
||||
if (response.code() != 200 && response.code() != 204) {
|
||||
throw new IOException("Received error code: "+ response.code());
|
||||
}
|
||||
ResponseBody responseBody = Objects.requireNonNull(response.body());
|
||||
InputStream inputStream = responseBody.byteStream();
|
||||
byte[] buff = new byte[1024 * 4];
|
||||
long downloaded = 0;
|
||||
long target = responseBody.contentLength();
|
||||
ByteArrayOutputStream byteArrayOutputStream = Files.makeBuffer(target);
|
||||
int divider = 1; // Make everything go in an int
|
||||
while ((target / divider) > (Integer.MAX_VALUE / 2)) {
|
||||
divider *= 2;
|
||||
}
|
||||
final long UPDATE_INTERVAL = 100;
|
||||
long nextUpdate = System.currentTimeMillis() + UPDATE_INTERVAL;
|
||||
long currentUpdate;
|
||||
Log.d("Http", "Target: " + target + " Divider: " + divider);
|
||||
progressListener.onUpdate(0, (int) (target / divider), false);
|
||||
while (true) {
|
||||
int read = inputStream.read(buff);
|
||||
if(read == -1) break;
|
||||
byteArrayOutputStream.write(buff, 0, read);
|
||||
downloaded += read;
|
||||
currentUpdate = System.currentTimeMillis();
|
||||
if (nextUpdate < currentUpdate) {
|
||||
nextUpdate = currentUpdate + UPDATE_INTERVAL;
|
||||
progressListener.onUpdate((int) (downloaded / divider), (int) (target / divider), false);
|
||||
}
|
||||
}
|
||||
inputStream.close();
|
||||
progressListener.onUpdate((int) (downloaded / divider), (int) (target / divider), true);
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
public static void cleanDnsCache() {
|
||||
if (Http.fallbackDNS != null) {
|
||||
Http.fallbackDNS.cleanDnsCache();
|
||||
}
|
||||
}
|
||||
|
||||
public static String getAndroidacyUA() {
|
||||
return androidacyUA;
|
||||
}
|
||||
|
||||
public static String getMagiskUA() {
|
||||
return "Magisk/" + InstallerInitializer.peekMagiskVersion();
|
||||
}
|
||||
|
||||
public static void setDoh(boolean doh) {
|
||||
Log.d(TAG, "DoH: " + Http.doh + " -> " + doh);
|
||||
Http.doh = doh;
|
||||
}
|
||||
|
||||
public static String getAndroidacyCookies(String url) {
|
||||
if (!AndroidacyUtil.isAndroidacyLink(url)) return "";
|
||||
return cookieJar.getAndroidacyCookies(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie jar that allow CDN cookies, reset on app relaunch
|
||||
* Note: An argument can be made that it allow tracking but
|
||||
* caching is a better attack vector for tracking, this system
|
||||
* only exist to improve CDN response time, any other cookies
|
||||
* that are not CDN related are just simply ignored.
|
||||
*
|
||||
* Note: CDNCookies are only stored in RAM unlike https cache
|
||||
* */
|
||||
private static class CDNCookieJar implements CookieJar {
|
||||
private final HashMap<String, Cookie> cookieMap = new HashMap<>();
|
||||
private final boolean androidacySupport;
|
||||
private final CookieManager cookieManager;
|
||||
private List<Cookie> androidacyCookies;
|
||||
|
||||
private CDNCookieJar() {
|
||||
this.androidacySupport = false;
|
||||
this.cookieManager = null;
|
||||
}
|
||||
|
||||
private CDNCookieJar(CookieManager cookieManager) {
|
||||
this.androidacySupport = true;
|
||||
this.cookieManager = cookieManager;
|
||||
if (cookieManager == null) {
|
||||
this.androidacyCookies = Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<Cookie> loadForRequest(@NonNull HttpUrl httpUrl) {
|
||||
if (!httpUrl.isHttps()) return Collections.emptyList();
|
||||
if (this.androidacySupport && httpUrl.host().endsWith(".androidacy.com")) {
|
||||
if (this.cookieManager == null) return this.androidacyCookies;
|
||||
String cookies = this.cookieManager.getCookie(httpUrl.uri().toString());
|
||||
if (cookies == null || cookies.isEmpty()) return Collections.emptyList();
|
||||
String[] splitCookies = cookies.split(";");
|
||||
ArrayList<Cookie> cookieList = new ArrayList<>(splitCookies.length);
|
||||
for (String cookie : splitCookies) {
|
||||
cookieList.add(Cookie.parse(httpUrl, cookie));
|
||||
}
|
||||
return cookieList;
|
||||
}
|
||||
Cookie cookies = cookieMap.get(httpUrl.url().getHost());
|
||||
return cookies == null || cookies.expiresAt() < System.currentTimeMillis() ?
|
||||
Collections.emptyList() : Collections.singletonList(cookies);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveFromResponse(@NonNull HttpUrl httpUrl, @NonNull List<Cookie> cookies) {
|
||||
if (!httpUrl.isHttps()) return;
|
||||
if (this.androidacySupport && httpUrl.host().endsWith(".androidacy.com")) {
|
||||
if (this.cookieManager == null) {
|
||||
if (httpUrl.host().equals(".androidacy.com") || !cookies.isEmpty())
|
||||
this.androidacyCookies = cookies;
|
||||
return;
|
||||
}
|
||||
for (Cookie cookie : cookies) {
|
||||
this.cookieManager.setCookie(
|
||||
httpUrl.uri().toString(), cookie.toString());
|
||||
}
|
||||
return;
|
||||
}
|
||||
String host = httpUrl.url().getHost();
|
||||
Iterator<Cookie> cookieIterator = cookies.iterator();
|
||||
Cookie cdnCookie = cookieMap.get(host);
|
||||
while (cookieIterator.hasNext()) {
|
||||
Cookie cookie = cookieIterator.next();
|
||||
if (host.equals(cookie.domain()) && cookie.secure() && cookie.httpOnly() &&
|
||||
cookie.expiresAt() < (System.currentTimeMillis() + 1000 * 60 * 60 * 48)) {
|
||||
if (cdnCookie != null &&
|
||||
!cdnCookie.name().equals(cookie.name())) {
|
||||
cookieMap.remove(host);
|
||||
cdnCookie = null;
|
||||
break;
|
||||
} else {
|
||||
cdnCookie = cookie;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cdnCookie == null) {
|
||||
cookieMap.remove(host);
|
||||
} else {
|
||||
cookieMap.put(host, cdnCookie);
|
||||
}
|
||||
}
|
||||
|
||||
String getAndroidacyCookies(String url) {
|
||||
if (this.cookieManager != null) {
|
||||
return this.cookieManager.getCookie(url);
|
||||
}
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
for (Cookie cookie : this.androidacyCookies) {
|
||||
stringBuilder.append(cookie.toString()).append("; ");
|
||||
}
|
||||
stringBuilder.setLength(stringBuilder.length() - 2);
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
public interface ProgressListener {
|
||||
void onUpdate(int downloaded,int total, boolean done);
|
||||
}
|
||||
|
||||
/**
|
||||
* FallBackDNS store successful DNS request to return them later
|
||||
* can help make the app to work later when the current DNS system
|
||||
* isn't functional or available.
|
||||
*
|
||||
* Note: DNS Cache is stored in user data.
|
||||
* */
|
||||
private static class FallBackDNS implements Dns {
|
||||
private final Dns parent;
|
||||
private final SharedPreferences sharedPreferences;
|
||||
private final HashSet<String> fallbacks;
|
||||
private final HashMap<String, List<InetAddress>> fallbackCache;
|
||||
|
||||
public FallBackDNS(Context context, Dns parent, String... fallbacks) {
|
||||
this.sharedPreferences = context.getSharedPreferences(
|
||||
"mmm_dns", Context.MODE_PRIVATE);
|
||||
this.parent = parent;
|
||||
this.fallbacks = new HashSet<>(Arrays.asList(fallbacks));
|
||||
this.fallbackCache = new HashMap<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<InetAddress> lookup(@NonNull String s) throws UnknownHostException {
|
||||
if (this.fallbacks.contains(s)) {
|
||||
List<InetAddress> addresses;
|
||||
synchronized (this.fallbackCache) {
|
||||
addresses = this.fallbackCache.get(s);
|
||||
if (addresses != null)
|
||||
return addresses;
|
||||
try {
|
||||
addresses = this.parent.lookup(s);
|
||||
if (addresses.isEmpty() || addresses.get(0).isLoopbackAddress())
|
||||
throw new UnknownHostException(s);
|
||||
this.fallbackCache.put(s, addresses);
|
||||
this.sharedPreferences.edit().putString(
|
||||
s.replace('.', '_'), toString(addresses)).apply();
|
||||
} catch (UnknownHostException e) {
|
||||
String key = this.sharedPreferences.getString(
|
||||
s.replace('.', '_'), "");
|
||||
if (key.isEmpty()) throw e;
|
||||
try {
|
||||
addresses = fromString(key);
|
||||
this.fallbackCache.put(s, addresses);
|
||||
} catch (UnknownHostException e2) {
|
||||
this.sharedPreferences.edit().remove(
|
||||
s.replace('.', '_')).apply();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
} else {
|
||||
return this.parent.lookup(s);
|
||||
}
|
||||
}
|
||||
|
||||
void cleanDnsCache() {
|
||||
synchronized (this.fallbackCache) {
|
||||
this.fallbackCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String toString(@NonNull List<InetAddress> inetAddresses) {
|
||||
if (inetAddresses.isEmpty()) return "";
|
||||
Iterator<InetAddress> inetAddressIterator = inetAddresses.iterator();
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
while (true) {
|
||||
stringBuilder.append(inetAddressIterator.next().getHostAddress());
|
||||
if (!inetAddressIterator.hasNext())
|
||||
return stringBuilder.toString();
|
||||
stringBuilder.append("|");
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static List<InetAddress> fromString(@NonNull String string)
|
||||
throws UnknownHostException {
|
||||
if (string.isEmpty()) return Collections.emptyList();
|
||||
String[] strings = string.split("\\|");
|
||||
ArrayList<InetAddress> inetAddresses = new ArrayList<>(strings.length);
|
||||
for (String address : strings) {
|
||||
inetAddresses.add(InetAddress.getByName(address));
|
||||
}
|
||||
return inetAddresses;
|
||||
}
|
||||
}
|
||||
|
||||
private static class JsonRequestBody extends RequestBody {
|
||||
private static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json");
|
||||
private static final JsonRequestBody EMPTY = new JsonRequestBody(new byte[0]);
|
||||
|
||||
static JsonRequestBody from(String data) {
|
||||
if (data == null || data.length() == 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
return new JsonRequestBody(data.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
final byte[] data;
|
||||
|
||||
private JsonRequestBody(byte[] data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public MediaType contentType() {
|
||||
return JSON_MEDIA_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(@NonNull BufferedSink bufferedSink) throws IOException {
|
||||
bufferedSink.write(this.data);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasWebView() {
|
||||
return hasWebView;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change URL to appropriate url and force Magisk link to use latest version.
|
||||
*/
|
||||
public static String updateLink(String string) {
|
||||
if (string.startsWith("https://cdn.jsdelivr.net/gh/Magisk-Modules-Repo/")) {
|
||||
String tmp = string.substring(48);
|
||||
int start = tmp.lastIndexOf('@'),
|
||||
end = tmp.lastIndexOf('/');
|
||||
if ((end - 8) <= start) return string; // Skip if not a commit id
|
||||
return "https://raw.githubusercontent.com/" +
|
||||
tmp.substring(0, start) + "/master" + string.substring(end);
|
||||
}
|
||||
if (string.startsWith("https://github.com/Magisk-Modules-Repo/")) {
|
||||
int i = string.lastIndexOf("/archive/");
|
||||
if (i != -1 && string.indexOf('/', i + 9) == -1)
|
||||
return string.substring(0, i + 9) + "master.zip";
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change GitHub user-content url to jsdelivr url
|
||||
* (Unused but kept as a documentation)
|
||||
*/
|
||||
public static String cdnIfyLink(String string) {
|
||||
if (string.startsWith("https://raw.githubusercontent.com/")) {
|
||||
String[] tokens = string.substring(34).split("/", 4);
|
||||
if (tokens.length != 4) return string;
|
||||
return "https://cdn.jsdelivr.net/gh/" +
|
||||
tokens[0] + "/" + tokens[1] + "@" + tokens[2] + "/" + tokens[3];
|
||||
}
|
||||
if (string.startsWith("https://github.com/")) {
|
||||
int i = string.lastIndexOf("/archive/");
|
||||
if (i == -1 || string.indexOf('/', i + 9) != -1)
|
||||
return string; // Not an archive link
|
||||
String[] tokens = string.substring(19).split("/", 4);
|
||||
return "https://cdn.jsdelivr.net/gh/" +
|
||||
tokens[0] + "/" + tokens[1] + "@" + tokens[2] + "/" + tokens[3];
|
||||
}
|
||||
return string;
|
||||
}
|
||||
}
|
@ -1,171 +0,0 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.util.Log;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Objects;
|
||||
|
||||
public class NoodleDebug {
|
||||
private static final String TAG = "NoodleDebug";
|
||||
private static final WeakReference<Thread> NULL_THREAD_REF = new WeakReference<>(null);
|
||||
private static final ThreadLocal<NoodleDebug> THREAD_NOODLE = new ThreadLocal<>();
|
||||
@SuppressLint("StaticFieldLeak") // <- Null initialized
|
||||
private static final NoodleDebug NULL = new NoodleDebug() {
|
||||
@Override
|
||||
public NoodleDebug bind() {
|
||||
getNoodleDebug().unbind();
|
||||
THREAD_NOODLE.remove();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setEnabled(boolean enabled) {}
|
||||
|
||||
@Override
|
||||
protected void markDirty() {}
|
||||
};
|
||||
private final Activity activity;
|
||||
private final TextView textView;
|
||||
private final LinkedList<String> tokens;
|
||||
private final StringBuilder debug;
|
||||
private WeakReference<Thread> thread;
|
||||
private boolean enabled, updating;
|
||||
|
||||
private NoodleDebug() {
|
||||
this.activity = null;
|
||||
this.textView = null;
|
||||
this.tokens = new LinkedList<>();
|
||||
this.debug = new StringBuilder(0);
|
||||
this.thread = NULL_THREAD_REF;
|
||||
}
|
||||
|
||||
public NoodleDebug(Activity activity,@IdRes int textViewId) {
|
||||
this(activity, activity.findViewById(textViewId));
|
||||
}
|
||||
|
||||
public NoodleDebug(Activity activity, TextView textView) {
|
||||
this.activity = Objects.requireNonNull(activity);
|
||||
this.textView = Objects.requireNonNull(textView);
|
||||
this.tokens = new LinkedList<>();
|
||||
this.debug = new StringBuilder(64);
|
||||
this.thread = NULL_THREAD_REF;
|
||||
}
|
||||
|
||||
public NoodleDebug bind() {
|
||||
synchronized (this.tokens) {
|
||||
Thread thread;
|
||||
if ((thread = this.thread.get()) != null) {
|
||||
Log.e(TAG, "Trying to bind to thread \"" + Thread.currentThread().getName() +
|
||||
"\" while already bound to \"" + thread.getName() + "\"");
|
||||
return NULL;
|
||||
}
|
||||
this.tokens.clear();
|
||||
}
|
||||
if (this.enabled) {
|
||||
this.thread = new WeakReference<>(Thread.currentThread());
|
||||
THREAD_NOODLE.set(this);
|
||||
} else {
|
||||
this.thread = NULL_THREAD_REF;
|
||||
THREAD_NOODLE.remove();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public void unbind() {
|
||||
this.thread = NULL_THREAD_REF;
|
||||
boolean markDirty;
|
||||
synchronized (this.tokens) {
|
||||
markDirty = !this.tokens.isEmpty();
|
||||
this.tokens.clear();
|
||||
}
|
||||
if (markDirty) this.markDirty();
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return this.thread.get() != null;
|
||||
}
|
||||
|
||||
public void push(String token) {
|
||||
if (!this.enabled) return;
|
||||
synchronized (this.tokens) {
|
||||
this.tokens.add(token);
|
||||
}
|
||||
if (!token.isEmpty())
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void pop() {
|
||||
if (!this.enabled) return;
|
||||
String last;
|
||||
synchronized (this.tokens) {
|
||||
last = this.tokens.removeLast();
|
||||
}
|
||||
if (!last.isEmpty())
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void replace(String token) {
|
||||
if (!this.enabled) return;
|
||||
String last;
|
||||
synchronized (this.tokens) {
|
||||
last = this.tokens.removeLast();
|
||||
this.tokens.add(token);
|
||||
}
|
||||
if (!last.equals(token))
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
if (this.enabled && !enabled) {
|
||||
this.thread = NULL_THREAD_REF;
|
||||
synchronized (this.tokens) {
|
||||
this.tokens.clear();
|
||||
}
|
||||
this.markDirty();
|
||||
}
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
protected void markDirty() {
|
||||
assert this.activity != null;
|
||||
assert this.textView != null;
|
||||
if (this.updating) return;
|
||||
this.updating = true;
|
||||
this.activity.runOnUiThread(() -> {
|
||||
String debugText;
|
||||
synchronized (this.tokens) {
|
||||
StringBuilder debug = this.debug;
|
||||
debug.setLength(0);
|
||||
boolean first = true;
|
||||
for (String text : this.tokens) {
|
||||
if (text.isEmpty()) continue;
|
||||
if (first) first = false;
|
||||
else debug.append(" > ");
|
||||
debug.append(text);
|
||||
}
|
||||
debugText = debug.toString();
|
||||
}
|
||||
this.updating = false;
|
||||
this.textView.setText(debugText);
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static NoodleDebug getNoodleDebug() {
|
||||
NoodleDebug noodleDebug = THREAD_NOODLE.get();
|
||||
if (noodleDebug == null) return NULL;
|
||||
if (noodleDebug.thread.get() != Thread.currentThread() ||
|
||||
noodleDebug.activity.isDestroyed()) {
|
||||
THREAD_NOODLE.remove();
|
||||
return NULL;
|
||||
}
|
||||
return noodleDebug;
|
||||
}
|
||||
}
|
@ -0,0 +1,25 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.app.AlarmManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
||||
import com.fox2code.mmm.MainActivity;
|
||||
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
|
||||
public enum ProcessHelper {
|
||||
;
|
||||
private static final int sPendingIntentId = ThreadLocalRandom.current().nextInt(100, 1000000 + 1);
|
||||
|
||||
public static void restartApplicationProcess(Context context) {
|
||||
Intent mStartActivity = new Intent(context, MainActivity.class);
|
||||
mStartActivity.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
PendingIntent mPendingIntent = PendingIntent.getActivity(context, sPendingIntentId,
|
||||
mStartActivity, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||
AlarmManager mgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
|
||||
mgr.set(AlarmManager.RTC, System.currentTimeMillis() + 100, mPendingIntent);
|
||||
System.exit(0); // Exit app process
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
package com.fox2code.mmm.utils
|
||||
import com.fox2code.mmm.BuildConfig
|
||||
import com.fox2code.mmm.MainApplication
|
||||
import com.fox2code.mmm.MainApplication.ReleaseTree
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryLevel
|
||||
import io.sentry.android.timber.SentryTimberTree
|
||||
import timber.log.Timber
|
||||
import timber.log.Timber.Forest.plant
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
object TimberUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun configTimber() {
|
||||
// init timber
|
||||
// init timber
|
||||
if (BuildConfig.DEBUG) {
|
||||
plant(Timber.DebugTree())
|
||||
} else {
|
||||
if (MainApplication.isCrashReportingEnabled()) {
|
||||
plant(
|
||||
SentryTimberTree(
|
||||
Sentry.getCurrentHub(),
|
||||
SentryLevel.ERROR,
|
||||
SentryLevel.ERROR
|
||||
)
|
||||
)
|
||||
} else {
|
||||
plant(ReleaseTree())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,186 @@
|
||||
package com.fox2code.mmm.utils;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import com.fox2code.foxcompat.app.FoxActivity;
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.R;
|
||||
import com.fox2code.mmm.installer.InstallerInitializer;
|
||||
import com.fox2code.mmm.utils.io.Files;
|
||||
import com.fox2code.mmm.utils.io.PropUtils;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public class ZipFileOpener extends FoxActivity {
|
||||
AlertDialog loading = null;
|
||||
|
||||
// Adds us as a handler for zip files, so we can pass them to the installer
|
||||
// We should have a content uri provided to us.
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
loading = BudgetProgressDialog.build(this, R.string.loading, R.string.zip_unpacking);
|
||||
new Thread(() -> {
|
||||
Timber.d("onCreate: %s", getIntent());
|
||||
File zipFile;
|
||||
Uri uri = getIntent().getData();
|
||||
if (uri == null) {
|
||||
Timber.e("onCreate: No data provided");
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.zip_load_failed, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Try to copy the file to our cache
|
||||
try {
|
||||
// check if its a file over 10MB
|
||||
Long fileSize = Files.getFileSize(this, uri);
|
||||
if (fileSize == null) fileSize = 0L;
|
||||
if (1000L * 1000 * 10 < fileSize) {
|
||||
runOnUiThread(() -> loading.show());
|
||||
}
|
||||
zipFile = File.createTempFile("module", ".zip", getCacheDir());
|
||||
try (InputStream inputStream = getContentResolver().openInputStream(uri); FileOutputStream outputStream = new FileOutputStream(zipFile)) {
|
||||
if (inputStream == null) {
|
||||
Timber.e("onCreate: Failed to open input stream");
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.zip_load_failed, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
return;
|
||||
}
|
||||
byte[] buffer = new byte[4096];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
} catch (
|
||||
Exception e) {
|
||||
Timber.e(e, "onCreate: Failed to copy zip file");
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.zip_load_failed, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Ensure zip is not empty
|
||||
if (zipFile.length() == 0) {
|
||||
Timber.e("onCreate: Zip file is empty");
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.zip_load_failed, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
Timber.d("onCreate: Zip file is " + zipFile.length() + " bytes");
|
||||
}
|
||||
ZipEntry entry;
|
||||
ZipFile zip = null;
|
||||
// Unpack the zip to validate it's a valid magisk module
|
||||
// It needs to have, at the bare minimum, a module.prop file. Everything else is technically optional.
|
||||
// First, check if it's a zip file
|
||||
try {
|
||||
zip = new ZipFile(zipFile);
|
||||
if ((entry = zip.getEntry("module.prop")) == null) {
|
||||
Timber.e("onCreate: Zip file is not a valid magisk module");
|
||||
if (BuildConfig.DEBUG) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Timber.d("onCreate: Zip file contents: %s", zip.stream().map(ZipEntry::getName).reduce((a, b) -> a + ", " + b).orElse("empty"));
|
||||
} else {
|
||||
Timber.d("onCreate: Zip file contents cannot be listed on this version of android");
|
||||
}
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.invalid_format, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (
|
||||
Exception e) {
|
||||
Timber.e(e, "onCreate: Failed to open zip file");
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.zip_load_failed, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
if (zip != null) {
|
||||
try {
|
||||
zip.close();
|
||||
} catch (IOException exception) {
|
||||
Timber.e(Log.getStackTraceString(exception));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Timber.d("onCreate: Zip file is valid");
|
||||
String moduleInfo;
|
||||
try {
|
||||
moduleInfo = PropUtils.readModulePropSimple(zip.getInputStream(entry), "name");
|
||||
if (moduleInfo == null) {
|
||||
moduleInfo = PropUtils.readModulePropSimple(zip.getInputStream(entry), "id");
|
||||
}
|
||||
if (moduleInfo == null) {
|
||||
throw new NullPointerException("moduleInfo is null, check earlier logs for root cause");
|
||||
}
|
||||
} catch (
|
||||
Exception e) {
|
||||
Timber.e(e, "onCreate: Failed to load module id");
|
||||
runOnUiThread(() -> {
|
||||
Toast.makeText(this, R.string.zip_prop_load_failed, Toast.LENGTH_LONG).show();
|
||||
finishAndRemoveTask();
|
||||
});
|
||||
try {
|
||||
zip.close();
|
||||
} catch (IOException exception) {
|
||||
Timber.e(Log.getStackTraceString(exception));
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
zip.close();
|
||||
} catch (IOException exception) {
|
||||
Timber.e(Log.getStackTraceString(exception));
|
||||
}
|
||||
String finalModuleInfo = moduleInfo;
|
||||
runOnUiThread(() -> {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.zip_security_warning, finalModuleInfo))
|
||||
.setMessage(getString(R.string.zip_intent_module_install, finalModuleInfo, Files.getFileName(this, uri)))
|
||||
.setCancelable(false)
|
||||
.setNegativeButton(R.string.no, (d, i) -> {
|
||||
d.dismiss();
|
||||
finishAndRemoveTask();
|
||||
})
|
||||
.setPositiveButton(R.string.yes, (d, i) -> {
|
||||
d.dismiss();
|
||||
// Pass the file to the installer
|
||||
FoxActivity compatActivity = FoxActivity.getFoxActivity(this);
|
||||
IntentHelper.openInstaller(compatActivity, zipFile.getAbsolutePath(),
|
||||
compatActivity.getString(
|
||||
R.string.local_install_title), null, null, false,
|
||||
BuildConfig.DEBUG && // Use debug mode if no root
|
||||
InstallerInitializer.peekMagiskPath() == null);
|
||||
finish();
|
||||
})
|
||||
.show();
|
||||
loading.dismiss();
|
||||
});
|
||||
}).start();
|
||||
}
|
||||
}
|
@ -0,0 +1,283 @@
|
||||
package com.fox2code.mmm.utils.io;
|
||||
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.topjohnwu.superuser.io.SuFile;
|
||||
import com.topjohnwu.superuser.io.SuFileInputStream;
|
||||
import com.topjohnwu.superuser.io.SuFileOutputStream;
|
||||
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
|
||||
import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
|
||||
import org.apache.commons.compress.archivers.zip.ZipFile;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.Closeable;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Objects;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
/** @noinspection ResultOfMethodCallIgnored*/
|
||||
public enum Files {
|
||||
;
|
||||
private static final boolean is64bit = Build.SUPPORTED_64_BIT_ABIS.length > 0;
|
||||
|
||||
// stolen from https://stackoverflow.com/a/25005243
|
||||
public static @NonNull String getFileName(Context context, Uri uri) {
|
||||
String result = null;
|
||||
if (Objects.equals(uri.getScheme(), "content")) {
|
||||
try (Cursor cursor = context.getContentResolver().query(uri, null, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (index != -1) {
|
||||
result = cursor.getString(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result == null) {
|
||||
result = uri.getPath();
|
||||
int cut = Objects.requireNonNull(result).lastIndexOf('/');
|
||||
if (cut != -1) {
|
||||
result = result.substring(cut + 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// based on https://stackoverflow.com/a/63018108
|
||||
public static @Nullable Long getFileSize(Context context, Uri uri) {
|
||||
Long result = null;
|
||||
try {
|
||||
String scheme = uri.getScheme();
|
||||
if (Objects.equals(scheme, "content")) {
|
||||
Cursor returnCursor = context.getContentResolver().
|
||||
query(uri, null, null, null, null);
|
||||
int sizeIndex = Objects.requireNonNull(returnCursor).getColumnIndex(OpenableColumns.SIZE);
|
||||
returnCursor.moveToFirst();
|
||||
|
||||
long size = returnCursor.getLong(sizeIndex);
|
||||
returnCursor.close();
|
||||
|
||||
result = size;
|
||||
}
|
||||
if (Objects.equals(scheme, "file")) {
|
||||
result = new File(Objects.requireNonNull(uri.getPath())).length();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Timber.e(Log.getStackTraceString(e));
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void write(File file, byte[] bytes) throws IOException {
|
||||
// make the dir if necessary
|
||||
Objects.requireNonNull(file.getParentFile()).mkdirs();
|
||||
try (OutputStream outputStream = new FileOutputStream(file)) {
|
||||
outputStream.write(bytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] read(File file) throws IOException {
|
||||
try (InputStream inputStream = new FileInputStream(file)) {
|
||||
return readAllBytes(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
public static void writeSU(File file, byte[] bytes) throws IOException {
|
||||
// make the dir if necessary
|
||||
Objects.requireNonNull(file.getParentFile()).mkdirs();
|
||||
try (OutputStream outputStream = SuFileOutputStream.open(file)) {
|
||||
outputStream.write(bytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] readSU(File file) throws IOException {
|
||||
if (file.isFile() && file.canRead()) {
|
||||
try { // Read as app if su not required
|
||||
return read(file);
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
try (InputStream inputStream = SuFileInputStream.open(file)) {
|
||||
return readAllBytes(inputStream);
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean existsSU(File file) {
|
||||
return file.exists() || new SuFile(file.getAbsolutePath()).exists();
|
||||
}
|
||||
|
||||
public static void copy(InputStream inputStream, OutputStream outputStream) throws IOException {
|
||||
int nRead;
|
||||
byte[] data = new byte[16384];
|
||||
while ((nRead = inputStream.read(data, 0, data.length)) != -1) {
|
||||
outputStream.write(data, 0, nRead);
|
||||
}
|
||||
outputStream.flush();
|
||||
}
|
||||
|
||||
public static void closeSilently(Closeable closeable) {
|
||||
try {
|
||||
if (closeable != null) closeable.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static ByteArrayOutputStream makeBuffer(long capacity) {
|
||||
// Cap buffer to 1 Gib (or 512 Mib for 32bit) to avoid memory errors
|
||||
return Files.makeBuffer((int) Math.min(capacity, is64bit ? 0x40000000 : 0x20000000));
|
||||
}
|
||||
|
||||
public static ByteArrayOutputStream makeBuffer(int capacity) {
|
||||
return new ByteArrayOutputStream(Math.max(0x20, capacity)) {
|
||||
@NonNull
|
||||
@Override
|
||||
public byte[] toByteArray() {
|
||||
return this.buf.length == this.count ?
|
||||
this.buf : super.toByteArray();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static byte[] readAllBytes(InputStream inputStream) throws IOException {
|
||||
ByteArrayOutputStream buffer = Files.makeBuffer(inputStream.available());
|
||||
copy(inputStream, buffer);
|
||||
return buffer.toByteArray();
|
||||
}
|
||||
|
||||
public static void fixJavaZipHax(byte[] bytes) {
|
||||
if (bytes.length > 8 && bytes[0x6] == 0x0 && bytes[0x7] == 0x0 && bytes[0x8] == 0x8)
|
||||
bytes[0x7] = 0x8; // Known hax to prevent java zip file read
|
||||
}
|
||||
|
||||
public static void patchModuleSimple(byte[] bytes, OutputStream outputStream) throws IOException {
|
||||
fixJavaZipHax(bytes);
|
||||
patchModuleSimple(new ByteArrayInputStream(bytes), outputStream);
|
||||
}
|
||||
|
||||
public static void patchModuleSimple(InputStream inputStream, OutputStream outputStream) throws IOException {
|
||||
ZipInputStream zipInputStream = new ZipInputStream(inputStream);
|
||||
ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream);
|
||||
int nRead;
|
||||
byte[] data = new byte[16384];
|
||||
ZipEntry zipEntry;
|
||||
while ((zipEntry = zipInputStream.getNextEntry()) != null) {
|
||||
String name = zipEntry.getName();
|
||||
int i = name.indexOf('/', 1);
|
||||
if (i == -1) continue;
|
||||
String newName = name.substring(i + 1);
|
||||
if (newName.startsWith(".git")) continue; // Skip metadata
|
||||
zipOutputStream.putNextEntry(new ZipEntry(newName));
|
||||
while ((nRead = zipInputStream.read(data, 0, data.length)) != -1) {
|
||||
zipOutputStream.write(data, 0, nRead);
|
||||
}
|
||||
zipOutputStream.flush();
|
||||
zipOutputStream.closeEntry();
|
||||
zipInputStream.closeEntry();
|
||||
}
|
||||
zipOutputStream.finish();
|
||||
zipOutputStream.flush();
|
||||
zipOutputStream.close();
|
||||
zipInputStream.close();
|
||||
}
|
||||
|
||||
public static void fixSourceArchiveShit(byte[] rawModule) {
|
||||
// unzip the module, check if it has just one folder within. if so, switch to the folder and zip up contents, and replace the original file with that
|
||||
try {
|
||||
File tempDir = new File(MainApplication.getINSTANCE().getCacheDir(), "temp");
|
||||
if (tempDir.exists()) {
|
||||
FileUtils.deleteDirectory(tempDir);
|
||||
}
|
||||
if (!tempDir.mkdirs()) {
|
||||
throw new IOException("Unable to create temp dir");
|
||||
}
|
||||
File tempFile = new File(tempDir, "module.zip");
|
||||
Files.write(tempFile, rawModule);
|
||||
File tempUnzipDir = new File(tempDir, "unzip");
|
||||
if (!tempUnzipDir.mkdirs()) {
|
||||
throw new IOException("Unable to create temp unzip dir");
|
||||
}
|
||||
// unzip
|
||||
Timber.d("Unzipping module to %s", tempUnzipDir.getAbsolutePath());
|
||||
try (ZipFile zipFile = new ZipFile(tempFile)) {
|
||||
Enumeration<ZipArchiveEntry> files = zipFile.getEntries();
|
||||
// check if there is only one folder in the top level
|
||||
int folderCount = 0;
|
||||
while (files.hasMoreElements()) {
|
||||
ZipArchiveEntry entry = files.nextElement();
|
||||
if (entry.isDirectory()) {
|
||||
folderCount++;
|
||||
}
|
||||
}
|
||||
if (folderCount == 1) {
|
||||
files = zipFile.getEntries();
|
||||
while (files.hasMoreElements()) {
|
||||
ZipArchiveEntry entry = files.nextElement();
|
||||
if (entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
File file = new File(tempUnzipDir, entry.getName());
|
||||
if (!Objects.requireNonNull(file.getParentFile()).exists()) {
|
||||
if (!file.getParentFile().mkdirs()) {
|
||||
throw new IOException("Unable to create parent dir");
|
||||
}
|
||||
}
|
||||
try (ZipArchiveOutputStream zipArchiveOutputStream = new ZipArchiveOutputStream(file)) {
|
||||
zipArchiveOutputStream.putArchiveEntry(entry);
|
||||
try (InputStream inputStream = zipFile.getInputStream(entry)) {
|
||||
copy(inputStream, zipArchiveOutputStream);
|
||||
}
|
||||
zipArchiveOutputStream.closeArchiveEntry();
|
||||
}
|
||||
}
|
||||
// zip up the contents of the folder but not the folder itself
|
||||
File[] filesInFolder = Objects.requireNonNull(tempUnzipDir.listFiles());
|
||||
// create a new zip file
|
||||
try (ZipArchiveOutputStream archive = new ZipArchiveOutputStream(new FileOutputStream("new.zip"))) {
|
||||
for (File files2 : filesInFolder) {
|
||||
// create a new ZipArchiveEntry and add it to the ZipArchiveOutputStream
|
||||
ZipArchiveEntry entry = new ZipArchiveEntry(files2, files2.getName());
|
||||
archive.putArchiveEntry(entry);
|
||||
try (InputStream input = new FileInputStream(files2)) {
|
||||
copy(input, archive);
|
||||
}
|
||||
archive.closeArchiveEntry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Unable to zip up module");
|
||||
}
|
||||
} else {
|
||||
Timber.d("Module does not have a single folder in the top level, skipping");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Unable to unzip module");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Unable to create temp dir");
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
package com.fox2code.mmm.utils.io;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import timber.log.Timber;
|
||||
|
||||
public enum Hashes {
|
||||
;
|
||||
private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray();
|
||||
private static final Pattern nonAlphaNum = Pattern.compile("[^a-zA-Z0-9]");
|
||||
|
||||
public static String bytesToHex(byte[] bytes) {
|
||||
char[] hexChars = new char[bytes.length * 2];
|
||||
for (int j = 0; j < bytes.length; j++) {
|
||||
int v = bytes[j] & 0xFF;
|
||||
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
|
||||
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
|
||||
}
|
||||
return String.valueOf(hexChars);
|
||||
}
|
||||
|
||||
public static String hashMd5(byte[] input) {
|
||||
throw new SecurityException("MD5 is not secure");
|
||||
}
|
||||
|
||||
public static String hashSha1(byte[] input) {
|
||||
throw new SecurityException("SHA-1 is not secure");
|
||||
}
|
||||
|
||||
public static String hashSha256(byte[] input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
return bytesToHex(md.digest(input));
|
||||
} catch (
|
||||
NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String hashSha512(byte[] input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-512");
|
||||
|
||||
return bytesToHex(md.digest(input));
|
||||
} catch (
|
||||
NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the checksum match a file by picking the correct
|
||||
* hashing algorithm depending on the length of the checksum
|
||||
*/
|
||||
public static boolean checkSumMatch(byte[] data, String checksum) {
|
||||
String hash;
|
||||
if (checksum == null)
|
||||
return false;
|
||||
switch (checksum.length()) {
|
||||
case 0:
|
||||
return true; // No checksum
|
||||
case 32:
|
||||
hash = Hashes.hashMd5(data);
|
||||
break;
|
||||
case 40:
|
||||
hash = Hashes.hashSha1(data);
|
||||
break;
|
||||
case 64:
|
||||
hash = Hashes.hashSha256(data);
|
||||
break;
|
||||
case 128:
|
||||
hash = Hashes.hashSha512(data);
|
||||
break;
|
||||
default:
|
||||
Timber.e("No hash algorithm for " + checksum.length() * 8 + "bit checksums");
|
||||
return false;
|
||||
}
|
||||
Timber.i("Checksum result (data: " + hash + ",expected: " + checksum + ")");
|
||||
return hash.equals(checksum.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public static boolean checkSumValid(String checksum) {
|
||||
if (checksum == null)
|
||||
return false;
|
||||
switch (checksum.length()) {
|
||||
case 0:
|
||||
default:
|
||||
return false;
|
||||
case 32:
|
||||
case 40:
|
||||
case 64:
|
||||
case 128:
|
||||
final int len = checksum.length();
|
||||
for (int i = 0; i < len; i++) {
|
||||
char c = checksum.charAt(i);
|
||||
if (c < '0' || c > 'f')
|
||||
return false;
|
||||
if (c > '9' && // Easier working with bits
|
||||
(c & 0b01011111) < 'A')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static String checkSumName(String checksum) {
|
||||
if (checksum == null)
|
||||
return null;
|
||||
return switch (checksum.length()) {
|
||||
default -> null;
|
||||
case 32 -> "MD5";
|
||||
case 40 -> "SHA-1";
|
||||
case 64 -> "SHA-256";
|
||||
case 128 -> "SHA-512";
|
||||
};
|
||||
}
|
||||
|
||||
public static String checkSumFormat(String checksum) {
|
||||
if (checksum == null)
|
||||
return null;
|
||||
// Remove all non-alphanumeric characters
|
||||
return nonAlphaNum.matcher(checksum.trim()).replaceAll("");
|
||||
}
|
||||
}
|
@ -0,0 +1,567 @@
|
||||
package com.fox2code.mmm.utils.io.net;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.WebSettings;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.webkit.WebViewCompat;
|
||||
|
||||
import com.fox2code.mmm.BuildConfig;
|
||||
import com.fox2code.mmm.MainActivity;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.R;
|
||||
import com.fox2code.mmm.androidacy.AndroidacyUtil;
|
||||
import com.fox2code.mmm.installer.InstallerInitializer;
|
||||
import com.fox2code.mmm.utils.io.Files;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
import com.google.net.cronet.okhttptransport.CronetInterceptor;
|
||||
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.chromium.net.CronetEngine;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.Proxy;
|
||||
import java.net.URL;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
import okhttp3.Cache;
|
||||
import okhttp3.Dns;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import okhttp3.dnsoverhttps.DnsOverHttps;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import okio.BufferedSink;
|
||||
import timber.log.Timber;
|
||||
|
||||
public enum Http {
|
||||
;
|
||||
private static final OkHttpClient httpClient;
|
||||
private static final OkHttpClient httpClientDoH;
|
||||
private static final OkHttpClient httpClientWithCache;
|
||||
private static final OkHttpClient httpClientWithCacheDoH;
|
||||
private static final FallBackDNS fallbackDNS;
|
||||
private static final String androidacyUA;
|
||||
private static final boolean hasWebView;
|
||||
private static String needCaptchaAndroidacyHost;
|
||||
private static boolean doh;
|
||||
private static boolean urlFactoryInstalled;
|
||||
|
||||
static {
|
||||
MainApplication mainApplication = MainApplication.getINSTANCE();
|
||||
if (mainApplication == null) {
|
||||
Error error = new Error("Initialized Http too soon!");
|
||||
error.fillInStackTrace();
|
||||
Timber.e(error, "Initialized Http too soon!");
|
||||
System.out.flush();
|
||||
System.err.flush();
|
||||
try {
|
||||
Os.kill(Os.getpid(), 9);
|
||||
} catch (ErrnoException e) {
|
||||
System.exit(9);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
CookieManager cookieManager;
|
||||
try {
|
||||
cookieManager = CookieManager.getInstance();
|
||||
cookieManager.setAcceptCookie(true);
|
||||
cookieManager.flush(); // Make sure the instance work
|
||||
} catch (Exception t) {
|
||||
cookieManager = null;
|
||||
Timber.e(t, "No WebView support!");
|
||||
// show a toast
|
||||
Context context = mainApplication.getApplicationContext();
|
||||
MainActivity.getFoxActivity(context).runOnUiThread(() -> Toast.makeText(mainApplication, R.string.error_creating_cookie_database, Toast.LENGTH_LONG).show());
|
||||
}
|
||||
// get webview version
|
||||
String webviewVersion = "0.0.0";
|
||||
PackageInfo pi = WebViewCompat.getCurrentWebViewPackage(mainApplication);
|
||||
if (pi != null) {
|
||||
webviewVersion = pi.versionName;
|
||||
}
|
||||
// webviewVersionMajor is the everything before the first dot
|
||||
int webviewVersionCode;
|
||||
// parse webview version
|
||||
// get the first dot
|
||||
int dot = webviewVersion.indexOf('.');
|
||||
if (dot == -1) {
|
||||
// no dot, use the whole string
|
||||
webviewVersionCode = Integer.parseInt(webviewVersion);
|
||||
} else {
|
||||
// use the first dot
|
||||
webviewVersionCode = Integer.parseInt(webviewVersion.substring(0, dot));
|
||||
}
|
||||
Timber.d("Webview version: %s (%d)", webviewVersion, webviewVersionCode);
|
||||
hasWebView = cookieManager != null && webviewVersionCode >= 83; // 83 is the first version Androidacy supports due to errors in 82
|
||||
OkHttpClient.Builder httpclientBuilder = new OkHttpClient.Builder();
|
||||
// Default is 10, extend it a bit for slow mobile connections.
|
||||
httpclientBuilder.connectTimeout(5, TimeUnit.SECONDS);
|
||||
httpclientBuilder.writeTimeout(10, TimeUnit.SECONDS);
|
||||
httpclientBuilder.readTimeout(15, TimeUnit.SECONDS);
|
||||
httpclientBuilder.proxy(Proxy.NO_PROXY); // Do not use system proxy
|
||||
Dns dns = Dns.SYSTEM;
|
||||
try {
|
||||
InetAddress[] cloudflareBootstrap = new InetAddress[]{InetAddress.getByName("162.159.36.1"), InetAddress.getByName("162.159.46.1"), InetAddress.getByName("1.1.1.1"), InetAddress.getByName("1.0.0.1"), InetAddress.getByName("162.159.132.53"), InetAddress.getByName("2606:4700:4700::1111"), InetAddress.getByName("2606:4700:4700::1001"), InetAddress.getByName("2606:4700:4700::0064"), InetAddress.getByName("2606:4700:4700::6400")};
|
||||
dns = s -> {
|
||||
if ("cloudflare-dns.com".equals(s)) {
|
||||
return Arrays.asList(cloudflareBootstrap);
|
||||
}
|
||||
return Dns.SYSTEM.lookup(s);
|
||||
};
|
||||
httpclientBuilder.dns(dns);
|
||||
WebkitCookieManagerProxy cookieJar = new WebkitCookieManagerProxy();
|
||||
httpclientBuilder.cookieJar(cookieJar);
|
||||
dns = new DnsOverHttps.Builder().client(httpclientBuilder.build()).url(Objects.requireNonNull(HttpUrl.parse("https://cloudflare-dns.com/dns-query"))).bootstrapDnsHosts(cloudflareBootstrap).resolvePrivateAddresses(true).build();
|
||||
} catch (UnknownHostException | RuntimeException e) {
|
||||
Timber.e(e, "Failed to init DoH");
|
||||
}
|
||||
// User-Agent format was agreed on telegram
|
||||
if (hasWebView) {
|
||||
androidacyUA = WebSettings.getDefaultUserAgent(mainApplication).replace("wv", "") + " FoxMMM/" + BuildConfig.VERSION_CODE;
|
||||
} else {
|
||||
androidacyUA = "Mozilla/5.0 (Linux; Android " + Build.VERSION.RELEASE + "; " + Build.DEVICE + ")" + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Mobile Safari/537.36" + " FoxMmm/" + BuildConfig.VERSION_CODE;
|
||||
}
|
||||
httpclientBuilder.addInterceptor(chain -> {
|
||||
Request.Builder request = chain.request().newBuilder();
|
||||
request.header("Upgrade-Insecure-Requests", "1");
|
||||
String host = chain.request().url().host();
|
||||
if (host.endsWith(".androidacy.com")) {
|
||||
request.header("User-Agent", androidacyUA);
|
||||
} else if (!(host.equals("github.com") || host.endsWith(".github.com") || host.endsWith(".jsdelivr.net") || host.endsWith(".githubusercontent.com"))) {
|
||||
if (InstallerInitializer.peekMagiskPath() != null) {
|
||||
request.header("User-Agent", // Declare Magisk version to the server
|
||||
"Magisk/" + InstallerInitializer.peekMagiskVersion());
|
||||
}
|
||||
}
|
||||
if (chain.request().header("Accept-Language") == null) {
|
||||
request.header("Accept-Language", // Send system language to the server
|
||||
mainApplication.getResources().getConfiguration().getLocales().get(0).toLanguageTag());
|
||||
}
|
||||
// add client hints
|
||||
request.header("Sec-CH-UA", androidacyUA);
|
||||
request.header("Sec-CH-UA-Mobile", "?1");
|
||||
request.header("Sec-CH-UA-Platform", "Android");
|
||||
request.header("Sec-CH-UA-Platform-Version", Build.VERSION.RELEASE);
|
||||
request.header("Sec-CH-UA-Arch", Build.SUPPORTED_ABIS[0]);
|
||||
request.header("Sec-CH-UA-Full-Version", BuildConfig.VERSION_NAME);
|
||||
request.header("Sec-CH-UA-Model", Build.DEVICE);
|
||||
request.header("Sec-CH-UA-Bitness", Build.SUPPORTED_64_BIT_ABIS.length > 0 ? "64" : "32");
|
||||
return chain.proceed(request.build());
|
||||
});
|
||||
|
||||
// for debug builds, add a logging interceptor
|
||||
// this spams the logcat, so it's disabled by default and hidden behind a build config flag
|
||||
if (BuildConfig.DEBUG && BuildConfig.DEBUG_HTTP) {
|
||||
Timber.w("HTTP logging is enabled. Performance may be impacted.");
|
||||
HttpLoggingInterceptor loggingInterceptor = new HttpLoggingInterceptor();
|
||||
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||
httpclientBuilder.addInterceptor(loggingInterceptor);
|
||||
}
|
||||
|
||||
// Add cronet interceptor
|
||||
// init cronet
|
||||
try {
|
||||
// Load the cronet library
|
||||
CronetEngine.Builder builder = new CronetEngine.Builder(mainApplication.getApplicationContext());
|
||||
builder.enableBrotli(true);
|
||||
builder.enableHttp2(true);
|
||||
builder.enableQuic(true);
|
||||
// Cache size is 10MB
|
||||
// Make the directory if it does not exist
|
||||
File cacheDir = new File(mainApplication.getCacheDir(), "cronet");
|
||||
if (!cacheDir.exists()) {
|
||||
if (!cacheDir.mkdirs()) {
|
||||
throw new IOException("Failed to create cronet cache directory");
|
||||
}
|
||||
}
|
||||
builder.setStoragePath(mainApplication.getCacheDir().getAbsolutePath() + "/cronet");
|
||||
builder.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK_NO_HTTP, 10 * 1024 * 1024);
|
||||
// Add quic hint
|
||||
builder.addQuicHint("github.com", 443, 443);
|
||||
builder.addQuicHint("githubusercontent.com", 443, 443);
|
||||
builder.addQuicHint("jsdelivr.net", 443, 443);
|
||||
builder.addQuicHint("androidacy.com", 443, 443);
|
||||
builder.addQuicHint("sentry.io", 443, 443);
|
||||
CronetEngine engine = builder.build();
|
||||
httpclientBuilder.addInterceptor(CronetInterceptor.newBuilder(engine).build());
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to init cronet");
|
||||
// Gracefully fallback to okhttp
|
||||
}
|
||||
// Fallback DNS cache responses in case request fail but already succeeded once in the past
|
||||
fallbackDNS = new FallBackDNS(mainApplication, dns, "github.com", "api.github.com", "raw.githubusercontent.com", "camo.githubusercontent.com", "user-images.githubusercontent.com", "cdn.jsdelivr.net", "img.shields.io", "magisk-modules-repo.github.io", "www.androidacy.com", "api.androidacy.com", "production-api.androidacy.com");
|
||||
httpclientBuilder.dns(Dns.SYSTEM);
|
||||
httpClient = followRedirects(httpclientBuilder, true).build();
|
||||
followRedirects(httpclientBuilder, false).build();
|
||||
httpclientBuilder.dns(fallbackDNS);
|
||||
httpClientDoH = followRedirects(httpclientBuilder, true).build();
|
||||
followRedirects(httpclientBuilder, false).build();
|
||||
httpclientBuilder.cache(new Cache(new File(mainApplication.getCacheDir(), "http_cache"), 16L * 1024L * 1024L)); // 16Mib of cache
|
||||
httpclientBuilder.dns(Dns.SYSTEM);
|
||||
httpClientWithCache = followRedirects(httpclientBuilder, true).build();
|
||||
httpclientBuilder.dns(fallbackDNS);
|
||||
httpClientWithCacheDoH = followRedirects(httpclientBuilder, true).build();
|
||||
Timber.i("Initialized Http successfully!");
|
||||
doh = MainApplication.isDohEnabled();
|
||||
}
|
||||
|
||||
private static OkHttpClient.Builder followRedirects(OkHttpClient.Builder builder, boolean followRedirects) {
|
||||
return builder.followRedirects(followRedirects).followSslRedirects(followRedirects);
|
||||
}
|
||||
|
||||
public static OkHttpClient getHttpClient() {
|
||||
return doh ? httpClientDoH : httpClient;
|
||||
}
|
||||
|
||||
public static OkHttpClient getHttpClientWithCache() {
|
||||
return doh ? httpClientWithCacheDoH : httpClientWithCache;
|
||||
}
|
||||
|
||||
private static void checkNeedCaptchaAndroidacy(String url, int errorCode) {
|
||||
if (errorCode == 403 && AndroidacyUtil.isAndroidacyLink(url)) {
|
||||
needCaptchaAndroidacyHost = Uri.parse(url).getHost();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean needCaptchaAndroidacy() {
|
||||
return needCaptchaAndroidacyHost != null;
|
||||
}
|
||||
|
||||
public static String needCaptchaAndroidacyHost() {
|
||||
return needCaptchaAndroidacyHost;
|
||||
}
|
||||
|
||||
public static void markCaptchaAndroidacySolved() {
|
||||
needCaptchaAndroidacyHost = null;
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
@SuppressWarnings("resource")
|
||||
public static byte[] doHttpGet(String url, boolean allowCache) throws IOException {
|
||||
if (BuildConfig.DEBUG_HTTP) {
|
||||
// Log, but set all query parameters values to "****" while keeping the keys
|
||||
Timber.d("doHttpGet: %s", url.replaceAll("=[^&]*", "=****"));
|
||||
}
|
||||
Response response;
|
||||
try {
|
||||
response = (allowCache ? getHttpClientWithCache() : getHttpClient()).newCall(new Request.Builder().url(url).get().build()).execute();
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Failed to fetch %s", url.replaceAll("=[^&]*", "=****"));
|
||||
throw new HttpException(e.getMessage(), 0);
|
||||
}
|
||||
if (BuildConfig.DEBUG_HTTP) {
|
||||
Timber.d("doHttpGet: request executed");
|
||||
}
|
||||
// 200/204 == success, 304 == cache valid
|
||||
if (response.code() != 200 && response.code() != 204 && (response.code() != 304 || !allowCache)) {
|
||||
Timber.e("Failed to fetch " + url.replaceAll("=[^&]*", "=****") + " with code " + response.code());
|
||||
checkNeedCaptchaAndroidacy(url, response.code());
|
||||
// If it's a 401, and an androidacy link, it's probably an invalid token
|
||||
if (response.code() == 401 && AndroidacyUtil.isAndroidacyLink(url)) {
|
||||
// Regenerate the token
|
||||
throw new HttpException("Androidacy token is invalid", 401);
|
||||
}
|
||||
throw new HttpException(response.code());
|
||||
}
|
||||
if (BuildConfig.DEBUG_HTTP) {
|
||||
Timber.d("doHttpGet: " + url.replaceAll("=[^&]*", "=****") + " succeeded");
|
||||
}
|
||||
ResponseBody responseBody = response.body();
|
||||
// Use cache api if used cached response
|
||||
if (response.code() == 304) {
|
||||
response = response.cacheResponse();
|
||||
if (response != null) responseBody = response.body();
|
||||
}
|
||||
if (BuildConfig.DEBUG_HTTP) {
|
||||
Timber.d("doHttpGet: returning " + responseBody.contentLength() + " bytes");
|
||||
}
|
||||
return responseBody.bytes();
|
||||
}
|
||||
|
||||
public static byte[] doHttpPost(String url, String data, boolean allowCache) throws IOException {
|
||||
return (byte[]) doHttpPostRaw(url, data, allowCache);
|
||||
}
|
||||
|
||||
@SuppressWarnings("resource")
|
||||
private static Object doHttpPostRaw(String url, String data, boolean allowCache) throws IOException {
|
||||
Response response;
|
||||
response = (allowCache ? getHttpClientWithCache() : getHttpClient()).newCall(new Request.Builder().url(url).post(JsonRequestBody.from(data)).header("Content-Type", "application/json").build()).execute();
|
||||
if (response.isRedirect()) {
|
||||
return response.request().url().uri().toString();
|
||||
}
|
||||
// 200/204 == success, 304 == cache valid
|
||||
if (response.code() != 200 && response.code() != 204 && (response.code() != 304 || !allowCache)) {
|
||||
if (BuildConfig.DEBUG_HTTP)
|
||||
Timber.e("Failed to fetch " + url + ", code: " + response.code() + ", body: " + response.body().string());
|
||||
checkNeedCaptchaAndroidacy(url, response.code());
|
||||
throw new HttpException(response.code());
|
||||
}
|
||||
ResponseBody responseBody = response.body();
|
||||
// Use cache api if used cached response
|
||||
if (response.code() == 304) {
|
||||
response = response.cacheResponse();
|
||||
if (response != null) responseBody = response.body();
|
||||
}
|
||||
return responseBody.bytes();
|
||||
}
|
||||
|
||||
public static byte[] doHttpGet(String url, ProgressListener progressListener) throws IOException {
|
||||
Response response = getHttpClient().newCall(new Request.Builder().url(url).get().build()).execute();
|
||||
if (response.code() != 200 && response.code() != 204) {
|
||||
Timber.e("Failed to fetch " + url + ", code: " + response.code());
|
||||
checkNeedCaptchaAndroidacy(url, response.code());
|
||||
throw new HttpException(response.code());
|
||||
}
|
||||
ResponseBody responseBody = Objects.requireNonNull(response.body());
|
||||
InputStream inputStream = responseBody.byteStream();
|
||||
byte[] buff = new byte[1024 * 4];
|
||||
long downloaded = 0;
|
||||
long target = responseBody.contentLength();
|
||||
ByteArrayOutputStream byteArrayOutputStream = Files.makeBuffer(target);
|
||||
int divider = 1; // Make everything go in an int
|
||||
while ((target / divider) > (Integer.MAX_VALUE / 2)) {
|
||||
divider *= 2;
|
||||
}
|
||||
final long UPDATE_INTERVAL = 100;
|
||||
long nextUpdate = System.currentTimeMillis() + UPDATE_INTERVAL;
|
||||
long currentUpdate;
|
||||
Timber.i("Target: " + target + " Divider: " + divider);
|
||||
progressListener.onUpdate(0, (int) (target / divider), false);
|
||||
while (true) {
|
||||
int read = inputStream.read(buff);
|
||||
if (read == -1) break;
|
||||
byteArrayOutputStream.write(buff, 0, read);
|
||||
downloaded += read;
|
||||
currentUpdate = System.currentTimeMillis();
|
||||
if (nextUpdate < currentUpdate) {
|
||||
nextUpdate = currentUpdate + UPDATE_INTERVAL;
|
||||
progressListener.onUpdate((int) (downloaded / divider), (int) (target / divider), false);
|
||||
}
|
||||
}
|
||||
inputStream.close();
|
||||
progressListener.onUpdate((int) (downloaded / divider), (int) (target / divider), true);
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
public static void cleanDnsCache() {
|
||||
if (Http.fallbackDNS != null) {
|
||||
Http.fallbackDNS.cleanDnsCache();
|
||||
}
|
||||
}
|
||||
|
||||
public static String getAndroidacyUA() {
|
||||
return androidacyUA;
|
||||
}
|
||||
|
||||
public static void setDoh(boolean doh) {
|
||||
Timber.i("DoH: " + Http.doh + " -> " + doh);
|
||||
Http.doh = doh;
|
||||
}
|
||||
|
||||
public static boolean hasWebView() {
|
||||
return hasWebView;
|
||||
}
|
||||
|
||||
public static void ensureCacheDirs() {
|
||||
try {
|
||||
FileUtils.forceMkdir(new File((MainApplication.getINSTANCE().getDataDir() + "/cache/WebView/Default/HTTP Cache/Code Cache/wasm").replaceAll("//", "/")));
|
||||
FileUtils.forceMkdir(new File((MainApplication.getINSTANCE().getDataDir() + "/cache/WebView/Default/HTTP Cache/Code Cache/js").replaceAll("//", "/")));
|
||||
FileUtils.forceMkdir(new File((MainApplication.getINSTANCE().getDataDir() + "/cache/cronet").replaceAll("//", "/")));
|
||||
} catch (IOException e) {
|
||||
Timber.e("Could not create cache dirs");
|
||||
}
|
||||
}
|
||||
|
||||
public static void ensureURLHandler(Context context) {
|
||||
if (!urlFactoryInstalled) {
|
||||
try {
|
||||
URL.setURLStreamHandlerFactory(new CronetEngine.Builder(context).build().createURLStreamHandlerFactory());
|
||||
urlFactoryInstalled = true;
|
||||
} catch (Error ignored) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasConnectivity() {
|
||||
// Check if we have internet connection
|
||||
Timber.d("Checking internet connection...");
|
||||
// this url is actually hosted by Cloudflare and is not dependent on Androidacy servers being up
|
||||
byte[] resp;
|
||||
try {
|
||||
resp = Http.doHttpGet("https://production-api.androidacy.com/cdn-cgi/trace", false);
|
||||
} catch (Exception e) {
|
||||
Timber.e(e, "Failed to check internet connection. Assuming no internet connection.");
|
||||
// check if it's a security or ssl exception
|
||||
if (e instanceof SSLException || e instanceof SecurityException) {
|
||||
// if it is, user installed a certificate that blocks the connection
|
||||
// show a snackbar to inform the user
|
||||
Activity context = MainApplication.getINSTANCE().getLastCompatActivity();
|
||||
new Handler(Looper.getMainLooper()).post(() -> {
|
||||
if (context != null) {
|
||||
Snackbar.make(context.findViewById(android.R.id.content), R.string.certificate_error, Snackbar.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// get the response body
|
||||
String response = new String(resp, StandardCharsets.UTF_8);
|
||||
// check if the response body contains "visit_scheme=https" and "http/<some number>"
|
||||
// if it does, we have internet connection
|
||||
return response.contains("visit_scheme=https") && response.contains("http/");
|
||||
}
|
||||
|
||||
public interface ProgressListener {
|
||||
void onUpdate(int downloaded, int total, boolean done);
|
||||
}
|
||||
|
||||
/**
|
||||
* FallBackDNS store successful DNS request to return them later
|
||||
* can help make the app to work later when the current DNS system
|
||||
* isn't functional or available.
|
||||
* <p>
|
||||
* Note: DNS Cache is stored in user data.
|
||||
*/
|
||||
private static class FallBackDNS implements Dns {
|
||||
private final Dns parent;
|
||||
private final SharedPreferences sharedPreferences;
|
||||
private final HashSet<String> fallbacks;
|
||||
private final HashMap<String, List<InetAddress>> fallbackCache;
|
||||
|
||||
public FallBackDNS(Context context, Dns parent, String... fallbacks) {
|
||||
this.sharedPreferences = context.getSharedPreferences("mmm_dns", Context.MODE_PRIVATE);
|
||||
this.parent = parent;
|
||||
this.fallbacks = new HashSet<>(Arrays.asList(fallbacks));
|
||||
this.fallbackCache = new HashMap<>();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static String toString(@NonNull List<InetAddress> inetAddresses) {
|
||||
if (inetAddresses.isEmpty()) return "";
|
||||
Iterator<InetAddress> inetAddressIterator = inetAddresses.iterator();
|
||||
StringBuilder stringBuilder = new StringBuilder();
|
||||
while (true) {
|
||||
stringBuilder.append(inetAddressIterator.next().getHostAddress());
|
||||
if (!inetAddressIterator.hasNext()) return stringBuilder.toString();
|
||||
stringBuilder.append("|");
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private static List<InetAddress> fromString(@NonNull String string) throws UnknownHostException {
|
||||
if (string.isEmpty()) return Collections.emptyList();
|
||||
String[] strings = string.split("\\|");
|
||||
ArrayList<InetAddress> inetAddresses = new ArrayList<>(strings.length);
|
||||
for (String address : strings) {
|
||||
inetAddresses.add(InetAddress.getByName(address));
|
||||
}
|
||||
return inetAddresses;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<InetAddress> lookup(@NonNull String s) throws UnknownHostException {
|
||||
if (this.fallbacks.contains(s)) {
|
||||
List<InetAddress> addresses;
|
||||
synchronized (this.fallbackCache) {
|
||||
addresses = this.fallbackCache.get(s);
|
||||
if (addresses != null) return addresses;
|
||||
try {
|
||||
addresses = this.parent.lookup(s);
|
||||
if (addresses.isEmpty() || addresses.get(0).isLoopbackAddress())
|
||||
throw new UnknownHostException(s);
|
||||
this.fallbackCache.put(s, addresses);
|
||||
this.sharedPreferences.edit().putString(s.replace('.', '_'), toString(addresses)).apply();
|
||||
} catch (UnknownHostException e) {
|
||||
String key = this.sharedPreferences.getString(s.replace('.', '_'), "");
|
||||
if (key.isEmpty()) throw e;
|
||||
try {
|
||||
addresses = fromString(key);
|
||||
this.fallbackCache.put(s, addresses);
|
||||
} catch (UnknownHostException e2) {
|
||||
this.sharedPreferences.edit().remove(s.replace('.', '_')).apply();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses;
|
||||
} else {
|
||||
return this.parent.lookup(s);
|
||||
}
|
||||
}
|
||||
|
||||
void cleanDnsCache() {
|
||||
synchronized (this.fallbackCache) {
|
||||
this.fallbackCache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class JsonRequestBody extends RequestBody {
|
||||
private static final MediaType JSON_MEDIA_TYPE = MediaType.get("application/json");
|
||||
private static final JsonRequestBody EMPTY = new JsonRequestBody(new byte[0]);
|
||||
final byte[] data;
|
||||
|
||||
private JsonRequestBody(byte[] data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
static JsonRequestBody from(String data) {
|
||||
if (data == null || data.length() == 0) {
|
||||
return EMPTY;
|
||||
}
|
||||
return new JsonRequestBody(data.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public MediaType contentType() {
|
||||
return JSON_MEDIA_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
return this.data.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeTo(@NonNull BufferedSink bufferedSink) throws IOException {
|
||||
bufferedSink.write(this.data);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
package com.fox2code.mmm.utils.io.net;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class HttpException extends IOException {
|
||||
private final int errorCode;
|
||||
|
||||
HttpException(String text, int errorCode) {
|
||||
super(text);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public HttpException(int errorCode) {
|
||||
super("Received error code: " + errorCode);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public int getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public boolean shouldTimeout() {
|
||||
return switch (errorCode) {
|
||||
case 419, 429, 503 -> true;
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean shouldTimeout(Exception exception) {
|
||||
return exception instanceof HttpException &&
|
||||
((HttpException) exception).shouldTimeout();
|
||||
}
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
package com.fox2code.mmm.utils.io.net;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.CookieManager;
|
||||
import java.net.CookiePolicy;
|
||||
import java.net.CookieStore;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import okhttp3.Cookie;
|
||||
import okhttp3.CookieJar;
|
||||
import okhttp3.HttpUrl;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class WebkitCookieManagerProxy extends CookieManager implements CookieJar {
|
||||
private final android.webkit.CookieManager webkitCookieManager;
|
||||
|
||||
public WebkitCookieManagerProxy() {
|
||||
this(null, null);
|
||||
}
|
||||
|
||||
WebkitCookieManagerProxy(CookieStore ignoredStore, CookiePolicy cookiePolicy) {
|
||||
super(null, cookiePolicy);
|
||||
this.webkitCookieManager = android.webkit.CookieManager.getInstance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(URI uri, Map<String, List<String>> responseHeaders)
|
||||
throws IOException {
|
||||
// make sure our args are valid
|
||||
if ((uri == null) || (responseHeaders == null))
|
||||
return;
|
||||
|
||||
// save our url once
|
||||
String url = uri.toString();
|
||||
|
||||
// go over the headers
|
||||
for (String headerKey : responseHeaders.keySet()) {
|
||||
// ignore headers which aren't cookie related
|
||||
if ((headerKey == null)
|
||||
|| !(headerKey.equalsIgnoreCase("Set-Cookie2") || headerKey
|
||||
.equalsIgnoreCase("Set-Cookie")))
|
||||
continue;
|
||||
|
||||
// process each of the headers
|
||||
for (String headerValue : Objects.requireNonNull(responseHeaders.get(headerKey))) {
|
||||
webkitCookieManager.setCookie(url, headerValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> get(URI uri,
|
||||
Map<String, List<String>> requestHeaders) throws IOException {
|
||||
// make sure our args are valid
|
||||
if ((uri == null) || (requestHeaders == null))
|
||||
throw new IllegalArgumentException("Argument is null");
|
||||
|
||||
// save our url once
|
||||
String url = uri.toString();
|
||||
|
||||
// prepare our response
|
||||
Map<String, List<String>> res = new java.util.HashMap<>();
|
||||
|
||||
// get the cookie
|
||||
String cookie = webkitCookieManager.getCookie(url);
|
||||
|
||||
// return it
|
||||
if (cookie != null) {
|
||||
res.put("Cookie", List.of(cookie));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CookieStore getCookieStore() {
|
||||
// we don't want anyone to work with this cookie store directly
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveFromResponse(@NonNull HttpUrl url, List<Cookie> cookies) {
|
||||
HashMap<String, List<String>> generatedResponseHeaders = new HashMap<>();
|
||||
ArrayList<String> cookiesList = new ArrayList<>();
|
||||
for (Cookie c : cookies) {
|
||||
// toString correctly generates a normal cookie string
|
||||
cookiesList.add(c.toString());
|
||||
}
|
||||
|
||||
generatedResponseHeaders.put("Set-Cookie", cookiesList);
|
||||
try {
|
||||
put(url.uri(), generatedResponseHeaders);
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Error adding cookies through okhttp");
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public List<Cookie> loadForRequest(HttpUrl url) {
|
||||
ArrayList<Cookie> cookieArrayList = new ArrayList<>();
|
||||
try {
|
||||
Map<String, List<String>> cookieList = get(url.uri(), new HashMap<>());
|
||||
// Format here looks like: "Cookie":["cookie1=val1;cookie2=val2;"]
|
||||
for (List<String> ls : cookieList.values()) {
|
||||
for (String s : ls) {
|
||||
String[] cookies = s.split(";");
|
||||
for (String cookie : cookies) {
|
||||
Cookie c = Cookie.parse(url, cookie);
|
||||
cookieArrayList.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "error making cookie!");
|
||||
}
|
||||
return cookieArrayList;
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,313 @@
|
||||
package com.fox2code.mmm.utils.realm;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import io.realm.Realm;
|
||||
import io.realm.RealmObject;
|
||||
import io.realm.RealmResults;
|
||||
import io.realm.annotations.PrimaryKey;
|
||||
import io.realm.annotations.Required;
|
||||
import timber.log.Timber;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class ModuleListCache extends RealmObject {
|
||||
// for compatibility, only id is required
|
||||
@PrimaryKey
|
||||
@Required
|
||||
private String codename;
|
||||
private String name;
|
||||
private String version;
|
||||
private int versionCode;
|
||||
private String author;
|
||||
private String description;
|
||||
private int minApi;
|
||||
private int maxApi;
|
||||
private int minMagisk;
|
||||
private boolean needRamdisk;
|
||||
private String support;
|
||||
private String donate;
|
||||
private String config;
|
||||
private boolean changeBoot;
|
||||
private boolean mmtReborn;
|
||||
private String repoId;
|
||||
private boolean installed;
|
||||
private int installedVersionCode;
|
||||
private int lastUpdate;
|
||||
// androidacy specific, may be added by other repos
|
||||
private boolean safe;
|
||||
private int stats;
|
||||
|
||||
public ModuleListCache(String codename, String name, String version, int versionCode, String author, String description, int minApi, int maxApi, int minMagisk, boolean needRamdisk, String support, String donate, String config, boolean changeBoot, boolean mmtReborn, String repoId, boolean installed, int installedVersionCode, int lastUpdate, int stats) {
|
||||
this.codename = codename;
|
||||
this.name = name;
|
||||
this.version = version;
|
||||
this.versionCode = versionCode;
|
||||
this.author = author;
|
||||
this.description = description;
|
||||
this.minApi = minApi;
|
||||
this.maxApi = maxApi;
|
||||
this.minMagisk = minMagisk;
|
||||
this.needRamdisk = needRamdisk;
|
||||
this.support = support;
|
||||
this.donate = donate;
|
||||
this.config = config;
|
||||
this.changeBoot = changeBoot;
|
||||
this.mmtReborn = mmtReborn;
|
||||
this.repoId = repoId;
|
||||
this.installed = installed;
|
||||
this.installedVersionCode = installedVersionCode;
|
||||
this.lastUpdate = lastUpdate;
|
||||
this.safe = false;
|
||||
this.stats = stats;
|
||||
}
|
||||
|
||||
public ModuleListCache() {
|
||||
}
|
||||
|
||||
// get all modules from a repo as a json object
|
||||
public static JSONObject getRepoModulesAsJson(String repoId) {
|
||||
Realm realm = Realm.getDefaultInstance();
|
||||
RealmResults<ModuleListCache> modules = realm.where(ModuleListCache.class).equalTo("repoId", repoId).findAll();
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
for (ModuleListCache module : modules) {
|
||||
try {
|
||||
jsonObject.put(module.getCodename(), module.toJson());
|
||||
} catch (
|
||||
JSONException e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
}
|
||||
realm.close();
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
public String getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public void setAuthor(String author) {
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public int getVersionCode() {
|
||||
return versionCode;
|
||||
}
|
||||
|
||||
public void setVersionCode(int versionCode) {
|
||||
this.versionCode = versionCode;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getSupport() {
|
||||
return support;
|
||||
}
|
||||
|
||||
public void setSupport(String support) {
|
||||
this.support = support;
|
||||
}
|
||||
|
||||
public String getDonate() {
|
||||
return donate;
|
||||
}
|
||||
|
||||
public void setDonate(String donate) {
|
||||
this.donate = donate;
|
||||
}
|
||||
|
||||
public String getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
public void setConfig(String config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public boolean isChangeBoot() {
|
||||
return changeBoot;
|
||||
}
|
||||
|
||||
public void setChangeBoot(boolean changeBoot) {
|
||||
this.changeBoot = changeBoot;
|
||||
}
|
||||
|
||||
public boolean isMmtReborn() {
|
||||
return mmtReborn;
|
||||
}
|
||||
|
||||
public void setMmtReborn(boolean mmtReborn) {
|
||||
this.mmtReborn = mmtReborn;
|
||||
}
|
||||
|
||||
public String getRepoId() {
|
||||
return repoId;
|
||||
}
|
||||
|
||||
public void setRepoId(String repoId) {
|
||||
this.repoId = repoId;
|
||||
}
|
||||
|
||||
public boolean isInstalled() {
|
||||
return installed;
|
||||
}
|
||||
|
||||
public void setInstalled(boolean installed) {
|
||||
this.installed = installed;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public int getMinApi() {
|
||||
return minApi;
|
||||
}
|
||||
|
||||
public void setMinApi(int minApi) {
|
||||
this.minApi = minApi;
|
||||
}
|
||||
|
||||
public int getMaxApi() {
|
||||
return maxApi;
|
||||
}
|
||||
|
||||
public void setMaxApi(int maxApi) {
|
||||
this.maxApi = maxApi;
|
||||
}
|
||||
|
||||
public int getMinMagisk() {
|
||||
return minMagisk;
|
||||
}
|
||||
|
||||
public void setMinMagisk(int minMagisk) {
|
||||
this.minMagisk = minMagisk;
|
||||
}
|
||||
|
||||
public boolean isNeedRamdisk() {
|
||||
return needRamdisk;
|
||||
}
|
||||
|
||||
public void setNeedRamdisk(boolean needRamdisk) {
|
||||
this.needRamdisk = needRamdisk;
|
||||
}
|
||||
|
||||
public int getInstalledVersionCode() {
|
||||
return installedVersionCode;
|
||||
}
|
||||
|
||||
public void setInstalledVersionCode(int installedVersionCode) {
|
||||
this.installedVersionCode = installedVersionCode;
|
||||
}
|
||||
|
||||
public String getCodename() {
|
||||
return codename;
|
||||
}
|
||||
|
||||
public void setCodename(String codename) {
|
||||
this.codename = codename;
|
||||
}
|
||||
|
||||
public int getLastUpdate() {
|
||||
return lastUpdate;
|
||||
}
|
||||
|
||||
public void setLastUpdate(int lastUpdate) {
|
||||
this.lastUpdate = lastUpdate;
|
||||
}
|
||||
|
||||
public boolean isSafe() {
|
||||
return safe;
|
||||
}
|
||||
|
||||
public void setSafe(boolean safe) {
|
||||
this.safe = safe;
|
||||
}
|
||||
|
||||
public int getStats() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
public void setStats(int stats) {
|
||||
this.stats = stats;
|
||||
}
|
||||
|
||||
private JSONObject toJson() {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
try {
|
||||
jsonObject.put("name", name);
|
||||
jsonObject.put("version", version);
|
||||
jsonObject.put("versionCode", versionCode);
|
||||
jsonObject.put("author", author);
|
||||
jsonObject.put("description", description);
|
||||
jsonObject.put("minApi", minApi);
|
||||
jsonObject.put("maxApi", maxApi);
|
||||
jsonObject.put("minMagisk", minMagisk);
|
||||
jsonObject.put("needRamdisk", needRamdisk);
|
||||
jsonObject.put("support", support);
|
||||
jsonObject.put("donate", donate);
|
||||
jsonObject.put("config", config);
|
||||
jsonObject.put("changeBoot", changeBoot);
|
||||
jsonObject.put("mmtReborn", mmtReborn);
|
||||
jsonObject.put("repoId", repoId);
|
||||
jsonObject.put("installed", installed);
|
||||
jsonObject.put("installedVersionCode", installedVersionCode);
|
||||
jsonObject.put("lastUpdate", lastUpdate);
|
||||
jsonObject.put("safe", safe);
|
||||
jsonObject.put("stats", stats);
|
||||
} catch (JSONException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
public RealmResults<ModuleListCache> getModules() {
|
||||
// return all modules matching the repo id
|
||||
Realm realm = Realm.getDefaultInstance();
|
||||
RealmResults<ModuleListCache> modules = realm.where(ModuleListCache.class).equalTo("repoId", repoId).findAll();
|
||||
realm.close();
|
||||
return modules;
|
||||
}
|
||||
|
||||
// same as above but returns a json object
|
||||
public JSONObject getModulesAsJson(String repoId) {
|
||||
Realm realm = Realm.getDefaultInstance();
|
||||
RealmResults<ModuleListCache> modules = realm.where(ModuleListCache.class).equalTo("repoId", repoId).findAll();
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
// everything goes under top level "modules" key
|
||||
try {
|
||||
jsonObject.put("modules", new JSONArray());
|
||||
} catch (JSONException ignored) {
|
||||
// we should never get here
|
||||
}
|
||||
for (ModuleListCache module : modules) {
|
||||
try {
|
||||
jsonObject.getJSONArray("modules").put(module.toJson());
|
||||
} catch (
|
||||
JSONException e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
}
|
||||
realm.close();
|
||||
return jsonObject;
|
||||
}
|
||||
}
|
@ -0,0 +1,118 @@
|
||||
package com.fox2code.mmm.utils.realm;
|
||||
|
||||
import io.realm.Realm;
|
||||
import io.realm.RealmObject;
|
||||
import io.realm.annotations.PrimaryKey;
|
||||
import io.realm.annotations.Required;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class ReposList extends RealmObject {
|
||||
// Each repo is identified by its id, has a url field, and an enabled field
|
||||
// there's also an optional donate and support field
|
||||
|
||||
@Required
|
||||
@PrimaryKey
|
||||
private String id;
|
||||
@Required
|
||||
private String url;
|
||||
private boolean enabled;
|
||||
private String donate;
|
||||
private String support;
|
||||
private String submitModule;
|
||||
private int lastUpdate;
|
||||
private String website;
|
||||
private String name;
|
||||
|
||||
public ReposList(String id, String url, boolean enabled, String donate, String support) {
|
||||
this.id = id;
|
||||
this.url = url;
|
||||
this.enabled = enabled;
|
||||
this.donate = donate;
|
||||
this.support = support;
|
||||
this.submitModule = null;
|
||||
this.lastUpdate = 0;
|
||||
}
|
||||
|
||||
public ReposList() {
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public String getDonate() {
|
||||
return donate;
|
||||
}
|
||||
|
||||
public void setDonate(String donate) {
|
||||
this.donate = donate;
|
||||
}
|
||||
|
||||
public String getSupport() {
|
||||
return support;
|
||||
}
|
||||
|
||||
public void setSupport(String support) {
|
||||
this.support = support;
|
||||
}
|
||||
|
||||
// get metadata for a repo
|
||||
public static ReposList getRepo(String id) {
|
||||
Realm realm = Realm.getDefaultInstance();
|
||||
ReposList repo = realm.where(ReposList.class).equalTo("id", id).findFirst();
|
||||
realm.close();
|
||||
return repo;
|
||||
}
|
||||
|
||||
public String getSubmitModule() {
|
||||
return submitModule;
|
||||
}
|
||||
|
||||
public void setSubmitModule(String submitModule) {
|
||||
this.submitModule = submitModule;
|
||||
}
|
||||
|
||||
public int getLastUpdate() {
|
||||
return lastUpdate;
|
||||
}
|
||||
|
||||
public void setLastUpdate(int lastUpdate) {
|
||||
this.lastUpdate = lastUpdate;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getWebsite() {
|
||||
return website;
|
||||
}
|
||||
|
||||
public void setWebsite(String website) {
|
||||
this.website = website;
|
||||
}
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
package com.fox2code.mmm.utils.sentry;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.net.Uri;
|
||||
|
||||
import com.fox2code.mmm.CrashHandler;
|
||||
import com.fox2code.mmm.MainApplication;
|
||||
import com.fox2code.mmm.androidacy.AndroidacyUtil;
|
||||
|
||||
import org.matomo.sdk.extra.TrackHelper;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
import io.sentry.Sentry;
|
||||
import io.sentry.android.core.SentryAndroid;
|
||||
import io.sentry.android.fragment.FragmentLifecycleIntegration;
|
||||
import io.sentry.android.timber.SentryTimberIntegration;
|
||||
import timber.log.Timber;
|
||||
|
||||
public class SentryMain {
|
||||
public static final boolean IS_SENTRY_INSTALLED = true;
|
||||
public static boolean isCrashing = false;
|
||||
private static boolean sentryEnabled = false;
|
||||
|
||||
/**
|
||||
* Initialize Sentry
|
||||
* Sentry is used for crash reporting and performance monitoring.
|
||||
*/
|
||||
@SuppressLint({"RestrictedApi", "UnspecifiedImmutableFlag"})
|
||||
public static void initialize(final MainApplication mainApplication) {
|
||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
||||
isCrashing = true;
|
||||
TrackHelper.track().exception(throwable).with(MainApplication.getINSTANCE().getTracker());
|
||||
SharedPreferences.Editor editor = MainApplication.getINSTANCE().getSharedPreferences("sentry", Context.MODE_PRIVATE).edit();
|
||||
editor.putString("lastExitReason", "crash");
|
||||
editor.putLong("lastExitTime", System.currentTimeMillis());
|
||||
editor.putString("lastExitReason", "crash");
|
||||
editor.putString("lastExitId", String.valueOf(Sentry.getLastEventId()));
|
||||
editor.apply();
|
||||
Timber.e("Uncaught exception with sentry ID %s and stacktrace %s", Sentry.getLastEventId(), throwable.getStackTrace());
|
||||
// open crash handler and exit
|
||||
Intent intent = new Intent(mainApplication, CrashHandler.class);
|
||||
// pass the entire exception to the crash handler
|
||||
intent.putExtra("exception", throwable);
|
||||
// add stacktrace as string
|
||||
intent.putExtra("stacktrace", throwable.getStackTrace());
|
||||
// put lastEventId in intent (get from preferences)
|
||||
intent.putExtra("lastEventId", String.valueOf(Sentry.getLastEventId()));
|
||||
// serialize Sentry.captureException and pass it to the crash handler
|
||||
intent.putExtra("sentryException", throwable);
|
||||
// pass crashReportingEnabled to crash handler
|
||||
intent.putExtra("crashReportingEnabled", isSentryEnabled());
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||
Timber.e("Starting crash handler");
|
||||
mainApplication.startActivity(intent);
|
||||
Timber.e("Exiting");
|
||||
android.os.Process.killProcess(android.os.Process.myPid());
|
||||
});
|
||||
// If first_launch pref is not false, refuse to initialize Sentry
|
||||
SharedPreferences sharedPreferences = MainApplication.getSharedPreferences("mmm");
|
||||
if (!Objects.equals(sharedPreferences.getString("last_shown_setup", null), "v2")) {
|
||||
return;
|
||||
}
|
||||
sentryEnabled = sharedPreferences.getBoolean("pref_crash_reporting_enabled", false);
|
||||
// set sentryEnabled on preference change of pref_crash_reporting_enabled
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener((sharedPreferences1, s) -> {
|
||||
if (s.equals("pref_crash_reporting_enabled")) {
|
||||
sentryEnabled = sharedPreferences1.getBoolean(s, false);
|
||||
}
|
||||
});
|
||||
SentryAndroid.init(mainApplication, options -> {
|
||||
// If crash reporting is disabled, stop here.
|
||||
if (!MainApplication.isCrashReportingEnabled()) {
|
||||
sentryEnabled = false; // Set sentry state to disabled
|
||||
options.setDsn("");
|
||||
} else {
|
||||
// get pref_crash_reporting_pii pref
|
||||
boolean crashReportingPii = sharedPreferences.getBoolean("crashReportingPii", false);
|
||||
sentryEnabled = true; // Set sentry state to enabled
|
||||
options.addIntegration(new FragmentLifecycleIntegration(mainApplication, true, true));
|
||||
// Enable automatic activity lifecycle breadcrumbs
|
||||
options.setEnableActivityLifecycleBreadcrumbs(true);
|
||||
// Enable automatic fragment lifecycle breadcrumbs
|
||||
options.addIntegration(new SentryTimberIntegration());
|
||||
options.setCollectAdditionalContext(true);
|
||||
options.setAttachThreads(true);
|
||||
options.setAttachStacktrace(true);
|
||||
options.setEnableNdk(true);
|
||||
options.addInAppInclude("com.fox2code.mmm");
|
||||
options.addInAppInclude("com.fox2code.mmm.debug");
|
||||
options.addInAppInclude("com.fox2code.mmm.fdroid");
|
||||
options.addInAppExclude("com.fox2code.mmm.utils.sentry.SentryMain");
|
||||
// Respect user preference for sending PII. default is true on non fdroid builds, false on fdroid builds
|
||||
options.setSendDefaultPii(crashReportingPii);
|
||||
options.enableAllAutoBreadcrumbs(true);
|
||||
// in-app screenshots are only sent if the app crashes, and it only shows the last activity. so no, we won't see your, ahem, "private" stuff
|
||||
options.setAttachScreenshot(true);
|
||||
// It just tell if sentry should ping the sentry dsn to tell the app is running. Useful for performance and profiling.
|
||||
options.setEnableAutoSessionTracking(true);
|
||||
// disable crash tracking - we handle that ourselves
|
||||
options.setEnableUncaughtExceptionHandler(false);
|
||||
// Add a callback that will be used before the event is sent to Sentry.
|
||||
// With this callback, you can modify the event or, when returning null, also discard the event.
|
||||
options.setBeforeSend((event, hint) -> {
|
||||
// in the rare event that crash reporting has been disabled since we started the app, we don't want to send the crash report
|
||||
if (!sentryEnabled) {
|
||||
return null;
|
||||
}
|
||||
if (isCrashing) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
});
|
||||
// Filter breadcrumb content from crash report.
|
||||
options.setBeforeBreadcrumb((breadcrumb, hint) -> {
|
||||
String url = (String) breadcrumb.getData("url");
|
||||
if (url == null || url.isEmpty()) return breadcrumb;
|
||||
if ("cloudflare-dns.com".equals(Uri.parse(url).getHost())) return null;
|
||||
if (AndroidacyUtil.isAndroidacyLink(url)) {
|
||||
breadcrumb.setData("url", AndroidacyUtil.hideToken(url));
|
||||
}
|
||||
return breadcrumb;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void addSentryBreadcrumb(SentryBreadcrumb sentryBreadcrumb) {
|
||||
if (MainApplication.isCrashReportingEnabled()) {
|
||||
Sentry.addBreadcrumb(sentryBreadcrumb.breadcrumb);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public static boolean isSentryEnabled() {
|
||||
return sentryEnabled;
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
<vector android:autoMirrored="true" android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal" android:viewportHeight="24"
|
||||
android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM4,12c0,-4.4 3.6,-8 8,-8 1.9,0 3.6,0.6 4.9,1.7L5.7,16.9C4.6,15.6 4,13.9 4,12zM12,20c-1.9,0 -3.6,-0.6 -4.9,-1.7L18.3,7.1C19.4,8.5 20,10.2 20,12c0,4.4 -3.6,8 -8,8z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19,6.4L17.6,5 12,10.6 6.4,5 5,6.4 10.6,12 5,17.6 6.4,19 12,13.4 17.6,19 19,17.6 13.4,12z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19.4,10C18.7,6.6 15.6,4 12,4 9.1,4 6.6,5.6 5.4,8 2.3,8.4 0,10.9 0,14c0,3.3 2.7,6 6,6h13c2.8,0 5,-2.2 5,-5 0,-2.6 -2.1,-4.8 -4.7,-5zM17,13l-5,5 -5,-5h3V9h4v4h3z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,2H9C7.9,2 7,2.9 7,4v12c0,1.1 0.9,2 2,2h9c1.1,0 2,-0.9 2,-2V4C20,2.9 19.1,2 18,2zM18,16H9V4h9V16zM3,15v-2h2v2H3zM3,9.5h2v2H3V9.5zM10,20h2v2h-2V20zM3,18.5v-2h2v2H3zM5,22c-1.1,0 -2,-0.9 -2,-2h2V22zM8.5,22h-2v-2h2V22zM13.5,22L13.5,22l0,-2h2v0C15.5,21.1 14.6,22 13.5,22zM5,6L5,6l0,2H3v0C3,6.9 3.9,6 5,6z"/>
|
||||
</vector>
|
@ -0,0 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white" android:pathData="M12,21.4l-1.5,-1.3C5.4,15.4 2,12.3 2,8.5 2,5.4 4.4,3 7.5,3c1.7,0 3.4,0.8 4.5,2.1C13.1,3.8 14.8,3 16.5,3 19.6,3 22,5.4 22,8.5c0,3.8 -3.4,6.9 -8.6,11.5L12,21.4z" />
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM12.5,14L9,10.5l1.4,-1.4 2.1,2.1L17.6,6 19,7.4 12.5,14zM4,6L2,6v14c0,1.1 0.9,2 2,2h14v-2L4,20L4,6z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.5,2 2,6.5 2,12s4.5,10 10,10 10,-4.5 10,-10S17.5,2 12,2zM13.4,18.1L13.4,20h-2.7v-1.9c-1.7,-0.4 -3.2,-1.5 -3.3,-3.4h2c0.1,1.1 0.8,1.9 2.7,1.9 2,0 2.4,-1 2.4,-1.6 0,-0.8 -0.4,-1.6 -2.7,-2.1 -2.5,-0.6 -4.2,-1.6 -4.2,-3.7 0,-1.7 1.4,-2.8 3.1,-3.2L10.7,4h2.7v2c1.9,0.5 2.8,1.9 2.9,3.4L14.3,9.3c-0.1,-1.1 -0.6,-1.9 -2.2,-1.9 -1.5,0 -2.4,0.7 -2.4,1.6 0,0.8 0.7,1.4 2.7,1.9s4.2,1.4 4.2,3.9c0,1.8 -1.4,2.8 -3.1,3.2z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M24,9C20.9,5.9 16.7,4 12,4C7.3,4 3.1,5.9 0,9L12,21v0l0,0L24,9zM2.9,9.1C5.5,7.1 8.7,6 12,6s6.5,1.1 9.1,3.1l-1.4,1.4C17.5,8.9 14.9,8 12,8s-5.5,0.9 -7.7,2.5L2.9,9.1z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,16v-5c0,-3.1 -1.6,-5.6 -4.5,-6.3L13.5,4c0,-0.8 -0.7,-1.5 -1.5,-1.5s-1.5,0.7 -1.5,1.5v0.7C7.6,5.4 6,7.9 6,11v5l-2,2v1h16v-1l-2,-2zM13,16h-2v-2h2v2zM13,12h-2L11,8h2v4zM12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M17,3L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.7,0 -3,-1.3 -3,-3s1.3,-3 3,-3 3,1.3 3,3 -1.3,3 -3,3zM15,9L5,9L5,5h10v4z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M15.5,14h-0.8l-0.3,-0.3C15.4,12.6 16,11.1 16,9.5 16,5.9 13.1,3 9.5,3S3,5.9 3,9.5 5.9,16 9.5,16c1.6,0 3.1,-0.6 4.2,-1.6l0.3,0.3v0.8l5,5L20.5,19l-5,-5zM9.5,14C7,14 5,12 5,9.5S7,5 9.5,5 14,7 14,9.5 12,14 9.5,14z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M17,1L7,1c-1.1,0 -2,0.9 -2,2v18c0,1.1 0.9,2 2,2h10c1.1,0 2,-0.9 2,-2L19,3c0,-1.1 -0.9,-2 -2,-2zM17,19L7,19L7,5h10v14zM16,13h-3L13,8h-2v5L8,13l4,4 4,-4z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M16,18v2H8v-2H16zM11,8V16h2V8h3L12,4L8,8H11z"/>
|
||||
</vector>
|
@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp"
|
||||
android:height="24dp" android:autoMirrored="true"
|
||||
android:tint="?attr/colorControlNormal" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12,1L3,5v6c0,5.6 3.8,10.7 9,12 5.2,-1.3 9,-6.5 9,-12L21,5l-9,-4zM10,17l-4,-4 1.4,-1.4L10,14.2l6.6,-6.6L18,9l-8,8z"/>
|
||||
</vector>
|
@ -1,5 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="?attr/colorControlNormal"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M5,14.5h14v-6L5,8.5v6zM11,0.55L11,3.5h2L13,0.55h-2zM19.04,3.05l-1.79,1.79 1.41,1.41 1.8,-1.79 -1.42,-1.41zM13,22.45L13,19.5h-2v2.95h2zM20.45,18.54l-1.8,-1.79 -1.41,1.41 1.79,1.8 1.42,-1.42zM3.55,4.46l1.79,1.79 1.41,-1.41 -1.79,-1.79 -1.41,1.41zM4.96,19.95l1.79,-1.8 -1.41,-1.41 -1.79,1.79 1.41,1.42z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M5,14.5h14v-6L5,8.5v6zM11,0.6L11,3.5h2L13,0.6h-2zM19,3.1l-1.8,1.8 1.4,1.4 1.8,-1.8 -1.4,-1.4zM13,22.5L13,19.5h-2v3h2zM20.5,18.5l-1.8,-1.8 -1.4,1.4 1.8,1.8 1.4,-1.4zM3.6,4.5l1.8,1.8 1.4,-1.4 -1.8,-1.8 -1.4,1.4zM5,20l1.8,-1.8 -1.4,-1.4 -1.8,1.8 1.4,1.4z"/>
|
||||
</vector>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue