Compare commits

..
360 Commits
Author SHA1 Message Date
Kr328 260ebc83ff fix access control & remove context support in fetch.go 2020-04-14 23:44:41 +08:00
Kr328 640cf3e75c add scroll view to support activity 2020-04-14 20:25:13 +08:00
Kr328 7466990e0c update version 1.1.11 -> 1.2.0 2020-04-14 20:07:07 +08:00
Kr328 b41715686c cleanup code & rename util -> utils 2020-04-14 19:50:56 +08:00
Kr328 bca2a7a520 decrease waiting time 2020-04-14 19:47:32 +08:00
Kr328 545c43fe2c fix external profile 2020-04-14 19:47:16 +08:00
Kr328 eaf16318de fix external intent lost & replace application observer 2020-04-14 19:17:24 +08:00
Kr328 a809f94a02 add profile notification intent & detect is modified 2020-04-14 18:47:43 +08:00
Kr328 541862cda8 change routing system traffic string 2020-04-14 14:28:54 +08:00
Kr328 7ab2df9afe fix profile reload detect 2020-04-14 14:25:18 +08:00
Kr328 c45c4a1ad8 fix profile edit 2020-04-14 14:18:41 +08:00
Kr328 2b71f9a9f7 remove channels on migration 2020-04-14 13:57:28 +08:00
Kr328 3db2e1c618 fix profile edit & cleanup code 2020-04-14 12:25:20 +08:00
Kr328 d24617d842 add clone profile detect 2020-04-14 11:03:30 +08:00
Kr328 29dc8dc492 fix as Entity.Profile 2020-04-14 10:48:29 +08:00
Kr328 57b8ce3d7f rename ProfileMetadata.kt -> Profile.kt 2020-04-14 10:47:14 +08:00
Kr328 1536c9f056 fix auto update 2020-04-14 10:45:55 +08:00
Kr328 c2280555d3 fix dns hijacking 2020-04-14 02:17:07 +08:00
Kr328 532760e71a add loading notification 2020-04-14 02:16:58 +08:00
Kr328 6da7eff62c fix reload logic 2020-04-14 02:07:19 +08:00
Kr328 a75192b6bf improve code by Inspect Code 2020-04-14 01:23:15 +08:00
Kr328 f4ceebb12c rename ipc -> transact 2020-04-14 01:03:29 +08:00
Kr328 151327e9ba add waiting status for profile service 2020-04-14 00:58:07 +08:00
Kr328 a343678bad fix auto restart 2020-04-14 00:55:24 +08:00
Kr328 1b2c97d381 bug fix 2020-04-13 23:48:40 +08:00
Kr328 d034eb6f4b remove unused resources 2020-04-13 16:35:09 +08:00
Kr328 08dc165450 rename makeSnackbar & remove modified detect 2020-04-13 16:28:59 +08:00
Kr328 50eef54ab6 update support activity 2020-04-13 16:28:13 +08:00
Kr328 a75bdd457f profile ui refactored 2020-04-13 14:59:24 +08:00
Kr328 d5d219789f add clone support 2020-04-13 12:15:37 +08:00
Kr328 04eed1a768 broadcast with permission 2020-04-13 12:00:48 +08:00
Kr328 87d17bcb9a remove providers only on new profile 2020-04-13 11:58:01 +08:00
Kr328 2d3bd5c5cf add temp acquire 2020-04-13 11:45:05 +08:00
Kr328 12d76a2ad1 cleanup code 2020-04-13 11:13:04 +08:00
Kr328 a0026b1bb3 add timeout to profile processor 2020-04-13 11:12:45 +08:00
Kr328 761f11fa5f move utils to common 2020-04-13 11:12:28 +08:00
Kr328 a7b28692ea remove sync map 2020-04-13 10:48:05 +08:00
Kr328 daac6e1e5c service refactored 2020-04-13 10:46:02 +08:00
Kr328 8c9b7e4a01 decrease mtu 2020-04-11 19:49:20 +08:00
Kr328 1b0778141c update README.md 2020-04-10 14:35:27 +08:00
Kr328 96ed63b704 update README.md 2020-04-10 14:34:32 +08:00
Kr328 bb07bc639d update README.md 2020-04-10 13:49:25 +08:00
Kr328 c3eb4b2f51 update clash core 2020-04-10 13:45:51 +08:00
Kr328 9deeb37f21 add auto signing with properties 2020-04-10 13:45:16 +08:00
Kr328 0befdc0d34 clean up code 2020-04-09 23:19:59 +08:00
Kr328 606a21dff0 add documents provider 2020-04-09 14:39:57 +08:00
Kr328 ef6a37fb7c remove default network detect logs 2020-04-08 16:23:37 +08:00
Kr328 2f7b8c4b59 fix default network detect 2020-04-08 16:21:22 +08:00
Kr328 968e82b072 move load.go init to patch.go 2020-04-08 16:14:09 +08:00
Kr328 0240eea776 android: add fake pool host override 2020-04-08 11:13:40 +08:00
Kr328 dc8d94fa31 remove ipv6 for VpnService 2020-04-08 11:11:38 +08:00
Kr328 f6ec8cc882 update tun2socket & remove ipv6 for VpnService 2020-04-08 11:10:13 +08:00
Kr328 b648d21068 use ServiceStatusProvider instead of requery database 2020-04-08 02:05:05 +08:00
Kr328 2b63997b22 cleanup code 2020-04-08 01:58:14 +08:00
Kr328 198daaf720 add restore selected proxy 2020-04-08 01:57:50 +08:00
Kr328 a1847dc6f2 update core & broadcast on loaded & stop on load failure 2020-04-08 01:42:21 +08:00
Kr328 8ebb3a5f31 fix dns hijack not working 2020-04-08 01:00:06 +08:00
Kr328 7cf3dc2bf2 clean up code 2020-04-08 00:44:28 +08:00
Kr328 2346095323 increase mtu 2020-04-08 00:43:47 +08:00
Kr328 40ae2d456c rename module component -> common 2020-04-08 00:42:41 +08:00
Kr328 3152bcaaa6 refactor clash core context 2020-04-08 00:42:22 +08:00
Kr328 c69c41c57f refactor clash core context 2020-04-03 01:08:04 +08:00
Kr328 ea1f424b33 refactor clash core context 2020-04-02 00:44:26 +08:00
Kr328 1ef828497c remove gc period 2020-04-01 14:45:53 +08:00
Kr328 88b28faa48 add component module & clean up code 2020-04-01 14:45:06 +08:00
Kr328 7bfaacf936 call gc period to decrease ram usage 2020-04-01 01:12:50 +08:00
Kr328 05d8dae4e0 fix notification not show profile 2020-04-01 00:52:36 +08:00
Kr328 27cd92b4b1 recycle dns udp packet 2020-03-31 23:21:02 +08:00
Kr328 4b6b5b5d95 update core 2020-03-30 16:51:39 +08:00
Kr328 6468575a42 fix fake ip filter not working 2020-03-30 16:38:33 +08:00
Kr328 5ea22cecad update tun2socket & fix crash on tcp dns query 2020-03-30 13:09:36 +08:00
Kr328 c8205d3f95 update tun2socket 2020-03-29 20:14:45 +08:00
Kr328 639377760c update tun2socket 2020-03-29 15:25:39 +08:00
Kr328 a9323f0528 update tun2socket & core & mtu 2020-03-28 23:41:52 +08:00
Kr328 2e31e90225 update core 2020-03-28 14:49:11 +08:00
Kr328 9d5b8188eb fix patch bug 2020-03-28 14:39:28 +08:00
Kr328 1f9e330f6a fix patch bug 2020-03-28 13:56:54 +08:00
Kr328 a69776fe33 update clash core 2020-03-28 12:31:48 +08:00
Kr328 d42dc46b8c use tun2socket instead of netstack 2020-03-28 12:30:26 +08:00
Kr328 c37dc2f874 fix delete geoip database on rebuild 2020-03-28 12:29:28 +08:00
Kr328 9c20f13f95 update dependencies 2020-03-27 23:59:33 +08:00
Kr328 88aec66ef8 Merge branch 'master' into dev 2020-03-21 16:13:55 +08:00
Kr328 f500596621 update README.md 2020-03-21 16:11:03 +08:00
Kr328 df5bafd0bb update README.md 2020-03-21 16:01:35 +08:00
Kr328 01fe9deb20 update issue template 2020-03-20 13:16:43 +08:00
Kr328 4e44298e98 sorted github issue 2020-03-19 19:46:12 +08:00
Kr328 a7c3a05c23 add application info request 2020-03-19 19:09:20 +08:00
Kr328 05fa36497b rename issues template file 2020-03-19 18:56:41 +08:00
Kr328 e6f8ae265e Add issue templates 2020-03-19 18:53:05 +08:00
Kr328 d107847747 remove unused General.Mode serializer 2020-03-19 15:21:20 +08:00
Kr328 ea8e270df6 add auto activity name detect 2020-03-19 15:16:50 +08:00
Kr328 4bc40e0663 update dependencies 2020-03-19 15:16:39 +08:00
Kr328 5e6ccd990a update dependencies 2020-03-19 12:29:34 +08:00
Kr328 1a8d673742 update clash core 2020-03-03 22:48:54 +08:00
Kr328 a4069aa6f6 remove unused var 2020-03-03 22:01:07 +08:00
Kr328 3cfb90c078 use kotlin build script 2020-03-03 21:50:30 +08:00
Kr328 594949d3f0 format code & update core 2020-02-28 23:52:58 +08:00
Kr328 8fd34b5258 improve apk broken detect & fix udp crash 2020-02-28 14:00:38 +08:00
Kr328 1161482a5b fix udp crash 2020-02-28 11:19:06 +08:00
Kr328 8a123e7a0d improve network settings 2020-02-28 01:13:11 +08:00
Kr328 4ef3a20929 add vpn component detect 2020-02-28 00:32:49 +08:00
Kr328 16c2e9b694 increase refresh interval 2020-02-28 00:24:24 +08:00
Kr328 c9a9d310ef update version 2020-02-28 00:22:21 +08:00
Kr328 84996a5652 update apk broken detect 2020-02-27 16:30:57 +08:00
Kr328 115afc5735 update apk broken detect 2020-02-27 16:30:34 +08:00
Kr328 2ae75e876d fix uri case 2020-02-27 16:03:28 +08:00
Kr328 126291d544 improve duplicate profile 2020-02-27 14:26:47 +08:00
Kr328 37db81f6b1 improve profile update & update clash core 2020-02-27 14:05:00 +08:00
Kr328 a3cfd10d39 fix udp crash 2020-02-26 16:31:59 +08:00
Kr328 0e61f02046 fix typo 2020-02-26 15:20:22 +08:00
Kr328 d8b1c0b7b8 remove debug symbol of clash core 2020-02-26 14:58:29 +08:00
Kr328 8bf908b731 remove default disallow package 2020-02-26 14:53:30 +08:00
Kr328 568f1f12e7 fix crash on ipv6 enable & reduce provider call 2020-02-26 14:06:46 +08:00
Kr328 ca739a5e2f add abi split 2020-02-26 13:53:42 +08:00
Kr328 7833f747d5 format & cleanup 2020-02-26 13:33:44 +08:00
Kr328 977519b383 change crash reporter
fix crash on log polling
fix crash on udp write back
fix goroutine leak
fix default rule inject
2020-02-26 13:32:29 +08:00
Kr328 b39b3812d0 add crash reporter 2020-02-25 20:53:39 +08:00
Kr328 f7dddb41fa add crash reporter 2020-02-25 20:53:26 +08:00
Kr328 053300919c add crash reporter 2020-02-25 20:38:55 +08:00
Kr328 93dff86383 clear dumper 2020-02-25 14:42:06 +08:00
Kr328 1b7e9c6b12 add golang crash dumper 2020-02-25 14:41:53 +08:00
Kr328 f072cd0839 update dependencies 2020-02-25 12:43:58 +08:00
Kr328 f626a0fc5c fix ipv6 support
https://github.com/shadowsocks/shadowsocks-android/commit/cc840c9fddb3f4f6677005de18f1fcb387b84064#diff-e089fe63dcb3674c0a1e459a95508e3e
2020-02-24 20:33:29 +08:00
Kr328 63da12f046 1.1.4 2020-02-23 23:50:41 +08:00
Kr328 ce1beafb08 fix proxies active cache 2020-02-23 23:22:32 +08:00
Kr328 f6ecc5c953 remove reduce recreate call 2020-02-23 22:55:10 +08:00
Kr328 de87e3fd74 add crash log 2020-02-23 22:54:13 +08:00
Kr328 e34a7a3081 update version 2020-02-22 23:42:28 +08:00
Kr328 ecb1678e48 fix not start vpn after request 2020-02-22 23:41:34 +08:00
Kr328 ca395033a5 update clash core 2020-02-22 23:27:42 +08:00
Kr328 8095451e14 remove testing code 2020-02-22 23:27:00 +08:00
Kr328 c0435ead01 fix tun leak 2020-02-22 23:23:50 +08:00
Kr328 d346344e17 simple ui adjust 2020-02-22 23:23:22 +08:00
Kr328 68d6af4255 fix notification cancel delay 2020-02-22 22:24:45 +08:00
Kr328 253d465d32 Merge pull request #135 from tsln1998/fix_windows_build
fix source path on windows
2020-02-22 21:57:16 +08:00
Kr328 7ab7c8abee fix receiver leak 2020-02-22 21:43:28 +08:00
Kr328 ea3aee0883 fix proxy only not load profile default 2020-02-22 21:19:33 +08:00
Kr328 b4a5d7b1be Use single service to handle clash 2020-02-22 21:17:29 +08:00
tsln1998 028346713e fix source path on windows 2020-02-22 18:15:40 +08:00
Kr328 d519bc1a23 update README.md 2020-02-22 01:48:38 +08:00
Kr328 a6d5fbc2ca update PRIVACY_POLICY.md 2020-02-22 01:46:45 +08:00
Kr328 e9a6bc2fbc add delay for proxies 2020-02-22 01:13:15 +08:00
Kr328 ef42612c4b change launchMode & fix tile icon size 2020-02-22 00:51:04 +08:00
Kr328 f4b421c953 1.1.1 2020-02-22 00:35:25 +08:00
Kr328 14aa981ca4 update base version 2020-02-20 23:33:50 +08:00
Kr328 515942274d bug fix 2020-02-20 23:33:30 +08:00
Kr328 03785f427a fix start on boot reset alarm 2020-02-20 15:14:07 +08:00
Kr328 5cc1f43a03 add option margin end 2020-02-20 15:11:56 +08:00
Kr328 a557255099 fix access control mode & packages 2020-02-20 15:02:47 +08:00
Kr328 932778b153 add request ua & add tile long press action 2020-02-20 14:44:05 +08:00
Kr328 1e8cadb878 add app broken detect 2020-02-20 14:27:37 +08:00
Kr328 eba2869c1b bug fix & cleanup code 2020-02-20 13:43:38 +08:00
Kr328 121108c416 bug fix & add tile 2020-02-20 03:13:56 +08:00
Kr328 780dfda610 add README.md 2020-02-20 01:43:54 +08:00
Kr328 3dd3431401 add settings migration 2020-02-20 01:40:52 +08:00
Kr328 ba8a6247f5 add tips for profiles 2020-02-20 01:21:59 +08:00
Kr328 5f1ee513f2 fix 1 -> 2 migration 2020-02-20 00:54:16 +08:00
Kr328 17fd5954a0 add support click action 2020-02-19 22:57:10 +08:00
Kr328 87e4061879 add about 2020-02-19 22:52:46 +08:00
Kr328 5c1ce96239 fix auto update 2020-02-19 22:41:42 +08:00
Kr328 ef058f223d update build scripts 2020-02-19 15:47:26 +08:00
Kr328 ab03e551da add package list 2020-02-19 15:28:32 +08:00
Kr328 168a097111 add zh-cn 2020-02-19 13:57:59 +08:00
Kr328 232572114c Merge remote-tracking branch 'private/dev' into dev 2020-02-19 02:56:55 +08:00
Kr328 54c67f83ab add zh-cn 2020-02-19 02:56:33 +08:00
Kr328 e63f210cc8 add zh-cn 2020-02-19 02:56:32 +08:00
Kr328 48169224c0 add settings 2020-02-19 02:46:38 +08:00
Kr328 f2d99a50a7 add settings 2020-02-19 02:21:48 +08:00
Kr328 bfa3c8375b new method to detect default network 2020-02-18 16:50:36 +08:00
Kr328 34697da10c new method to detect default network 2020-02-18 00:30:59 +08:00
Kr328 3e7d7f5d60 add dns patch 2020-02-18 15:16:43 +08:00
Kr328 8a4149c800 fix broadcast 2020-02-18 01:17:05 +08:00
Kr328 0d7650cbc0 add logs activity 2020-02-18 00:53:18 +08:00
Kr328 72932a255d add basic multiple language support 2020-02-17 01:54:12 +08:00
Kr328 4848a0f6d9 fix auto proxy scroll 2020-02-17 00:43:31 +08:00
Kr328 a5064fdd4f proxy activity refactored 2020-02-17 00:16:41 +08:00
Kr328 5d4b6b4fb5 add prefix merger 2020-02-16 15:54:22 +08:00
Kr328 d5a0299d91 [WIP] UI refactor 2020-02-16 02:04:54 +08:00
Kr328 718388db51 [WIP] UI refactor 2020-02-15 14:42:09 +08:00
Kr328 01d9d231b5 [WIP] UI refactor 2020-02-15 01:03:23 +08:00
Kr328 88e0425736 [WIP] UI refactor 2020-02-14 20:17:58 +08:00
Kr328 05f2a12baf refactor file storage logic 2020-02-13 16:34:13 +08:00
Kr328 c9d3b8e836 [WIP] UI logic refactor 2020-02-12 00:14:09 +08:00
Kr328 33fbd378c0 [WIP] UI logic refactor 2020-02-11 16:20:36 +08:00
Kr328 f76bed2f8b add profile process service 2020-02-09 00:53:40 +08:00
Kr328 23027b8813 [WIP] refactor profile service 2020-02-08 02:30:09 +08:00
Kr328 896c57aa03 ProfileService refactored 2020-02-02 16:15:24 +08:00
Kr328 ee6458a60e [WIP] refactor UI 2020-01-26 14:47:59 +08:00
Kr328 114456a1f2 [WIP] refactor UI 2020-01-22 13:23:27 +08:00
Kr328 cca7ec81e1 service refactored 2020-01-21 20:27:05 +08:00
Kr328 e9e59f6026 service refactored 2020-01-21 20:23:02 +08:00
Kr328 a29c95ceef service refactored 2020-01-21 20:17:31 +08:00
Kr328 c60d8cfb53 [WIP] refactor service 2020-01-21 13:24:58 +08:00
Kr328 4ee8679d29 [WIP] refactor service 2020-01-20 23:31:14 +08:00
Kr328 419c6301dd [WIP] refactor services 2020-01-20 12:09:11 +08:00
Kr328 e979bec321 [WIP] refactor service 2020-01-17 00:17:40 +08:00
Kr328 fa6ac580d0 [WIP] refactor service 2020-01-16 23:42:10 +08:00
Kr328 a44bb05345 [WIP] refactor services 2020-01-14 15:45:57 +08:00
Kr328 19ddb86430 [WIP] refactor clash bridge 2020-01-12 23:43:52 +08:00
Kr328 1eb6f59167 trim gopath for output 2020-01-11 21:25:22 +08:00
Kr328 2ba82ebd97 migrate to netstack 2020-01-11 21:05:59 +08:00
Kr328 00163fbfd4 update submodule 2020-01-11 14:15:10 +08:00
Kr328 0ef8734856 fix udp reject 2019-12-22 15:55:46 +08:00
Kr328 a4151fb198 cleanup code & update version code 2019-12-21 11:56:02 +08:00
Kr328 292ba5af95 add feedback id (use Build.ID & PackageArchive.lastUpdateTime) 2019-12-21 11:55:13 +08:00
Kr328 90456263ae add google play & split apk detect 2019-12-21 10:59:06 +08:00
Kr328 bc3f7d0549 use 172.31.255.252/30 instead of 172.19.0.0/30 2019-12-20 11:01:45 +08:00
Kr328 382f083fd1 fix crash on tile start
reset clash bandwidth on start
add version name to about dialog
2019-12-20 10:51:55 +08:00
Kr328 a287e287b9 fix tun 2019-12-19 22:35:11 +08:00
Kr328 166558433e decrease start on boot delay 2019-12-19 21:54:31 +08:00
Kr328 1915b005a9 change start activity flags 2019-12-19 21:52:55 +08:00
Kr328 ed63036329 fix crash on proxy only mode 2019-12-19 21:44:15 +08:00
Kr328 a49899add3 update clash core 2019-12-19 21:03:00 +08:00
Kr328 85072e4520 fix broadcast on sysstack 2019-12-19 21:02:13 +08:00
Kr328 a31f864ab8 fix bugs 2019-12-17 14:25:11 +08:00
Kr328 5bcbc4563e fix bugs 2019-12-17 13:59:54 +08:00
Kr328 62910f5e7e try fix bugs & disable ipv6 for default dns 2019-12-17 13:51:32 +08:00
Kr328 b3ff9abc0d refactor service 2019-12-16 23:06:13 +08:00
Kr328 eccc9bd886 refactor event service dispatch 2019-12-16 18:57:23 +08:00
Kr328 0bd1fcf51b fix crash on close 2019-12-15 16:03:48 +08:00
Kr328 51acbc75f5 fix crash on close 2019-12-15 16:01:48 +08:00
Kr328 c3cd2351a5 redirect tun initial 2019-12-15 14:06:36 +08:00
Kr328 dde7f77c29 add golang output to source sets 2019-12-14 15:24:57 +08:00
Kr328 dafba83b8d auto build clash bind 2019-12-14 14:42:00 +08:00
Kr328 ba472b5768 fix libclash.so not found 2019-12-11 10:21:56 +08:00
Kr328 c4f4c157bd try fix app bundle not extract native libs 2019-12-11 09:29:59 +08:00
Kr328 5ddeaf2783 add privacy policy 2019-12-10 22:58:13 +08:00
Kr328 1b87bc0417 finish activity on import successfully 2019-12-10 22:35:31 +08:00
Kr328 a154230bca fix some bugs 2019-12-10 22:28:43 +08:00
Kr328 4b498d7dc7 update tile on clash reload 2019-12-10 14:59:19 +08:00
Kr328 41b263a7ee fix fabric 2019-12-10 14:21:02 +08:00
Kr328 ddeff030e3 update version code 2019-12-10 14:17:54 +08:00
Kr328 d48beadcbc fix empty tile name 2019-12-10 14:14:26 +08:00
Kr328 7238e919c5 fix tile service clash start failure 2019-12-10 13:45:08 +08:00
Kr328 7cd52a84db update version & remove log runControlNoException 2019-12-10 13:22:40 +08:00
Kr328 e97a2d58c8 add crashlytics 2019-12-10 13:10:56 +08:00
Kr328 340fa28c6a add marquee text view 2019-12-10 11:33:35 +08:00
Kr328 6c58f552b8 tile title = profile name 2019-12-10 11:23:47 +08:00
Kr328 7cc53bbf32 add clash://install-config handler 2019-12-10 11:14:13 +08:00
Kr328 88ef4d2f10 fix dns hijacking not working 2019-12-10 01:22:02 +08:00
Kr328 968ef77896 update version 2019-12-10 00:33:08 +08:00
Kr328 64803bef1e compatible with legacy rules 2019-12-10 00:32:32 +08:00
Kr328 d7df27b91d adjust ui 2019-12-10 00:24:00 +08:00
Kr328 32b6608f11 update version code 2019-12-09 23:54:45 +08:00
Kr328 a665acaf63 fix profile event 2019-12-09 23:52:33 +08:00
Kr328 e2073b5f7c disable colorized 2019-12-09 23:51:59 +08:00
Kr328 475dd96ac2 adjust text 2019-12-09 23:45:28 +08:00
Kr328 9fe2ec39ae adjust ui 2019-12-09 23:42:53 +08:00
Kr328 9cca3c958e fix access control order 2019-12-09 01:08:35 +08:00
Kr328 903c201f2d update version code 2019-12-09 01:04:32 +08:00
Kr328 2b47776554 fix profile scroll not working & pin selected access control apps 2019-12-09 01:03:05 +08:00
Kr328 41a2e7effc fix proxy name display & profile not fully display 2019-12-09 00:41:13 +08:00
Kr328 6947118d2e update version code 2019-12-09 00:13:50 +08:00
Kr328 3b7a0b0101 fix start on boot display 2019-12-09 00:06:04 +08:00
Kr328 19dcc4a1fa use profile log level 2019-12-08 23:57:29 +08:00
Kr328 8ec689a893 fix restore selected proxy after update profile 2019-12-08 23:55:40 +08:00
Kr328 a1d623c42b use marquee for proxy name overflow 2019-12-08 23:50:45 +08:00
Kr328 9a893fc0a0 fix form adapter dialog title 2019-12-08 23:49:36 +08:00
Kr328 f13abcbe49 fix url test not working after refreshing 2019-12-08 23:47:35 +08:00
Kr328 24e1732091 add back button on action bar & add dns hijacking option 2019-12-08 23:40:24 +08:00
Kr328 8a2826c242 Merge pull request #7 from ccg2018/fix-open-app-main
fix(launch): when open the app from launcher, stay current activity
2019-12-08 22:42:47 +08:00
Kr328 b36f19f4c4 Merge pull request #4 from ccg2018/tile
feat(Tile): long click to open in app
2019-12-08 22:42:05 +08:00
CCG 9967601e3e fix(launch): when open the app from launcher, stay current activity 2019-12-08 20:07:05 +08:00
CCG c330bf8238 feat(Tile): long click to open in app 2019-12-08 17:26:33 +08:00
Kr328 b38d9ec95e update version code & add README.md NOTICE 2019-12-08 12:22:09 +08:00
Kr328 1069ba1991 use proxy order from GLOBAL & add tile 2019-12-07 15:48:45 +08:00
Kr328 ea29017687 Revert "keep proxy original order"
This reverts commit b41b9267
2019-12-07 15:22:02 +08:00
Kr328 b41b9267bd keep proxy original order 2019-12-07 15:01:05 +08:00
Kr328 5ffa1aeb1d add rule extra param 2019-12-07 08:45:04 +08:00
Kr328 d2b91341f0 fix default fallback dns not working 2019-12-07 08:24:23 +08:00
Kr328 5e69959cf5 fix profile delete file 2019-12-07 07:39:23 +08:00
Kr328 845f1b37ca add IP-CIDR6 parse 2019-12-07 07:36:46 +08:00
Kr328 21fb12ef71 fix crash by obfs 2019-12-07 07:10:13 +08:00
Kr328 53fa597556 add simple error notice 2019-12-07 06:03:08 +08:00
Kr328 adffebadbc add feedback & about 2019-12-07 05:51:27 +08:00
Kr328 ea93c0b3ab add log 2019-12-07 05:06:58 +08:00
Kr328 ea3b63fd80 Create LICENSE 2019-12-07 01:51:30 +08:00
Kr328 bda542d2e5 add url test 2019-12-07 01:30:35 +08:00
Kr328 a72c2d8abb url profile update 2019-12-06 14:18:30 +08:00
Kr328 8ad202cc07 fix double click in access control & add ramdom http listener 2019-12-06 00:41:57 +08:00
Kr328 bc8cea7bd8 clean up code 2019-12-06 00:37:28 +08:00
Kr328 8878070efc fix typo & add profile reload event 2019-12-05 23:48:10 +08:00
Kr328 4e2dceef48 format code 2019-12-05 07:33:51 +08:00
Kr328 c9973393ff add import from url (1/3) 2019-12-05 00:11:04 +08:00
Kr328 f0aba3758d rebase "comzyh/add-water" & add dns config fallback 2019-12-03 11:32:52 +08:00
Kr328 d8e5e74444 update ui text 2019-12-03 09:25:26 +08:00
Kr328 0a05d76850 application settings & start on boot 2019-12-02 23:55:00 +08:00
Kr328 996d82dfbf proxy mode ui 2019-12-02 22:53:16 +08:00
Kr328 961f9c7633 add black/white list 2019-12-02 21:43:19 +08:00
Kr328 e7d59ec633 fix bypass loopback 2019-12-02 16:51:17 +08:00
Kr328 32828a6ee9 fix load profile return nil invalid selected 2019-12-02 00:21:36 +08:00
Kr328 e8a48c0f0d settings draft 2019-12-01 23:41:39 +08:00
Kr328 afbebb4c8b redirect ALL dns to clash dns 2019-12-01 15:16:20 +08:00
Kr328 a911a0c4dd fix dns redirect 2019-12-01 14:38:38 +08:00
Kr328 fe9047ee40 fix profile 2019-11-30 23:09:26 +08:00
Kr328 bd159ecb6e add selector persistence 2019-11-30 21:26:08 +08:00
Kr328 1e8a57e949 refactor proxy list 2019-11-30 16:41:14 +08:00
Kr328 f418a3649e split traffic 2019-11-30 14:43:07 +08:00
Kr328 fa99b3f981 add ipv6 dnsredirect 2019-11-30 02:32:23 +08:00
Kr328 2661f01c19 reset submodule 2019-11-30 02:08:27 +08:00
Kr328 8633ab2de6 remove event 2019-11-30 02:00:03 +08:00
Kr328 70c8bb19a7 fix buffer & dns nil 2019-11-30 01:58:06 +08:00
Kr328 cdf7107d5a fix typo 2019-11-30 01:37:50 +08:00
Kr328 c11a1bb2a3 use comzyh/add-water 2019-11-30 01:30:05 +08:00
Kr328 8466662226 proxy activity 2019-11-29 00:19:45 +08:00
Kr328 7765d14588 proxy activity 2019-11-28 23:49:11 +08:00
Kr328 8968469310 refactor Parcels 2019-11-27 12:04:39 +08:00
Kr328 d7d36f32a3 fix bugs 2019-11-27 00:03:20 +08:00
Kr328 aa4b6c23bb proxy activity 2019-11-26 23:30:51 +08:00
Kr328 ceae371826 proxy activity 2019-11-26 23:14:46 +08:00
Kr328 7fba8b09cc dynamic disable notification update 2019-11-26 13:53:40 +08:00
Kr328 527f99ea00 dynamic disable notification update 2019-11-26 13:52:28 +08:00
Kr328 db1efcb85d dynamic disable notification update 2019-11-26 13:50:41 +08:00
Kr328 456c853923 new default network observer 2019-11-26 00:48:01 +08:00
Kr328 e5abd0cba8 notification 2019-11-26 00:24:20 +08:00
Kr328 f61013d99d basic function 2019-11-25 20:19:06 +08:00
Kr328 4768bd92dc profile framework 2019-11-25 17:25:40 +08:00
Kr328 9023809c89 service event framework 2019-11-25 17:01:20 +08:00
Kr328 e14e91a2a4 notification 2019-11-25 00:52:13 +08:00
Kr328 0e6cb05796 event framework 2019-11-25 00:35:06 +08:00
Kr328 da532df009 create event poll 2019-11-24 21:47:40 +08:00
Kr328 9aa20c6715 create event poll 2019-11-24 21:47:30 +08:00
Kr328 c7107e9d9b create event poll 2019-11-24 17:35:12 +08:00
Kr328 f882375cfa create event poll 2019-11-24 17:25:46 +08:00
Kr328 a6abb51046 create event poll 2019-11-24 16:29:13 +08:00
Kr328 438392ddf6 use serializable for parcelable 2019-11-24 14:10:48 +08:00
Kr328 6646593c5b add query proxies 2019-11-24 12:54:58 +08:00
Kr328 ab4b8b5057 add query proxies 2019-11-24 11:19:06 +08:00
Kr328 3b03624f32 use custom view 2019-11-23 14:06:10 +08:00
Kr328 7270f94930 fix metered vpn 2019-11-23 04:10:34 +08:00
Kr328 838d492334 fix metered vpn 2019-11-23 03:44:23 +08:00
Kr328 600537af9c basic proxy function available 2019-11-23 02:58:08 +08:00
Kr328 774d83ed9f new profile activity 2019-11-22 19:22:51 +08:00
Kr328 e3c5fb1a8c profiles activity 2019-11-22 09:56:57 +08:00
Kr328 f237879921 fix margin 2019-11-22 00:10:47 +08:00
Kr328 765892e768 set default state to stopped 2019-11-22 00:05:23 +08:00
Kr328 5caa778a71 fix tun unable to close 2019-11-21 23:58:52 +08:00
Kr328 f2a0e4877a use object instance instead of int 2019-11-21 23:43:00 +08:00
Kr328 7d04980e30 main activity 2019-11-21 23:40:35 +08:00
Kr328 565415d8d1 add layout id 2019-11-21 21:54:35 +08:00
Kr328 3f91c10c10 MainActivity 2019-11-21 21:49:53 +08:00
Kr328 96b8fe110b MainActivity 2019-11-21 00:15:00 +08:00
Kr328 d0aa037b4c add database 2019-11-20 22:48:59 +08:00
Kr328 792190b5f4 split ui / service 2019-11-20 18:13:28 +08:00
Kr328 d332b6941a split ui / service 2019-11-20 18:13:05 +08:00
Kr328 cd1a8bf5e4 cleanup code 2019-11-20 17:44:31 +08:00
Kr328 5c7e00ce59 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	app/src/main/java/com/github/kr328/clash/ClashService.kt
2019-11-20 17:41:35 +08:00
Kr328 2b1c32faf7 IClashService 2019-11-20 17:37:12 +08:00
Kr328 b98d5a5ce0 clash service 2019-11-20 12:10:33 +08:00
Kr328 4c84616797 clash service 2019-11-20 00:40:12 +08:00
Kr328 b1d22f3c3c clash service 2019-11-19 23:20:50 +08:00
Kr328 e8a3335e21 tun device work fine 2019-11-19 14:36:17 +08:00
Kr328 6cecb23dd8 clash process starter 2019-11-19 11:49:15 +08:00
Kr328 9987a83334 use kotlin for core 2019-11-19 09:30:41 +08:00
Kr328 fe194eac76 tun device private 2019-11-19 09:27:18 +08:00
Kr328 21b9d0fa27 use start process instead of jni 2019-11-19 09:23:54 +08:00
Kr328 cf4e1c5b95 new impl for clash core 2019-11-19 00:06:19 +08:00
Kr328 e624618be6 try 2019-11-15 16:21:24 +08:00
Kr328 fa6e5841bf add load default 2019-11-15 09:15:27 +08:00
Kr328 c3ebd9d6e3 initial commit 2019-11-15 08:12:33 +08:00
646 changed files with 12622 additions and 23424 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
* text=auto eol=lf
*.bat text eol=crlf
*.jar binary
+5 -19
View File
@@ -7,10 +7,6 @@ assignees: ''
---
<!-- Be sure to put a clear title after [BUG] in the text box above -->
<!-- Be sure to put a clear title after [BUG] in the text box above -->
<!-- Be sure to put a clear title after [BUG] in the text box above -->
**Describe the bug**
A clear and concise description of what the bug is.
@@ -27,12 +23,15 @@ A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
if applicable, add logs to help detect problem
**Device Info (please complete the following information):**
- Device: [e.g. Pixel 4]
- ROM: [e.g: AOSP]
- ROM Version:
- Android Version [e.g. 10]
- Android Version [e.g. Oreo]
**Application Info (please complete the following information):**
@@ -41,17 +40,4 @@ If applicable, add screenshots to help explain your problem.
- Distribution Channel: [e.g. Google Play]
**Additional context**
Add any other context about the problem here.
**Configure**
Paste configure file which **removed server info**
```yaml
# paste here
```
**Logs**
Paste logs to help detect problem
```
<paste here>
```
Add any other context about the problem here.
@@ -7,10 +7,6 @@ assignees: ''
---
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
<!-- Be sure to put a clear title after [Feature Request] in the text box above -->
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+6 -19
View File
@@ -7,10 +7,6 @@ assignees: ''
---
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [BUG] 后填入清晰明了的标题 -->
**描述出现的错误**
请简洁的描述你遇到的错误
@@ -27,10 +23,14 @@ assignees: ''
**屏幕截图**
如果适用, 上传屏幕截图以帮助描述错误
**设备信息 (请完成以下信息):**
**日志**
如果适用, 上传日志以帮助侦测错误
**设备信息 (请完成一下信息):**
- 机型: [例如: Pixel 4]
- 系统/ROM: [例如: MIUI 11]
- Android 版本 [例如: 10]
- Android 版本 [例如: Oreo]
- ROM版本 [例如: 20.3.19]
**应用信息**
@@ -40,16 +40,3 @@ assignees: ''
**附加信息**
其他的可能与改错误相关的信息
**配置文件**
在此粘贴 **去除服务器信息的** 的 **配置文件**
```yaml
# 在此粘贴
```
**日志**
粘贴日志以帮助侦测错误
```
<在此粘贴>
```
@@ -7,10 +7,6 @@ assignees: ''
---
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
<!-- 请务必在上方文本框处 [Feature Request] 后填入清晰明了的标题 -->
**功能描述**
请清晰的描述你想要的功能
-1
View File
@@ -1 +0,0 @@
blank_issues_enabled: false
-46
View File
@@ -1,46 +0,0 @@
name: Build Unsigned
on:
push:
branches:
- main
paths-ignore:
- '.idea/**'
- '.gitattributes'
- '.gitignore'
- '.gitmodules'
- '**.md'
- 'LICENSE'
- 'NOTICE'
pull_request:
paths-ignore:
- '.idea/**'
- '.gitattributes'
- '.gitignore'
- '.gitmodules'
- '**.md'
- 'LICENSE'
- 'NOTICE'
jobs:
BuildUnsigned:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v2
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v2
with:
distribution: 'adopt'
java-version: 11
- name: Gradle cache
uses: actions/cache@v2
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle/wrapper/gradle-wrapper.properties', '**/buildSrc/src/main/kotlin/**.kt') }}
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.17
- name: Build
run: ./gradlew --no-daemon app:assembleFossRelease
+4 -20
View File
@@ -1,7 +1,6 @@
.gradle
build/
/app/foss/release
/app/premium/release
/app/release/
/captures
# Ignore Gradle GUI config
@@ -17,18 +16,10 @@ gradle-app.setting
# gradle/wrapper/gradle-wrapper.properties
# Ignore IDEA config
.idea
*.iml
/.idea/*
!/.idea/codeStyles
/core/src/main/golang/.idea/*
!/core/src/main/golang/.idea/codeStyles
/core/src/foss/golang/.idea/*
!/core/src/foss/golang/.idea/codeStyles
/core/src/premium/golang/.idea/*
!/core/src/premium/golang/.idea/codeStyles
# KeyStore
signing.properties
*.keystore
*.jks
@@ -38,9 +29,8 @@ cmake-build-*
# local.properties
local.properties
# tracker
tracker.properties
# keystore
keystore.properties
# vscode
.vscode
@@ -55,9 +45,3 @@ google-services.json
# Dolphin
.directory
# logs
*.log
# MacOS
.DS_Store
+3 -6
View File
@@ -1,6 +1,3 @@
[submodule "clash-foss"]
path = core/src/foss/golang/clash
url = https://github.com/Kr328/clash.git
[submodule "clash-premium"]
path = core/src/premium/golang/clash
url = https://github.com/Kr328/clash.git
[submodule "core/src/main/golang/clash"]
path = core/src/main/golang/clash
url = https://github.com/Kr328/Clash
-151
View File
@@ -1,151 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<AndroidXmlCodeStyleSettings>
<option name="LAYOUT_SETTINGS">
<value>
<option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
</value>
</option>
<option name="VALUE_RESOURCE_FILE_SETTINGS">
<value>
<option name="INSERT_LINE_BREAKS_AROUND_STYLE" value="false" />
</value>
</option>
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value>
<package name="java.util" alias="false" withSubpackages="false" />
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
<package name="io.ktor" alias="false" withSubpackages="true" />
</value>
</option>
<option name="PACKAGES_IMPORT_LAYOUT">
<value>
<package name="" alias="false" withSubpackages="true" />
<package name="java" alias="false" withSubpackages="true" />
<package name="javax" alias="false" withSubpackages="true" />
<package name="kotlin" alias="false" withSubpackages="true" />
<package name="" alias="true" withSubpackages="true" />
</value>
</option>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<option name="FORCE_REARRANGE_MODE" value="1" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>
-5
View File
@@ -1,5 +0,0 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>
-14
View File
@@ -1,14 +0,0 @@
## Contributing to Clash for Android
#### Code Style
Please use `Android Studio` or `Intellij IDEA` to open the project and use the project code style profile.
`File` -> `Settings` -> `Editor` -> `Code Style` -> `C/C++ and Kotlin` -> `Scheme` -> `Project`
#### License
Contributing to Clash for Android that assumes you allow code to be merged into closed-source branch of Clash for Android. Other terms follow the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html)
+21 -14
View File
@@ -8,20 +8,25 @@ A Graphical user interface of [clash](https://github.com/Dreamacro/clash) for An
Fully feature of [clash](https://github.com/Dreamacro/clash) ~~(Exclude `external-controller`~~
### Requirement
- Android 5.0+ (minimum)
- Android 7.0+ (recommend)
- `armeabi-v7a` , `arm64-v8a`, `x86` or `x86_64` Architecture
* Android 7.0+
* `armeabi-v7a` , `arm64-v8a`, `x86` or `x86_64` Architecture
### License
See also [LICENSE](./LICENSE) and [NOTICE](./NOTICE)
### Privacy Policy
### Privacy Policy
See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
### Build
1. Update submodules
@@ -30,27 +35,29 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
git submodule update --init --recursive
```
2. Install **OpenJDK 11**, **Android SDK**, **CMake** and **Golang**
2. Install `JDK 1.8`, `Android SDK` ,`Android NDK` and `Golang`
3. Create `local.properties` in project root with
3. Create `local.properties` in project root with
```properties
sdk.dir=/path/to/android-sdk
ndk.dir=/path/to/android-ndk
appcenter.key=<AppCenter Key> # Optional, from "appcenter.ms"
```
4. Create `signing.properties` in project root with
4. Create `keystore.properties` in project root with
```properties
keystore.path=/path/to/keystore/file
keystore.password=<key store password>
key.alias=<key alias>
key.password=<key password>
```
storeFile=/path/to/keystore/file
storePassword=<key store password>
keyAlias=<key alias>
keyPassword=<key password>
```
5. Build
```bash
./gradlew app:assembleFossRelease
./gradlew app:assembleRelease
```
6. Pick `app-foss-<arch>-release.apk` in `app/build/outputs/apk/foss/release/`
6. Pick `app-release-<arch>.apk` in `app/build/outputs/apks`
+1
View File
@@ -0,0 +1 @@
/build
+114 -20
View File
@@ -1,32 +1,126 @@
import java.util.*
plugins {
kotlin("android")
kotlin("kapt")
id("com.android.application")
id("kotlin-android")
id("kotlin-android-extensions")
}
val rootExtra = rootProject.extra
val gCompileSdkVersion: Int by rootExtra
val gBuildToolsVersion: String by rootExtra
val gMinSdkVersion: Int by rootExtra
val gTargetSdkVersion: Int by rootExtra
val gVersionCode: Int by rootExtra
val gVersionName: String by rootExtra
val gKotlinVersion: String by rootExtra
val gKotlinCoroutineVersion: String by rootExtra
val gAppCenterVersion: String by rootExtra
val gAndroidKtxVersion: String by rootExtra
val gRecyclerviewVersion: String by rootExtra
val gAppCompatVersion: String by rootExtra
val gMaterialDesignVersion: String by rootExtra
val gShizukuPreferenceVersion: String by rootExtra
val gMultiprocessPreferenceVersion: String by rootExtra
android {
compileSdkVersion(gCompileSdkVersion)
buildToolsVersion(gBuildToolsVersion)
defaultConfig {
applicationId = "com.github.kr328.clash"
minSdkVersion(gMinSdkVersion)
targetSdkVersion(gTargetSdkVersion)
versionCode = gVersionCode
versionName = gVersionName
}
buildTypes {
maybeCreate("release").apply {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
splits {
abi {
isEnable = true
isUniversalApk = true
}
}
val signingFile = rootProject.file("keystore.properties")
if ( signingFile.exists() ) {
val properties = Properties().apply {
signingFile.inputStream().use {
load(it)
}
}
signingConfigs {
maybeCreate("release").apply {
storeFile = rootProject.file(Objects.requireNonNull(properties.getProperty("storeFile")))
storePassword = Objects.requireNonNull(properties.getProperty("storePassword"))
keyAlias = Objects.requireNonNull(properties.getProperty("keyAlias"))
keyPassword = Objects.requireNonNull(properties.getProperty("keyPassword"))
}
}
buildTypes {
maybeCreate("release").apply {
this.signingConfig = signingConfigs.findByName("release")
}
}
}
}
dependencies {
compileOnly(project(":hideapi"))
implementation(project(":core"))
implementation(project(":service"))
implementation(project(":design"))
implementation(project(":common"))
implementation(deps.kotlin.coroutine)
implementation(deps.androidx.core)
implementation(deps.androidx.activity)
implementation(deps.androidx.fragment)
implementation(deps.androidx.appcompat)
implementation(deps.androidx.coordinator)
implementation(deps.androidx.recyclerview)
implementation(deps.google.material)
val premiumImplementation by configurations
premiumImplementation(deps.appcenter.analytics)
premiumImplementation(deps.appcenter.crashes)
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$gKotlinVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$gKotlinCoroutineVersion")
implementation("androidx.recyclerview:recyclerview:$gRecyclerviewVersion")
implementation("androidx.core:core-ktx:$gAndroidKtxVersion")
implementation("androidx.appcompat:appcompat:$gAppCompatVersion")
implementation("com.google.android.material:material:$gMaterialDesignVersion")
implementation("moe.shizuku.preference:preference-appcompat:$gShizukuPreferenceVersion")
implementation("moe.shizuku.preference:preference-simplemenu-appcompat:$gShizukuPreferenceVersion")
implementation("com.microsoft.appcenter:appcenter-analytics:$gAppCenterVersion")
implementation("com.microsoft.appcenter:appcenter-crashes:$gAppCenterVersion")
}
tasks.getByName("clean", type = Delete::class) {
delete(file("release"))
task("injectAppCenterKey") {
doFirst {
val properties = Properties().apply {
rootProject.file("local.properties").inputStream().use {
load(it)
}
}
val key = properties.getProperty("appcenter.key", "")
android.buildTypes.forEach {
it.buildConfigField("String", "APP_CENTER_KEY", "\"$key\"")
}
}
}
afterEvaluate {
tasks["preBuild"].dependsOn(tasks["injectAppCenterKey"])
}
+1 -37
View File
@@ -20,40 +20,4 @@
# hide the original source file name.
#-renamesourcefileattribute SourceFile
-dontobfuscate
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
public static void checkNotNull(...);
public static void checkExpressionValueIsNotNull(...);
public static void checkNotNullExpressionValue(...);
public static void checkReturnedValueIsNotNull(...);
public static void checkFieldIsNotNull(...);
public static void checkParameterIsNotNull(...);
public static void checkNotNullParameter(...);
}
# Kotlin Coroutine
# Allow R8 to optimize away the FastServiceLoader.
# Together with ServiceLoader optimization in R8
# this results in direct instantiation when loading Dispatchers.Main
-assumenosideeffects class kotlinx.coroutines.internal.MainDispatcherLoader {
boolean FAST_SERVICE_LOADER_ENABLED return false;
}
-assumenosideeffects class kotlinx.coroutines.internal.FastServiceLoaderKt {
boolean ANDROID_DETECTED return true;
}
-keep class kotlinx.coroutines.android.AndroidDispatcherFactory {*;}
# Disable support for "Missing Main Dispatcher", since we always have Android main dispatcher
-assumenosideeffects class kotlinx.coroutines.internal.MainDispatchersKt {
boolean SUPPORT_MISSING return false;
}
# Statically turn off all debugging facilities and assertions
-assumenosideeffects class kotlinx.coroutines.DebugKt {
boolean getASSERTIONS_ENABLED() return false;
boolean getDEBUG() return false;
boolean getRECOVER_STACK_TRACES() return false;
}
-dontobfuscate
@@ -1,14 +0,0 @@
package com.github.kr328.clash
import android.app.Application
@Suppress("UNUSED_PARAMETER")
object Tracker {
fun initialize(application: Application) {
// do nothing
}
fun uploadLogcat(logcat: String) {
// do nothing
}
}
+40 -107
View File
@@ -3,178 +3,111 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.github.kr328.clash">
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".MainApplication"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:fullBackupContent="@xml/full_backup_content"
android:icon="@mipmap/ic_launcher"
android:label="@string/application_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/BootstrapTheme"
tools:ignore="GoogleAppIndexingWarning"
tools:targetApi="N">
<meta-data
android:name="releaseName"
android:value="@string/release_name" />
<meta-data
android:name="releaseCode"
android:value="@integer/release_code" />
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
<activity
android:name=".MainActivity"
android:configChanges="uiMode"
android:exported="true"
android:label="@string/launch_name"
android:configChanges="uiMode"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<activity
android:name=".ExternalImportActivity"
android:exported="true"
android:label="@string/import_from_file"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="install-config"
android:scheme="clash" />
</intent-filter>
</activity>
<activity
android:name=".ApkBrokenActivity"
android:configChanges="uiMode"
android:label="@string/application_broken"
android:exported="false"
android:label="@string/application_broken" />
<activity
android:name=".AppCrashedActivity"
android:configChanges="uiMode"
android:exported="false"
android:label="@string/application_crashed"
android:launchMode="singleTask" />
android:configChanges="uiMode"/>
<activity
android:name=".ProfilesActivity"
android:configChanges="uiMode"
android:label="@string/profiles"
android:exported="false"
android:label="@string/profiles" />
android:configChanges="uiMode"/>
<activity
android:name=".NewProfileActivity"
android:configChanges="uiMode"
android:name=".CreateProfileActivity"
android:label="@string/create_profile"
android:exported="false"
android:label="@string/create_profile" />
android:configChanges="uiMode" />
<activity
android:name=".PropertiesActivity"
android:configChanges="uiMode"
android:name=".ProfileEditActivity"
android:label="@string/profile"
android:exported="false"
android:label="@string/profile" />
android:configChanges="uiMode" />
<activity
android:name=".ProxyActivity"
android:configChanges="uiMode"
android:name=".ProxiesActivity"
android:label="@string/proxy"
android:exported="false"
android:label="@string/proxy" />
<activity
android:name=".ProvidersActivity"
android:configChanges="uiMode"
android:exported="false"
android:label="@string/providers" />
android:configChanges="uiMode" />
<activity
android:name=".LogsActivity"
android:configChanges="uiMode"
android:label="@string/logs"
android:exported="false"
android:label="@string/logs" />
android:configChanges="uiMode" />
<activity
android:name=".LogcatActivity"
android:configChanges="uiMode"
android:name=".LogViewerActivity"
android:label="@string/log_viewer"
android:exported="false"
android:label="@string/logcat" />
android:configChanges="uiMode"
android:launchMode="singleTop"/>
<activity
android:name=".SettingsActivity"
android:configChanges="uiMode"
android:label="@string/settings"
android:exported="false"
android:label="@string/settings" />
android:configChanges="uiMode" />
<activity
android:name=".NetworkSettingsActivity"
android:configChanges="uiMode"
android:name=".SettingsNetworkActivity"
android:label="@string/network"
android:exported="false"
android:label="@string/network" />
android:configChanges="uiMode" />
<activity
android:name=".AppSettingsActivity"
android:configChanges="uiMode"
android:name=".SettingsBehaviorActivity"
android:label="@string/behavior"
android:exported="false"
android:label="@string/app" />
android:configChanges="uiMode" />
<activity
android:name=".OverrideSettingsActivity"
android:configChanges="uiMode"
android:name=".SettingsInterfaceActivity"
android:label="@string/interface_"
android:exported="false"
android:label="@string/override" />
android:configChanges="uiMode" />
<activity
android:name=".AccessControlActivity"
android:configChanges="uiMode"
android:name=".PackagesActivity"
android:label="@string/access_control_packages"
android:exported="false"
android:label="@string/access_control_packages" />
android:configChanges="uiMode" />
<activity
android:name=".HelpActivity"
android:configChanges="uiMode"
android:name=".SupportActivity"
android:label="@string/support"
android:exported="false"
android:label="@string/help" />
<activity
android:name=".FilesActivity"
android:configChanges="uiMode"
android:exported="false"
android:label="@string/files" />
android:configChanges="uiMode" />
<service
android:name=".LogcatService"
android:exported="false"
android:label="@string/clash_logcat" />
<service
android:name=".TileService"
android:exported="true"
android:icon="@drawable/ic_logo_service"
android:icon="@drawable/ic_notification"
android:label="@string/launch_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<receiver
android:name=".RestartReceiver"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -1,142 +0,0 @@
package com.github.kr328.clash
import android.Manifest.permission.INTERNET
import android.content.ClipData
import android.content.ClipboardManager
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import androidx.core.content.getSystemService
import com.github.kr328.clash.design.AccessControlDesign
import com.github.kr328.clash.design.model.AppInfo
import com.github.kr328.clash.design.util.toAppInfo
import com.github.kr328.clash.service.store.ServiceStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
class AccessControlActivity : BaseActivity<AccessControlDesign>() {
override suspend fun main() {
val service = ServiceStore(this)
val selected = withContext(Dispatchers.IO) {
service.accessControlPackages.toMutableSet()
}
defer {
withContext(Dispatchers.IO) {
service.accessControlPackages = selected
}
}
val design = AccessControlDesign(this, uiStore, selected)
setContentDesign(design)
design.requests.send(AccessControlDesign.Request.ReloadApps)
while (isActive) {
select<Unit> {
events.onReceive {
}
design.requests.onReceive {
when (it) {
AccessControlDesign.Request.ReloadApps -> {
design.patchApps(loadApps(selected))
}
AccessControlDesign.Request.SelectAll -> {
val all = withContext(Dispatchers.Default) {
design.apps.map(AppInfo::packageName)
}
selected.clear()
selected.addAll(all)
design.rebindAll()
}
AccessControlDesign.Request.SelectNone -> {
selected.clear()
design.rebindAll()
}
AccessControlDesign.Request.SelectInvert -> {
val all = withContext(Dispatchers.Default) {
design.apps.map(AppInfo::packageName).toSet() - selected
}
selected.clear()
selected.addAll(all)
design.rebindAll()
}
AccessControlDesign.Request.Import -> {
val clipboard = getSystemService<ClipboardManager>()
val data = clipboard?.primaryClip
if (data != null && data.itemCount > 0) {
val all = withContext(Dispatchers.IO) {
val packages = data.getItemAt(0).text.split("\n").toSet()
design.apps.map(AppInfo::packageName).intersect(packages)
}
selected.clear()
selected.addAll(all)
}
design.rebindAll()
}
AccessControlDesign.Request.Export -> {
val clipboard = getSystemService<ClipboardManager>()
withContext(Dispatchers.IO) {
val data = ClipData.newPlainText(
"packages",
selected.joinToString("\n")
)
clipboard?.setPrimaryClip(data)
}
}
}
}
}
}
}
private suspend fun loadApps(selected: Set<String>): List<AppInfo> =
withContext(Dispatchers.IO) {
val reverse = uiStore.accessControlReverse
val sort = uiStore.accessControlSort
val systemApp = uiStore.accessControlSystemApp
val base = compareByDescending<AppInfo> { it.packageName in selected }
val comparator = if (reverse) base.thenDescending(sort) else base.then(sort)
val pm = packageManager
val packages = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS)
packages.asSequence()
.filter {
it.packageName != packageName
}
.filter {
it.packageName == "android" || it.requestedPermissions?.contains(INTERNET) == true
}
.filter {
systemApp || !it.isSystemApp
}
.map {
it.toAppInfo(pm)
}
.sortedWith(comparator)
.toList()
}
private val PackageInfo.isSystemApp: Boolean
get() {
return applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
}
}
@@ -2,19 +2,67 @@ package com.github.kr328.clash
import android.content.Intent
import android.net.Uri
import com.github.kr328.clash.design.ApkBrokenDesign
import kotlinx.coroutines.isActive
import android.os.Bundle
import android.text.Html
import kotlinx.android.synthetic.main.activity_application_broken.*
class ApkBrokenActivity : BaseActivity<ApkBrokenDesign>() {
override suspend fun main() {
val design = ApkBrokenDesign(this)
class ApkBrokenActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentDesign(design)
setContentView(R.layout.activity_application_broken)
setSupportActionBar(toolbar)
while (isActive) {
val req = design.requests.receive()
text.text = Html.fromHtml(
getString(R.string.application_broken_description),
Html.FROM_HTML_MODE_COMPACT
)
startActivity(Intent(Intent.ACTION_VIEW).setData(Uri.parse(req.url)))
commonUi.build {
option(
icon = getDrawable(R.drawable.ic_info),
title = getString(R.string.learn_more_about_split_apks)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.about_split_apks_url)))
)
}
}
option(
icon = getDrawable(R.drawable.ic_input),
title = getString(R.string.reinstall_from_google_play)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.google_play_url)))
)
}
}
option(
icon = getDrawable(R.drawable.ic_play_for_work),
title = getString(R.string.download_from_github_releases)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.github_releases_url)))
)
}
}
}
}
override fun onBackPressed() {
super.onBackPressed()
finishAffinity()
finish()
}
override fun shouldDisplayHomeAsUpEnabled(): Boolean {
return false
}
}
@@ -1,33 +0,0 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.compat.versionCodeCompat
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.design.AppCrashedDesign
import com.github.kr328.clash.log.SystemLogcat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
class AppCrashedActivity : BaseActivity<AppCrashedDesign>() {
override suspend fun main() {
val design = AppCrashedDesign(this)
setContentDesign(design)
val packageInfo = withContext(Dispatchers.IO) {
packageManager.getPackageInfo(packageName, 0)
}
Log.i("App version: versionName = ${packageInfo.versionName} versionCode = ${packageInfo.versionCodeCompat}")
val logs = withContext(Dispatchers.IO) {
SystemLogcat.dumpCrash()
}
design.setAppLogs(logs)
while (isActive) {
events.receive()
}
}
}
@@ -1,62 +0,0 @@
package com.github.kr328.clash
import android.content.pm.PackageManager
import com.github.kr328.clash.common.util.componentName
import com.github.kr328.clash.design.AppSettingsDesign
import com.github.kr328.clash.design.model.Behavior
import com.github.kr328.clash.service.store.ServiceStore
import com.github.kr328.clash.util.ApplicationObserver
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
class AppSettingsActivity : BaseActivity<AppSettingsDesign>(), Behavior {
override suspend fun main() {
val design = AppSettingsDesign(
this,
uiStore,
ServiceStore(this),
this,
clashRunning,
)
setContentDesign(design)
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ClashStart, Event.ClashStop, Event.ServiceRecreated ->
recreate()
else -> Unit
}
}
design.requests.onReceive {
ApplicationObserver.createdActivities.forEach {
it.recreate()
}
}
}
}
}
override var autoRestart: Boolean
get() {
val status = packageManager.getComponentEnabledSetting(
RestartReceiver::class.componentName
)
return status == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
set(value) {
val status = if (value)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
packageManager.setComponentEnabledSetting(
RestartReceiver::class.componentName,
status,
PackageManager.DONT_KILL_APP,
)
}
}
@@ -1,165 +1,155 @@
package com.github.kr328.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContract
import android.view.*
import android.view.View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.github.kr328.clash.common.compat.isAllowForceDarkCompat
import com.github.kr328.clash.common.compat.isLightNavigationBarCompat
import com.github.kr328.clash.common.compat.isLightStatusBarsCompat
import com.github.kr328.clash.common.compat.isSystemBarsTranslucentCompat
import com.github.kr328.clash.core.bridge.ClashException
import com.github.kr328.clash.design.Design
import com.github.kr328.clash.design.model.DarkMode
import com.github.kr328.clash.design.store.UiStore
import com.github.kr328.clash.design.ui.DayNight
import com.github.kr328.clash.design.util.resolveThemedBoolean
import com.github.kr328.clash.design.util.resolveThemedColor
import com.github.kr328.clash.design.util.showExceptionToast
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.github.kr328.clash.common.utils.createLanguageConfigurationContext
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.remote.Remote
import com.github.kr328.clash.util.ActivityResultLifecycle
import com.github.kr328.clash.util.ApplicationObserver
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
abstract class BaseActivity<D : Design<*>> :
AppCompatActivity(),
CoroutineScope by MainScope(),
Broadcasts.Observer {
enum class Event {
ServiceRecreated,
ActivityStart,
ActivityStop,
ClashStop,
ClashStart,
ProfileLoaded,
ProfileChanged
abstract class BaseActivity : AppCompatActivity(), CoroutineScope by MainScope() {
class EmptyBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {}
}
protected val uiStore by lazy { UiStore(this) }
protected val events = Channel<Event>(Channel.UNLIMITED)
protected var activityStarted: Boolean = false
protected val clashRunning: Boolean
get() = Remote.broadcasts.clashRunning
protected var design: D? = null
private set(value) {
field = value
if (value != null) {
setContentView(value.root)
} else {
setContentView(View(this))
private val receiver = object : Broadcasts.Receiver {
override fun onStarted() {
launch {
onClashStarted()
}
}
private var defer: suspend () -> Unit = {}
private var deferRunning = false
private val nextRequestKey = AtomicInteger(0)
private var dayNight: DayNight = DayNight.Day
override fun onStopped(cause: String?) {
launch {
onClashStopped(cause)
}
}
protected abstract suspend fun main()
override fun onProfileChanged() {
launch {
onClashProfileChanged()
}
}
fun defer(operation: suspend () -> Unit) {
this.defer = operation
}
suspend fun <I, O> startActivityForResult(
contracts: ActivityResultContract<I, O>,
input: I
): O = withContext(Dispatchers.Main) {
val requestKey = nextRequestKey.getAndIncrement().toString()
ActivityResultLifecycle().use { lifecycle, start ->
suspendCoroutine { c ->
activityResultRegistry.register(requestKey, lifecycle, contracts) {
c.resumeWith(Result.success(it))
}.apply { start() }.launch(input)
override fun onProfileLoaded() {
launch {
onClashProfileLoaded()
}
}
}
suspend fun setContentDesign(design: D) {
suspendCoroutine<Unit> {
window.decorView.post {
this.design = design
private var overrideRootView: View? = null
it.resume(Unit)
val clashRunning: Boolean
get() = Broadcasts.clashRunning
val rootView: View
get() = overrideRootView ?: window.decorView
var menu: Menu? = null
lateinit var uiSettings: UiSettings
private set
private lateinit var language: String
private lateinit var darkMode: String
open suspend fun onClashStarted() {}
open suspend fun onClashStopped(reason: String?) {}
open suspend fun onClashProfileChanged() {}
open suspend fun onClashProfileLoaded() {}
override fun setContentView(layoutResID: Int) {
val base = CoordinatorLayout(this).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val displayMetrics = resources.displayMetrics
if (displayMetrics.widthPixels > displayMetrics.heightPixels) {
val padding = (displayMetrics.widthPixels - displayMetrics.heightPixels) / 2
setPadding(padding, 0, padding, 0)
}
}
overrideRootView = LayoutInflater.from(this).inflate(layoutResID, base, true)
super.setContentView(base)
}
override fun attachBaseContext(newBase: Context?) {
val base = newBase ?: return super.attachBaseContext(newBase)
uiSettings = UiSettings(base)
language = uiSettings.get(UiSettings.LANGUAGE)
super.attachBaseContext(base.createLanguageConfigurationContext(language))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
applyDayNight()
language = uiSettings.get(UiSettings.LANGUAGE)
launch {
main()
resetDarkMode()
finish()
}
resetLightNavigationBar()
title = resolveActivityTitle()
}
override fun onStart() {
super.onStart()
activityStarted = true
if (language != uiSettings.get(UiSettings.LANGUAGE) || darkMode != uiSettings.get(UiSettings.DARK_MODE))
recreate()
Remote.broadcasts.addObserver(this)
events.trySend(Event.ActivityStart)
Broadcasts.register(receiver)
}
override fun onStop() {
super.onStop()
activityStarted = false
Remote.broadcasts.removeObserver(this)
events.trySend(Event.ActivityStop)
Broadcasts.unregister(receiver)
}
override fun onDestroy() {
design?.cancel()
cancel()
super.onDestroy()
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
this.menu = menu
return super.onCreateOptionsMenu(menu)
}
override fun finish() {
if (deferRunning) {
return
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item))
return true
if (item.itemId == android.R.id.home) {
onSupportNavigateUp()
return true
}
deferRunning = true
launch {
try {
defer()
} finally {
withContext(NonCancellable) {
super.finish()
}
}
}
return false
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
override fun setSupportActionBar(toolbar: Toolbar?) {
super.setSupportActionBar(toolbar)
if (queryDayNight(newConfig) != dayNight) {
ApplicationObserver.createdActivities.forEach {
it.recreate()
}
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(shouldDisplayHomeAsUpEnabled())
}
}
@@ -173,77 +163,59 @@ abstract class BaseActivity<D : Design<*>> :
return true
}
override fun onProfileChanged() {
events.trySend(Event.ProfileChanged)
override fun onDestroy() {
cancel()
super.onDestroy()
}
override fun onProfileLoaded() {
events.trySend(Event.ProfileLoaded)
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
recreate()
}
override fun onServiceRecreated() {
events.trySend(Event.ServiceRecreated)
protected fun showSnackbarException(title: String, detail: String?) {
Snackbar.make(rootView, title, Snackbar.LENGTH_LONG).setAction(R.string.detail) {
AlertDialog.Builder(this).setTitle(R.string.detail).setMessage(detail ?: "Unknown")
.show()
}.show()
}
override fun onStarted() {
events.trySend(Event.ClashStart)
}
override fun onStopped(cause: String?) {
events.trySend(Event.ClashStop)
if (cause != null && activityStarted) {
launch {
design?.showExceptionToast(ClashException(cause))
}
private fun resetDarkMode() {
when (uiSettings.get(UiSettings.DARK_MODE).also { darkMode = it }) {
UiSettings.DARK_MODE_AUTO ->
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
UiSettings.DARK_MODE_DARK ->
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
UiSettings.DARK_MODE_LIGHT ->
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_NO
}
}
private fun queryDayNight(config: Configuration = resources.configuration): DayNight {
return when (uiStore.darkMode) {
DarkMode.Auto -> {
if (config.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES)
DayNight.Night
else
DayNight.Day
}
DarkMode.ForceLight -> {
DayNight.Day
}
DarkMode.ForceDark -> {
DayNight.Night
}
private fun resetLightNavigationBar() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
return
val light = resources.getBoolean(R.bool.lightStatusBar)
if (light) {
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility or SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
} else {
window.decorView.systemUiVisibility =
window.decorView.systemUiVisibility and SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
}
window.navigationBarColor = getColor(R.color.backgroundColor)
}
private fun applyDayNight(config: Configuration = resources.configuration) {
val dayNight = queryDayNight(config)
private fun resolveActivityTitle(): CharSequence {
val info = packageManager.getActivityInfo(componentName, 0)
when (dayNight) {
DayNight.Night -> {
theme.applyStyle(R.style.AppThemeDark, true)
}
DayNight.Day -> {
theme.applyStyle(R.style.AppThemeLight, true)
}
}
if (info.labelRes <= 0)
return title
window.isAllowForceDarkCompat = false
window.isSystemBarsTranslucentCompat = true
window.statusBarColor = resolveThemedColor(android.R.attr.statusBarColor)
window.navigationBarColor = resolveThemedColor(android.R.attr.navigationBarColor)
if (Build.VERSION.SDK_INT >= 23) {
window.isLightStatusBarsCompat =
resolveThemedBoolean(android.R.attr.windowLightStatusBar)
}
if (Build.VERSION.SDK_INT >= 27) {
window.isLightNavigationBarCompat =
resolveThemedBoolean(android.R.attr.windowLightNavigationBar)
}
this.dayNight = dayNight
return resources.getText(info.labelRes)
}
}
}
@@ -0,0 +1,11 @@
package com.github.kr328.clash
object Constants {
const val PREFERENCE_NAME_APP = "app"
const val PREFERENCE_KEY_LAST_INSTALL = "last_install"
const val LOG_DIR_NAME = "logs"
const val URL_PROVIDER_INTENT_ACTION = "com.github.kr328.clash.action.PROVIDE_URL"
const val URL_PROVIDER_INTENT_EXTRA_NAME = "name"
}
@@ -0,0 +1,162 @@
package com.github.kr328.clash
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.model.Profile.Type
import kotlinx.android.synthetic.main.activity_create_profile.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class CreateProfileActivity : BaseActivity() {
companion object {
const val REQUEST_CODE = 20000
}
private val self = this
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_create_profile)
setSupportActionBar(toolbar)
launch {
val providers = queryUrlProviders()
mainList.adapter = Adapter(this@CreateProfileActivity, providers)
mainList.divider = null
mainList.dividerHeight = 0
mainList.setOnItemClickListener { _, _, position, _ ->
val item = providers[position]
self.launch {
val id = withProfile {
acquireUnused(item.type, item.intent?.toUri(0))
}
startActivityForResult(
ProfileEditActivity::class.intent.setData(
Uri.fromParts(
"id",
id.toString(),
null
)
),
REQUEST_CODE
)
}
}
mainList.setOnItemLongClickListener { _, _, position, _ ->
val item = providers[position]
val packageName = item.intent?.component?.packageName
if (packageName != null) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.fromParts("package", packageName, null))
startActivity(intent)
true
} else {
false
}
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE && resultCode == Activity.RESULT_OK)
return finish()
super.onActivityResult(requestCode, resultCode, data)
}
private suspend fun queryUrlProviders(): List<UrlProvider> =
withContext(Dispatchers.IO) {
val common = listOf(
UrlProvider(
getText(R.string.file),
getText(R.string.import_from_file),
getDrawable(R.drawable.ic_file)!!,
Type.FILE,
null
),
UrlProvider(
getText(R.string.url),
getText(R.string.import_from_url),
getDrawable(R.drawable.ic_download)!!,
Type.URL,
null
)
)
val providers = packageManager.queryIntentActivities(
Intent(Constants.URL_PROVIDER_INTENT_ACTION),
0
).map {
val activity = it.activityInfo
val name = activity.applicationInfo.loadLabel(packageManager)
val summary = activity.loadLabel(packageManager)
val icon = activity.loadIcon(packageManager)
val type = Type.EXTERNAL
val intent = Intent(Constants.URL_PROVIDER_INTENT_ACTION)
.setComponent(
ComponentName.createRelative(
activity.packageName,
activity.name
)
)
UrlProvider(name, summary, icon, type, intent)
}
common + providers
}
private data class UrlProvider(
val name: CharSequence,
val summary: CharSequence,
val icon: Drawable,
val type: Type,
val intent: Intent?
)
private class Adapter(private val context: Context, private val providers: List<UrlProvider>) :
BaseAdapter() {
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val provider = providers[position]
val view = convertView ?: LayoutInflater.from(context).inflate(
R.layout.adapter_url_provider,
parent,
false
)
view.findViewById<TextView>(android.R.id.title).text = provider.name
view.findViewById<TextView>(android.R.id.summary).text = provider.summary
view.findViewById<View>(android.R.id.icon).background = provider.icon
return view
}
override fun getItem(position: Int): Any = providers[position]
override fun getItemId(position: Int): Long = providers[position].hashCode().toLong()
override fun getCount(): Int = providers.size
}
}
@@ -1,44 +0,0 @@
package com.github.kr328.clash
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.setUUID
import com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.util.withProfile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch
import java.util.*
class ExternalImportActivity : Activity(), CoroutineScope by MainScope() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent.action != Intent.ACTION_VIEW)
return finish()
val uri = intent.data ?: return finish()
val url = uri.getQueryParameter("url") ?: return finish()
launch {
val uuid = withProfile {
val type = when (uri.getQueryParameter("type")?.lowercase(Locale.getDefault())) {
"url" -> Profile.Type.Url
"file" -> Profile.Type.File
else -> Profile.Type.Url
}
val name = uri.getQueryParameter("name") ?: getString(R.string.new_profile)
create(type, name).also {
patch(it, name, url, 0)
}
}
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
finish()
}
}
}
@@ -1,160 +0,0 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.github.kr328.clash
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.github.kr328.clash.common.util.grantPermissions
import com.github.kr328.clash.common.util.ticker
import com.github.kr328.clash.common.util.uuid
import com.github.kr328.clash.design.FilesDesign
import com.github.kr328.clash.design.util.showExceptionToast
import com.github.kr328.clash.remote.FilesClient
import com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.util.fileName
import com.github.kr328.clash.util.withProfile
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import java.util.*
import java.util.concurrent.TimeUnit
class FilesActivity : BaseActivity<FilesDesign>() {
override suspend fun main() {
val uuid = intent.uuid ?: return finish()
val profile = withProfile { queryByUUID(uuid) } ?: return finish()
val root = uuid.toString()
val design = FilesDesign(this)
val client = FilesClient(this)
val stack = Stack<String>()
design.configurationEditable = profile.type != Profile.Type.Url
design.fetch(client, stack, root)
setContentDesign(design)
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ActivityStart, Event.ActivityStop -> {
design.fetch(client, stack, root)
}
else -> Unit
}
}
design.requests.onReceive {
try {
when (it) {
FilesDesign.Request.PopStack -> {
if (stack.empty()) {
finish()
} else {
stack.pop()
}
}
is FilesDesign.Request.OpenDirectory -> {
stack.push(it.file.id)
}
is FilesDesign.Request.OpenFile -> {
startActivityForResult(
ActivityResultContracts.StartActivityForResult(),
Intent(Intent.ACTION_VIEW).setDataAndType(
client.buildDocumentUri(it.file.id),
"text/plain"
).grantPermissions()
)
}
is FilesDesign.Request.DeleteFile -> {
client.deleteDocument(it.file.id)
}
is FilesDesign.Request.RenameFile -> {
val newName = design.requestFileName(it.file.name)
client.renameDocument(it.file.id, newName)
}
is FilesDesign.Request.ImportFile -> {
if (Build.VERSION.SDK_INT >= 23) {
val hasPermission = ContextCompat.checkSelfPermission(
this@FilesActivity,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
if (!hasPermission) {
val granted = startActivityForResult(
ActivityResultContracts.RequestPermission(),
Manifest.permission.READ_EXTERNAL_STORAGE,
)
if (!granted) {
return@onReceive
}
}
}
val uri: Uri? = startActivityForResult(
ActivityResultContracts.GetContent(),
"*/*"
)
if (uri != null) {
if (it.file == null) {
val name = design.requestFileName(uri.fileName ?: "File")
client.importDocument(stack.last(), uri, name)
} else {
client.copyDocument(it.file!!.id, uri)
}
}
}
is FilesDesign.Request.ExportFile -> {
val uri: Uri? = startActivityForResult(
ActivityResultContracts.CreateDocument(),
it.file.name
)
if (uri != null) {
client.copyDocument(uri, it.file.id)
}
}
}
} catch (e: Exception) {
design.showExceptionToast(e)
}
design.fetch(client, stack, root)
}
if (activityStarted) {
ticker.onReceive {
design.updateElapsed()
}
}
}
}
}
override fun onBackPressed() {
design?.requests?.trySend(FilesDesign.Request.PopStack)
}
private suspend fun FilesDesign.fetch(client: FilesClient, stack: Stack<String>, root: String) {
val documentId = stack.lastOrNull() ?: root
val files = if (stack.empty()) {
val list = client.list(documentId)
val config = list.firstOrNull { it.id.endsWith("config.yaml") }
if (config == null || config.size > 0) list else listOf(config)
} else {
client.list(documentId)
}
swapFiles(files, stack.empty())
}
}
@@ -1,19 +0,0 @@
package com.github.kr328.clash
import android.content.Intent
import com.github.kr328.clash.design.HelpDesign
import kotlinx.coroutines.isActive
class HelpActivity : BaseActivity<HelpDesign>() {
override suspend fun main() {
val design = HelpDesign(this) {
startActivity(Intent(Intent.ACTION_VIEW).setData(it))
}
setContentDesign(design)
while (isActive) {
events.receive()
}
}
}
@@ -0,0 +1,141 @@
package com.github.kr328.clash
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.View
import androidx.core.net.toFile
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.kr328.clash.adapter.LiveLogAdapter
import com.github.kr328.clash.adapter.LogAdapter
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.core.event.LogEvent
import kotlinx.android.synthetic.main.activity_log_viewer.*
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import java.io.File
import kotlin.streams.toList
class LogViewerActivity : BaseActivity() {
private val pauseMutex = Mutex()
private var pollingThread: Thread? = null
private val connection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName?) {
finish()
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val logcat =
requireNotNull(service?.queryLocalInterface(LogcatService::class.java.name)) as LogcatService
startLogcatPoll(logcat)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_log_viewer)
setSupportActionBar(toolbar)
val file = intent?.data
if (file == null)
startLiveMode()
else
startFileMode(file.toFile())
}
override fun onDestroy() {
super.onDestroy()
pollingThread?.interrupt()
}
override fun onStop() {
super.onStop()
launch {
pauseMutex.lock()
}
}
override fun onStart() {
super.onStart()
launch {
if (pauseMutex.isLocked)
pauseMutex.unlock()
}
}
private fun startLiveMode() {
mainList.layoutManager = LinearLayoutManager(this)
mainList.adapter = LiveLogAdapter(this)
mainList.itemAnimator?.addDuration = 100
mainList.itemAnimator?.removeDuration = 100
stop.setOnClickListener {
stopService(LogcatService::class.intent)
finish()
}
bindService(LogcatService::class.intent, connection, Context.BIND_AUTO_CREATE)
}
private fun startFileMode(file: File) {
stop.visibility = View.GONE
launch {
val items = withContext(Dispatchers.IO) {
try {
file.readText()
.split("\n")
.parallelStream()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") }
.map { it.split(" ", limit = 3) }
.filter { it.size == 3 }
.map { LogEvent(LogEvent.Level.valueOf(it[1]), it[2], it[0].toLong()) }
.toList()
} catch (e: Exception) {
showSnackbarException(getString(R.string.open_log_failure), e.message)
throw CancellationException()
}
}
mainList.layoutManager = LinearLayoutManager(this@LogViewerActivity)
mainList.adapter = LogAdapter(this@LogViewerActivity, items)
mainList.adapter!!.notifyItemRangeInserted(0, items.size)
}
}
private fun startLogcatPoll(service: LogcatService) {
launch {
var offset = 0L
while (isActive) {
pauseMutex.lock()
val response = service.pollLogEvent(offset).await()
(mainList.adapter as LiveLogAdapter).insertItems(response.logs)
mainList.apply {
if (computeVerticalScrollOffset() < 30)
scrollToPosition(0)
}
offset = response.offset + response.logs.size
pauseMutex.unlock()
delay(200)
}
}
}
}
@@ -1,185 +0,0 @@
package com.github.kr328.clash
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.net.Uri
import android.os.IBinder
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import com.github.kr328.clash.common.compat.startForegroundServiceCompat
import com.github.kr328.clash.common.util.fileName
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.ticker
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.design.LogcatDesign
import com.github.kr328.clash.design.dialog.withModelProgressBar
import com.github.kr328.clash.design.model.LogFile
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.showExceptionToast
import com.github.kr328.clash.log.LogcatFilter
import com.github.kr328.clash.log.LogcatReader
import com.github.kr328.clash.util.logsDir
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import java.io.OutputStreamWriter
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class LogcatActivity : BaseActivity<LogcatDesign>() {
private var conn: ServiceConnection? = null
override suspend fun main() {
val fileName = intent?.fileName
if (fileName != null) {
val file = LogFile.parseFromFileName(fileName) ?: return showInvalid()
return mainLocalFile(file)
}
return mainStreaming()
}
private suspend fun mainLocalFile(file: LogFile) {
val messages = try {
LogcatReader(this, file).readAll()
} catch (e: Exception) {
return showInvalid()
}
val design = LogcatDesign(this, false)
setContentDesign(design)
design.patchMessages(messages, 0, messages.size)
while (isActive) {
when (design.requests.receive()) {
LogcatDesign.Request.Delete -> {
withContext(Dispatchers.IO) {
logsDir.resolve(file.fileName).delete()
}
finish()
}
LogcatDesign.Request.Export -> {
val output = startActivityForResult(
ActivityResultContracts.CreateDocument(),
file.fileName
)
if (output != null) {
try {
withContext(Dispatchers.IO) {
writeLogTo(messages, file, output)
}
design.showToast(R.string.file_exported, ToastDuration.Long)
} catch (e: Exception) {
design.showExceptionToast(e)
}
}
}
else -> Unit
}
}
}
private suspend fun mainStreaming() {
val design = LogcatDesign(this, true)
setContentDesign(design)
startForegroundServiceCompat(LogcatService::class.intent)
val logcat = bindLogcatService()
val ticker = ticker(500)
var initial = true
while (isActive) {
select<Unit> {
events.onReceive {
}
design.requests.onReceive {
when (it) {
LogcatDesign.Request.Close -> {
stopService(LogcatService::class.intent)
finish()
}
else -> Unit
}
}
if (activityStarted) {
ticker.onReceive {
val snapshot = logcat.snapshot(initial) ?: return@onReceive
design.patchMessages(snapshot.messages, snapshot.removed, snapshot.appended)
initial = false
}
}
}
}
}
override fun onDestroy() {
conn?.apply(this::unbindService)
super.onDestroy()
}
private suspend fun bindLogcatService(): LogcatService {
return suspendCoroutine { ctx ->
bindService(LogcatService::class.intent, object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val srv = service!!.queryLocalInterface("") as LogcatService
ctx.resume(srv)
conn = this
}
override fun onServiceDisconnected(name: ComponentName?) {
conn = null
}
}, Context.BIND_AUTO_CREATE)
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun writeLogTo(messages: List<LogMessage>, file: LogFile, uri: Uri) {
LogcatFilter(OutputStreamWriter(contentResolver.openOutputStream(uri)), this).use {
withContext(Dispatchers.Main) {
withModelProgressBar {
configure {
isIndeterminate = true
max = messages.size
}
withContext(Dispatchers.IO) {
it.writeHeader(file.date)
messages.forEachIndexed { idx, msg ->
configure {
isIndeterminate = false
progress = idx
}
it.writeMessage(msg)
}
}
}
}
}
}
private fun showInvalid() {
Toast.makeText(this, R.string.invalid_log_file, Toast.LENGTH_LONG).show()
}
}
@@ -1,5 +1,7 @@
package com.github.kr328.clash
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.ComponentName
@@ -7,158 +9,258 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.IInterface
import androidx.core.app.NotificationChannelCompat
import androidx.collection.CircularArray
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.github.kr328.clash.common.compat.getColorCompat
import com.github.kr328.clash.common.compat.pendingIntentFlags
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.log.LogcatCache
import com.github.kr328.clash.log.LogcatWriter
import com.github.kr328.clash.service.RemoteService
import com.github.kr328.clash.service.remote.ILogObserver
import com.github.kr328.clash.service.remote.IRemoteService
import com.github.kr328.clash.service.remote.unwrap
import com.github.kr328.clash.util.logsDir
import com.github.kr328.clash.common.utils.createLanguageConfigurationContext
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.Log
import com.github.kr328.clash.core.event.LogEvent
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.service.ClashManagerService
import com.github.kr328.clash.service.IClashManager
import com.github.kr328.clash.service.transact.IStreamCallback
import com.github.kr328.clash.service.transact.ParcelableContainer
import com.github.kr328.clash.utils.format
import com.github.kr328.clash.utils.logsDir
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.selects.select
import java.io.FileWriter
import java.io.IOException
import java.util.*
import kotlin.math.max
class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.Default), IInterface {
private val cache = LogcatCache()
class LogcatService : Service(), CoroutineScope by MainScope(), IInterface {
companion object {
private const val NOTIFICATION_CHANNEL_ID = "clash_logcat_channel"
private const val NOTIFICATION_ID = 256
private const val MAX_CACHE_COUNT = 200
private const val LOG_LISTENER_KEY = "logcat_service"
private const val LOG_CONTENT_FORMAT = "%d %s %s"
var isServiceRunning: Boolean = false
}
data class Request(val offset: Long, val response: CompletableDeferred<Response>)
data class Response(val offset: Long, val logs: List<LogEvent>)
private val logChannel = Channel<LogEvent>(MAX_CACHE_COUNT)
private val requestChannel = Channel<Request>()
private val cache: CircularArray<LogEvent> = CircularArray()
private var cacheOffset = 0L
private val entity = LogFile.generate()
private val connection = object : ServiceConnection {
private var manager: IClashManager? = null
override fun onServiceDisconnected(name: ComponentName?) {
stopSelf()
manager?.unregisterLogListener(LOG_LISTENER_KEY)
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
startObserver(service ?: return stopSelf())
manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
manager?.registerLogListener(LOG_LISTENER_KEY, object : IStreamCallback.Stub() {
override fun complete() {}
override fun completeExceptionally(reason: String?) {}
override fun send(data: ParcelableContainer?) {
data ?: return
data.data ?: return
logChannel.offer(data.data as LogEvent)
}
})
}
}
override fun onCreate() {
super.onCreate()
running = true
isServiceRunning = true
createNotificationChannel()
showNotification()
bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
bindService(ClashManagerService::class.intent, connection, Context.BIND_AUTO_CREATE)
launchProcessor()
launchSaveThread()
}
override fun onDestroy() {
logChannel.close()
cancel()
connection.onServiceDisconnected(null)
unbindService(connection)
stopForeground(true)
running = false
isServiceRunning = false
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder {
override fun onBind(intent: Intent?): IBinder? {
return this.asBinder()
}
override fun asBinder(): IBinder {
return object : Binder() {
override fun queryLocalInterface(descriptor: String): IInterface {
override fun queryLocalInterface(descriptor: String): IInterface? {
return this@LogcatService
}
}
}
suspend fun snapshot(full: Boolean): LogcatCache.Snapshot? {
return cache.snapshot(full)
override fun attachBaseContext(base: Context?) {
val b = base ?: return super.attachBaseContext(base)
val language = UiSettings(b).get(UiSettings.LANGUAGE)
super.attachBaseContext(b.createLanguageConfigurationContext(language))
}
private fun startObserver(binder: IBinder) {
if (!binder.isBinderAlive)
return stopSelf()
// Export to UI
suspend fun pollLogEvent(offset: Long): CompletableDeferred<Response> {
val request = Request(offset, CompletableDeferred())
launch(Dispatchers.IO) {
val service = binder.unwrap(IRemoteService::class).clash()
val channel = Channel<LogMessage>(CACHE_CAPACITY)
requestChannel.send(request)
return request.response
}
private fun launchProcessor() {
launch {
val pendingRequest: MutableList<Request> = mutableListOf()
try {
logsDir.mkdirs()
withContext(Dispatchers.Default) {
while (isActive) {
select<Unit> {
logChannel.onReceive {
cache.addLast(it)
LogcatWriter(this@LogcatService).use {
val observer = object : ILogObserver {
override fun newItem(log: LogMessage) {
channel.trySend(log)
if (cache.size() > MAX_CACHE_COUNT) {
cache.removeFromStart(1)
cacheOffset++
}
}
requestChannel.onReceive {
pendingRequest.add(it)
}
}
// Handle pending requests
val iterator = pendingRequest.iterator()
while (iterator.hasNext()) {
val request = iterator.next()
if (request.offset >= cacheOffset + cache.size())
continue
val logs = mutableListOf<LogEvent>()
val responseOffset = max(cacheOffset, request.offset)
val begin = (responseOffset - cacheOffset).toInt()
for (i in begin until cache.size())
logs.add(cache[i])
request.response.complete(Response(responseOffset, logs))
iterator.remove()
}
}
service.setLogObserver(observer)
while (isActive) {
val msg = channel.receive()
it.appendMessage(msg)
cache.append(msg)
}
}
} catch (e: IOException) {
Log.e("Write log file: $e", e)
} finally {
withContext(NonCancellable) {
if (binder.isBinderAlive) {
service.setLogObserver(null)
}
} catch (e: Exception) {
return@launch
}
}
}
stopSelf()
private fun launchSaveThread() {
launch {
withContext(Dispatchers.IO) {
logsDir.mkdirs()
try {
FileWriter(logsDir.resolve(entity.fileName)).buffered().use { output ->
var offset = 0L
output.write("# Logcat on ${Date(entity.date).format(this@LogcatService)}")
output.newLine()
while (isActive) {
val response = pollLogEvent(offset).await()
if (response.offset != offset) {
output.write("# Lost ${response.offset - offset} items")
output.newLine()
}
response.logs.forEach {
output.write(
LOG_CONTENT_FORMAT.format(
it.time,
it.level,
it.message
)
)
output.newLine()
}
offset = response.offset + response.logs.size
}
}
} catch (e: IOException) {
Log.w("Logcat file write failure", e)
}
}
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O)
return
NotificationManagerCompat.from(this)
.createNotificationChannel(
NotificationChannelCompat.Builder(
CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_DEFAULT
).setName(getString(R.string.clash_logcat)).build()
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
getString(R.string.clash_logcat),
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
private fun showNotification() {
val notification = NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_logo_service)
.setColor(getColorCompat(R.color.color_clash_light))
.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setColor(getColor(R.color.colorAccentService))
.setContentTitle(getString(R.string.clash_logcat))
.setContentText(getString(R.string.running))
.setGroup(NOTIFICATION_CHANNEL_ID)
.setContentIntent(
PendingIntent.getActivity(
this,
R.id.nf_logcat_status,
LogcatActivity::class.intent
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP),
pendingIntentFlags(PendingIntent.FLAG_UPDATE_CURRENT)
NOTIFICATION_ID,
LogsActivity::class.intent
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
PendingIntent.FLAG_UPDATE_CURRENT
)
)
.build()
startForeground(R.id.nf_logcat_status, notification)
}
companion object {
private const val CHANNEL_ID = "clash_logcat_channel"
private const val CACHE_CAPACITY = 128
var running: Boolean = false
startForeground(NOTIFICATION_ID, notification)
}
}
@@ -1,71 +1,268 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.setFileName
import com.github.kr328.clash.design.LogsDesign
import com.github.kr328.clash.design.model.LogFile
import com.github.kr328.clash.util.logsDir
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.TypedValue
import android.view.ViewGroup
import androidx.annotation.ColorInt
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.kr328.clash.adapter.LogFileAdapter
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.startForegroundServiceCompat
import com.github.kr328.clash.design.common.Category
import com.github.kr328.clash.design.view.CommonUiLayout
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.utils.format
import com.github.kr328.clash.utils.logsDir
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_logs.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.FileInputStream
import java.util.*
class LogsActivity : BaseActivity<LogsDesign>() {
override suspend fun main() {
if (LogcatService.running) {
return startActivity(LogcatActivity::class.intent)
class LogsActivity : BaseActivity() {
companion object {
const val REQUEST_CODE = 50000
}
private var lastWriteFile: LogFile? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_logs)
setSupportActionBar(toolbar)
if (LogcatService.isServiceRunning) {
startActivity(LogViewerActivity::class.intent)
finish()
return
}
val design = LogsDesign(this)
commonUi.build {
option(
title = getString(R.string.clash_logcat),
summary = getString(R.string.tap_to_start),
icon = getDrawable(R.drawable.ic_adb)
) {
onClick {
startForegroundServiceCompat(LogcatService::class.intent)
setContentDesign(design)
startActivity(LogViewerActivity::class.intent)
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ActivityStart -> {
val files = withContext(Dispatchers.IO) {
loadFiles()
}
design.patchLogs(files)
}
else -> Unit
}
finish()
}
design.requests.onReceive {
when (it) {
LogsDesign.Request.StartLogcat -> {
startActivity(LogcatActivity::class.intent)
}
category(text = getString(R.string.history), id = "history", showTopSeparator = true)
}
finish()
}
LogsDesign.Request.DeleteAll -> {
if (design.requestDeleteAll()) {
withContext(Dispatchers.IO) {
deleteAllLogs()
}
clearAll.setOnClickListener {
showClearAllDialog()
}
events.trySend(Event.ActivityStart)
val adapter = LogFileAdapter(
this@LogsActivity,
onItemClicked = {
startActivity(
LogViewerActivity::class.intent
.setData(Uri.fromFile(logsDir.resolve(it.fileName)))
)
},
onMenuClicked = this::showMenu
)
val layoutManager = LinearLayoutManager(this@LogsActivity)
mainList.layoutManager = layoutManager
mainList.adapter = adapter
}
override fun onStart() {
super.onStart()
if (LogcatService.isServiceRunning)
return
refreshList()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode == Activity.RESULT_OK) {
val url = data?.data ?: return
val file = lastWriteFile ?: return
lastWriteFile = null
launch {
withContext(Dispatchers.IO) {
contentResolver.openOutputStream(url)?.use { output ->
FileInputStream(logsDir.resolve(file.fileName)).use { input ->
input.copyTo(output)
}
}
is LogsDesign.Request.OpenFile -> {
startActivity(LogcatActivity::class.intent.setFileName(it.file.fileName))
}
}
Snackbar.make(rootView, R.string.file_exported, Snackbar.LENGTH_LONG).show()
}
}
return
}
super.onActivityResult(requestCode, resultCode, data)
}
private fun refreshList() {
launch {
val files = withContext(Dispatchers.IO) {
(logsDir.listFiles() ?: emptyArray())
.asSequence()
.filter { it.name.endsWith(".log") }
.map { LogFile.parseFromFileName(it.name) }
.filterNotNull()
.toList()
}
if (files.isEmpty())
commonUi.screen.requireElement<Category>("history").isHidden = true
val adapter = mainList.adapter as LogFileAdapter
val old = adapter.fileList
val result = withContext(Dispatchers.Default) {
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
return old[oldItemPosition].fileName == files[newItemPosition].fileName
}
override fun getOldListSize(): Int {
return old.size
}
override fun getNewListSize(): Int {
return files.size
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
return old[oldItemPosition] == files[newItemPosition]
}
})
}
adapter.fileList = files
result.dispatchUpdatesTo(adapter)
}
}
private fun showClearAllDialog() {
AlertDialog.Builder(this)
.setTitle(R.string.delete_all_logs)
.setMessage(R.string.delete_all_logs_warn)
.setPositiveButton(R.string.ok) { _, _ -> deleteAllLogs() }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
}
private fun showMenu(logFile: LogFile) {
val dialog = BottomSheetDialog(this)
val menu = CommonUiLayout(this).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
@ColorInt
val errorColor = TypedValue().run {
theme.resolveAttribute(R.attr.colorError, this, true)
data
}
menu.build {
option(
icon = getDrawable(R.drawable.ic_save),
title = getString(R.string.export)
) {
onClick {
export(logFile)
dialog.dismiss()
}
}
option(
icon = getDrawable(R.drawable.ic_delete_colorful),
title = getString(R.string.delete)
) {
textColor = errorColor
onClick {
delete(logFile)
dialog.dismiss()
}
}
}
}
private fun loadFiles(): List<LogFile> {
val list = cacheDir.resolve("logs").listFiles()?.toList() ?: emptyList()
return list.mapNotNull { LogFile.parseFromFileName(it.name) }
dialog.dismissWithAnimation = true
dialog.setContentView(menu)
dialog.show()
}
private fun deleteAllLogs() {
logsDir.deleteRecursively()
launch {
withContext(Dispatchers.IO) {
logsDir.deleteRecursively()
}
refreshList()
}
}
private fun export(file: LogFile) {
if (lastWriteFile != null)
return
val d = Date(file.date)
val exportName = getString(R.string.format_export_log_name, d.format(this))
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.setType("text/plain")
.putExtra(Intent.EXTRA_TITLE, exportName)
lastWriteFile = file
startActivityForResult(intent, REQUEST_CODE)
}
private fun delete(file: LogFile) {
val d = {
launch {
withContext(Dispatchers.IO) {
logsDir.resolve(file.fileName).delete()
}
refreshList()
}
}
AlertDialog.Builder(this)
.setTitle(R.string.delete_log)
.setMessage(getString(R.string.delete_log_warn, file.fileName))
.setPositiveButton(R.string.ok) { _, _ -> d() }
.setNegativeButton(R.string.cancel) { _, _ -> }
.show()
}
}
@@ -1,151 +1,202 @@
package com.github.kr328.clash
import androidx.activity.result.contract.ActivityResultContracts
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.ticker
import com.github.kr328.clash.design.MainDesign
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.store.TipsStore
import com.github.kr328.clash.util.startClashService
import com.github.kr328.clash.util.stopClashService
import com.github.kr328.clash.util.withClash
import com.github.kr328.clash.util.withProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.asBytesString
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.util.startClashService
import com.github.kr328.clash.service.util.stopClashService
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
class MainActivity : BaseActivity<MainDesign>() {
override suspend fun main() {
val design = MainDesign(this)
class MainActivity : BaseActivity() {
companion object {
private const val REQUEST_CODE = 40000
}
setContentDesign(design)
private var bandwidthJob: Job? = null
launch(Dispatchers.IO) {
showUpdatedTips(design)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
design.fetch()
setContentView(R.layout.activity_main)
val ticker = ticker(TimeUnit.SECONDS.toMillis(1))
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ActivityStart,
Event.ServiceRecreated,
Event.ClashStop, Event.ClashStart,
Event.ProfileLoaded, Event.ProfileChanged -> design.fetch()
else -> Unit
}
}
design.requests.onReceive {
when (it) {
MainDesign.Request.ToggleStatus -> {
if (clashRunning)
stopClashService()
else
design.startClash()
}
MainDesign.Request.OpenProxy ->
startActivity(ProxyActivity::class.intent)
MainDesign.Request.OpenProfiles ->
startActivity(ProfilesActivity::class.intent)
MainDesign.Request.OpenProviders ->
startActivity(ProvidersActivity::class.intent)
MainDesign.Request.OpenLogs ->
startActivity(LogsActivity::class.intent)
MainDesign.Request.OpenSettings ->
startActivity(SettingsActivity::class.intent)
MainDesign.Request.OpenHelp ->
startActivity(HelpActivity::class.intent)
MainDesign.Request.OpenAbout ->
design.showAbout(queryAppVersionName())
}
}
if (clashRunning) {
ticker.onReceive {
design.fetchTraffic()
status.setOnClickListener {
if (clashRunning) {
stopClashService()
} else {
val vpnRequest = startClashService()
if (vpnRequest != null) {
val resolved = packageManager.resolveActivity(vpnRequest, 0)
if (resolved != null) {
startActivityForResult(vpnRequest, REQUEST_CODE)
} else {
showSnackbarException(getString(R.string.missing_vpn_component), null)
}
}
}
}
}
private suspend fun showUpdatedTips(design: MainDesign) {
val tips = TipsStore(this)
proxies.setOnClickListener {
startActivity(ProxiesActivity::class.intent)
}
if (tips.primaryVersion != TipsStore.CURRENT_PRIMARY_VERSION) {
tips.primaryVersion = TipsStore.CURRENT_PRIMARY_VERSION
profiles.setOnClickListener {
startActivity(ProfilesActivity::class.intent)
}
val pkg = packageManager.getPackageInfo(packageName, 0)
logs.setOnClickListener {
startActivity(LogsActivity::class.intent)
}
if (pkg.firstInstallTime != pkg.lastUpdateTime) {
design.showUpdatedTips()
}
settings.setOnClickListener {
startActivity(SettingsActivity::class.intent)
}
support.setOnClickListener {
startActivity(SupportActivity::class.intent)
}
about.setOnClickListener {
showAboutDialog()
}
}
private suspend fun MainDesign.fetch() {
setClashRunning(clashRunning)
override fun onStart() {
super.onStart()
val state = withClash {
queryTunnelState()
}
val providers = withClash {
queryProviders()
}
setMode(state.mode)
setHasProviders(providers.isNotEmpty())
withProfile {
setProfileName(queryActive()?.name)
}
updateClashStatus()
}
private suspend fun MainDesign.fetchTraffic() {
withClash {
setForwarded(queryTrafficTotal())
}
override fun onStop() {
super.onStop()
stopBandwidthPolling()
}
private suspend fun MainDesign.startClash() {
val active = withProfile { queryActive() }
if (active == null || !active.imported) {
showToast(R.string.no_profile_selected, ToastDuration.Long) {
setAction(R.string.profiles) {
startActivity(ProfilesActivity::class.intent)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode == Activity.RESULT_OK)
startClashService()
return
}
val vpnRequest = startClashService()
super.onActivityResult(requestCode, resultCode, data)
}
try {
if (vpnRequest != null) {
val result = startActivityForResult(
ActivityResultContracts.StartActivityForResult(),
vpnRequest
)
override suspend fun onClashStarted() {
updateClashStatus()
}
if (result.resultCode == RESULT_OK)
startClashService()
override suspend fun onClashStopped(reason: String?) {
updateClashStatus()
if (reason != null)
showSnackbarException(getString(R.string.clash_start_failure), reason)
}
override suspend fun onClashProfileLoaded() {
updateClashStatus()
}
private fun startBandwidthPolling() {
if (bandwidthJob != null)
return
bandwidthJob = launch {
withClash {
try {
while (clashRunning && isActive) {
val bandwidth = queryBandwidth()
status.summary = getString(
R.string.format_traffic_forwarded,
bandwidth.asBytesString()
)
delay(1000)
}
} finally {
bandwidthJob = null
}
}
} catch (e: Exception) {
design?.showToast(R.string.unable_to_start_vpn, ToastDuration.Long)
}
}
private suspend fun queryAppVersionName(): String {
return withContext(Dispatchers.IO) {
packageManager.getPackageInfo(packageName, 0).versionName
private fun stopBandwidthPolling() {
bandwidthJob?.cancel()
}
private fun updateClashStatus() {
if (clashRunning) {
startBandwidthPolling()
status.setCardBackgroundColor(getColor(R.color.primaryCardColorStarted))
status.icon = getDrawable(R.drawable.ic_started)
status.title = getText(R.string.running)
proxies.visibility = View.VISIBLE
} else {
stopBandwidthPolling()
status.setCardBackgroundColor(getColor(R.color.primaryCardColorStopped))
status.icon = getDrawable(R.drawable.ic_stopped)
status.title = getText(R.string.stopped)
status.summary = getText(R.string.tap_to_start)
proxies.visibility = View.GONE
}
launch {
val general = withClash {
queryGeneral()
}
val active = withProfile {
queryActive()
}
val modeResId = when (general.mode) {
General.Mode.DIRECT -> R.string.direct_mode
General.Mode.GLOBAL -> R.string.global_mode
General.Mode.RULE -> R.string.rule_mode
}
val profileString =
if (active == null)
getText(R.string.not_selected)
else
getString(R.string.format_profile_activated, active.name)
proxies.summary = getText(modeResId)
profiles.summary = profileString
}
}
private fun showAboutDialog() {
launch {
val content = withContext(Dispatchers.Default) {
val packageInfo = packageManager.getPackageInfo(packageName, 0)
LayoutInflater.from(this@MainActivity)
.inflate(R.layout.dialog_abort, rootView as ViewGroup?, false).apply {
findViewById<View>(android.R.id.icon).background =
getDrawable(R.drawable.ic_logo)
findViewById<TextView>(android.R.id.title).text =
getText(R.string.application_name)
findViewById<TextView>(android.R.id.summary).text = packageInfo.versionName
}
}
AlertDialog.Builder(this@MainActivity)
.setView(content)
.show()
}
}
}
@@ -2,11 +2,19 @@ package com.github.kr328.clash
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.kr328.clash.common.Global
import com.github.kr328.clash.common.compat.currentProcessName
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.common.utils.componentName
import com.github.kr328.clash.dump.LogcatDumper
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.remote.Remote
import com.github.kr328.clash.service.util.sendServiceRecreated
import com.microsoft.appcenter.AppCenter
import com.microsoft.appcenter.analytics.Analytics
import com.microsoft.appcenter.crashes.AbstractCrashesListener
import com.microsoft.appcenter.crashes.Crashes
import com.microsoft.appcenter.crashes.ingestion.models.ErrorAttachmentLog
import com.microsoft.appcenter.crashes.model.ErrorReport
@Suppress("unused")
class MainApplication : Application() {
@@ -20,20 +28,44 @@ class MainApplication : Application() {
super.onCreate()
// Initialize AppCenter
Tracker.initialize(this)
if (BuildConfig.APP_CENTER_KEY.isNotEmpty() && !BuildConfig.DEBUG) {
AppCenter.start(
this,
BuildConfig.APP_CENTER_KEY,
Analytics::class.java, Crashes::class.java
)
val processName = currentProcessName
Crashes.setListener(object : AbstractCrashesListener() {
override fun getErrorAttachments(report: ErrorReport?): MutableIterable<ErrorAttachmentLog> {
report ?: return mutableListOf()
Log.d("Process $processName started")
if (!report.stackTrace.contains("DeadObjectException"))
return mutableListOf()
if (processName == packageName) {
Remote.launch()
} else {
sendServiceRecreated()
val logcat = LogcatDumper.dump().joinToString(separator = "\n")
return mutableListOf(
ErrorAttachmentLog.attachmentWithText(logcat, "logcat.txt")
)
}
})
}
}
fun finalize() {
Global.destroy()
Global.openMainIntent = {
Intent(Intent.ACTION_MAIN).apply {
component = MainActivity::class.componentName
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
}
Global.openProfileIntent = {
Intent(Intent.ACTION_MAIN).apply {
component = ProfileEditActivity::class.componentName
data = Uri.fromParts("id", it.toString(), null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
}
Remote.init(this)
Broadcasts.init(this)
}
}
@@ -1,39 +0,0 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.design.NetworkSettingsDesign
import com.github.kr328.clash.service.store.ServiceStore
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
class NetworkSettingsActivity : BaseActivity<NetworkSettingsDesign>() {
override suspend fun main() {
val design = NetworkSettingsDesign(
this,
uiStore,
ServiceStore(this),
clashRunning,
)
setContentDesign(design)
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ClashStart, Event.ClashStop, Event.ServiceRecreated ->
recreate()
else -> Unit
}
}
design.requests.onReceive {
when (it) {
NetworkSettingsDesign.Request.StartAccessControlList ->
startActivity(AccessControlActivity::class.intent)
}
}
}
}
}
}
@@ -1,143 +0,0 @@
package com.github.kr328.clash
import android.app.Activity
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.result.contract.ActivityResultContracts
import com.github.kr328.clash.common.constants.Intents
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.setUUID
import com.github.kr328.clash.design.NewProfileDesign
import com.github.kr328.clash.design.model.ProfileProvider
import com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.util.withProfile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
import java.util.*
class NewProfileActivity : BaseActivity<NewProfileDesign>() {
private val self: NewProfileActivity
get() = this
override suspend fun main() {
val design = NewProfileDesign(this)
design.patchProviders(queryProfileProviders())
setContentDesign(design)
while (isActive) {
select<Unit> {
events.onReceive {
}
design.requests.onReceive {
when (it) {
is NewProfileDesign.Request.Create -> {
withProfile {
val name = getString(R.string.new_profile)
val uuid: UUID? = when (val p = it.provider) {
is ProfileProvider.File ->
create(Profile.Type.File, name)
is ProfileProvider.Url ->
create(Profile.Type.Url, name)
is ProfileProvider.External -> {
val data = p.get()
if (data != null) {
val (uri, initialName) = data
create(
Profile.Type.External,
initialName ?: name,
uri.toString()
)
} else {
null
}
}
}
if (uuid != null)
launchProperties(uuid)
}
}
is NewProfileDesign.Request.OpenDetail -> {
launchAppDetailed(it.provider)
}
}
}
}
}
}
private fun launchAppDetailed(provider: ProfileProvider.External) {
val data = Uri.fromParts(
"package",
provider.intent.component?.packageName ?: return,
null
)
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData(data))
}
private suspend fun launchProperties(uuid: UUID) {
val r = startActivityForResult(
ActivityResultContracts.StartActivityForResult(),
PropertiesActivity::class.intent.setUUID(uuid)
)
if (r.resultCode == Activity.RESULT_OK)
finish()
}
private suspend fun ProfileProvider.External.get(): Pair<Uri, String?>? {
val result = startActivityForResult(
ActivityResultContracts.StartActivityForResult(),
intent
)
if (result.resultCode != RESULT_OK)
return null
val uri = result.data?.data
val name = result.data?.getStringExtra(Intents.EXTRA_NAME)
if (uri != null) {
return uri to name
}
return null
}
private suspend fun queryProfileProviders(): List<ProfileProvider> {
return withContext(Dispatchers.IO) {
val providers = packageManager.queryIntentActivities(
Intent(Intents.ACTION_PROVIDE_URL),
0
).map {
val activity = it.activityInfo
val name = activity.applicationInfo.loadLabel(packageManager)
val summary = activity.loadLabel(packageManager)
val icon = activity.loadIcon(packageManager)
val intent = Intent(Intents.ACTION_PROVIDE_URL)
.setComponent(
ComponentName(
activity.packageName,
activity.name
)
)
ProfileProvider.External(name.toString(), summary.toString(), icon, intent)
}
listOf(ProfileProvider.File(self), ProfileProvider.Url(self)) + providers
}
}
}
@@ -1,89 +0,0 @@
package com.github.kr328.clash
import android.content.pm.PackageManager
import com.github.kr328.clash.common.compat.getDrawableCompat
import com.github.kr328.clash.common.constants.Metadata
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.design.OverrideSettingsDesign
import com.github.kr328.clash.design.model.AppInfo
import com.github.kr328.clash.design.util.toAppInfo
import com.github.kr328.clash.service.store.ServiceStore
import com.github.kr328.clash.util.withClash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.withContext
class OverrideSettingsActivity : BaseActivity<OverrideSettingsDesign>() {
override suspend fun main() {
val configuration = withClash { queryOverride(Clash.OverrideSlot.Persist) }
val service = ServiceStore(this)
defer {
withClash {
patchOverride(Clash.OverrideSlot.Persist, configuration)
}
}
val design = OverrideSettingsDesign(
this,
configuration
)
setContentDesign(design)
while (isActive) {
select<Unit> {
events.onReceive {
}
design.requests.onReceive {
when (it) {
OverrideSettingsDesign.Request.ResetOverride -> {
if (design.requestResetConfirm()) {
defer {
withClash {
clearOverride(Clash.OverrideSlot.Persist)
}
service.sideloadGeoip = ""
}
finish()
}
}
OverrideSettingsDesign.Request.EditSideloadGeoip -> {
withContext(Dispatchers.IO) {
val list = querySideloadProviders()
val initial = service.sideloadGeoip
val exist = list.any { info -> info.packageName == initial }
service.sideloadGeoip =
design.requestSelectSideload(if (exist) initial else "", list)
}
}
}
}
}
}
}
private fun querySideloadProviders(): List<AppInfo> {
val apps = packageManager.getInstalledPackages(PackageManager.GET_META_DATA)
.filter {
it.applicationInfo.metaData?.containsKey(Metadata.GEOIP_FILE_NAME)
?: false
}
.map { it.toAppInfo(packageManager) }
return listOf(
AppInfo(
packageName = "",
label = getString(R.string.use_built_in),
icon = getDrawableCompat(R.drawable.ic_baseline_work)!!,
installTime = 0,
updateDate = 0,
)
) + apps
}
}
@@ -0,0 +1,167 @@
package com.github.kr328.clash
import android.content.pm.ApplicationInfo
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.SearchView
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.kr328.clash.adapter.PackagesAdapter
import com.github.kr328.clash.service.settings.ServiceSettings
import kotlinx.android.synthetic.main.activity_access_control_packages.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlin.streams.toList
class PackagesActivity : BaseActivity() {
private val activity: PackagesActivity
get() = this
private val adapter: PackagesAdapter?
get() = mainList.adapter as PackagesAdapter?
private val refreshChannel = Channel<Unit>(Channel.CONFLATED)
private var keyword: String = ""
private var sort: PackagesAdapter.Sort = PackagesAdapter.Sort.NAME
private var decrease: Boolean = false
private var systemApp: Boolean = true
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_access_control_packages)
setSupportActionBar(toolbar)
launch {
val appsDeferred = async {
withContext(Dispatchers.IO) {
val pm = packageManager
val packages = pm.getInstalledPackages(0)
packages.parallelStream()
.map {
PackagesAdapter.AppInfo(
it.packageName,
it.applicationInfo.loadLabel(pm).toString(),
it.applicationInfo.loadIcon(pm),
it.firstInstallTime, it.lastUpdateTime,
it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
)
}
.toList()
}
}
val selectedDeferred = async {
withContext(Dispatchers.IO) {
ServiceSettings(activity).get(ServiceSettings.ACCESS_CONTROL_PACKAGES)
}
}
val apps = appsDeferred.await()
val selected = selectedDeferred.await()
val adapter = PackagesAdapter(activity, apps)
mainList.adapter = adapter
mainList.layoutManager = LinearLayoutManager(activity)
adapter.selectedPackages.addAll(selected)
progress.visibility = View.GONE
refreshChannel.offer(Unit)
while (isActive) {
refreshChannel.receive()
adapter.applyFilter(keyword, sort, decrease, systemApp)
mainList.scrollToPosition(0)
delay(200)
}
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
if (!super.onCreateOptionsMenu(menu))
return false
menuInflater.inflate(R.menu.packages, menu)
menu?.apply {
(findItem(R.id.search).actionView as SearchView).apply {
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?) = false
override fun onQueryTextChange(newText: String?): Boolean {
keyword = newText ?: ""
refreshChannel.offer(Unit)
return true
}
})
}
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item))
return true
when (item.itemId) {
R.id.systemApps -> {
item.isChecked = !item.isChecked
systemApp = item.isChecked
}
R.id.sortReverse -> {
item.isChecked = !item.isChecked
decrease = item.isChecked
}
R.id.sortName -> {
item.isChecked = true
sort = PackagesAdapter.Sort.NAME
}
R.id.sortPackageName -> {
item.isChecked = true
sort = PackagesAdapter.Sort.PACKAGE
}
R.id.sortUpdateTime -> {
item.isChecked = true
sort = PackagesAdapter.Sort.UPDATE_TIME
}
R.id.sortInstallTime -> {
item.isChecked = true
sort = PackagesAdapter.Sort.INSTALL_TIME
}
else -> return false
}
refreshChannel.offer(Unit)
return true
}
override fun onStop() {
super.onStop()
val packageList = adapter?.selectedPackages ?: return
GlobalScope.launch {
withContext(Dispatchers.IO) {
ServiceSettings(activity).commit {
put(ServiceSettings.ACCESS_CONTROL_PACKAGES, packageList)
}
}
}
}
}
@@ -0,0 +1,135 @@
package com.github.kr328.clash
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.fragment.ProfileEditFragment
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.model.Profile
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_profile_edit.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class ProfileEditActivity : BaseActivity() {
private var editor: ProfileEditFragment? = null
private var processing = false
set(value) {
field = value
if (value) {
saving.visibility = View.VISIBLE
save.visibility = View.INVISIBLE
} else {
saving.visibility = View.INVISIBLE
save.visibility = View.VISIBLE
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile_edit)
setSupportActionBar(toolbar)
toolbar.setTitle(R.string.loading)
launch {
val id = intent.data?.schemeSpecificPart?.toLongOrNull() ?: return@launch finish()
val metadata = withProfile {
queryById(id)
} ?: return@launch finish()
when {
metadata.lastModified > 0 ->
toolbar.setTitle(R.string.edit_profile)
metadata.name.isBlank() ->
toolbar.setTitle(R.string.new_profile)
else ->
toolbar.setTitle(R.string.clone_profile)
}
val fragment = ProfileEditFragment(
metadata.id,
metadata.name, metadata.uri, metadata.interval,
metadata.type, metadata.source
)
editor = fragment
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, fragment)
.commit()
save.setOnClickListener {
val name = fragment.name
val uri = fragment.uri
val interval = fragment.interval
if (name.isBlank()) {
Snackbar.make(rootView, R.string.empty_name, Snackbar.LENGTH_LONG).show()
return@setOnClickListener
}
val newMetadata = metadata.copy(
name = name,
uri = uri,
interval = interval
)
processing = true
commit(newMetadata)
}
}
}
override fun onBackPressed() {
if (processing) {
Snackbar.make(rootView, R.string.processing, Snackbar.LENGTH_LONG).show()
return
}
if ( editor?.isModified != true)
return finish()
AlertDialog.Builder(this)
.setTitle(R.string.exit_without_save)
.setMessage(R.string.exit_without_save_warning)
.setNegativeButton(R.string.cancel) { _, _ -> }
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.show()
}
override fun onDestroy() {
runBlocking {
withProfile {
val id = editor?.id ?: return@withProfile
release(id)
}
}
super.onDestroy()
}
private fun commit(metadata: Profile) {
launch {
try {
withProfile {
update(metadata.id, metadata)
commitAsync(metadata.id).await()
}
setResult(Activity.RESULT_OK)
finish()
} catch (e: Exception) {
showSnackbarException(getString(R.string.download_failure), e.message)
}
processing = false
}
}
}
@@ -1,77 +1,180 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.setUUID
import com.github.kr328.clash.common.util.ticker
import com.github.kr328.clash.design.ProfilesDesign
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import com.github.kr328.clash.adapter.ProfileAdapter
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.ProfileReceiver
import com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.util.withProfile
import com.github.kr328.clash.service.util.sendBroadcastSelf
import com.github.kr328.clash.weight.ProfilesMenu
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_profiles.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import java.io.FileNotFoundException
import java.util.*
class ProfilesActivity : BaseActivity<ProfilesDesign>() {
override suspend fun main() {
val design = ProfilesDesign(this)
class ProfilesActivity : BaseActivity(), ProfileAdapter.Callback, ProfilesMenu.Callback {
companion object {
private const val EDITOR_REQUEST_CODE = 30000
}
setContentDesign(design)
private var backgroundJob: Job? = null
private val reloadMutex = Mutex()
private val editorStack = Stack<Profile>()
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profiles)
setSupportActionBar(toolbar)
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ActivityStart, Event.ProfileChanged -> {
design.fetch()
}
else -> Unit
}
}
design.requests.onReceive {
when (it) {
ProfilesDesign.Request.Create ->
startActivity(NewProfileActivity::class.intent)
ProfilesDesign.Request.UpdateAll ->
withProfile {
queryAll().forEach { p ->
if (p.imported && p.type != Profile.Type.File)
update(p.uuid)
}
}
is ProfilesDesign.Request.Update ->
withProfile { update(it.profile.uuid) }
is ProfilesDesign.Request.Delete ->
withProfile { delete(it.profile.uuid) }
is ProfilesDesign.Request.Edit ->
startActivity(PropertiesActivity::class.intent.setUUID(it.profile.uuid))
is ProfilesDesign.Request.Active -> {
withProfile {
if (it.profile.imported)
setActive(it.profile)
else
design.requestSave(it.profile)
}
}
is ProfilesDesign.Request.Duplicate -> {
val uuid = withProfile { clone(it.profile.uuid) }
mainList.layoutManager = LinearLayoutManager(this)
mainList.adapter = ProfileAdapter(this, this)
}
startActivity(PropertiesActivity::class.intent.setUUID(uuid))
}
}
}
if (activityStarted) {
ticker.onReceive {
design.updateElapsed()
}
}
override fun onStart() {
super.onStart()
backgroundJob = launch {
reloadProfiles()
while (isActive) {
delay(1000 * 60)
// Refresh without animation
(mainList.adapter as ProfileAdapter).notifyDataSetChanged()
}
}
}
private suspend fun ProfilesDesign.fetch() {
withProfile {
patchProfiles(queryAll())
override fun onStop() {
super.onStop()
backgroundJob?.cancel()
backgroundJob = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == EDITOR_REQUEST_CODE) {
launch {
val profile = editorStack.pop()
withProfile {
update(profile.id, profile)
startUpdate(profile.id)
}
}
return
}
super.onActivityResult(requestCode, resultCode, data)
}
override suspend fun onClashProfileChanged() {
reloadProfiles()
}
private suspend fun reloadProfiles() {
if (!reloadMutex.tryLock())
return
val profiles = withProfile {
queryAll()
}
(mainList.adapter as ProfileAdapter).setEntitiesAsync(profiles.toList())
reloadMutex.unlock()
}
override fun onProfileClicked(entity: Profile) {
launch {
withProfile {
setActive(entity.id)
}
}
}
override fun onMenuClicked(entity: Profile) {
ProfilesMenu(this, entity, this).show()
}
override fun onNewProfile() {
startActivity(CreateProfileActivity::class.intent)
}
private fun openProperties(id: Long) {
startActivity(
ProfileEditActivity::class.intent
.setData(Uri.fromParts("id", id.toString(), null))
)
}
private fun openEditor(profile: Profile) = launch {
try {
val uri = withProfile {
acquireTempUri(profile.id)
} ?: throw FileNotFoundException()
editorStack.push(profile.copy(uri = Uri.parse(uri)))
startActivityForResult(
Intent(Intent.ACTION_VIEW)
.setDataAndType(Uri.parse(uri), "text/plain")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION),
EDITOR_REQUEST_CODE
)
} catch (e: Exception) {
Snackbar.make(rootView, getText(R.string.profile_not_found), Snackbar.LENGTH_LONG)
.show()
}
}
private fun startUpdate(id: Long) {
sendBroadcastSelf(ProfileReceiver.buildUpdateIntentForId(id))
}
override fun onOpenEditor(entity: Profile) {
openEditor(entity)
}
override fun onUpdate(entity: Profile) {
startUpdate(entity.id)
}
override fun onOpenProperties(entity: Profile) {
openProperties(entity.id)
}
override fun onDuplicate(entity: Profile) {
launch {
withProfile {
openProperties(acquireCloned(entity.id))
}
}
}
override fun onResetProvider(entity: Profile) {
launch {
withProfile {
clear(entity.id)
}
}
}
override fun onDelete(entity: Profile) {
launch {
withProfile {
delete(entity.id)
}
}
}
}
@@ -1,114 +0,0 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.setUUID
import com.github.kr328.clash.common.util.uuid
import com.github.kr328.clash.design.PropertiesDesign
import com.github.kr328.clash.design.ui.ToastDuration
import com.github.kr328.clash.design.util.showExceptionToast
import com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.util.withProfile
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
class PropertiesActivity : BaseActivity<PropertiesDesign>() {
private var canceled: Boolean = false
override suspend fun main() {
setResult(RESULT_CANCELED)
val uuid = intent.uuid ?: return finish()
val design = PropertiesDesign(this)
val original = withProfile { queryByUUID(uuid) } ?: return finish()
design.profile = original
setContentDesign(design)
defer {
canceled = true
withProfile { release(uuid) }
}
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ActivityStop -> {
val profile = design.profile
if (!canceled && profile != original) {
withProfile {
patch(profile.uuid, profile.name, profile.source, profile.interval)
}
}
}
Event.ServiceRecreated -> {
finish()
}
else -> Unit
}
}
design.requests.onReceive {
when (it) {
PropertiesDesign.Request.BrowseFiles -> {
startActivity(FilesActivity::class.intent.setUUID(uuid))
}
PropertiesDesign.Request.Commit -> {
design.verifyAndCommit()
}
}
}
}
}
}
override fun onBackPressed() {
design?.apply {
launch {
if (!progressing) {
if (requestExitWithoutSaving())
finish()
}
}
} ?: return super.onBackPressed()
}
private suspend fun PropertiesDesign.verifyAndCommit() {
when {
profile.name.isBlank() -> {
showToast(R.string.empty_name, ToastDuration.Long)
}
profile.type != Profile.Type.File && profile.source.isBlank() -> {
showToast(R.string.invalid_url, ToastDuration.Long)
}
else -> {
try {
withProcessing { updateStatus ->
withProfile {
patch(profile.uuid, profile.name, profile.source, profile.interval)
coroutineScope {
commit(profile.uuid) {
launch {
updateStatus(it)
}
}
}
}
}
setResult(RESULT_OK)
finish()
} catch (e: Exception) {
showExceptionToast(e)
}
}
}
}
}
@@ -1,71 +0,0 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.common.util.ticker
import com.github.kr328.clash.design.ProvidersDesign
import com.github.kr328.clash.design.util.showExceptionToast
import com.github.kr328.clash.util.withClash
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import java.util.concurrent.TimeUnit
class ProvidersActivity : BaseActivity<ProvidersDesign>() {
override suspend fun main() {
val providers = withClash { queryProviders().sorted() }
val design = ProvidersDesign(this, providers)
setContentDesign(design)
val ticker = ticker(TimeUnit.MINUTES.toMillis(1))
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ProfileLoaded -> {
val newList = withClash { queryProviders().sorted() }
if (newList != providers) {
startActivity(ProvidersActivity::class.intent)
finish()
}
}
else -> Unit
}
}
design.requests.onReceive {
when (it) {
is ProvidersDesign.Request.Update -> {
launch {
try {
withClash {
updateProvider(it.provider.type, it.provider.name)
}
design.notifyChanged(it.index)
} catch (e: Exception) {
design.showExceptionToast(
getString(
R.string.format_update_provider_failure,
it.provider.name,
e.message
)
)
design.notifyUpdated(it.index)
}
}
}
}
}
if (activityStarted) {
ticker.onReceive {
design.updateElapsed()
}
}
}
}
}
}
@@ -0,0 +1,295 @@
package com.github.kr328.clash
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.adapter.ProxyAdapter
import com.github.kr328.clash.adapter.ProxyChipAdapter
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.pipeline.Pipeline
import com.github.kr328.clash.pipeline.mergePrefix
import com.github.kr328.clash.pipeline.sort
import com.github.kr328.clash.pipeline.toAdapterElement
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.utils.ScrollBinding
import kotlinx.android.synthetic.main.activity_proxies.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
class ProxiesActivity : BaseActivity(), ScrollBinding.Callback {
private val refreshMutex = Mutex()
private val scrollBinding = ScrollBinding(this, this)
private var scrollToLast = true
private val mainListAdapter: ProxyAdapter
get() = mainList.adapter as ProxyAdapter
private val chipListAdapter: ProxyChipAdapter
get() = chipList.adapter as ProxyChipAdapter
private val urlTesting: MutableSet<String> = mutableSetOf()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_proxies)
setSupportActionBar(toolbar)
mainList.adapter = ProxyAdapter(this, this::setGroupSelected, this::startUrlTesting)
mainList.layoutManager = mainListAdapter.layoutManager
chipList.adapter = ProxyChipAdapter(this, this::chipClicked)
chipList.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
chipList.itemAnimator?.changeDuration = 0
launch {
mainList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
scrollBinding.sendMasterScrolled()
}
})
scrollBinding.exec()
}
refreshList()
}
override fun onStop() {
uiSettings.commit {
put(
UiSettings.PROXY_LAST_SELECT_GROUP,
(chipList.adapter!! as ProxyChipAdapter).selected
)
}
super.onStop()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
if (!super.onCreateOptionsMenu(menu))
return false
menuInflater.inflate(R.menu.proxies, menu)
setupMenu()
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (super.onOptionsItemSelected(item))
return true
if (item.itemId == R.id.menuRefresh) {
refreshList()
return true
}
launch {
var scrollTop = false
when (item.itemId) {
R.id.modeDirect -> {
withClash {
setProxyMode(General.Mode.DIRECT)
}
}
R.id.modeGlobal -> {
withClash {
setProxyMode(General.Mode.GLOBAL)
scrollTop = true
}
}
R.id.modeRule -> {
withClash {
setProxyMode(General.Mode.RULE)
}
}
R.id.groupDefault -> {
uiSettings.commit {
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_DEFAULT)
}
}
R.id.groupName -> {
uiSettings.commit {
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_NAME)
}
}
R.id.groupDelay -> {
uiSettings.commit {
put(UiSettings.PROXY_GROUP_SORT, UiSettings.PROXY_SORT_DELAY)
}
}
R.id.proxyDefault -> {
uiSettings.commit {
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_DEFAULT)
}
}
R.id.proxyName -> {
uiSettings.commit {
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_NAME)
}
}
R.id.proxyDelay -> {
uiSettings.commit {
put(UiSettings.PROXY_PROXY_SORT, UiSettings.PROXY_SORT_DELAY)
}
}
R.id.utilsMergePrefix -> {
item.isChecked = !item.isChecked
uiSettings.commit {
put(UiSettings.PROXY_MERGE_PREFIX, item.isChecked)
}
refreshList()
return@launch
}
else -> return@launch
}
item.isChecked = true
refreshList(scrollTop)
}
return true
}
override suspend fun onClashStopped(reason: String?) {
finish()
}
private fun setupMenu() {
launch {
val general = withClash {
queryGeneral()
}
menu?.apply {
when (general.mode) {
General.Mode.DIRECT ->
findItem(R.id.modeDirect).isChecked = true
General.Mode.GLOBAL ->
findItem(R.id.modeGlobal).isChecked = true
General.Mode.RULE ->
findItem(R.id.modeRule).isChecked = true
}
when (uiSettings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
findItem(R.id.groupDefault).isChecked = true
UiSettings.PROXY_SORT_NAME ->
findItem(R.id.groupName).isChecked = true
UiSettings.PROXY_SORT_DELAY ->
findItem(R.id.proxyDelay).isChecked = true
}
when (uiSettings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
findItem(R.id.proxyDefault).isChecked = true
UiSettings.PROXY_SORT_NAME ->
findItem(R.id.proxyName).isChecked = true
UiSettings.PROXY_SORT_DELAY ->
findItem(R.id.proxyDelay).isChecked = true
}
findItem(R.id.utilsMergePrefix).isChecked =
uiSettings.get(UiSettings.PROXY_MERGE_PREFIX)
}
}
}
private fun setGroupSelected(group: String, select: String) {
launch {
withClash {
setSelectProxy(group, select)
}
}
}
private fun startUrlTesting(group: String) {
launch {
urlTesting.add(group)
withClash {
startHealthCheck(group)
}
urlTesting.remove(group)
refreshList()
}
}
private fun chipClicked(name: String) {
launch {
scrollBinding.scrollMaster(name)
}
}
private fun refreshList(scrollTop: Boolean = false) {
launch {
if (!refreshMutex.tryLock())
return@launch
val general = withClash {
queryGeneral()
}
val proxies = withClash {
queryAllProxyGroups()
}
val merged = Pipeline(proxies, uiSettings).mergePrefix()
val sorted = Pipeline(proxies, uiSettings).sort()
val newList = sorted.toAdapterElement(merged.input, general)
mainListAdapter.applyChange(newList, urlTesting)
(chipList.adapter!! as ProxyChipAdapter).apply {
chips = newList.map { it.name }
notifyDataSetChanged()
}
if (scrollTop)
mainList.smoothScrollToPosition(0)
else if (scrollToLast) {
scrollToLast = false
val selected = uiSettings.get(UiSettings.PROXY_LAST_SELECT_GROUP)
scrollBinding.scrollMaster(selected)
}
delay(500)
refreshMutex.unlock()
}
}
override fun getCurrentMasterToken(): String {
return mainListAdapter.getCurrentGroup()
}
override fun onMasterTokenChanged(token: String) {
chipListAdapter.selected = token
val position = chipListAdapter.chips.indexOf(token)
if (position < 0)
return
chipList.smoothScrollToPosition(position)
}
override fun getMasterTokenPosition(token: String): Int {
return mainListAdapter.getGroupPosition(token)
}
override fun doMasterScroll(scroller: LinearSmoothScroller, target: Int) {
mainListAdapter.layoutManager.startSmoothScroll(scroller)
}
}
@@ -1,133 +0,0 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.core.Clash
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.design.ProxyDesign
import com.github.kr328.clash.design.model.ProxyState
import com.github.kr328.clash.store.TipsStore
import com.github.kr328.clash.util.withClash
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.TimeUnit
class ProxyActivity : BaseActivity<ProxyDesign>() {
override suspend fun main() {
val mode = withClash { queryOverride(Clash.OverrideSlot.Session).mode }
val names = withClash { queryProxyGroupNames(uiStore.proxyExcludeNotSelectable) }
val states = List(names.size) { ProxyState("?") }
val unorderedStates = names.indices.map { names[it] to states[it] }.toMap()
val reloadLock = Semaphore(10)
val tips = TipsStore(this)
val design = ProxyDesign(
this,
mode,
names,
uiStore
)
setContentDesign(design)
launch(Dispatchers.IO) {
val pkg = packageManager.getPackageInfo(packageName, 0)
val validate = System.currentTimeMillis() - pkg.firstInstallTime > TimeUnit.DAYS.toMillis(5)
if (tips.requestDonate && validate) {
tips.requestDonate = false
design.requestDonate()
}
}
design.requests.send(ProxyDesign.Request.ReloadAll)
while (isActive) {
select<Unit> {
events.onReceive {
when (it) {
Event.ProfileLoaded -> {
val newNames = withClash {
queryProxyGroupNames(uiStore.proxyExcludeNotSelectable)
}
if (newNames != names) {
startActivity(ProxyActivity::class.intent)
finish()
}
}
else -> Unit
}
}
design.requests.onReceive {
when (it) {
ProxyDesign.Request.ReLaunch -> {
startActivity(ProxyActivity::class.intent)
finish()
}
ProxyDesign.Request.ReloadAll -> {
names.indices.forEach { idx ->
design.requests.trySend(ProxyDesign.Request.Reload(idx))
}
}
is ProxyDesign.Request.Reload -> {
launch {
val group = reloadLock.withPermit {
withClash {
queryProxyGroup(names[it.index], uiStore.proxySort)
}
}
val state = states[it.index]
state.now = group.now
design.updateGroup(
it.index,
group.proxies,
group.type == Proxy.Type.Selector,
state,
unorderedStates
)
}
}
is ProxyDesign.Request.Select -> {
withClash {
patchSelector(names[it.index], it.name)
states[it.index].now = it.name
}
design.requestRedrawVisible()
}
is ProxyDesign.Request.UrlTest -> {
launch {
withClash {
healthCheck(names[it.index])
}
design.requests.send(ProxyDesign.Request.Reload(it.index))
}
}
is ProxyDesign.Request.PatchMode -> {
design.showModeSwitchTips()
withClash {
val o = queryOverride(Clash.OverrideSlot.Session)
o.mode = it.mode
patchOverride(Clash.OverrideSlot.Session, o)
}
}
}
}
}
}
}
}
@@ -1,18 +0,0 @@
package com.github.kr328.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.github.kr328.clash.service.StatusProvider
import com.github.kr328.clash.util.startClashService
class RestartReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
if (StatusProvider.shouldStartClashOnBoot)
context.startClashService()
}
}
}
}
@@ -1,30 +1,44 @@
package com.github.kr328.clash
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.design.SettingsDesign
import kotlinx.coroutines.isActive
import kotlinx.coroutines.selects.select
import android.os.Bundle
import com.github.kr328.clash.common.utils.intent
import kotlinx.android.synthetic.main.activity_settings.*
class SettingsActivity : BaseActivity<SettingsDesign>() {
override suspend fun main() {
val design = SettingsDesign(this)
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
setSupportActionBar(toolbar)
setContentDesign(design)
while (isActive) {
select<Unit> {
events.onReceive {
commonUi.build {
option(
icon = getDrawable(R.drawable.ic_settings_applications),
title = getString(R.string.behavior)
) {
paddingHeight = true
onClick {
startActivity(SettingsBehaviorActivity::class.intent)
}
design.requests.onReceive {
when (it) {
SettingsDesign.Request.StartApp ->
startActivity(AppSettingsActivity::class.intent)
SettingsDesign.Request.StartNetwork ->
startActivity(NetworkSettingsActivity::class.intent)
SettingsDesign.Request.StartOverride ->
startActivity(OverrideSettingsActivity::class.intent)
}
}
option(
icon = getDrawable(R.drawable.ic_network),
title = getString(R.string.network)
) {
paddingHeight = true
onClick {
startActivity(SettingsNetworkActivity::class.intent)
}
}
option(
icon = getDrawable(R.drawable.ic_interface),
title = getString(R.string.interface_)
) {
paddingHeight = true
onClick {
startActivity(SettingsInterfaceActivity::class.intent)
}
}
}
@@ -0,0 +1,17 @@
package com.github.kr328.clash
import android.os.Bundle
import com.github.kr328.clash.settings.BehaviorFragment
class SettingsBehaviorActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_fragment)
setSupportActionBar(findViewById(R.id.toolbar))
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, BehaviorFragment())
.commit()
}
}
@@ -0,0 +1,17 @@
package com.github.kr328.clash
import android.os.Bundle
import com.github.kr328.clash.settings.InterfaceFragment
class SettingsInterfaceActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_fragment)
setSupportActionBar(findViewById(R.id.toolbar))
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, InterfaceFragment())
.commit()
}
}
@@ -0,0 +1,29 @@
package com.github.kr328.clash
import android.os.Bundle
import com.github.kr328.clash.settings.NetworkFragment
import com.google.android.material.snackbar.Snackbar
class SettingsNetworkActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_fragment)
setSupportActionBar(findViewById(R.id.toolbar))
supportFragmentManager.beginTransaction()
.replace(R.id.fragment, NetworkFragment())
.commit()
if (clashRunning)
Snackbar.make(rootView, R.string.options_unavailable, Snackbar.LENGTH_INDEFINITE).show()
}
override suspend fun onClashStopped(reason: String?) {
recreate()
}
override suspend fun onClashStarted() {
recreate()
}
}
@@ -0,0 +1,80 @@
package com.github.kr328.clash
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Html
import kotlinx.android.synthetic.main.activity_support.*
class SupportActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_support)
setSupportActionBar(toolbar)
commonUi.build {
tips {
icon = getDrawable(R.drawable.ic_info)
title = Html.fromHtml(getString(R.string.tips_support), Html.FROM_HTML_MODE_LEGACY)
}
category(text = getString(R.string.sources))
option(
title = getString(R.string.clash),
summary = getString(R.string.clash_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.clash_url)))
)
}
}
option(
title = getString(R.string.clash_for_android),
summary = getString(R.string.clash_for_android_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.clash_for_android_url)))
)
}
}
category(text = getString(R.string.feedback))
option(
title = getString(R.string.github_issues),
summary = getString(R.string.github_issues_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.github_issues_url)))
)
}
}
val firstLanguage = resources.configuration.locales.get(0).language
if (firstLanguage.equals("zh", true)) {
category(getString(R.string.donate))
option(
title = getString(R.string.telegram_channel),
summary = getString(R.string.telegram_channel_url)
) {
onClick {
startActivity(
Intent(Intent.ACTION_VIEW)
.setData(Uri.parse(getString(R.string.telegram_channel_url)))
)
}
}
}
}
}
}
@@ -5,23 +5,20 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import com.github.kr328.clash.common.constants.Intents
import com.github.kr328.clash.common.constants.Permissions
import com.github.kr328.clash.remote.StatusClient
import com.github.kr328.clash.util.startClashService
import com.github.kr328.clash.util.stopClashService
import com.github.kr328.clash.common.Permissions
import com.github.kr328.clash.common.ids.Intents
import com.github.kr328.clash.remote.RemoteUtils
import com.github.kr328.clash.service.util.startClashService
import com.github.kr328.clash.service.util.stopClashService
@RequiresApi(Build.VERSION_CODES.N)
class TileService : TileService() {
private var currentProfile = ""
private var clashRunning = false
override fun onClick() {
val tile = qsTile ?: return
val tile = qsTile
when (tile.state) {
Tile.STATE_INACTIVE -> {
@@ -34,71 +31,74 @@ class TileService : TileService() {
}
override fun onStartListening() {
super.onStartListening()
registerReceiver(
receiver,
IntentFilter().apply {
addAction(Intents.ACTION_CLASH_STARTED)
addAction(Intents.ACTION_CLASH_STOPPED)
addAction(Intents.ACTION_PROFILE_LOADED)
addAction(Intents.ACTION_SERVICE_RECREATED)
},
Permissions.RECEIVE_SELF_BROADCASTS,
null
)
val name = StatusClient(this).currentProfile()
clashRunning = name != null
currentProfile = name ?: ""
updateTile()
refreshStatus()
}
override fun onStopListening() {
super.onStopListening()
private fun refreshStatus() {
if (qsTile == null)
return
unregisterReceiver(receiver)
}
private fun updateTile() {
val tile = qsTile ?: return
tile.state = if (clashRunning)
qsTile.state = if (clashRunning)
Tile.STATE_ACTIVE
else
Tile.STATE_INACTIVE
tile.label = if (currentProfile.isEmpty())
qsTile.label = if (currentProfile.isEmpty())
getText(R.string.launch_name)
else
currentProfile
tile.icon = Icon.createWithResource(this, R.drawable.ic_logo_service)
qsTile.icon = Icon.createWithResource(this, R.drawable.ic_notification)
tile.updateTile()
qsTile.updateTile()
}
private val receiver = object : BroadcastReceiver() {
private val clashStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intents.ACTION_CLASH_STARTED -> {
Intents.INTENT_ACTION_CLASH_STARTED -> {
clashRunning = true
currentProfile = ""
}
Intents.ACTION_CLASH_STOPPED, Intents.ACTION_SERVICE_RECREATED -> {
Intents.INTENT_ACTION_CLASH_STOPPED -> {
clashRunning = false
currentProfile = ""
}
Intents.ACTION_PROFILE_LOADED -> {
currentProfile = StatusClient(this@TileService).currentProfile() ?: ""
Intents.INTENT_ACTION_PROFILE_LOADED -> {
currentProfile = RemoteUtils
.getCurrentClashProfileName(this@TileService) ?: ""
}
}
updateTile()
refreshStatus()
}
}
override fun onCreate() {
super.onCreate()
registerReceiver(
clashStatusReceiver,
IntentFilter().apply {
addAction(Intents.INTENT_ACTION_CLASH_STARTED)
addAction(Intents.INTENT_ACTION_CLASH_STOPPED)
addAction(Intents.INTENT_ACTION_PROFILE_LOADED)
},
Permissions.PERMISSION_RECEIVE_BROADCASTS,
null
)
val name = RemoteUtils.getCurrentClashProfileName(this)
clashRunning = name != null
currentProfile = name ?: ""
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(clashStatusReceiver)
}
}
@@ -0,0 +1,55 @@
package com.github.kr328.clash.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.collection.CircularArray
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.github.kr328.clash.core.event.LogEvent
class LiveLogAdapter(private val context: Context) : RecyclerView.Adapter<LogAdapter.Holder>() {
companion object {
const val MAX_LOG_ITEMS = 100
}
private val circularArray = CircularArray<LogEvent>(MAX_LOG_ITEMS)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogAdapter.Holder {
return LogAdapter.Holder(
LayoutInflater.from(context).inflate(
R.layout.adapter_log,
parent,
false
)
)
}
override fun getItemCount(): Int {
return circularArray.size()
}
override fun onBindViewHolder(holder: LogAdapter.Holder, position: Int) {
holder.bind(circularArray[position])
}
fun insertItems(i: List<LogEvent>) {
val items = if (i.size > MAX_LOG_ITEMS) {
i.subList(i.size - MAX_LOG_ITEMS, i.size)
} else i
val predictSize = items.size + circularArray.size()
if (predictSize > MAX_LOG_ITEMS) {
val removeSize = predictSize - MAX_LOG_ITEMS
notifyItemRangeRemoved(MAX_LOG_ITEMS - removeSize, removeSize)
circularArray.removeFromEnd(removeSize)
}
items.forEach {
circularArray.addFirst(it)
}
notifyItemRangeInserted(0, items.size)
}
}
@@ -0,0 +1,41 @@
package com.github.kr328.clash.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.github.kr328.clash.core.event.LogEvent
import com.github.kr328.clash.utils.format
import java.util.*
class LogAdapter(
private val context: Context,
private val logs: List<LogEvent>
) : RecyclerView.Adapter<LogAdapter.Holder>() {
class Holder(view: View) : RecyclerView.ViewHolder(view) {
private val level: TextView = view.findViewById(R.id.level)
private val time: TextView = view.findViewById(R.id.time)
private val payload: TextView = view.findViewById(R.id.payload)
fun bind(logEvent: LogEvent) {
level.text = logEvent.level.toString()
time.text = Date(logEvent.time).format(itemView.context, includeDate = false)
payload.text = logEvent.message
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(LayoutInflater.from(context).inflate(R.layout.adapter_log, parent, false))
}
override fun getItemCount(): Int {
return logs.size
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.bind(logs[position])
}
}
@@ -0,0 +1,55 @@
package com.github.kr328.clash.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.utils.format
import java.util.*
class LogFileAdapter(
private val context: Context,
private val onItemClicked: (LogFile) -> Unit,
private val onMenuClicked: (LogFile) -> Unit
) : RecyclerView.Adapter<LogFileAdapter.Holder>() {
var fileList: List<LogFile> = emptyList()
class Holder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.root)
val fileName: TextView = view.findViewById(R.id.fileName)
val date: TextView = view.findViewById(R.id.date)
val menu: View = view.findViewById(R.id.menu)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(
LayoutInflater.from(context).inflate(
R.layout.adapter_log_file,
parent,
false
)
)
}
override fun getItemCount(): Int {
return fileList.size
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = fileList[position]
val date = Date(current.date)
holder.fileName.text = current.fileName
holder.date.text = date.format(context)
holder.menu.setOnClickListener {
onMenuClicked(current)
}
holder.root.setOnClickListener {
onItemClicked(current)
}
}
}
@@ -0,0 +1,131 @@
package com.github.kr328.clash.adapter
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.google.android.material.checkbox.MaterialCheckBox
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.streams.toList
class PackagesAdapter(
private val context: Context,
private val apps: List<AppInfo>
) :
RecyclerView.Adapter<PackagesAdapter.Holder>() {
data class AppInfo(
val packageName: String, val label: String, val icon: Drawable,
val installTime: Long, val updateTime: Long, val isSystem: Boolean
)
enum class Sort {
NAME, PACKAGE, INSTALL_TIME, UPDATE_TIME
}
val selectedPackages: MutableSet<String> = mutableSetOf()
private var filteredCache: List<AppInfo> = apps
inner class Holder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.root)
val icon: ImageView = view.findViewById(R.id.icon)
val label: TextView = view.findViewById(R.id.label)
val packageName: TextView = view.findViewById(R.id.packageName)
val checkbox: MaterialCheckBox = view.findViewById(R.id.checkbox)
}
suspend fun applyFilter(keyword: String, sort: Sort, decrease: Boolean, systemApp: Boolean) {
withContext(Dispatchers.Default) {
val newList = apps.parallelStream()
.filter {
(it.label.contains(keyword, true)
|| it.packageName.contains(keyword, true))
&& (systemApp || !it.isSystem)
}
.sorted { a, b ->
val sA = selectedPackages.contains(a.packageName)
val sB = selectedPackages.contains(b.packageName)
if (sA != sB) {
when {
sA -> return@sorted -1
sB -> return@sorted 1
}
}
val result = when (sort) {
Sort.NAME -> a.label.compareTo(b.label, true)
Sort.PACKAGE -> a.packageName.compareTo(b.packageName)
Sort.INSTALL_TIME -> (a.installTime - b.installTime).toInt()
Sort.UPDATE_TIME -> (a.updateTime - b.updateTime).toInt()
}
if (decrease) -result else result
}
.toList()
val oldList = filteredCache
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition].packageName == newList[newItemPosition].packageName
}
override fun getOldListSize(): Int {
return oldList.size
}
override fun getNewListSize(): Int {
return newList.size
}
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean {
return areItemsTheSame(oldItemPosition, newItemPosition) &&
(selectedPackages.contains(oldList[oldItemPosition].packageName) ==
selectedPackages.contains(newList[newItemPosition].packageName))
}
})
withContext(Dispatchers.Main) {
filteredCache = newList
result.dispatchUpdatesTo(this@PackagesAdapter)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
return Holder(LayoutInflater.from(context).inflate(R.layout.adapter_package, parent, false))
}
override fun getItemCount(): Int {
return filteredCache.size
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = filteredCache[position]
holder.icon.setImageDrawable(current.icon)
holder.label.text = current.label
holder.packageName.text = current.packageName
holder.checkbox.isChecked = selectedPackages.contains(current.packageName)
holder.root.setOnClickListener {
if (selectedPackages.contains(current.packageName)) {
selectedPackages.remove(current.packageName)
} else {
selectedPackages.add(current.packageName)
}
notifyItemChanged(filteredCache.indexOf(current))
}
}
}
@@ -0,0 +1,137 @@
package com.github.kr328.clash.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RadioButton
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.utils.IntervalUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProfileAdapter(private val context: Context, private val callback: Callback) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
interface Callback {
fun onProfileClicked(entity: Profile)
fun onMenuClicked(entity: Profile)
fun onNewProfile()
}
private var entities: List<Profile> = emptyList()
class EntityHolder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.root)
val menu: View = view.findViewById(R.id.menu)
val radio: RadioButton = view.findViewById(R.id.radio)
val name: TextView = view.findViewById(R.id.name)
val type: TextView = view.findViewById(R.id.type)
val interval: TextView = view.findViewById(R.id.interval)
}
class FooterHolder(view: View) : RecyclerView.ViewHolder(view) {
val root: View = view.findViewById(R.id.root)
}
suspend fun setEntitiesAsync(new: List<Profile>) {
val old = withContext(Dispatchers.Main) {
entities
}
val result = withContext(Dispatchers.Default) {
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old[oldItemPosition].id == new[newItemPosition].id
override fun areContentsTheSame(
oldItemPosition: Int,
newItemPosition: Int
): Boolean = old[oldItemPosition] == new[newItemPosition]
override fun getOldListSize(): Int = old.size
override fun getNewListSize(): Int = new.size
}, false)
}
withContext(Dispatchers.Main) {
entities = new
result.dispatchUpdatesTo(this@ProfileAdapter)
}
}
override fun getItemViewType(position: Int): Int {
return if (position == entities.size)
Int.MAX_VALUE
else
super.getItemViewType(position)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
if (viewType == Int.MAX_VALUE) {
return FooterHolder(
LayoutInflater.from(context).inflate(
R.layout.adapter_profile_footer,
parent,
false
)
)
}
return EntityHolder(
LayoutInflater.from(context).inflate(
R.layout.adapter_profile_entity,
parent,
false
)
)
}
override fun getItemCount(): Int {
return entities.size + 1
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is EntityHolder -> {
val current = entities[position]
holder.radio.isChecked = current.active
holder.name.text = current.name
holder.type.text = getTypeName(current.type)
holder.interval.text = offsetDate(current.lastModified)
holder.root.setOnClickListener {
callback.onProfileClicked(current)
}
holder.menu.setOnClickListener {
callback.onMenuClicked(current)
}
}
is FooterHolder -> {
holder.root.setOnClickListener {
callback.onNewProfile()
}
}
}
}
private fun getTypeName(type: Profile.Type): CharSequence {
return when (type) {
Profile.Type.FILE ->
context.getText(R.string.file)
Profile.Type.URL ->
context.getText(R.string.url)
Profile.Type.EXTERNAL ->
context.getText(R.string.external)
else ->
context.getText(R.string.unknown)
}
}
private fun offsetDate(date: Long): CharSequence {
return IntervalUtils.intervalString(context, System.currentTimeMillis() - date)
}
}
@@ -0,0 +1,282 @@
package com.github.kr328.clash.adapter
import android.content.Context
import android.graphics.Color
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.google.android.material.card.MaterialCardView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProxyAdapter(
private val context: Context,
val onSelect: (String, String) -> Unit,
val onUrlTest: (String) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
const val DEFAULT_SPAN_COUNT = 2
}
data class ProxyGroupInfo(
val name: String,
val current: String,
val proxies: List<ProxyInfo>
)
data class ProxyInfo(
val name: String,
val group: String,
val title: String,
val summary: String,
val delay: Short,
val selectable: Boolean,
val active: Boolean
)
interface RenderInfo {
val name: String
val group: String
}
private data class ProxyGroupRenderInfo(val info: ProxyGroupInfo) :
RenderInfo {
override val name: String
get() = info.name
override val group: String
get() = info.name
}
private data class ProxyRenderInfo(val info: ProxyInfo) : RenderInfo {
override val name: String
get() = info.name
override val group: String
get() = info.group
}
private var urlTesting: Set<String> = emptySet()
private var renderList = mutableListOf<RenderInfo>()
private var activeList: MutableMap<String, Int> = mutableMapOf()
private var groupPosition: MutableMap<String, Int> = mutableMapOf()
@ColorInt
private val colorSurface: Int
@ColorInt
private val colorOnSurface: Int
init {
val typedValue = TypedValue()
context.theme.resolveAttribute(R.attr.colorSurface, typedValue, true)
colorSurface = typedValue.data
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
colorOnSurface = typedValue.data
}
val layoutManager = GridLayoutManager(context, DEFAULT_SPAN_COUNT).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
val current = renderList.getOrNull(position)
?: renderList.getOrNull(position) ?: return spanCount
return when (current) {
is ProxyGroupRenderInfo -> spanCount
is ProxyRenderInfo -> 1
else -> throw IllegalArgumentException()
}
}
}
}
private var root = listOf<ProxyGroupInfo>()
private class ProxyGroupHeader(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = view.findViewById(R.id.name)
val urlTest: View = view.findViewById(R.id.urlTest)
val urlTestProgress: View = view.findViewById(R.id.urlTestProgress)
}
private class ProxyItem(view: View) : RecyclerView.ViewHolder(view) {
val root: MaterialCardView = view.findViewById(R.id.root)
val prefix: TextView = view.findViewById(R.id.prefix)
val content: TextView = view.findViewById(R.id.content)
val delay: TextView = view.findViewById(R.id.delay)
}
suspend fun applyChange(newList: List<ProxyGroupInfo>, testing: Set<String>) =
withContext(Dispatchers.Default) {
val newRenderList = newList
.flatMap {
listOf(ProxyGroupRenderInfo(it)) + it.proxies.map { p -> ProxyRenderInfo(p) }
}
val oldRenderList = renderList
val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldRenderList[oldItemPosition]::class == newRenderList[newItemPosition]::class &&
oldRenderList[oldItemPosition].name == newRenderList[newItemPosition].name
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
oldRenderList[oldItemPosition] == newRenderList[newItemPosition]
override fun getOldListSize(): Int = oldRenderList.size
override fun getNewListSize(): Int = newRenderList.size
})
val groupCache: MutableMap<String, Int> = mutableMapOf()
val activeCache: MutableMap<String, Int> = mutableMapOf()
newRenderList.forEachIndexed { index, it ->
when (it) {
is ProxyGroupRenderInfo ->
groupCache[it.name] = index
is ProxyRenderInfo -> {
if (it.info.active)
activeCache[it.group] = index
}
}
}
withContext(Dispatchers.Main) {
root = newList
renderList = newRenderList.toMutableList()
urlTesting = testing
groupPosition = groupCache
activeList = activeCache
result.dispatchUpdatesTo(this@ProxyAdapter)
groupCache.forEach { (_, u) ->
notifyItemChanged(u)
}
}
}
fun getGroupPosition(name: String): Int {
return groupPosition[name] ?: -1
}
fun getCurrentGroup(): String {
val position = layoutManager.findFirstCompletelyVisibleItemPosition()
if (position < 0)
return ""
return renderList[position].group
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater = LayoutInflater.from(context)
return when (viewType) {
1 -> ProxyGroupHeader(
layoutInflater
.inflate(R.layout.adapter_grid_proxy_group, parent, false)
)
2 -> ProxyItem(
layoutInflater
.inflate(R.layout.adapter_grid_proxy, parent, false)
)
else -> throw IllegalArgumentException()
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is ProxyGroupHeader -> {
val current = renderList[position] as ProxyGroupRenderInfo
holder.title.text = context.getString(
R.string.format_proxy_group_title,
current.info.name, current.info.current
)
holder.urlTest.setOnClickListener {
holder.urlTest.visibility = View.GONE
holder.urlTestProgress.visibility = View.VISIBLE
onUrlTest(current.name)
}
if (urlTesting.contains(current.name)) {
holder.urlTest.visibility = View.GONE
holder.urlTestProgress.visibility = View.VISIBLE
} else {
holder.urlTest.visibility = View.VISIBLE
holder.urlTestProgress.visibility = View.GONE
}
}
is ProxyItem -> {
val current = renderList[position] as ProxyRenderInfo
holder.prefix.text = current.info.title
holder.content.text = current.info.summary
if (current.info.delay > 0)
holder.delay.text = current.info.delay.toString()
else
holder.delay.text = if (current.info.selectable) "" else "N/A"
if (current.info.active) {
holder.prefix.setTextColor(Color.WHITE)
holder.content.setTextColor(Color.WHITE)
holder.delay.setTextColor(Color.WHITE)
holder.root.setCardBackgroundColor(context.getColor(R.color.primaryCardColorStarted))
} else {
holder.prefix.setTextColor(colorOnSurface)
holder.content.setTextColor(colorOnSurface)
holder.delay.setTextColor(colorOnSurface)
holder.root.setCardBackgroundColor(colorSurface)
}
if (current.info.selectable) {
holder.root.setOnClickListener {
val oldPosition = activeList[current.group] ?: return@setOnClickListener
val groupPosition =
groupPosition[current.group] ?: return@setOnClickListener
val old = renderList[oldPosition] as ProxyRenderInfo
val new = renderList[position] as ProxyRenderInfo
val group = renderList[groupPosition] as ProxyGroupRenderInfo
renderList[oldPosition] = old.copy(info = old.info.copy(active = false))
renderList[position] = new.copy(info = new.info.copy(active = true))
renderList[groupPosition] =
group.copy(info = group.info.copy(current = current.name))
activeList[current.group] = position
notifyItemChanged(oldPosition)
notifyItemChanged(position)
notifyItemChanged(groupPosition)
onSelect(current.group, current.name)
}
holder.root.isClickable = true
holder.root.isFocusable = true
} else {
holder.root.setOnClickListener(null)
holder.root.isClickable = false
holder.root.isFocusable = false
}
}
}
}
override fun getItemCount(): Int = renderList.size
override fun getItemViewType(position: Int): Int {
return when (renderList[position]) {
is ProxyGroupRenderInfo -> 1
is ProxyRenderInfo -> 2
else -> throw IllegalArgumentException()
}
}
}
@@ -0,0 +1,121 @@
package com.github.kr328.clash.adapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Color
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.recyclerview.widget.RecyclerView
import com.github.kr328.clash.R
import com.google.android.material.card.MaterialCardView
class ProxyChipAdapter(
private val context: Context,
val onClick: (String) -> Unit
) :
RecyclerView.Adapter<ProxyChipAdapter.Holder>() {
var chips = listOf<String>()
var selected: String = ""
set(value) {
val lastIndex = chips.indexOf(field)
val newIndex = chips.indexOf(value)
field = value
if (lastIndex >= 0)
notifyItemChanged(lastIndex)
if (newIndex >= 0)
notifyItemChanged(newIndex)
}
@ColorInt
private val colorOnSurface: Int
init {
val typedValue = TypedValue()
context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true)
colorOnSurface = typedValue.data
}
class Holder(root: View) : RecyclerView.ViewHolder(root) {
val card: MaterialCardView = root.findViewById(R.id.root)
val title: TextView = root.findViewById(android.R.id.title)
private var cardAnimator: ValueAnimator? = null
private var cardColor: Int = card.cardBackgroundColor.defaultColor
private var titleAnimator: ValueAnimator? = null
private var titleColor: Int = title.textColors.defaultColor
fun setCardColorAnimation(color: Int) {
if (cardColor == color)
return
cardAnimator?.cancel()
cardAnimator = ValueAnimator.ofArgb(cardColor, color).apply {
addUpdateListener {
val v = animatedValue as Int
card.setCardBackgroundColor(v)
cardColor = v
}
duration = 200
start()
}
}
fun setTitleColorAnimation(color: Int) {
if (color == titleColor)
return
titleAnimator?.cancel()
titleAnimator = ValueAnimator.ofArgb(titleColor, color).apply {
addUpdateListener {
val v = animatedValue as Int
title.setTextColor(v)
titleColor = v
}
duration = 200
start()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
val layoutInflater = LayoutInflater.from(context)
return Holder(layoutInflater.inflate(R.layout.adapter_proxies_chip, parent, false))
}
override fun getItemCount(): Int {
return chips.size
}
override fun onBindViewHolder(holder: Holder, position: Int) {
val current = chips[position]
holder.title.text = current
holder.card.setOnClickListener {
onClick(current)
}
if (selected == current) {
holder.setTitleColorAnimation(Color.WHITE)
holder.setCardColorAnimation(context.getColor(R.color.primaryCardColorStarted))
} else {
holder.setTitleColorAnimation(colorOnSurface)
holder.setCardColorAnimation(context.getColor(R.color.chipBackgroundColor))
}
}
}
@@ -0,0 +1,34 @@
package com.github.kr328.clash.dump
object LogcatDumper {
fun dump(): List<String> {
return try {
val process =
Runtime.getRuntime().exec(arrayOf("logcat", "-d", "-s", "-v", "raw", "Go"))
val result = process.inputStream.bufferedReader().useLines {
var list = mutableListOf<String>()
var capture = false
it.forEach { line ->
if (line.startsWith("panic")) {
capture = true
list = mutableListOf()
}
if (capture)
list.add(line)
}
list
}
process.waitFor()
result
} catch (e: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,206 @@
package com.github.kr328.clash.fragment
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Html
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.webkit.MimeTypeMap
import android.webkit.URLUtil
import androidx.fragment.app.Fragment
import com.github.kr328.clash.Constants
import com.github.kr328.clash.R
import com.github.kr328.clash.design.common.TextInput
import com.github.kr328.clash.design.view.CommonUiLayout
import com.github.kr328.clash.service.model.Profile.Type
import com.google.android.material.snackbar.Snackbar
class ProfileEditFragment(
val id: Long,
var name: String,
var uri: Uri,
var interval: Long,
private val type: Type,
private val source: String?
) : Fragment() {
var isModified = false
companion object {
private const val REQUEST_CODE = 10000
private const val KEY_NAME = "name"
private const val KEY_URL = "url"
private const val KEY_AUTO_UPDATE = "auto_update"
private val TYPE_YAML = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension("yaml") ?: "*/*"
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return CommonUiLayout(requireContext()).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
build {
tips(icon = requireContext().getDrawable(R.drawable.ic_info)) {
title =
Html.fromHtml(getString(R.string.tips_profile), Html.FROM_HTML_MODE_LEGACY)
}
textInput(
title = getString(R.string.name),
icon = requireContext().getDrawable(R.drawable.ic_label_outline),
hint = getString(R.string.profile_name),
id = KEY_NAME,
content = name
) {
onTextChanged {
name = content.toString()
isModified = true
}
}
textInput(
title = getString(R.string.url),
icon = requireContext().getDrawable(R.drawable.ic_content),
hint = getString(R.string.profile_url),
id = KEY_URL,
content = uri.toString()
) {
onOpenInput {
if (!openUrlProvider())
openDialogInput()
}
onDisplayContent {
it.split("/").last()
}
onTextChanged {
if (!URLUtil.isValidUrl(content.toString())) {
content = ""
Snackbar.make(view, R.string.invalid_url, Snackbar.LENGTH_LONG).show()
return@onTextChanged
}
uri = Uri.parse(content.toString())
isModified = true
}
}
textInput(
title = getString(R.string.auto_update),
icon = requireContext().getDrawable(R.drawable.ic_update),
hint = getString(R.string.more_than_15_minutes),
id = KEY_AUTO_UPDATE,
content = (interval / 1000 / 60).toStringIfNonZero()
) {
onDisplayContent {
val interval = it.toString().toIntOrNull() ?: 0
if (interval <= 0)
getString(R.string.disabled)
else
getString(R.string.format_minutes, interval)
}
onTextChanged {
val s = it.toString()
if (s.isBlank()) {
content = ""
return@onTextChanged
}
val value = s.toIntOrNull()
if (value == null || value < 15) {
content = ""
Snackbar.make(view, R.string.invalid_interval, Snackbar.LENGTH_LONG)
.show()
return@onTextChanged
}
interval = content.toString().toLong() * 1000 * 60
isModified = true
}
if ( type == Type.FILE )
isHidden = true
}
screen.restoreState(savedInstanceState)
if (type == Type.EXTERNAL)
openUrlProvider()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode != Activity.RESULT_OK || data == null)
return
val layout = view as CommonUiLayout
data.data?.apply {
layout.screen.requireElement<TextInput>(KEY_URL).content = this.toString()
}
data.getStringExtra(Constants.URL_PROVIDER_INTENT_EXTRA_NAME)?.also {
layout.screen.requireElement<TextInput>(KEY_NAME).apply {
if (content.isBlank())
content = it
}
}
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
(view as CommonUiLayout?)?.screen?.saveState(outState)
}
private fun openUrlProvider(): Boolean {
try {
when (type) {
Type.FILE ->
startActivityForResult(
Intent(Intent.ACTION_GET_CONTENT).setType(TYPE_YAML),
REQUEST_CODE
)
Type.EXTERNAL ->
startActivityForResult(
source?.toIntent() ?: return false,
REQUEST_CODE
)
else -> return false
}
} catch (e: Exception) {
Snackbar.make(
view as ViewGroup,
R.string.start_url_provider_failure,
Snackbar.LENGTH_LONG
).show()
}
return true
}
private fun Long.toStringIfNonZero(): String {
return if ( this == 0L ) "" else this.toString()
}
private fun String.toIntent(): Intent? {
return try {
Intent.parseUri(this, 0)
} catch (e: Exception) {
null
}
}
}
@@ -1,52 +0,0 @@
package com.github.kr328.clash.log
import androidx.collection.CircularArray
import com.github.kr328.clash.core.model.LogMessage
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class LogcatCache {
data class Snapshot(val messages: List<LogMessage>, val removed: Int, val appended: Int)
private val array = CircularArray<LogMessage>(CAPACITY)
private val lock = Mutex()
private var removed: Int = 0
private var appended: Int = 0
suspend fun append(msg: LogMessage) {
lock.withLock {
if (array.size() >= CAPACITY) {
array.removeFromStart(1)
removed++
appended--
}
array.addLast(msg)
appended++
}
}
suspend fun snapshot(full: Boolean): Snapshot? {
return lock.withLock {
if (!full && removed == 0 && appended == 0) {
return@withLock null
}
Snapshot(
List(array.size()) { array[it] },
removed,
if (full) array.size() + appended else appended
).also {
removed = 0
appended = 0
}
}
}
companion object {
const val CAPACITY = 128
}
}
@@ -1,25 +0,0 @@
package com.github.kr328.clash.log
import android.content.Context
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.design.util.format
import java.io.BufferedWriter
import java.io.Writer
import java.util.*
class LogcatFilter(output: Writer, private val context: Context) : BufferedWriter(output) {
fun writeHeader(time: Date) {
appendLine("# Capture on ${time.format(context)}")
}
fun writeMessage(message: LogMessage) {
val time = message.time.format(context, includeDate = false)
val level = message.level.name
appendLine(FORMAT.format(time, level, message.message))
}
companion object {
private const val FORMAT = "%12s %7s: %s"
}
}
@@ -1,32 +0,0 @@
package com.github.kr328.clash.log
import android.content.Context
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.design.model.LogFile
import com.github.kr328.clash.util.logsDir
import java.io.BufferedReader
import java.io.FileReader
import java.util.*
class LogcatReader(context: Context, file: LogFile) : AutoCloseable {
private val reader = BufferedReader(FileReader(context.logsDir.resolve(file.fileName)))
override fun close() {
reader.close()
}
fun readAll(): List<LogMessage> {
return reader.lineSequence()
.map { it.trim() }
.filter { !it.startsWith("#") }
.map { it.split(":", limit = 3) }
.map {
LogMessage(
time = Date(it[0].toLong()),
level = LogMessage.Level.valueOf(it[1]),
message = it[2]
)
}
.toList()
}
}
@@ -1,25 +0,0 @@
package com.github.kr328.clash.log
import android.content.Context
import com.github.kr328.clash.core.model.LogMessage
import com.github.kr328.clash.design.model.LogFile
import com.github.kr328.clash.util.logsDir
import java.io.BufferedWriter
import java.io.FileWriter
class LogcatWriter(context: Context) : AutoCloseable {
private val file = LogFile.generate()
private val writer = BufferedWriter(FileWriter(context.logsDir.resolve(file.fileName)))
override fun close() {
writer.close()
}
fun appendMessage(message: LogMessage) {
writer.appendLine(FORMAT.format(message.time.time, message.level.name, message.message))
}
companion object {
private const val FORMAT = "%d:%s:%s"
}
}
@@ -1,32 +0,0 @@
package com.github.kr328.clash.log
object SystemLogcat {
private val command = arrayOf(
"logcat",
"-d",
"-s",
"Go",
"DEBUG",
"AndroidRuntime",
"ClashForAndroid",
"LwIP",
)
fun dumpCrash(): String {
return try {
val process = Runtime.getRuntime().exec(command)
val result = process.inputStream.use { stream ->
stream.reader().readLines()
.filterNot { it.startsWith("------") }
.joinToString("\n")
}
process.waitFor()
result.trim()
} catch (e: Exception) {
""
}
}
}
@@ -0,0 +1,20 @@
package com.github.kr328.clash.model
data class LogFile(val fileName: String, val date: Long) {
companion object {
private val REGEX_FILE = Regex("clash-(\\d+).log")
private const val FORMAT_FILE_NAME = "clash-%d.log"
fun parseFromFileName(fileName: String): LogFile? {
return REGEX_FILE.matchEntire(fileName)?.run {
LogFile(fileName, groupValues[1].toLong())
}
}
fun generate(date: Long = System.currentTimeMillis()): LogFile {
val fileName = FORMAT_FILE_NAME.format(date)
return LogFile(fileName, date)
}
}
}
@@ -0,0 +1,5 @@
package com.github.kr328.clash.pipeline
import com.github.kr328.clash.common.settings.BaseSettings
data class Pipeline<T>(val input: T, val settings: BaseSettings)
@@ -0,0 +1,100 @@
package com.github.kr328.clash.pipeline
import com.github.kr328.clash.adapter.ProxyAdapter
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.core.model.ProxyGroup
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.utils.PrefixMerger
import com.github.kr328.clash.utils.ProxySorter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
data class ProxyEntry(val group: String, val name: String)
data class ProxyMerged(val prefix: String, val content: String)
suspend fun Pipeline<List<ProxyGroup>>.mergePrefix(): Pipeline<Map<ProxyEntry, ProxyMerged>> {
if (!settings.get(UiSettings.PROXY_MERGE_PREFIX))
return Pipeline(emptyMap(), settings)
val result = coroutineScope {
input
.map {
async {
it.name to PrefixMerger.merge(it.proxies, Proxy::name)
}
}
.map {
it.await()
}
.flatMap {
it.second.map { merged ->
ProxyEntry(it.first, merged.value.name) to ProxyMerged(
merged.prefix,
merged.content
)
}
}
.toMap()
}
return Pipeline(result, settings)
}
suspend fun Pipeline<List<ProxyGroup>>.sort(): Pipeline<List<ProxyGroup>> {
val groupSort = when (settings.get(UiSettings.PROXY_GROUP_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val proxySort = when (settings.get(UiSettings.PROXY_PROXY_SORT)) {
UiSettings.PROXY_SORT_DEFAULT ->
ProxySorter.Order.DEFAULT
UiSettings.PROXY_SORT_NAME ->
ProxySorter.Order.NAME_INCREASE
UiSettings.PROXY_SORT_DELAY ->
ProxySorter.Order.DELAY_INCREASE
else -> throw IllegalArgumentException()
}
val sorter = ProxySorter(groupSort, proxySort)
return copy(input = sorter.sort(input))
}
suspend fun Pipeline<List<ProxyGroup>>.toAdapterElement(
prefixMerged: Map<ProxyEntry, ProxyMerged>,
general: General
): List<ProxyAdapter.ProxyGroupInfo> {
return input.map { group ->
val proxies = group.proxies.map { proxy ->
val merged = prefixMerged[ProxyEntry(group.name, proxy.name)]?.takeIf {
it.prefix.isNotBlank() && it.content.isNotBlank()
} ?: ProxyMerged(proxy.type.toString(), proxy.name)
ProxyAdapter.ProxyInfo(
proxy.name, group.name, merged.content, merged.prefix,
proxy.delay.toShort(), group.type == Proxy.Type.SELECT,
group.current == proxy.name
)
}
ProxyAdapter.ProxyGroupInfo(group.name, group.current, proxies)
}.let {
withContext(Dispatchers.Default) {
when (general.mode) {
General.Mode.DIRECT -> emptyList()
General.Mode.GLOBAL -> it
General.Mode.RULE -> it.filter { it.name != "GLOBAL" }
}
}
}
}
@@ -0,0 +1,26 @@
package com.github.kr328.clash.preference
import android.content.Context
import com.github.kr328.clash.common.settings.BaseSettings
class UiSettings(context: Context) :
BaseSettings(context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)) {
companion object {
private const val FILE_NAME = "ui"
const val PROXY_SORT_DEFAULT = "default"
const val PROXY_SORT_NAME = "name"
const val PROXY_SORT_DELAY = "delay"
const val DARK_MODE_AUTO = "auto"
const val DARK_MODE_DARK = "dark"
const val DARK_MODE_LIGHT = "light"
val PROXY_GROUP_SORT = StringEntry("proxy_group_sort", PROXY_SORT_DEFAULT)
val PROXY_PROXY_SORT = StringEntry("proxy_proxy_sort", PROXY_SORT_DEFAULT)
val PROXY_LAST_SELECT_GROUP = StringEntry("proxy_last_select_group", "")
val PROXY_MERGE_PREFIX = BooleanEntry("proxy_merge_prefix", false)
val LANGUAGE = StringEntry("language", "")
val DARK_MODE = StringEntry("dark_mode", DARK_MODE_AUTO)
}
}
@@ -5,12 +5,14 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.github.kr328.clash.common.constants.Intents
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.common.Global
import com.github.kr328.clash.common.Permissions
import com.github.kr328.clash.common.ids.Intents
import com.github.kr328.clash.common.utils.Log
import com.github.kr328.clash.utils.ApplicationObserver
class Broadcasts(private val context: Application) {
interface Observer {
fun onServiceRecreated()
object Broadcasts {
interface Receiver {
fun onStarted()
fun onStopped(cause: String?)
fun onProfileChanged()
@@ -19,40 +21,32 @@ class Broadcasts(private val context: Application) {
var clashRunning: Boolean = false
private var registered = false
private val receivers = mutableListOf<Observer>()
private val receivers = mutableListOf<Receiver>()
private val broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.`package` != context?.packageName)
return
when (intent?.action) {
Intents.ACTION_SERVICE_RECREATED -> {
clashRunning = false
receivers.forEach {
it.onServiceRecreated()
}
}
Intents.ACTION_CLASH_STARTED -> {
Intents.INTENT_ACTION_CLASH_STARTED -> {
clashRunning = true
receivers.forEach {
it.onStarted()
}
}
Intents.ACTION_CLASH_STOPPED -> {
Intents.INTENT_ACTION_CLASH_STOPPED -> {
clashRunning = false
receivers.forEach {
it.onStopped(intent.getStringExtra(Intents.EXTRA_STOP_REASON))
it.onStopped(intent.getStringExtra(Intents.INTENT_EXTRA_CLASH_STOP_REASON))
}
}
Intents.ACTION_PROFILE_CHANGED ->
Intents.INTENT_ACTION_PROFILE_CHANGED ->
receivers.forEach {
it.onProfileChanged()
}
Intents.ACTION_PROFILE_LOADED -> {
Intents.INTENT_ACTION_PROFILE_LOADED -> {
receivers.forEach {
it.onProfileLoaded()
}
@@ -61,43 +55,45 @@ class Broadcasts(private val context: Application) {
}
}
fun addObserver(observer: Observer) {
receivers.add(observer)
fun register(receiver: Receiver) {
receivers.add(receiver)
}
fun removeObserver(observer: Observer) {
receivers.remove(observer)
fun unregister(receiver: Receiver) {
receivers.remove(receiver)
}
fun register() {
if (registered)
return
private val observer = ApplicationObserver {
Log.d("Global Broadcast Receiver State = $it")
try {
context.registerReceiver(broadcastReceiver, IntentFilter().apply {
addAction(Intents.ACTION_SERVICE_RECREATED)
addAction(Intents.ACTION_CLASH_STARTED)
addAction(Intents.ACTION_CLASH_STOPPED)
addAction(Intents.ACTION_PROFILE_CHANGED)
addAction(Intents.ACTION_PROFILE_LOADED)
})
if ( it ) {
Global.application.registerReceiver(broadcastReceiver, IntentFilter().apply {
addAction(Intents.INTENT_ACTION_PROFILE_CHANGED)
addAction(Intents.INTENT_ACTION_CLASH_STOPPED)
addAction(Intents.INTENT_ACTION_CLASH_STARTED)
addAction(Intents.INTENT_ACTION_PROFILE_LOADED)
}, Permissions.PERMISSION_RECEIVE_BROADCASTS, null)
clashRunning = StatusClient(context).currentProfile() != null
} catch (e: Exception) {
Log.w("Register global receiver: $e", e)
val current = RemoteUtils.detectClashRunning(Global.application)
if (current != clashRunning) {
clashRunning = current
if (current) {
receivers.forEach { receiver ->
receiver.onStarted()
}
} else {
receivers.forEach { receiver ->
receiver.onStopped(null)
}
}
}
} else {
Global.application.unregisterReceiver(broadcastReceiver)
}
}
fun unregister() {
if (!registered)
return
try {
context.unregisterReceiver(broadcastReceiver)
clashRunning = false
} catch (e: Exception) {
Log.w("Unregister global receiver: $e", e)
}
fun init(application: Application) {
observer.register(application)
}
}
@@ -0,0 +1,13 @@
package com.github.kr328.clash.remote
suspend fun <T> withClash(block: suspend ClashClient.() -> T): T {
val client = Remote.clash.receive()
return client.block()
}
suspend fun <T> withProfile(block: suspend ProfileClient.() -> T): T {
val client = Remote.profile.receive()
return client.block()
}
@@ -0,0 +1,49 @@
package com.github.kr328.clash.remote
import android.os.RemoteException
import com.github.kr328.clash.core.model.General
import com.github.kr328.clash.core.model.ProxyGroup
import com.github.kr328.clash.service.IClashManager
import com.github.kr328.clash.service.transact.IStreamCallback
import com.github.kr328.clash.service.transact.ParcelableContainer
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ClashClient(val service: IClashManager) {
suspend fun setSelectProxy(name: String, proxy: String): Boolean = withContext(Dispatchers.IO) {
service.setSelectProxy(name, proxy)
}
suspend fun startHealthCheck(group: String) = withContext(Dispatchers.IO) {
CompletableDeferred<Unit>().apply {
service.startHealthCheck(group, object : IStreamCallback.Stub() {
override fun complete() {
this@apply.complete(Unit)
}
override fun completeExceptionally(reason: String?) {
this@apply.completeExceptionally(RemoteException(reason))
}
override fun send(data: ParcelableContainer?) {}
})
}
}.await()
suspend fun queryAllProxyGroups(): List<ProxyGroup> = withContext(Dispatchers.IO) {
service.queryAllProxies().list
}
suspend fun queryGeneral(): General = withContext(Dispatchers.IO) {
service.queryGeneral()
}
suspend fun queryBandwidth(): Long = withContext(Dispatchers.IO) {
service.queryBandwidth()
}
suspend fun setProxyMode(mode: General.Mode) = withContext(Dispatchers.IO) {
service.setProxyMode(mode.toString())
}
}
@@ -1,94 +0,0 @@
@file:Suppress("BlockingMethodInNonBlockingContext")
package com.github.kr328.clash.remote
import android.content.Context
import android.net.Uri
import com.github.kr328.clash.common.constants.Authorities
import com.github.kr328.clash.design.model.File
import com.github.kr328.clash.util.copyContentTo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import android.provider.DocumentsContract as DC
class FilesClient(private val context: Context) {
suspend fun list(parentDocumentId: String): List<File> = withContext(Dispatchers.IO) {
val uri = DC.buildChildDocumentsUri(Authorities.FILES_PROVIDER, parentDocumentId)
context.contentResolver.query(uri, FilesProjection, null, null, null)?.use { cursor ->
val idIndex = cursor.getColumnIndex(DC.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndex(DC.Document.COLUMN_DISPLAY_NAME)
val sizeIndex = cursor.getColumnIndex(DC.Document.COLUMN_SIZE)
val lastModified = cursor.getColumnIndex(DC.Document.COLUMN_LAST_MODIFIED)
val mimeTypeIndex = cursor.getColumnIndex(DC.Document.COLUMN_MIME_TYPE)
cursor.moveToFirst()
List(cursor.count) {
File(
id = cursor.getString(idIndex),
name = cursor.getString(nameIndex),
size = cursor.getLong(sizeIndex),
lastModified = cursor.getLong(lastModified),
isDirectory = cursor.getString(mimeTypeIndex) == DC.Document.MIME_TYPE_DIR,
).also {
cursor.moveToNext()
}
}.sortedWith(compareBy({ !it.isDirectory }, { it.name }))
} ?: emptyList()
}
suspend fun renameDocument(documentId: String, name: String) = withContext(Dispatchers.IO) {
val uri = buildDocumentUri(documentId)
DC.renameDocument(context.contentResolver, uri, name)
}
suspend fun deleteDocument(documentId: String) = withContext(Dispatchers.IO) {
val uri = buildDocumentUri(documentId)
DC.deleteDocument(context.contentResolver, uri)
}
suspend fun importDocument(
parentDocumentId: String,
source: Uri,
name: String
) = withContext(Dispatchers.IO) {
val target = buildDocumentUri("$parentDocumentId/$name")
context.contentResolver.copyContentTo(source, target)
}
suspend fun copyDocument(
documentId: String,
source: Uri
) {
val target = buildDocumentUri(documentId)
context.contentResolver.copyContentTo(source, target)
}
suspend fun copyDocument(
target: Uri,
documentId: String
) {
val source = buildDocumentUri(documentId)
context.contentResolver.copyContentTo(source, target)
}
fun buildDocumentUri(documentId: String): Uri {
return DC.buildDocumentUri(Authorities.FILES_PROVIDER, documentId)
}
companion object {
private val FilesProjection = arrayOf(
DC.Document.COLUMN_DOCUMENT_ID,
DC.Document.COLUMN_DISPLAY_NAME,
DC.Document.COLUMN_SIZE,
DC.Document.COLUMN_LAST_MODIFIED,
DC.Document.COLUMN_MIME_TYPE,
)
}
}
@@ -0,0 +1,72 @@
package com.github.kr328.clash.remote
import android.os.RemoteException
import com.github.kr328.clash.service.IProfileService
import com.github.kr328.clash.service.transact.IStreamCallback
import com.github.kr328.clash.service.transact.ParcelableContainer
import com.github.kr328.clash.service.model.Profile
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProfileClient(private val service: IProfileService) {
suspend fun acquireUnused(type: Profile.Type, source: String?) = withContext(Dispatchers.IO) {
service.acquireUnused(type.name, source)
}
suspend fun acquireCloned(id: Long) = withContext(Dispatchers.IO) {
service.acquireCloned(id)
}
suspend fun acquireTempUri(id: Long): String? = withContext(Dispatchers.IO) {
service.acquireTempUri(id)
}
suspend fun update(id: Long, metadata: Profile) = withContext(Dispatchers.IO) {
service.update(id, metadata)
}
suspend fun commitAsync(id: Long) = withContext(Dispatchers.IO) {
CompletableDeferred<Unit>().apply {
service.commit(id, object : IStreamCallback.Stub() {
override fun complete() {
complete(Unit)
}
override fun completeExceptionally(reason: String?) {
completeExceptionally(RemoteException(reason))
}
override fun send(data: ParcelableContainer?) {}
})
}
}
suspend fun release(id: Long) = withContext(Dispatchers.IO) {
service.release(id)
}
suspend fun delete(id: Long) = withContext(Dispatchers.IO) {
service.delete(id)
}
suspend fun clear(id: Long) = withContext(Dispatchers.IO) {
service.clear(id)
}
suspend fun queryAll(): Array<Profile> = withContext(Dispatchers.IO) {
service.queryAll()
}
suspend fun queryActive(): Profile? = withContext(Dispatchers.IO) {
service.queryActive()
}
suspend fun queryById(id: Long): Profile? = withContext(Dispatchers.IO) {
service.queryById(id)
}
suspend fun setActive(id: Long) = withContext(Dispatchers.IO) {
service.setActive(id)
}
}
@@ -1,71 +1,185 @@
package com.github.kr328.clash.remote
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.Handler
import android.os.IBinder
import androidx.core.content.edit
import com.github.kr328.clash.ApkBrokenActivity
import com.github.kr328.clash.AppCrashedActivity
import com.github.kr328.clash.Constants
import com.github.kr328.clash.common.Global
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.store.AppStore
import com.github.kr328.clash.util.ApplicationObserver
import com.github.kr328.clash.util.verifyApk
import kotlinx.coroutines.Dispatchers
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.common.utils.Log
import com.github.kr328.clash.service.ClashManagerService
import com.github.kr328.clash.service.IClashManager
import com.github.kr328.clash.service.IProfileService
import com.github.kr328.clash.service.ProfileService
import com.github.kr328.clash.utils.ApplicationObserver
import com.microsoft.appcenter.crashes.Crashes
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.io.File
import java.util.zip.ZipFile
object Remote {
val broadcasts: Broadcasts = Broadcasts(Global.application)
val service: Service = Service(Global.application) {
ApplicationObserver.createdActivities.forEach { it.finish() }
var clash = Channel<ClashClient>()
var profile = Channel<ProfileClient>()
val intent = AppCrashedActivity::class.intent
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
private var clashConnection: ClashConnection? = null
private var profileConnection: ProfileConnection? = null
Global.application.startActivity(intent)
}
class ClashConnection : ServiceConnection {
private var instance: ClashClient? = null
private var sender: Job? = null
private val visible = Channel<Boolean>(Channel.CONFLATED)
fun launch() {
ApplicationObserver.attach(Global.application)
ApplicationObserver.onVisibleChanged { visible.trySend(it) }
Global.launch(Dispatchers.IO) {
run()
}
}
private suspend fun run() {
val context = Global.application
val store = AppStore(context)
val updatedAt = getLastUpdated(context)
if (store.updatedAt != updatedAt) {
if (!context.verifyApk()) {
ApplicationObserver.createdActivities.forEach { it.finish() }
val intent = ApkBrokenActivity::class.intent
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return context.startActivity(intent)
} else {
store.updatedAt = updatedAt
}
override fun onServiceDisconnected(name: ComponentName?) {
sender?.cancel()
instance = null
sender = null
}
while (true) {
if (visible.receive()) {
service.bind()
broadcasts.register()
} else {
service.unbind()
broadcasts.unregister()
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
if (service != null)
instance = ClashClient(IClashManager.Stub.asInterface(service))
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
sender = GlobalScope.launch {
while (isActive) {
val client = instance ?: return@launch
clash.send(client)
}
}
}
}
private fun getLastUpdated(context: Context): Long {
return context.packageManager.getPackageInfo(context.packageName, 0).lastUpdateTime
class ProfileConnection : ServiceConnection {
private var instance: ProfileClient? = null
private var sender: Job? = null
override fun onServiceDisconnected(name: ComponentName?) {
sender?.cancel()
instance = null
sender = null
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
if (service != null)
instance = ProfileClient(IProfileService.Stub.asInterface(service))
service?.linkToDeath({ onServiceDisconnected(null) }, 0)
sender = GlobalScope.launch {
while (isActive) {
val client = instance ?: return@launch
profile.send(client)
}
}
}
}
private val handler = Handler()
private val observer = ApplicationObserver {
Log.d("Remote Connection State = $it")
val application = Global.application
if ( it ) {
handler.removeMessages(0)
GlobalScope.launch {
val valid = withContext(Dispatchers.IO) {
verifyApk(application)
}
if (!valid) {
application.startActivity(
ApkBrokenActivity::class.intent
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
return@launch
}
clashConnection = ClashConnection().apply {
application.bindService(
ClashManagerService::class.intent,
this,
Context.BIND_AUTO_CREATE
)
}
profileConnection = ProfileConnection().apply {
application.bindService(
ProfileService::class.intent,
this,
Context.BIND_AUTO_CREATE
)
}
}
} else {
handler.postDelayed({
clashConnection?.also {
application.unbindService(it)
it.onServiceDisconnected(null)
}
profileConnection?.also {
application.unbindService(it)
it.onServiceDisconnected(null)
}
clashConnection = null
profileConnection = null
}, 5000)
}
}
fun init(application: Application) {
observer.register(application)
}
private fun verifyApk(application: Application): Boolean {
return try {
val sp = application.getSharedPreferences(
Constants.PREFERENCE_NAME_APP,
Context.MODE_PRIVATE
)
val pkg = application.packageManager.getPackageInfo(application.packageName, 0)
if (sp.getLong(Constants.PREFERENCE_KEY_LAST_INSTALL, 0) == pkg.lastUpdateTime)
return true
val info = application.applicationInfo
val sources =
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return false
val regexNativeLibrary = Regex("lib/(\\S+)/libgojni.so")
val availableAbi = Build.SUPPORTED_ABIS.toSet()
val apkAbi =
sources
.asSequence()
.filter { File(it).exists() }
.flatMap { ZipFile(it).entries().asSequence() }
.mapNotNull { regexNativeLibrary.matchEntire(it.name) }
.mapNotNull { it.groups[1]?.value }
.toSet()
if (availableAbi.intersect(apkAbi).isNotEmpty()) {
sp.edit {
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
}
true
} else {
false
}
} catch (e: Exception) {
Crashes.trackError(e)
false
}
}
}
@@ -0,0 +1,55 @@
package com.github.kr328.clash.remote
import android.content.Context
import android.content.Intent
import android.net.Uri
import com.github.kr328.clash.ApkBrokenActivity
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.service.Constants
import com.github.kr328.clash.service.ServiceStatusProvider
object RemoteUtils {
fun detectClashRunning(context: Context): Boolean {
try {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong != null
} catch (e: IllegalArgumentException) {
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return false
}
}
fun getCurrentClashProfileName(context: Context): String? {
try {
val authority = Uri.Builder()
.scheme("content")
.authority("${context.packageName}${Constants.STATUS_PROVIDER_SUFFIX}")
.build()
val pong = context.contentResolver.call(
authority,
ServiceStatusProvider.METHOD_PING_CLASH_SERVICE,
null,
null
)
return pong?.getString("name")
} catch (e: IllegalArgumentException) {
context.startActivity(ApkBrokenActivity::class.intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return null
}
}
}
@@ -1,74 +0,0 @@
package com.github.kr328.clash.remote
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class Resource<T> {
private interface Callback<T> {
fun accept(value: T)
}
private val pending: MutableSet<Callback<T>> = mutableSetOf()
private var value: T? = null
suspend fun get(): T {
return suspendCancellableCoroutine { ctx ->
val callback = object : Callback<T> {
override fun accept(value: T) {
ctx.resume(value)
}
}
ctx.invokeOnCancellation {
cancel(callback)
}
get(callback)
}
}
fun set(v: T?) {
setAndNotify(v)
}
fun reset(v: T) {
resetIfMatched(v)
}
@Synchronized
private fun get(callback: Callback<T>) {
val v = value
if (v == null) {
pending.add(callback)
} else {
callback.accept(v)
}
}
@Synchronized
private fun setAndNotify(value: T?) {
this.value = value
if (value != null) {
pending.forEach {
it.accept(value)
}
pending.clear()
}
}
@Synchronized
private fun resetIfMatched(value: T) {
if (this.value === value) {
this.value = null
}
}
@Synchronized
private fun cancel(callback: Callback<T>) {
pending.remove(callback)
}
}
@@ -1,64 +0,0 @@
package com.github.kr328.clash.remote
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.ServiceConnection
import android.os.IBinder
import com.github.kr328.clash.Tracker
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.common.util.intent
import com.github.kr328.clash.log.SystemLogcat
import com.github.kr328.clash.service.RemoteService
import com.github.kr328.clash.service.remote.IRemoteService
import com.github.kr328.clash.service.remote.unwrap
import com.github.kr328.clash.util.unbindServiceSilent
import java.util.concurrent.TimeUnit
class Service(private val context: Application, val crashed: () -> Unit) {
val remote = Resource<IRemoteService>()
private val connection = object : ServiceConnection {
private var lastCrashed: Long = -1
override fun onServiceConnected(name: ComponentName?, service: IBinder) {
remote.set(service.unwrap(IRemoteService::class))
}
override fun onServiceDisconnected(name: ComponentName?) {
remote.set(null)
Tracker.uploadLogcat(SystemLogcat.dumpCrash())
if (System.currentTimeMillis() - lastCrashed < TOGGLE_CRASHED_INTERVAL) {
unbind()
crashed()
}
lastCrashed = System.currentTimeMillis()
Log.w("RemoteManager crashed")
}
}
fun bind() {
try {
context.bindService(RemoteService::class.intent, connection, Context.BIND_AUTO_CREATE)
} catch (e: Exception) {
unbind()
crashed()
}
}
fun unbind() {
context.unbindServiceSilent(connection)
remote.set(null)
}
companion object {
private val TOGGLE_CRASHED_INTERVAL = TimeUnit.SECONDS.toMillis(10)
}
}
@@ -1,34 +0,0 @@
package com.github.kr328.clash.remote
import android.content.Context
import android.net.Uri
import com.github.kr328.clash.common.constants.Authorities
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.service.StatusProvider
class StatusClient(private val context: Context) {
private val uri: Uri
get() {
return Uri.Builder()
.scheme("content")
.authority(Authorities.STATUS_PROVIDER)
.build()
}
fun currentProfile(): String? {
return try {
val result = context.contentResolver.call(
uri,
StatusProvider.METHOD_CURRENT_PROFILE,
null,
null
)
result?.getString("name")
} catch (e: Exception) {
Log.w("Query current profile: $e", e)
null
}
}
}
@@ -0,0 +1,36 @@
package com.github.kr328.clash.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.service.settings.ServiceSettings
import moe.shizuku.preference.PreferenceFragment
abstract class BaseSettingFragment : PreferenceFragment() {
abstract fun onCreateDataStore(): SettingsDataStore
abstract val xmlResourceId: Int
protected val service: ServiceSettings by lazy { ServiceSettings(requireActivity()) }
protected val ui: UiSettings by lazy { UiSettings(requireActivity()) }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = onCreateDataStore()
setPreferencesFromResource(xmlResourceId, rootKey)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val result = super.onCreateView(inflater, container, savedInstanceState)
setDivider(null)
setDividerHeight(0)
return result
}
}
@@ -0,0 +1,56 @@
package com.github.kr328.clash.settings
import android.content.pm.PackageManager
import android.os.Bundle
import com.github.kr328.clash.R
import com.github.kr328.clash.common.utils.componentName
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.RestartReceiver
import com.github.kr328.clash.service.settings.ServiceSettings
class BehaviorFragment : BaseSettingFragment() {
companion object {
private const val KEY_START_ON_BOOT = "start_on_boot"
private const val KEY_SHOW_TRAFFIC = "show_traffic"
}
override fun onCreateDataStore(): SettingsDataStore {
return SettingsDataStore().apply {
on(KEY_START_ON_BOOT, StartOnBootSource())
on(KEY_SHOW_TRAFFIC, ServiceSettings.NOTIFICATION_REFRESH.asSource(service))
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
findPreference(KEY_SHOW_TRAFFIC).isEnabled = !Broadcasts.clashRunning
}
override val xmlResourceId: Int
get() = R.xml.settings_behavior
private inner class StartOnBootSource : SettingsDataStore.Source {
override fun set(value: Any?) {
val v = value as Boolean? ?: return
val status = if (v)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
requireActivity().packageManager.setComponentEnabledSetting(
RestartReceiver::class.componentName,
status,
PackageManager.DONT_KILL_APP
)
}
override fun get(): Any? {
val status = requireActivity().packageManager
.getComponentEnabledSetting(RestartReceiver::class.componentName)
return status == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
}
}
@@ -0,0 +1,30 @@
package com.github.kr328.clash.settings
import com.github.kr328.clash.R
import com.github.kr328.clash.preference.UiSettings
import com.github.kr328.clash.service.settings.ServiceSettings
class InterfaceFragment : BaseSettingFragment() {
companion object {
private const val KEY_DARK_MODE = "dark_mode"
private const val KEY_LANGUAGE = "language"
}
override fun onCreateDataStore(): SettingsDataStore {
return SettingsDataStore().apply {
on(KEY_DARK_MODE, UiSettings.DARK_MODE.asSource(ui))
on(KEY_LANGUAGE, UiSettings.LANGUAGE.asSource(ui))
onApply {
service.commit {
put(ServiceSettings.LANGUAGE, ui.get(UiSettings.LANGUAGE))
}
requireActivity().recreate()
}
}
}
override val xmlResourceId: Int
get() = R.xml.settings_interface
}
@@ -0,0 +1,46 @@
package com.github.kr328.clash.settings
import android.os.Bundle
import com.github.kr328.clash.PackagesActivity
import com.github.kr328.clash.R
import com.github.kr328.clash.common.utils.intent
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.settings.ServiceSettings
class NetworkFragment : BaseSettingFragment() {
companion object {
private const val KEY_ENABLE_VPN_SERVICE = "enable_vpn_service"
private const val BYPASS_PRIVATE_NETWORK = "bypass_private_network"
private const val KEY_DNS_HIJACKING = "dns_hijacking"
private const val KEY_DNS_OVERRIDE = "dns_override"
private const val KEY_APPEND_SYS_DNS = "append_system_dns"
private const val KEY_ACCESS_CONTROL_MODE = "access_control_mode"
private const val KEY_ACCESS_CONTROL_PACKAGES = "access_control_packages"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
preferenceScreen.isEnabled = !Broadcasts.clashRunning
findPreference(KEY_ACCESS_CONTROL_PACKAGES).setOnPreferenceClickListener {
startActivity(PackagesActivity::class.intent)
true
}
}
override fun onCreateDataStore(): SettingsDataStore {
return SettingsDataStore().apply {
on(KEY_ENABLE_VPN_SERVICE, ServiceSettings.ENABLE_VPN.asSource(service))
on(BYPASS_PRIVATE_NETWORK, ServiceSettings.BYPASS_PRIVATE_NETWORK.asSource(service))
on(KEY_DNS_HIJACKING, ServiceSettings.DNS_HIJACKING.asSource(service))
on(KEY_DNS_OVERRIDE, ServiceSettings.OVERRIDE_DNS.asSource(service))
on(KEY_APPEND_SYS_DNS, ServiceSettings.AUTO_ADD_SYSTEM_DNS.asSource(service))
on(KEY_ACCESS_CONTROL_MODE, ServiceSettings.ACCESS_CONTROL_MODE.asSource(service))
}
}
override val xmlResourceId: Int
get() = R.xml.settings_network
}
@@ -0,0 +1,100 @@
package com.github.kr328.clash.settings
import com.github.kr328.clash.common.settings.BaseSettings
import moe.shizuku.preference.PreferenceDataStore
class SettingsDataStore : PreferenceDataStore() {
interface Source {
fun set(value: Any?)
fun get(): Any?
}
private val sources: MutableMap<String, Source> = mutableMapOf()
var applyListener: () -> Unit = {}
fun on(key: String, source: Source) {
sources[key] = source
}
fun onApply(block: () -> Unit) {
this.applyListener = block
}
inline fun <reified T> BaseSettings.Entry<T>.asSource(settings: BaseSettings): Source {
return object : Source {
override fun set(value: Any?) {
val v = value ?: throw NullPointerException()
settings.commit {
put(this@asSource, v as T)
}
applyListener()
}
override fun get(): Any? {
return settings.get(this@asSource)
}
}
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
val source = sources[key] ?: return defValue
return (source.get() as Boolean?) ?: defValue
}
override fun putLong(key: String?, value: Long) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun putInt(key: String?, value: Int) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun getInt(key: String?, defValue: Int): Int {
val source = sources[key] ?: return defValue
return (source.get() as Int?) ?: defValue
}
override fun putBoolean(key: String?, value: Boolean) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun getLong(key: String?, defValue: Long): Long {
val source = sources[key] ?: return defValue
return (source.get() as Long?) ?: defValue
}
override fun getFloat(key: String?, defValue: Float): Float {
val source = sources[key] ?: return defValue
return (source.get() as Float?) ?: defValue
}
override fun putFloat(key: String?, value: Float) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
override fun getString(key: String?, defValue: String?): String? {
val source = sources[key] ?: return defValue
return (source.get() as String?) ?: defValue
}
override fun putString(key: String?, value: String?) {
val source = sources[key] ?: throw NullPointerException()
source.set(value)
}
}
@@ -1,22 +0,0 @@
package com.github.kr328.clash.store
import android.content.Context
import com.github.kr328.clash.common.store.Store
import com.github.kr328.clash.common.store.asStoreProvider
class AppStore(context: Context) {
private val store = Store(
context
.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
.asStoreProvider()
)
var updatedAt: Long by store.long(
key = "updated_at",
defaultValue = -1,
)
companion object {
private const val FILE_NAME = "app"
}
}
@@ -1,29 +0,0 @@
package com.github.kr328.clash.store
import android.content.Context
import com.github.kr328.clash.common.store.Store
import com.github.kr328.clash.common.store.asStoreProvider
class TipsStore(context: Context) {
private val store = Store(
context
.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
.asStoreProvider()
)
var requestDonate: Boolean by store.boolean(
key = "request_donate",
defaultValue = true,
)
var primaryVersion: Int by store.int(
key = "primary_version",
defaultValue = -1,
)
companion object {
const val CURRENT_PRIMARY_VERSION = 1
private const val FILE_NAME = "tips"
}
}
@@ -1,44 +0,0 @@
package com.github.kr328.clash.util
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class ActivityResultLifecycle : LifecycleOwner {
private val lifecycle = LifecycleRegistry(this)
init {
lifecycle.currentState = Lifecycle.State.INITIALIZED
}
override fun getLifecycle(): Lifecycle {
return lifecycle
}
suspend fun <T> use(block: suspend (lifecycle: ActivityResultLifecycle, start: () -> Unit) -> T): T {
return try {
markCreated()
block(this, this::markStarted)
} finally {
withContext(NonCancellable) {
markDestroy()
}
}
}
private fun markCreated() {
lifecycle.currentState = Lifecycle.State.CREATED
}
private fun markStarted() {
lifecycle.currentState = Lifecycle.State.STARTED
lifecycle.currentState = Lifecycle.State.RESUMED
}
private fun markDestroy() {
lifecycle.currentState = Lifecycle.State.DESTROYED
}
}
@@ -1,78 +0,0 @@
package com.github.kr328.clash.util
import android.app.Activity
import android.app.Application
import android.content.Context
import android.os.Build
import android.os.Bundle
import java.io.File
import java.util.zip.ZipFile
object ApplicationObserver {
private val activities: MutableSet<Activity> = mutableSetOf()
private var visibleChanged: (Boolean) -> Unit = {}
private var appVisible = false
private set(value) {
if (field != value) {
field = value
visibleChanged(value)
}
}
val createdActivities: Set<Activity>
get() = activities
private val activityObserver = object : Application.ActivityLifecycleCallbacks {
@Synchronized
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
activities.add(activity)
appVisible = true
}
@Synchronized
override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity)
appVisible = activities.isNotEmpty()
}
override fun onActivityStarted(activity: Activity) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
}
fun onVisibleChanged(visibleChanged: (Boolean) -> Unit) {
this.visibleChanged = visibleChanged
}
fun attach(application: Application) {
application.registerActivityLifecycleCallbacks(activityObserver)
}
}
fun Context.verifyApk(): Boolean {
return try {
val info = applicationInfo
val sources = info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return false
val regexNativeLibrary = Regex("lib/(\\S+)/libclash.so")
val availableAbi = Build.SUPPORTED_ABIS.toSet()
val apkAbi = sources
.asSequence()
.filter { File(it).exists() }
.flatMap { ZipFile(it).entries().asSequence() }
.mapNotNull { regexNativeLibrary.matchEntire(it.name) }
.mapNotNull { it.groups[1]?.value }
.toSet()
availableAbi.intersect(apkAbi).isNotEmpty()
} catch (e: Exception) {
false
}
}
@@ -1,25 +0,0 @@
package com.github.kr328.clash.util
import android.content.ContentResolver
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.FileNotFoundException
private fun fileNotFound(file: Uri): FileNotFoundException {
return FileNotFoundException("$file not found")
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun ContentResolver.copyContentTo(
source: Uri,
target: Uri
) {
withContext(Dispatchers.IO) {
(openInputStream(source) ?: throw fileNotFound(source)).use { input ->
(openOutputStream(target, "rwt") ?: throw fileNotFound(target)).use { output ->
input.copyTo(output)
}
}
}
}
@@ -1,7 +0,0 @@
package com.github.kr328.clash.util
import android.content.Context
import java.io.File
val Context.logsDir: File
get() = cacheDir.resolve("logs")
@@ -1,46 +0,0 @@
package com.github.kr328.clash.util
import android.os.DeadObjectException
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.remote.Remote
import com.github.kr328.clash.service.remote.IClashManager
import com.github.kr328.clash.service.remote.IProfileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext
suspend fun <T> withClash(
context: CoroutineContext = Dispatchers.IO,
block: suspend IClashManager.() -> T
): T {
while (true) {
val remote = Remote.service.remote.get()
val client = remote.clash()
try {
return withContext(context) { client.block() }
} catch (e: DeadObjectException) {
Log.w("Remote services panic")
Remote.service.remote.reset(remote)
}
}
}
suspend fun <T> withProfile(
context: CoroutineContext = Dispatchers.IO,
block: suspend IProfileManager.() -> T
): T {
while (true) {
val remote = Remote.service.remote.get()
val client = remote.profile()
try {
return withContext(context) { client.block() }
} catch (e: DeadObjectException) {
Log.w("Remote services panic")
Remote.service.remote.reset(remote)
}
}
}
@@ -1,12 +0,0 @@
package com.github.kr328.clash.util
import android.content.Context
import android.content.ServiceConnection
fun Context.unbindServiceSilent(connection: ServiceConnection) {
try {
unbindService(connection)
} catch (e: Exception) {
// ignore
}
}
@@ -1,6 +0,0 @@
package com.github.kr328.clash.util
import android.net.Uri
val Uri.fileName: String?
get() = schemeSpecificPart.split("/").lastOrNull()
@@ -0,0 +1,40 @@
package com.github.kr328.clash.utils
import android.app.Activity
import android.app.Application
import android.os.Bundle
class ApplicationObserver(val stateChanged: (Boolean) -> Unit) {
private var applicationRunning = false
private set(value) {
if ( field != value )
stateChanged(value)
field = value
}
private var activityCount: Int = 0
private val activityObserver = object: Application.ActivityLifecycleCallbacks {
override fun onActivityPaused(activity: Activity) {}
override fun onActivityStarted(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityStopped(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityDestroyed(activity: Activity) {
synchronized(this) {
activityCount--
applicationRunning = activityCount > 0
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
synchronized(this) {
activityCount++
applicationRunning = activityCount > 0
}
}
}
fun register(application: Application) {
application.registerActivityLifecycleCallbacks(activityObserver)
}
}
@@ -0,0 +1,30 @@
package com.github.kr328.clash.utils
import android.content.Context
import java.text.SimpleDateFormat
import java.util.*
const val DATE_DATE_ONLY = "yyyy-MM-dd"
const val DATE_TIME_ONLY = "HH:mm:ss"
const val DATE_ALL = "$DATE_DATE_ONLY $DATE_TIME_ONLY"
fun Date.format(
context: Context,
includeDate: Boolean = true,
includeTime: Boolean = true,
custom: String = ""
): String {
val locale = context.resources.configuration.locales[0]
return when {
custom.isNotEmpty() ->
SimpleDateFormat(custom, locale).format(this)
includeDate && includeTime ->
SimpleDateFormat(DATE_ALL, locale).format(this)
includeDate ->
SimpleDateFormat(DATE_DATE_ONLY, locale).format(this)
includeTime ->
SimpleDateFormat(DATE_TIME_ONLY, locale).format(this)
else -> ""
}
}
@@ -0,0 +1,8 @@
package com.github.kr328.clash.utils
import android.content.Context
import com.github.kr328.clash.Constants
import java.io.File
val Context.logsDir: File
get() = (externalCacheDir ?: cacheDir).resolve(Constants.LOG_DIR_NAME)
@@ -0,0 +1,32 @@
package com.github.kr328.clash.utils
import android.content.Context
import com.github.kr328.clash.R
object IntervalUtils {
private const val MILLIS_SECOND = 1000L
private const val MILLIS_MINUTE = MILLIS_SECOND * 60
private const val MILLIS_HOUR = MILLIS_MINUTE * 60
private const val MILLIS_DAY = MILLIS_HOUR * 24
private const val MILLIS_MONTH = MILLIS_DAY * 30
private const val MILLIS_YEAR = MILLIS_MONTH * 12
fun intervalString(context: Context, interval: Long): String {
val year = interval / MILLIS_YEAR
val month = interval / MILLIS_MONTH
val day = interval / MILLIS_DAY
val hour = interval / MILLIS_HOUR
val minute = interval / MILLIS_MINUTE
System.currentTimeMillis()
return when {
year > 0 -> context.getString(R.string.format_years, year)
month > 0 -> context.getString(R.string.format_months, month)
day > 0 -> context.getString(R.string.format_days, day)
hour > 0 -> context.getString(R.string.format_hours, hour)
minute > 0 -> context.getString(R.string.format_minutes, minute)
else -> context.getString(R.string.recently)
}
}
}
@@ -0,0 +1,70 @@
package com.github.kr328.clash.utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object PrefixMerger {
private val REGEX_PREFIX_TRIM = Regex("[-]*$")
data class Result<T>(val prefix: String, val content: String, val value: T)
suspend fun <T> merge(values: List<T>, transform: (T) -> String): List<Result<T>> =
withContext(Dispatchers.Default) {
val pairs = values.map {
transform(it).trim().toCodePointList() to it
}
val groups = mutableListOf<List<Pair<List<Int>, T>>>()
var mergingGroup = mutableListOf<Pair<List<Int>, T>>()
var currentCodePoint = 0
val result = mutableListOf<Result<T>>()
for (pair in pairs) {
if (pair.first.isEmpty())
continue
if (pair.first[0] == currentCodePoint) {
mergingGroup.add(pair)
} else {
if (mergingGroup.isNotEmpty()) {
groups.add(mergingGroup)
mergingGroup = mutableListOf()
}
currentCodePoint = pair.first[0]
mergingGroup.add(pair)
}
}
if (mergingGroup.isNotEmpty())
groups.add(mergingGroup)
for (group in groups) {
var diffIndex = 0
val size = group.map { it.first.size }.min() ?: 0
diff@ for (charIndex in 0 until size) {
for (stringIndex in 0 until (group.size - 1)) {
if (group[stringIndex].first[charIndex] != group[stringIndex + 1].first[charIndex])
break@diff
}
diffIndex++
}
group.forEach {
val prefix = it.first.subList(0, diffIndex)
val content = it.first.subList(diffIndex, it.first.size)
result.add(
Result(
prefix.asCodePointString().replace(REGEX_PREFIX_TRIM, ""),
content.asCodePointString(), it.second
)
)
}
}
result
}
}
@@ -0,0 +1,105 @@
package com.github.kr328.clash.utils
import com.github.kr328.clash.core.model.Proxy
import com.github.kr328.clash.core.model.ProxyGroup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProxySorter(private val groupOrder: Order, private val proxyOrder: Order) {
enum class Order {
DEFAULT, DELAY_INCREASE, DELAY_DECREASE, NAME_INCREASE, NAME_DECREASE
}
suspend fun sort(proxyGroup: List<ProxyGroup>): List<ProxyGroup> =
withContext(Dispatchers.Default) {
val groups = proxyGroup.groupBy {
if (it.name == "GLOBAL")
"GLOBAL"
else
"OTHER"
}
val global = groups["GLOBAL"]?.singleOrNull()
val other = groups["OTHER"] ?: emptyList()
val sortedGroup = when (groupOrder) {
Order.DEFAULT -> groupSortWithDefault(global, other)
Order.DELAY_INCREASE -> groupSortWithDelay(true, other)
Order.DELAY_DECREASE -> groupSortWithDelay(false, other)
Order.NAME_INCREASE -> groupSortWithName(true, other)
Order.NAME_DECREASE -> groupSortWithName(false, other)
}
val sorted = if (global == null)
sortedGroup
else
listOf(global) + sortedGroup
sorted.map {
val sortedProxy = when (proxyOrder) {
Order.DEFAULT -> it.proxies
Order.DELAY_INCREASE -> proxySortWithDelay(true, it.proxies)
Order.DELAY_DECREASE -> proxySortWithDelay(false, it.proxies)
Order.NAME_INCREASE -> proxySortWithName(true, it.proxies)
Order.NAME_DECREASE -> proxySortWithName(false, it.proxies)
}
it.copy(proxies = sortedProxy)
}
}
private fun groupSortWithDefault(
global: ProxyGroup?,
proxyGroup: List<ProxyGroup>
): List<ProxyGroup> {
if (global == null) return proxyGroup
val orderMap = global.proxies.mapIndexed { index, proxy ->
proxy.name to index
}.toMap()
return proxyGroup.sortedBy {
orderMap[it.name] ?: Int.MAX_VALUE
}
}
private fun groupSortWithName(
increase: Boolean,
proxyGroup: List<ProxyGroup>
): List<ProxyGroup> {
return if (increase)
proxyGroup.sortedBy { it.name }
else
proxyGroup.sortedByDescending { it.name }
}
private fun groupSortWithDelay(
increase: Boolean,
proxyGroup: List<ProxyGroup>
): List<ProxyGroup> {
return if (increase)
proxyGroup.sortedBy { it.delay }
else
proxyGroup.sortedByDescending { it.delay }
}
private fun proxySortWithName(
increase: Boolean,
proxies: List<Proxy>
): List<Proxy> {
return if (increase)
proxies.sortedBy { it.name }
else
proxies.sortedByDescending { it.name }
}
private fun proxySortWithDelay(
increase: Boolean,
proxies: List<Proxy>
): List<Proxy> {
return if (increase)
proxies.sortedBy { it.delay }
else
proxies.sortedByDescending { it.delay }
}
}
@@ -0,0 +1,60 @@
package com.github.kr328.clash.utils
import android.content.Context
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
class QuickSmoothScroller(context: Context, target: Int) :
LinearSmoothScroller(context) {
companion object {
const val MAX_OFFSET = 2
}
var started = {}
var stopped = {}
init {
targetPosition = target
}
override fun getVerticalSnapPreference(): Int {
return SNAP_TO_START
}
override fun onStop() {
super.onStop()
stopped()
}
override fun onStart() {
super.onStart()
started()
}
override fun onSeekTargetStep(dx: Int, dy: Int, state: RecyclerView.State, action: Action) {
when (val lm = layoutManager) {
is LinearLayoutManager -> {
val current = lm.findFirstCompletelyVisibleItemPosition()
if (targetPosition > current && targetPosition - current > MAX_OFFSET)
action.jumpTo(targetPosition - MAX_OFFSET)
else if (current > targetPosition && current - targetPosition > MAX_OFFSET)
action.jumpTo(targetPosition + MAX_OFFSET)
}
is GridLayoutManager -> {
val current = lm.findFirstCompletelyVisibleItemPosition()
if (targetPosition > current && targetPosition - current > MAX_OFFSET)
action.jumpTo(targetPosition - MAX_OFFSET)
else if (current > targetPosition && current - targetPosition > MAX_OFFSET)
action.jumpTo(targetPosition + MAX_OFFSET)
}
}
super.onSeekTargetStep(dx, dy, state, action)
}
}
Loaded 100 of 646 files, more files were not shown because too many files have changed in this diff. Show more