Compare commits

..
224 Commits
Author SHA1 Message Date
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
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
657 changed files with 11693 additions and 23692 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
* text=auto eol=lf
*.bat text eol=crlf
*.jar binary
-104
View File
@@ -1,104 +0,0 @@
name: "[English] Bug Report"
description: "Create a report to help us debug bugs"
title: "[BUG] "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
NOTE: Be sure to put a clear and concise title **AFTER** `[BUG]` in the text box above.
NOTE: We do not provide any services such as proxies, DO NOT feedback any problems not caused by this application here.
<!-- template -->
- type: textarea
id: description
attributes:
label: "Describe the bug"
description: "A clear and concise description of what the bug is."
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: "To Reproduce"
description: "Steps to reproduce the behavior:"
value: |
Step 1: ...
Step 2: ...
Step 3: ...
...
validations:
required: true
- type: textarea
id: device-info
attributes:
label: "Device Info"
description: |
Input your device information.
Example:
- Device: Pixel 4
- ROM: AOSP
- Android Version: 10
value: |
- Device:
- ROM:
- Android Version:
validations:
required: true
- type: textarea
id: app-info
attributes:
label: "Application Info"
description: |
Input application you are using information.
Example:
```
- Version: 2.5.4-premium
- APK filename: cfa-2.5.4-premium-arm64-v8a-release.apk
- Distribution Channel: Google Play
```
value: |
- Version:
- APK filename:
- Distribution Channel:
validations:
required: true
- type: textarea
id: configure
attributes:
label: "Configure File"
description: |
Please paste or upload the configuration file here.
TIPS: If you only have a subscription link, please use your browser to download it.
**NOTE: Please remove proxies from the configuration file before uploading it.**
**NOTE: Please remove proxies from the configuration file before uploading it.**
**NOTE: Please remove proxies from the configuration file before uploading it.**
validations:
required: true
- type: textarea
id: logs
attributes:
label: "Logs"
description: |
Please paste or upload the log file here.
TIPS: Please use the `Logcat` in application or `adb logcat`. `adb logcat` would be better.
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: "Screenshot"
description: "If applicable, add screenshots to help explain your problem."
placeholder: "Optional"
- type: textarea
id: additional
attributes:
label: "Additional"
description: "Add any other context about the problem here."
@@ -1,26 +0,0 @@
name: "[English] Feature Request"
description: "Create a report to help us improve"
title: "[Feature Request] "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this feature request!
NOTE: Be sure to put a clear and concise title **AFTER** `[Feature Request]` in the text box above.
<!-- template -->
- type: textarea
id: "description"
attributes:
label: "Feature Description"
description: |
A clear and concise description of the feature.
validations:
required: true
- type: textarea
id: "additional"
attributes:
label: "Additional"
description: |
Add any other context or screenshots about the feature request here.
@@ -1,105 +0,0 @@
name: "[简体中文] 错误报告"
description: "创建错误报告以帮助我们修正应用"
title: "[BUG] "
body:
- type: markdown
attributes:
value: |
感谢您在百忙之中填写此错误报告。
注意: 请务必在上方文本框的 `[BUG]` **之后**填写清晰明了的标题。
注意:这里不提供像是代理服务器之类的服务,请不要反馈非应用自身引起的问题。
<!-- template -->
- type: textarea
id: description
attributes:
label: "描述此错误"
description: "请清晰简洁的描述你遇到的错误。"
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: "如何复现该错误"
description: "复现步骤:"
value: |
步骤 1: ...
步骤 2: ...
步骤 3: ...
...
validations:
required: true
- type: textarea
id: device-info
attributes:
label: "设备信息"
description: |
输入您正在使用的设备信息。
例子:
- 机型: Pixel 4
- 系统类型: MIUI/AOSP
- Android 版本: 10
value: |
- 机型:
- 系统类型:
- Android 版本:
validations:
required: true
- type: textarea
id: app-info
attributes:
label: "应用信息"
description: |
输入您正在使用的应用信息。
例子:
```
- 版本: 2.5.4-premium
- 安装包文件名: cfa-2.5.4-premium-arm64-v8a-release.apk
- 应用来源: Google Play
```
value: |
- 版本:
- 安装包文件名:
- 应用来源:
validations:
required: true
- type: textarea
id: configure
attributes:
label: "配置文件"
description: |
请在此粘贴和上传配置文件。
提示:如果您仅有一个订阅链接,请使用浏览器打开此链接以下载配置文件。
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
**注意: 请在上传配置文件前,移除其中的代理服务器信息。**
validations:
required: true
- type: textarea
id: logs
attributes:
label: "日志"
description: |
请在此粘贴或上传日志。
提示: 请使用应用内的 `Logcat` 或 `adb logcat` 捕获日志. `adb logcat` 能更好地帮助侦测问题.
validations:
required: true
- type: textarea
id: screenshot
attributes:
label: "屏幕截图"
description: "如果适用,请在此粘贴或上传屏幕截图。"
placeholder: "可选"
- type: textarea
id: additional
attributes:
label: "附加信息"
description: "其他的可能与改错误相关的信息。"
placeholder: "可选"
@@ -1,26 +0,0 @@
name: "[简体中文] 功能请求"
description: "您希望的能够在应用中增加功能"
title: "[Feature Request] "
body:
- type: markdown
attributes:
value: |
感谢您在百忙之中填写此功能请求报告。
注意: 请务必在上方文本框的 `[Feature Request]` **之后**填写清晰明了的标题。
<!-- template -->
- type: textarea
id: "description"
attributes:
label: "功能描述"
description: |
简介明了的描述此功能。
validations:
required: true
- type: textarea
id: "additional"
attributes:
label: "附加信息"
description: |
与此功能相关的其他附加信息。
-1
View File
@@ -1 +0,0 @@
blank_issues_enabled: false
-54
View File
@@ -1,54 +0,0 @@
name: Build Unsigned
on:
push:
branches:
- main
paths-ignore:
- '.github/**'
- '.idea/**'
- '.gitattributes'
- '.gitignore'
- '.gitmodules'
- '**.md'
- 'LICENSE'
- 'NOTICE'
pull_request:
paths-ignore:
- '.github/**'
- '.idea/**'
- '.gitattributes'
- '.gitignore'
- '.gitmodules'
- '**.md'
- 'LICENSE'
- 'NOTICE'
jobs:
BuildUnsigned:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
with:
submodules: recursive
- name: Setup Java
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: 17
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.18
- uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build
uses: gradle/gradle-build-action@v2
with:
arguments: --no-daemon app:assembleFossRelease
+7 -20
View File
@@ -1,7 +1,6 @@
.gradle
build/
/app/foss/release
/app/premium/release
/app/release/
/captures
# Ignore Gradle GUI config
@@ -17,30 +16,24 @@ 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
# gradle
.gradle
# clion cmake build
cmake-build-*
# local.properties
local.properties
# tracker
tracker.properties
# keystore
keystore.properties
# vscode
.vscode
@@ -55,9 +48,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)
+29 -19
View File
@@ -1,27 +1,36 @@
## Clash for Android
A Graphical user interface of [clash](https://github.com/Dreamacro/clash) for Android
A GUI for [clash](https://github.com/Dreamacro/clash) on Android
> NOTICE: Early testing currently
<a href="https://play.google.com/store/apps/details?id=com.github.kr328.clash"><img width="200px" alt="Get it on Google Play" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/></a> or [Releases](https://github.com/Kr328/ClashForAndroid/releases)
### Feature
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+
* `arm64` 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 +39,28 @@ See also [PRIVACY_POLICY.md](./PRIVACY_POLICY.md)
git submodule update --init --recursive
```
2. Install **OpenJDK 11**, **Android SDK**, **CMake** and **Golang**
2. Install `Android SDK (include JDK)` ,`Android NDK` and `Golang`
3. Create `local.properties` in project root with
3. Configure `local.properties`
```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. Build
```properties
keystore.path=/path/to/keystore/file
keystore.password=<key store password>
key.alias=<key alias>
key.password=<key password>
```
5. Build
on Linux
```bash
./gradlew app:assembleFossRelease
./gradlew build
```
6. Pick `app-foss-<arch>-release.apk` in `app/build/outputs/apk/foss/release/`
on Windows
```bash
.\gradlew.bat build
```
+1
View File
@@ -0,0 +1 @@
/build
+69
View File
@@ -0,0 +1,69 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlinx-serialization'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion gCompileSdkVersion
buildToolsVersion gBuildToolsVersion
defaultConfig {
applicationId "com.github.kr328.clash"
minSdkVersion gMinSdkVersion
targetSdkVersion gTargetSdkVersion
versionCode gVersionCode
versionName gVersionName
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
kapt "androidx.room:room-compiler:$gRoomVersion"
implementation project(":core")
implementation project(":service")
implementation project(":design")
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$gKotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$gKotlinCoroutineVersion"
implementation "androidx.lifecycle:lifecycle-extensions:$gLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$gLifecycleVersion"
implementation "androidx.recyclerview:recyclerview:$gRecyclerviewVersion"
implementation "androidx.core:core-ktx:$gAndroidKtxVersion"
implementation "androidx.appcompat:appcompat:$gAppCompatVersion"
implementation "androidx.room:room-runtime:$gRoomVersion"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$gKotlinSerializationVersion"
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"
}
task injectAppCenterKey() {
doFirst {
Properties properties = new Properties()
properties.load(rootProject.file('local.properties').newDataInputStream())
def key = properties.getProperty("appcenter.key", "")
android.buildTypes.each {
it.buildConfigField 'String', 'APP_CENTER_KEY', "\"$key\""
}
}
}
afterEvaluate {
preBuild.dependsOn(injectAppCenterKey)
}
-32
View File
@@ -1,32 +0,0 @@
plugins {
kotlin("android")
kotlin("kapt")
id("com.android.application")
}
dependencies {
compileOnly(project(":hideapi"))
implementation(project(":core"))
implementation(project(":service"))
implementation(project(":design"))
implementation(project(":common"))
implementation(libs.kotlin.coroutine)
implementation(libs.androidx.core)
implementation(libs.androidx.activity)
implementation(libs.androidx.fragment)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.coordinator)
implementation(libs.androidx.recyclerview)
implementation(libs.google.material)
val premiumImplementation by configurations
premiumImplementation(libs.appcenter.analytics)
premiumImplementation(libs.appcenter.crashes)
}
tasks.getByName("clean", type = Delete::class) {
delete(file("release"))
}
+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
}
}
+42 -102
View File
@@ -3,177 +3,117 @@
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">
android:name=".OnBootReceiver"
android:enabled="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
@@ -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,62 @@ 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
}
override val activityLabel: CharSequence
get() = getText(R.string.application_broken)
}
@@ -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,164 +1,160 @@
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.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.github.kr328.clash.service.util.createLanguageConfigurationContext
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()
}
override fun onStart() {
super.onStart()
activityStarted = true
if (language != uiSettings.get(UiSettings.LANGUAGE))
recreate()
if (darkMode != uiSettings.get(UiSettings.DARK_MODE)) {
resetDarkMode()
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())
activityLabel?.let {
title = it
}
}
}
@@ -167,83 +163,58 @@ abstract class BaseActivity<D : Design<*>> :
return true
}
abstract val activityLabel: CharSequence?
override fun onSupportNavigateUp(): Boolean {
this.onBackPressed()
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 makeSnackbarException(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)
when (dayNight) {
DayNight.Night -> {
theme.applyStyle(R.style.AppThemeDark, true)
}
DayNight.Day -> {
theme.applyStyle(R.style.AppThemeLight, true)
}
}
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
}
}
}
@@ -0,0 +1,16 @@
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_TYPE_FILE = "file"
const val URL_PROVIDER_TYPE_URL = "url"
const val URL_PROVIDER_TYPE_EXTERNAL = "external"
const val URL_PROVIDER_INTENT_ACTION = "com.github.kr328.clash.action.PROVIDE_URL"
const val URL_PROVIDER_INTENT_EXTRA_NAME = "name"
}
@@ -0,0 +1,151 @@
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.service.util.intent
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
}
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]
startActivityForResult(
ProfileEditActivity::class.intent
.putExtra("type", item.type)
.putExtra("intent", item.intent),
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 val activityLabel: CharSequence
get() = getText(R.string.create_profile)
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)!!,
Constants.URL_PROVIDER_TYPE_FILE,
null
),
UrlProvider(
getText(R.string.url),
getText(R.string.import_from_url),
getDrawable(R.drawable.ic_download)!!,
Constants.URL_PROVIDER_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 = Constants.URL_PROVIDER_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: String,
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,145 @@
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.core.event.LogEvent
import com.github.kr328.clash.service.util.intent
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 val activityLabel: CharSequence
get() = getText(R.string.log_viewer)
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) {
makeSnackbarException(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,30 +9,51 @@ 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.core.event.LogEvent
import com.github.kr328.clash.core.utils.Log
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.ipc.IStreamCallback
import com.github.kr328.clash.service.ipc.ParcelableContainer
import com.github.kr328.clash.service.util.createLanguageConfigurationContext
import com.github.kr328.clash.service.util.intent
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_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 {
override fun onServiceDisconnected(name: ComponentName?) {
@@ -38,127 +61,206 @@ class LogcatService : Service(), CoroutineScope by CoroutineScope(Dispatchers.De
}
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
startObserver(service ?: return stopSelf())
val manager = IClashManager.Stub.asInterface(service) ?: return stopSelf()
manager.openLogEvent(object : IStreamCallback.Stub() {
override fun complete() {
stopSelf()
}
override fun completeExceptionally(reason: String?) {
stopSelf()
}
override fun send(data: ParcelableContainer?) {
val logEvent = (data?.data as LogEvent?) ?: return
if (!logChannel.offer(logEvent))
Log.w("Drop log $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() {
cancel()
logChannel.close()
unbindService(connection)
cancel()
stopForeground(true)
running = false
super.onDestroy()
isServiceRunning = false
}
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,261 @@
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.design.common.Category
import com.github.kr328.clash.design.view.CommonUiLayout
import com.github.kr328.clash.model.LogFile
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.service.util.startForegroundServiceCompat
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 val activityLabel: CharSequence
get() = getText(R.string.logs)
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,200 @@
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.core.model.General
import com.github.kr328.clash.core.utils.asBytesString
import com.github.kr328.clash.remote.withClash
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.ClashService
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.service.util.startForegroundServiceCompat
import com.github.kr328.clash.utils.startClashService
import com.github.kr328.clash.utils.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)
startActivityForResult(vpnRequest, REQUEST_CODE)
}
}
}
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 val activityLabel: CharSequence? = null
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode == Activity.RESULT_OK)
startForegroundServiceCompat(ClashService::class.intent)
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)
makeSnackbarException(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 {
queryActiveProfile()
}
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,12 @@ package com.github.kr328.clash
import android.app.Application
import android.content.Context
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.core.Global
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.Crashes
@Suppress("unused")
class MainApplication : Application() {
@@ -20,20 +21,15 @@ class MainApplication : Application() {
super.onCreate()
// Initialize AppCenter
Tracker.initialize(this)
val processName = currentProcessName
Log.d("Process $processName started")
if (processName == packageName) {
Remote.launch()
} else {
sendServiceRecreated()
if (BuildConfig.APP_CENTER_KEY.isNotEmpty() && !BuildConfig.DEBUG) {
AppCenter.start(
this,
BuildConfig.APP_CENTER_KEY,
Analytics::class.java, Crashes::class.java
)
}
}
fun finalize() {
Global.destroy()
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
}
}
}
@@ -0,0 +1,21 @@
package com.github.kr328.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.service.ProfileBackgroundService
import com.github.kr328.clash.service.util.componentName
import com.github.kr328.clash.service.util.startForegroundServiceCompat
import com.github.kr328.clash.utils.startClashService
class OnBootReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if ( intent?.action != Intent.ACTION_BOOT_COMPLETED || context == null )
return
context.startClashService()
context.startForegroundServiceCompat(Intent(Intents.INTENT_ACTION_PROFILE_SETUP)
.setComponent(ProfileBackgroundService::class.componentName))
}
}
@@ -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,169 @@
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() {
override val activityLabel: CharSequence?
get() = getText(R.string.access_control_packages)
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,278 @@
package com.github.kr328.clash
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.text.Html
import android.view.View
import android.webkit.MimeTypeMap
import androidx.appcompat.app.AlertDialog
import com.github.kr328.clash.core.utils.Log
import com.github.kr328.clash.design.common.TextInput
import com.github.kr328.clash.remote.withProfile
import com.github.kr328.clash.service.data.ClashProfileEntity
import com.github.kr328.clash.service.ipc.IStreamCallback
import com.github.kr328.clash.service.ipc.ParcelableContainer
import com.github.kr328.clash.service.transact.ProfileRequest
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_profile_edit.*
import kotlinx.coroutines.launch
class ProfileEditActivity : BaseActivity() {
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") ?: "*/*"
}
private var modified = false
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
}
}
private val requestCallback = object : IStreamCallback.Stub() {
override fun complete() {
launch {
setResult(Activity.RESULT_OK)
finish()
}
}
override fun completeExceptionally(reason: String?) {
launch {
makeSnackbarException(getString(R.string.download_failure), reason ?: "Unknown")
processing = false
}
}
override fun send(data: ParcelableContainer?) {}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_profile_edit)
setSupportActionBar(toolbar)
settings.build {
tips(icon = 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 = getDrawable(R.drawable.ic_label_outline),
hint = getString(R.string.profile_name),
content = intent.getStringExtra("name") ?: "",
id = KEY_NAME
) {
onTextChanged {
modified = true
}
}
textInput(
title = getString(R.string.url),
icon = getDrawable(R.drawable.ic_content),
hint = getString(R.string.profile_url),
content = intent.getStringExtra("url") ?: "",
id = KEY_URL
) {
onOpenInput {
if (!openUrlProvider())
openDialogInput()
}
onDisplayContent {
it.split("/").last()
}
onTextChanged {
modified = true
}
}
textInput(
title = getString(R.string.auto_update),
icon = getDrawable(R.drawable.ic_update),
hint = getString(R.string.seconds),
id = KEY_AUTO_UPDATE,
content = intent.getStringExtra("interval") ?: ""
) {
onDisplayContent {
val interval = it.toString().toIntOrNull() ?: 0
if (interval <= 0)
getString(R.string.disabled)
else
getString(R.string.format_seconds, interval)
}
onTextChanged {
val s = it.toString()
if (s.isNotEmpty() && s.toIntOrNull() == null) {
content = ""
Snackbar.make(rootView, R.string.invalid_interval, Snackbar.LENGTH_LONG)
.show()
} else {
modified = true
}
}
if (intent.getStringExtra("type") == Constants.URL_PROVIDER_TYPE_FILE)
isHidden = true
}
}
settings.screen.restoreState(savedInstanceState)
save.setOnClickListener {
with(settings.screen) {
val name = requireElement<TextInput>(KEY_NAME).content.toString()
val url = Uri.parse(requireElement<TextInput>(KEY_URL).content.toString())
val interval = requireElement<TextInput>(KEY_AUTO_UPDATE).content.toString()
.toLongOrNull()?: 0
if (name.isBlank()) {
Snackbar.make(rootView, R.string.empty_name, Snackbar.LENGTH_LONG).show()
return@setOnClickListener
}
if (url == null || url == Uri.EMPTY ||
(url.scheme != "http" && url.scheme != "https" && url.scheme != "content")
) {
Snackbar.make(rootView, R.string.invalid_url, Snackbar.LENGTH_LONG).show()
return@setOnClickListener
}
processing = true
sendProfileRequest(name, url, interval)
}
}
when (intent.extras?.getLong("id", Long.MIN_VALUE)) {
Long.MIN_VALUE -> {
openUrlProvider()
setTitle(R.string.new_profile)
}
-1L -> {
setTitle(R.string.new_profile)
}
else -> {
setTitle(R.string.edit_profile)
}
}
}
override val activityLabel: CharSequence? = null
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE) {
if (resultCode != Activity.RESULT_OK || data == null)
return
data.data?.apply {
settings.screen.requireElement<TextInput>(KEY_URL).content = this.toString()
}
data.getStringExtra(Constants.URL_PROVIDER_INTENT_EXTRA_NAME)?.also {
settings.screen.requireElement<TextInput>(KEY_NAME).apply {
if (content.isBlank())
content = it
}
}
}
super.onActivityResult(requestCode, resultCode, data)
}
override fun onBackPressed() {
if (!modified)
return super.onBackPressed()
if (processing) {
Snackbar.make(rootView, R.string.processing, Snackbar.LENGTH_LONG).show()
return
}
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 onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
settings.screen.saveState(outState)
}
private fun openUrlProvider(): Boolean {
val type = intent.getStringExtra("type")
val externalIntent = intent.getParcelableExtra<Intent>("intent")
try {
when (type) {
Constants.URL_PROVIDER_TYPE_FILE ->
startActivityForResult(
Intent(Intent.ACTION_GET_CONTENT).setType(TYPE_YAML),
REQUEST_CODE
)
Constants.URL_PROVIDER_TYPE_EXTERNAL ->
startActivityForResult(
externalIntent ?: throw NullPointerException(),
REQUEST_CODE
)
else -> return false
}
} catch (e: Exception) {
makeSnackbarException(getString(R.string.start_url_provider_failure), e.message)
}
return true
}
private fun sendProfileRequest(name: String, url: Uri, interval: Long) {
launch {
val source = intent?.getParcelableExtra<Intent>("intent")?.toUri(0)?.run(Uri::parse)
val type = when (intent?.getStringExtra("type")) {
Constants.URL_PROVIDER_TYPE_FILE ->
ClashProfileEntity.TYPE_FILE
Constants.URL_PROVIDER_TYPE_URL ->
ClashProfileEntity.TYPE_URL
Constants.URL_PROVIDER_TYPE_EXTERNAL ->
ClashProfileEntity.TYPE_EXTERNAL
else -> throw IllegalArgumentException()
}
Log.d(interval.toString())
val request = ProfileRequest()
.action(ProfileRequest.Action.UPDATE_OR_CREATE)
.withId(intent.getLongExtra("id", -1L))
.withName(name)
.withURL(url)
.withUpdateInterval(interval)
.withCallback(requestCallback)
.withType(type)
.withSource(source)
withProfile {
enqueueRequest(request)
}
}
}
}
@@ -1,77 +1,210 @@
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 com.github.kr328.clash.service.model.Profile
import com.github.kr328.clash.util.withProfile
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.remote.withProfile
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.service.ProfileBackgroundService
import com.github.kr328.clash.service.data.ClashProfileEntity
import com.github.kr328.clash.service.transact.ProfileRequest
import com.github.kr328.clash.service.util.componentName
import com.github.kr328.clash.service.util.intent
import com.github.kr328.clash.service.util.startForegroundServiceCompat
import com.github.kr328.clash.weight.ProfilesMenu
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.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<String>()
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 val activityLabel: CharSequence?
get() = getText(R.string.profiles)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == EDITOR_REQUEST_CODE) {
launch {
val uri = editorStack.pop()
withProfile {
commitProfileEditUri(uri)
}
}
return
}
super.onActivityResult(requestCode, resultCode, data)
}
override suspend fun onClashProfileChanged() {
reloadProfiles()
}
private suspend fun reloadProfiles() {
if (!reloadMutex.tryLock())
return
val profiles = withProfile {
queryProfiles()
}
(mainList.adapter as ProfileAdapter)
.setEntitiesAsync(profiles.toList())
reloadMutex.unlock()
}
override fun onProfileClicked(entity: ClashProfileEntity) {
launch {
withProfile {
setActiveProfile(entity.id)
}
}
}
override fun onMenuClicked(entity: ClashProfileEntity) {
ProfilesMenu(this, entity, this).show()
}
override fun onNewProfile() {
startActivity(CreateProfileActivity::class.intent)
}
private fun deleteProfile(entity: ClashProfileEntity) = launch {
val request = ProfileRequest().action(ProfileRequest.Action.REMOVE).withId(entity.id)
withProfile {
enqueueRequest(request)
}
}
private fun resetProviders(entity: ClashProfileEntity) = launch {
val request = ProfileRequest().action(ProfileRequest.Action.CLEAR).withId(entity.id)
withProfile {
enqueueRequest(request)
}
}
private fun openPropertiesEditor(entity: ClashProfileEntity, duplicate: Boolean) {
val type = when (entity.type) {
ClashProfileEntity.TYPE_FILE ->
Constants.URL_PROVIDER_TYPE_FILE
ClashProfileEntity.TYPE_URL ->
Constants.URL_PROVIDER_TYPE_URL
ClashProfileEntity.TYPE_EXTERNAL ->
Constants.URL_PROVIDER_TYPE_EXTERNAL
else -> throw IllegalArgumentException("Invalid type ${entity.type}")
}
val intent = entity.source?.run { Intent.parseUri(this, 0) }
val name = entity.name
val uri = entity.uri
val interval = entity.updateInterval.toString()
val editor = ProfileEditActivity::class.intent
.putExtra("id", if (duplicate) -1L else entity.id)
.putExtra("type", type)
.putExtra("intent", intent)
.putExtra("name", name)
.putExtra("url", uri)
.putExtra("interval", interval)
startActivity(editor)
}
private fun openEditor(entity: ClashProfileEntity) = launch {
val uri = withProfile {
requestProfileEditUri(entity.id)
} ?: return@launch
editorStack.push(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
)
}
private fun startUpdate(entity: ClashProfileEntity) {
val request = ProfileRequest()
.action(ProfileRequest.Action.UPDATE_OR_CREATE)
.withId(entity.id)
val intent = Intent(Intents.INTENT_ACTION_PROFILE_ENQUEUE_REQUEST)
.setComponent(ProfileBackgroundService::class.componentName)
.putExtra(Intents.INTENT_EXTRA_PROFILE_REQUEST, request)
startForegroundServiceCompat(intent)
}
override fun onOpenEditor(entity: ClashProfileEntity) {
openEditor(entity)
}
override fun onUpdate(entity: ClashProfileEntity) {
startUpdate(entity)
}
override fun onOpenProperties(entity: ClashProfileEntity) {
openPropertiesEditor(entity, false)
}
override fun onDuplicate(entity: ClashProfileEntity) {
openPropertiesEditor(entity, true)
}
override fun onResetProvider(entity: ClashProfileEntity) {
resetProviders(entity)
}
override fun onDelete(entity: ClashProfileEntity) {
deleteProfile(entity)
}
}
@@ -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,296 @@
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 val activityLabel: CharSequence?
get() = getText(R.string.proxy)
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(200)
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,32 +1,46 @@
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.service.util.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)
}
}
}
}
override val activityLabel: CharSequence?
get() = getText(R.string.settings)
}
@@ -0,0 +1,20 @@
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()
}
override val activityLabel: CharSequence?
get() = getText(R.string.behavior)
}
@@ -0,0 +1,20 @@
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()
}
override val activityLabel: CharSequence?
get() = getText(R.string.interface_)
}
@@ -0,0 +1,24 @@
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 val activityLabel: CharSequence?
get() = getText(R.string.network)
}
@@ -0,0 +1,81 @@
package com.github.kr328.clash
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.core.content.getSystemService
import com.google.android.material.snackbar.Snackbar
import kotlinx.android.synthetic.main.activity_support.*
class SupportActivity: BaseActivity() {
override val activityLabel: CharSequence?
get() = getText(R.string.support)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_support)
setSupportActionBar(toolbar)
commonUi.build {
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.contacts))
option(
title = getString(R.string.email),
summary = getString(R.string.email_url)
) {
onClick {
val data =
ClipData.newPlainText("email", getText(R.string.email_url))
getSystemService<ClipboardManager>()?.setPrimaryClip(data)
Snackbar.make(rootView, getText(R.string.copied), Snackbar.LENGTH_SHORT).show()
}
}
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))))
}
}
if ( resources.configuration.locales.get(0)
.language.equals("zh", true) ) {
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,19 @@ 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.remote.RemoteUtils
import com.github.kr328.clash.service.Intents
import com.github.kr328.clash.utils.startClashService
import com.github.kr328.clash.utils.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 +30,72 @@ 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 -> {
when ( intent?.action ) {
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)
}
)
val name = RemoteUtils.getCurrentClashProfileName(this)
clashRunning = name != null
currentProfile = name ?: ""
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(clashStatusReceiver)
}
}
@@ -0,0 +1,56 @@
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,128 @@
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.data.ClashProfileEntity
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: ClashProfileEntity)
fun onMenuClicked(entity: ClashProfileEntity)
fun onNewProfile()
}
private var entities: List<ClashProfileEntity> = 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<ClashProfileEntity>) {
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.lastUpdate)
holder.root.setOnClickListener {
callback.onProfileClicked(current)
}
holder.menu.setOnClickListener {
callback.onMenuClicked(current)
}
}
is FooterHolder -> {
holder.root.setOnClickListener {
callback.onNewProfile()
}
}
}
}
private fun getTypeName(type: Int): CharSequence {
return when (type) {
ClashProfileEntity.TYPE_FILE ->
context.getText(R.string.file)
ClashProfileEntity.TYPE_URL ->
context.getText(R.string.url)
ClashProfileEntity.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,274 @@
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.name] = index
}
}
}
withContext(Dispatchers.Main) {
root = newList
renderList = newRenderList.toMutableList()
urlTesting = testing
groupPosition = groupCache
activeList = activeCache
result.dispatchUpdatesTo(this@ProxyAdapter)
}
}
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
groupPosition[current.name] = position
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) {
activeList[current.group] = position
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))
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))
}
}
}
@@ -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.service.settings.BaseSettings
data class Pipeline<T>(val input: T, val settings: BaseSettings)
@@ -0,0 +1,93 @@
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,27 @@
package com.github.kr328.clash.preference
import android.content.Context
import com.github.kr328.clash.service.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 ENABLE_VPN = BooleanEntry("enable_vpn", true)
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,13 @@ 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 androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.github.kr328.clash.service.Intents
class Broadcasts(private val context: Application) {
interface Observer {
fun onServiceRecreated()
object Broadcasts {
interface Receiver {
fun onStarted()
fun onStopped(cause: String?)
fun onProfileChanged()
@@ -19,40 +20,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 +54,44 @@ 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
fun init(application: Application) {
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
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)
})
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)
})
clashRunning = StatusClient(context).currentProfile() != null
} catch (e: Exception) {
Log.w("Register global receiver: $e", e)
}
}
val current = RemoteUtils.detectClashRunning(application)
if (current != clashRunning) {
clashRunning = current
fun unregister() {
if (!registered)
return
if (current) {
receivers.forEach {
it.onStarted()
}
} else {
receivers.forEach {
it.onStopped(null)
}
}
}
}
try {
context.unregisterReceiver(broadcastReceiver)
clashRunning = false
} catch (e: Exception) {
Log.w("Unregister global receiver: $e", e)
}
override fun onStop(owner: LifecycleOwner) {
application.unregisterReceiver(broadcastReceiver)
}
})
}
}
@@ -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,50 @@
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.ipc.IStreamCallback
import com.github.kr328.clash.service.ipc.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) {
return@withContext 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,33 @@
package com.github.kr328.clash.remote
import com.github.kr328.clash.service.IProfileService
import com.github.kr328.clash.service.data.ClashProfileEntity
import com.github.kr328.clash.service.transact.ProfileRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ProfileClient(private val service: IProfileService) {
suspend fun queryProfiles(): Array<ClashProfileEntity> = withContext(Dispatchers.IO) {
service.queryProfiles()
}
suspend fun queryActiveProfile(): ClashProfileEntity? = withContext(Dispatchers.IO) {
service.queryActiveProfile()
}
suspend fun enqueueRequest(request: ProfileRequest) = withContext(Dispatchers.IO) {
service.enqueueRequest(request)
}
suspend fun setActiveProfile(id: Long) = withContext(Dispatchers.IO) {
service.setActiveProfile(id)
}
suspend fun requestProfileEditUri(id: Long): String? = withContext(Dispatchers.IO) {
service.requestProfileEditUri(id)
}
suspend fun commitProfileEditUri(uri: String) = withContext(Dispatchers.IO) {
service.commitProfileEditUri(uri)
}
}
@@ -1,71 +1,162 @@
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.Handler
import android.os.IBinder
import androidx.core.content.edit
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.github.kr328.clash.ApkBrokenActivity
import com.github.kr328.clash.AppCrashedActivity
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.Constants
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.service.util.intent
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
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)
}
}
}
}
fun init(application: Application) {
val handler = Handler()
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
handler.removeMessages(0)
GlobalScope.launch {
if (!verifyApk(application)) {
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
)
}
}
}
override fun onStop(owner: LifecycleOwner) {
handler.postDelayed({
clashConnection?.also {
application.unbindService(it)
it.onServiceDisconnected(null)
}
profileConnection?.also {
application.unbindService(it)
it.onServiceDisconnected(null)
}
clashConnection = null
profileConnection = null
}, 5000)
}
})
}
private suspend fun verifyApk(application: Application): Boolean {
return withContext(Dispatchers.IO) {
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@withContext true
val info = application.applicationInfo
val sources =
info.splitSourceDirs ?: arrayOf(info.sourceDir) ?: return@withContext false
for (apk in sources) {
if (ZipFile(apk).entries().asSequence().any { it.name.endsWith("libgojni.so") }) {
sp.edit {
putLong(Constants.PREFERENCE_KEY_LAST_INSTALL, pkg.lastUpdateTime)
}
return@withContext true
}
}
return@withContext false
}
}
}
@@ -0,0 +1,40 @@
package com.github.kr328.clash.remote
import android.content.Context
import android.net.Uri
import com.github.kr328.clash.service.Constants
import com.github.kr328.clash.service.ServiceStatusProvider
object RemoteUtils {
fun detectClashRunning(context: Context): Boolean {
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
}
fun getCurrentClashProfileName(context: Context): String? {
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")
}
}
@@ -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,55 @@
package com.github.kr328.clash.settings
import android.content.pm.PackageManager
import android.os.Bundle
import com.github.kr328.clash.OnBootReceiver
import com.github.kr328.clash.R
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.componentName
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(
OnBootReceiver::class.componentName,
status,
PackageManager.DONT_KILL_APP)
}
override fun get(): Any? {
val status = requireActivity().packageManager
.getComponentEnabledSetting(OnBootReceiver::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,49 @@
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.preference.UiSettings
import com.github.kr328.clash.remote.Broadcasts
import com.github.kr328.clash.service.settings.ServiceSettings
import com.github.kr328.clash.service.util.intent
class NetworkFragment: BaseSettingFragment() {
companion object {
private const val KEY_ENABLE_VPN_SERVICE = "enable_vpn_service"
private const val KEY_IPV6 = "ipv6"
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, UiSettings.ENABLE_VPN.asSource(ui))
on(KEY_IPV6, ServiceSettings.IPV6_SUPPORT.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.service.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,25 @@
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,66 @@
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)
}
}
@@ -0,0 +1,54 @@
package com.github.kr328.clash.utils
import android.content.Context
import androidx.recyclerview.widget.LinearSmoothScroller
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
class ScrollBinding(
private val context: Context,
private val callback: Callback
) {
interface Callback {
fun getCurrentMasterToken(): String
fun onMasterTokenChanged(token: String)
fun getMasterTokenPosition(token: String): Int
fun doMasterScroll(scroller: LinearSmoothScroller, target: Int)
}
private val updateChannel = Channel<Unit>(Channel.CONFLATED)
private var preventSlaveScroll = false
fun sendMasterScrolled() {
updateChannel.offer(Unit)
}
fun scrollMaster(token: String) {
val position = callback.getMasterTokenPosition(token)
if (position < 0)
return
val scroller = QuickSmoothScroller(context, position)
callback.doMasterScroll(scroller, position)
}
suspend fun exec() {
var lastToken: String? = null
while (true) {
updateChannel.receive()
val currentToken = callback.getCurrentMasterToken()
if (preventSlaveScroll || lastToken == currentToken)
continue
lastToken = currentToken
callback.onMasterTokenChanged(currentToken)
delay(200)
}
}
}
Loaded 100 of 657 files, more files were not shown because too many files have changed in this diff. Show more