diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..9dcf0bc1d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/termux-app"] + path = third_party/termux-app + url = https://github.com/SagerNet/termux-app.git diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49ec10c32..84293abb5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -328,6 +328,10 @@ dependencies { implementation("com.github.jeziellago:compose-markdown:0.5.8") implementation("org.kodein.emoji:emoji-kt:2.3.0") + // Terminal emulator + implementation(project(":terminal-emulator")) + implementation(project(":terminal-view")) + // Xposed API for self-hooking VPN hide module compileOnly("de.robv.android.xposed:api:82") compileOnly(project(":libxposed-api")) diff --git a/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/3.json b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/3.json new file mode 100644 index 000000000..521a3198b --- /dev/null +++ b/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/3.json @@ -0,0 +1,97 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "5e9ed567b06755e1a22c503a7390dea5", + "entities": [ + { + "tableName": "profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `icon` TEXT DEFAULT NULL, `typed` BLOB NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "typed", + "columnName": "typed", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "remote_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `secret` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5e9ed567b06755e1a22c503a7390dea5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl new file mode 100644 index 000000000..a2ed3cf53 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/INeighborTableCallback.aidl @@ -0,0 +1,7 @@ +package io.nekohasekai.sfa.bg; + +import io.nekohasekai.sfa.bg.ParceledListSlice; + +interface INeighborTableCallback { + oneway void onNeighborTableUpdated(in ParceledListSlice entries); +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl index fc5816115..ad0425adb 100644 --- a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl @@ -1,6 +1,8 @@ package io.nekohasekai.sfa.bg; import android.os.ParcelFileDescriptor; +import io.nekohasekai.sfa.bg.INeighborTableCallback; +import io.nekohasekai.sfa.bg.IRootShellSession; import io.nekohasekai.sfa.bg.ParceledListSlice; interface IRootService { @@ -11,4 +13,12 @@ interface IRootService { void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; String exportDebugInfo(String outputPath) = 3; + + void registerNeighborTableCallback(in INeighborTableCallback callback) = 4; + + oneway void unregisterNeighborTableCallback(in INeighborTableCallback callback) = 5; + + IRootShellSession openShellSession(String user, String command, in String[] env, String term, int rows, int cols) = 6; + + String lookupSFTPServer() = 7; } diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl new file mode 100644 index 000000000..2c8eaa03f --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootShellSession.aidl @@ -0,0 +1,11 @@ +package io.nekohasekai.sfa.bg; + +import android.os.ParcelFileDescriptor; + +interface IRootShellSession { + ParcelFileDescriptor getMasterFD(); + void resize(int rows, int cols); + void signal(int sig); + int waitFor(); + void close(); +} diff --git a/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl b/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl new file mode 100644 index 000000000..8c3cf8147 --- /dev/null +++ b/app/src/main/aidl/io/nekohasekai/sfa/bg/NeighborEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable NeighborEntry; diff --git a/app/src/main/assets/termux-colors/argonaut.properties b/app/src/main/assets/termux-colors/argonaut.properties new file mode 100644 index 000000000..9f1f4cf7a --- /dev/null +++ b/app/src/main/assets/termux-colors/argonaut.properties @@ -0,0 +1,21 @@ +# https://github.com/Mayccoll/Gogh/blob/master/themes/argonaut.sh +background=#0e1019 +foreground=#fffaf4 +cursor=#fffaf4 + +color0=#232323 +color1=#ff000f +color2=#8ce10b +color3=#ffb900 +color4=#008df8 +color5=#6d43a6 +color6=#00d8eb +color7=#ffffff +color8=#444444 +color9=#ff2740 +color10=#abe15b +color11=#ffd242 +color12=#0092ff +color13=#9a5feb +color14=#67fff0 +color15=#ffffff diff --git a/app/src/main/assets/termux-colors/base16-3024-dark.properties b/app/src/main/assets/termux-colors/base16-3024-dark.properties new file mode 100644 index 000000000..eaee909f8 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-3024-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-3024.dark.256.xresources +# Base16 3024 +# Scheme: Jan T. Sott (http://github.com/idleberg) +foreground=#a5a2a2 +background=#090300 +cursor=#a5a2a2 + +color0=#090300 +color1=#db2d20 +color2=#01a252 +color3=#fded02 +color4=#01a0e4 +color5=#a16a94 +color6=#b5e4f4 +color7=#a5a2a2 +color8=#5c5855 +color9=#db2d20 +color10=#01a252 +color11=#fded02 +color12=#01a0e4 +color13=#a16a94 +color14=#b5e4f4 +color15=#f7f7f7 + +color16=#e8bbd0 +color17=#cdab53 +color18=#3a3432 +color19=#4a4543 +color20=#807d7c +color21=#d6d5d4 diff --git a/app/src/main/assets/termux-colors/base16-3024-light.properties b/app/src/main/assets/termux-colors/base16-3024-light.properties new file mode 100644 index 000000000..1f4dfe3e6 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-3024-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-3024.light.256.xresources +# Base16 3024 +# Scheme: Jan T. Sott (http://github.com/idleberg) +foreground=#4a4543 +background=#f7f7f7 +cursor=#4a4543 + +color0=#090300 +color1=#db2d20 +color2=#01a252 +color3=#fded02 +color4=#01a0e4 +color5=#a16a94 +color6=#b5e4f4 +color7=#a5a2a2 +color8=#5c5855 +color9=#db2d20 +color10=#01a252 +color11=#fded02 +color12=#01a0e4 +color13=#a16a94 +color14=#b5e4f4 +color15=#f7f7f7 + +color16=#e8bbd0 +color17=#cdab53 +color18=#3a3432 +color19=#4a4543 +color20=#807d7c +color21=#d6d5d4 diff --git a/app/src/main/assets/termux-colors/base16-apathy-dark.properties b/app/src/main/assets/termux-colors/base16-apathy-dark.properties new file mode 100644 index 000000000..37839ccea --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-apathy-dark.properties @@ -0,0 +1,28 @@ +foreground= #81B5AC +background= #031A16 +cursor= #81B5AC + +color0= #031A16 +color1= #3E9688 +color2= #883E96 +color3= #3E4C96 +color4= #96883E +color5= #4C963E +color6= #963E4C +color7= #81B5AC + +color8= #2B685E +color9= #3E9688 +color10= #883E96 +color11= #3E4C96 +color12= #96883E +color13= #4C963E +color14= #963E4C +color15= #D2E7E4 + +color16= #3E7996 +color17= #3E965B +color18= #0B342D +color19= #184E45 +color20= #5F9C92 +color21= #A7CEC8 \ No newline at end of file diff --git a/app/src/main/assets/termux-colors/base16-apathy-light.properties b/app/src/main/assets/termux-colors/base16-apathy-light.properties new file mode 100644 index 000000000..4bd16ea56 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-apathy-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-apathy.light.256.xresources +# Base16 Apathy +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#184E45 +background=#D2E7E4 +cursor=#184E45 + +color0=#031A16 +color1=#3E9688 +color2=#883E96 +color3=#3E4C96 +color4=#96883E +color5=#4C963E +color6=#963E4C +color7=#81B5AC +color8=#2B685E +color9=#3E9688 +color10=#883E96 +color11=#3E4C96 +color12=#96883E +color13=#4C963E +color14=#963E4C +color15=#D2E7E4 + +color16=#3E7996 +color17=#3E965B +color18=#0B342D +color19=#184E45 +color20=#5F9C92 +color21=#A7CEC8 diff --git a/app/src/main/assets/termux-colors/base16-ashes-dark.properties b/app/src/main/assets/termux-colors/base16-ashes-dark.properties new file mode 100644 index 000000000..9518bcb3a --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-ashes-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-ashes.dark.256.xresources +# Base16 Ashes +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#C7CCD1 +background=#1C2023 +cursor=#C7CCD1 + +color0=#1C2023 +color1=#C7AE95 +color2=#95C7AE +color3=#AEC795 +color4=#AE95C7 +color5=#C795AE +color6=#95AEC7 +color7=#C7CCD1 +color8=#747C84 +color9=#C7AE95 +color10=#95C7AE +color11=#AEC795 +color12=#AE95C7 +color13=#C795AE +color14=#95AEC7 +color15=#F3F4F5 + +color16=#C7C795 +color17=#C79595 +color18=#393F45 +color19=#565E65 +color20=#ADB3BA +color21=#DFE2E5 diff --git a/app/src/main/assets/termux-colors/base16-ashes-light.properties b/app/src/main/assets/termux-colors/base16-ashes-light.properties new file mode 100644 index 000000000..d168e651b --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-ashes-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-ashes.light.256.xresources +# Base16 Ashes +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#565E65 +background=#F3F4F5 +cursor=#565E65 + +color0=#1C2023 +color1=#C7AE95 +color2=#95C7AE +color3=#AEC795 +color4=#AE95C7 +color5=#C795AE +color6=#95AEC7 +color7=#C7CCD1 +color8=#747C84 +color9=#C7AE95 +color10=#95C7AE +color11=#AEC795 +color12=#AE95C7 +color13=#C795AE +color14=#95AEC7 +color15=#F3F4F5 + +color16=#C7C795 +color17=#C79595 +color18=#393F45 +color19=#565E65 +color20=#ADB3BA +color21=#DFE2E5 diff --git a/app/src/main/assets/termux-colors/base16-atelierdune-dark.properties b/app/src/main/assets/termux-colors/base16-atelierdune-dark.properties new file mode 100644 index 000000000..1d1a72e49 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierdune-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierdune.dark.256.xresources +# Base16 Atelier Dune +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/dune) +foreground=#a6a28c +background=#20201d +cursor=#a6a28c + +color0=#20201d +color1=#d73737 +color2=#60ac39 +color3=#cfb017 +color4=#6684e1 +color5=#b854d4 +color6=#1fad83 +color7=#a6a28c +color8=#7d7a68 +color9=#d73737 +color10=#60ac39 +color11=#cfb017 +color12=#6684e1 +color13=#b854d4 +color14=#1fad83 +color15=#fefbec + +color16=#b65611 +color17=#d43552 +color18=#292824 +color19=#6e6b5e +color20=#999580 +color21=#e8e4cf diff --git a/app/src/main/assets/termux-colors/base16-atelierdune-light.properties b/app/src/main/assets/termux-colors/base16-atelierdune-light.properties new file mode 100644 index 000000000..686e8713a --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierdune-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierdune.light.256.xresources +# Base16 Atelier Dune +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/dune) +foreground=#6e6b5e +background=#fefbec +cursor=#6e6b5e + +color0=#20201d +color1=#d73737 +color2=#60ac39 +color3=#cfb017 +color4=#6684e1 +color5=#b854d4 +color6=#1fad83 +color7=#a6a28c +color8=#7d7a68 +color9=#d73737 +color10=#60ac39 +color11=#cfb017 +color12=#6684e1 +color13=#b854d4 +color14=#1fad83 +color15=#fefbec + +color16=#b65611 +color17=#d43552 +color18=#292824 +color19=#6e6b5e +color20=#999580 +color21=#e8e4cf diff --git a/app/src/main/assets/termux-colors/base16-atelierforest-dark.properties b/app/src/main/assets/termux-colors/base16-atelierforest-dark.properties new file mode 100644 index 000000000..74ebb8a7e --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierforest-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierforest.dark.256.xresources +# Base16 Atelier Forest +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/forest) +foreground=#a8a19f +background=#1b1918 +cursor=#a8a19f + +color0=#1b1918 +color1=#f22c40 +color2=#5ab738 +color3=#d5911a +color4=#407ee7 +color5=#6666ea +color6=#00ad9c +color7=#a8a19f +color8=#766e6b +color9=#f22c40 +color10=#5ab738 +color11=#d5911a +color12=#407ee7 +color13=#6666ea +color14=#00ad9c +color15=#f1efee + +color16=#df5320 +color17=#c33ff3 +color18=#2c2421 +color19=#68615e +color20=#9c9491 +color21=#e6e2e0 diff --git a/app/src/main/assets/termux-colors/base16-atelierforest-light.properties b/app/src/main/assets/termux-colors/base16-atelierforest-light.properties new file mode 100644 index 000000000..f653fc78e --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierforest-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierforest.light.256.xresources +# Base16 Atelier Forest +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/forest) +foreground=#68615e +background=#f1efee +cursor=#68615e + +color0=#1b1918 +color1=#f22c40 +color2=#5ab738 +color3=#d5911a +color4=#407ee7 +color5=#6666ea +color6=#00ad9c +color7=#a8a19f +color8=#766e6b +color9=#f22c40 +color10=#5ab738 +color11=#d5911a +color12=#407ee7 +color13=#6666ea +color14=#00ad9c +color15=#f1efee + +color16=#df5320 +color17=#c33ff3 +color18=#2c2421 +color19=#68615e +color20=#9c9491 +color21=#e6e2e0 diff --git a/app/src/main/assets/termux-colors/base16-atelierheath-dark.properties b/app/src/main/assets/termux-colors/base16-atelierheath-dark.properties new file mode 100644 index 000000000..963c9065a --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierheath-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierheath.dark.256.xresources +# Base16 Atelier Heath +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/heath) +foreground=#ab9bab +background=#1b181b +cursor=#ab9bab + +color0=#1b181b +color1=#ca402b +color2=#379a37 +color3=#bb8a35 +color4=#516aec +color5=#7b59c0 +color6=#159393 +color7=#ab9bab +color8=#776977 +color9=#ca402b +color10=#379a37 +color11=#bb8a35 +color12=#516aec +color13=#7b59c0 +color14=#159393 +color15=#f7f3f7 + +color16=#a65926 +color17=#cc33cc +color18=#292329 +color19=#695d69 +color20=#9e8f9e +color21=#d8cad8 diff --git a/app/src/main/assets/termux-colors/base16-atelierheath-light.properties b/app/src/main/assets/termux-colors/base16-atelierheath-light.properties new file mode 100644 index 000000000..2423c797f --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierheath-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierheath.light.256.xresources +# Base16 Atelier Heath +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/heath) +foreground=#695d69 +background=#f7f3f7 +cursor=#695d69 + +color0=#1b181b +color1=#ca402b +color2=#379a37 +color3=#bb8a35 +color4=#516aec +color5=#7b59c0 +color6=#159393 +color7=#ab9bab +color8=#776977 +color9=#ca402b +color10=#379a37 +color11=#bb8a35 +color12=#516aec +color13=#7b59c0 +color14=#159393 +color15=#f7f3f7 + +color16=#a65926 +color17=#cc33cc +color18=#292329 +color19=#695d69 +color20=#9e8f9e +color21=#d8cad8 diff --git a/app/src/main/assets/termux-colors/base16-atelierlakeside-dark.properties b/app/src/main/assets/termux-colors/base16-atelierlakeside-dark.properties new file mode 100644 index 000000000..ab5088969 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierlakeside-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierlakeside.dark.256.xresources +# Base16 Atelier Lakeside +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/lakeside/) +foreground=#7ea2b4 +background=#161b1d +cursor=#7ea2b4 + +color0=#161b1d +color1=#d22d72 +color2=#568c3b +color3=#8a8a0f +color4=#257fad +color5=#5d5db1 +color6=#2d8f6f +color7=#7ea2b4 +color8=#5a7b8c +color9=#d22d72 +color10=#568c3b +color11=#8a8a0f +color12=#257fad +color13=#5d5db1 +color14=#2d8f6f +color15=#ebf8ff + +color16=#935c25 +color17=#b72dd2 +color18=#1f292e +color19=#516d7b +color20=#7195a8 +color21=#c1e4f6 diff --git a/app/src/main/assets/termux-colors/base16-atelierlakeside-light.properties b/app/src/main/assets/termux-colors/base16-atelierlakeside-light.properties new file mode 100644 index 000000000..29f4e9fba --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierlakeside-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierlakeside.light.256.xresources +# Base16 Atelier Lakeside +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/lakeside/) +foreground=#516d7b +background=#ebf8ff +cursor=#516d7b + +color0=#161b1d +color1=#d22d72 +color2=#568c3b +color3=#8a8a0f +color4=#257fad +color5=#5d5db1 +color6=#2d8f6f +color7=#7ea2b4 +color8=#5a7b8c +color9=#d22d72 +color10=#568c3b +color11=#8a8a0f +color12=#257fad +color13=#5d5db1 +color14=#2d8f6f +color15=#ebf8ff + +color16=#935c25 +color17=#b72dd2 +color18=#1f292e +color19=#516d7b +color20=#7195a8 +color21=#c1e4f6 diff --git a/app/src/main/assets/termux-colors/base16-atelierseaside-dark.properties b/app/src/main/assets/termux-colors/base16-atelierseaside-dark.properties new file mode 100644 index 000000000..907390139 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierseaside-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierseaside.dark.256.xresources +# Base16 Atelier Seaside +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/seaside/) +foreground=#8ca68c +background=#131513 +cursor=#8ca68c + +color0=#131513 +color1=#e6193c +color2=#29a329 +color3=#c3c322 +color4=#3d62f5 +color5=#ad2bee +color6=#1999b3 +color7=#8ca68c +color8=#687d68 +color9=#e6193c +color10=#29a329 +color11=#c3c322 +color12=#3d62f5 +color13=#ad2bee +color14=#1999b3 +color15=#f0fff0 + +color16=#87711d +color17=#e619c3 +color18=#242924 +color19=#5e6e5e +color20=#809980 +color21=#cfe8cf diff --git a/app/src/main/assets/termux-colors/base16-atelierseaside-light.properties b/app/src/main/assets/termux-colors/base16-atelierseaside-light.properties new file mode 100644 index 000000000..30f1db6b1 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-atelierseaside-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-atelierseaside.light.256.xresources +# Base16 Atelier Seaside +# Scheme: Bram de Haan (http://atelierbram.github.io/syntax-highlighting/atelier-schemes/seaside/) +foreground=#5e6e5e +background=#f0fff0 +cursor=#5e6e5e + +color0=#131513 +color1=#e6193c +color2=#29a329 +color3=#c3c322 +color4=#3d62f5 +color5=#ad2bee +color6=#1999b3 +color7=#8ca68c +color8=#687d68 +color9=#e6193c +color10=#29a329 +color11=#c3c322 +color12=#3d62f5 +color13=#ad2bee +color14=#1999b3 +color15=#f0fff0 + +color16=#87711d +color17=#e619c3 +color18=#242924 +color19=#5e6e5e +color20=#809980 +color21=#cfe8cf diff --git a/app/src/main/assets/termux-colors/base16-bespin-dark.properties b/app/src/main/assets/termux-colors/base16-bespin-dark.properties new file mode 100644 index 000000000..44e290292 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-bespin-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-bespin.dark.256.xresources +# Base16 Bespin +# Scheme: Jan T. Sott +foreground=#8a8986 +background=#28211c +cursor=#8a8986 + +color0=#28211c +color1=#cf6a4c +color2=#54be0d +color3=#f9ee98 +color4=#5ea6ea +color5=#9b859d +color6=#afc4db +color7=#8a8986 +color8=#666666 +color9=#cf6a4c +color10=#54be0d +color11=#f9ee98 +color12=#5ea6ea +color13=#9b859d +color14=#afc4db +color15=#baae9e + +color16=#cf7d34 +color17=#937121 +color18=#36312e +color19=#5e5d5c +color20=#797977 +color21=#9d9b97 diff --git a/app/src/main/assets/termux-colors/base16-bespin-light.properties b/app/src/main/assets/termux-colors/base16-bespin-light.properties new file mode 100644 index 000000000..fe4a050d0 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-bespin-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-bespin.light.256.xresources +# Base16 Bespin +# Scheme: Jan T. Sott +foreground=#5e5d5c +background=#baae9e +cursor=#5e5d5c + +color0=#28211c +color1=#cf6a4c +color2=#54be0d +color3=#f9ee98 +color4=#5ea6ea +color5=#9b859d +color6=#afc4db +color7=#8a8986 +color8=#666666 +color9=#cf6a4c +color10=#54be0d +color11=#f9ee98 +color12=#5ea6ea +color13=#9b859d +color14=#afc4db +color15=#baae9e + +color16=#cf7d34 +color17=#937121 +color18=#36312e +color19=#5e5d5c +color20=#797977 +color21=#9d9b97 diff --git a/app/src/main/assets/termux-colors/base16-brewer-dark.properties b/app/src/main/assets/termux-colors/base16-brewer-dark.properties new file mode 100644 index 000000000..bcbca4fb9 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-brewer-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-brewer.dark.256.xresources +# Base16 Brewer +# Scheme: Timothée Poisot (http://github.com/tpoisot) +foreground=#b7b8b9 +background=#0c0d0e +cursor=#b7b8b9 + +color0=#0c0d0e +color1=#e31a1c +color2=#31a354 +color3=#dca060 +color4=#3182bd +color5=#756bb1 +color6=#80b1d3 +color7=#b7b8b9 +color8=#737475 +color9=#e31a1c +color10=#31a354 +color11=#dca060 +color12=#3182bd +color13=#756bb1 +color14=#80b1d3 +color15=#fcfdfe + +color16=#e6550d +color17=#b15928 +color18=#2e2f30 +color19=#515253 +color20=#959697 +color21=#dadbdc diff --git a/app/src/main/assets/termux-colors/base16-brewer-light.properties b/app/src/main/assets/termux-colors/base16-brewer-light.properties new file mode 100644 index 000000000..bff983274 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-brewer-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-brewer.light.256.xresources +# Base16 Brewer +# Scheme: Timothée Poisot (http://github.com/tpoisot) +foreground=#515253 +background=#fcfdfe +cursor=#515253 + +color0=#0c0d0e +color1=#e31a1c +color2=#31a354 +color3=#dca060 +color4=#3182bd +color5=#756bb1 +color6=#80b1d3 +color7=#b7b8b9 +color8=#737475 +color9=#e31a1c +color10=#31a354 +color11=#dca060 +color12=#3182bd +color13=#756bb1 +color14=#80b1d3 +color15=#fcfdfe + +color16=#e6550d +color17=#b15928 +color18=#2e2f30 +color19=#515253 +color20=#959697 +color21=#dadbdc diff --git a/app/src/main/assets/termux-colors/base16-bright-dark.properties b/app/src/main/assets/termux-colors/base16-bright-dark.properties new file mode 100644 index 000000000..6dd80cd83 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-bright-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-bright.dark.256.xresources +# Base16 Bright +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#e0e0e0 +background=#000000 +cursor=#e0e0e0 + +color0=#000000 +color1=#fb0120 +color2=#a1c659 +color3=#fda331 +color4=#6fb3d2 +color5=#d381c3 +color6=#76c7b7 +color7=#e0e0e0 +color8=#b0b0b0 +color9=#fb0120 +color10=#a1c659 +color11=#fda331 +color12=#6fb3d2 +color13=#d381c3 +color14=#76c7b7 +color15=#ffffff + +color16=#fc6d24 +color17=#be643c +color18=#303030 +color19=#505050 +color20=#d0d0d0 +color21=#f5f5f5 diff --git a/app/src/main/assets/termux-colors/base16-bright-light.properties b/app/src/main/assets/termux-colors/base16-bright-light.properties new file mode 100644 index 000000000..1d5b41f4b --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-bright-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-bright.light.256.xresources +# Base16 Bright +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#505050 +background=#ffffff +cursor=#505050 + +color0=#000000 +color1=#fb0120 +color2=#a1c659 +color3=#fda331 +color4=#6fb3d2 +color5=#d381c3 +color6=#76c7b7 +color7=#e0e0e0 +color8=#b0b0b0 +color9=#fb0120 +color10=#a1c659 +color11=#fda331 +color12=#6fb3d2 +color13=#d381c3 +color14=#76c7b7 +color15=#ffffff + +color16=#fc6d24 +color17=#be643c +color18=#303030 +color19=#505050 +color20=#d0d0d0 +color21=#f5f5f5 diff --git a/app/src/main/assets/termux-colors/base16-chalk-dark.properties b/app/src/main/assets/termux-colors/base16-chalk-dark.properties new file mode 100644 index 000000000..28eb66872 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-chalk-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-chalk.dark.256.xresources +# Base16 Chalk +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#d0d0d0 +background=#151515 +cursor=#d0d0d0 + +color0=#151515 +color1=#fb9fb1 +color2=#acc267 +color3=#ddb26f +color4=#6fc2ef +color5=#e1a3ee +color6=#12cfc0 +color7=#d0d0d0 +color8=#505050 +color9=#fb9fb1 +color10=#acc267 +color11=#ddb26f +color12=#6fc2ef +color13=#e1a3ee +color14=#12cfc0 +color15=#f5f5f5 + +color16=#eda987 +color17=#deaf8f +color18=#202020 +color19=#303030 +color20=#b0b0b0 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-chalk-light.properties b/app/src/main/assets/termux-colors/base16-chalk-light.properties new file mode 100644 index 000000000..155cb54ef --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-chalk-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-chalk.light.256.xresources +# Base16 Chalk +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#303030 +background=#f5f5f5 +cursor=#303030 + +color0=#151515 +color1=#fb9fb1 +color2=#acc267 +color3=#ddb26f +color4=#6fc2ef +color5=#e1a3ee +color6=#12cfc0 +color7=#d0d0d0 +color8=#505050 +color9=#fb9fb1 +color10=#acc267 +color11=#ddb26f +color12=#6fc2ef +color13=#e1a3ee +color14=#12cfc0 +color15=#f5f5f5 + +color16=#eda987 +color17=#deaf8f +color18=#202020 +color19=#303030 +color20=#b0b0b0 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-codeschool-dark.properties b/app/src/main/assets/termux-colors/base16-codeschool-dark.properties new file mode 100644 index 000000000..a06fbd765 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-codeschool-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-codeschool.dark.256.xresources +# Base16 Codeschool +# Scheme: brettof86 +foreground=#9ea7a6 +background=#232c31 +cursor=#9ea7a6 + +color0=#232c31 +color1=#2a5491 +color2=#237986 +color3=#a03b1e +color4=#484d79 +color5=#c59820 +color6=#b02f30 +color7=#9ea7a6 +color8=#3f4944 +color9=#2a5491 +color10=#237986 +color11=#a03b1e +color12=#484d79 +color13=#c59820 +color14=#b02f30 +color15=#b5d8f6 + +color16=#43820d +color17=#c98344 +color18=#1c3657 +color19=#2a343a +color20=#84898c +color21=#a7cfa3 diff --git a/app/src/main/assets/termux-colors/base16-codeschool-light.properties b/app/src/main/assets/termux-colors/base16-codeschool-light.properties new file mode 100644 index 000000000..080a882de --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-codeschool-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-codeschool.light.256.xresources +# Base16 Codeschool +# Scheme: brettof86 +foreground=#2a343a +background=#b5d8f6 +cursor=#2a343a + +color0=#232c31 +color1=#2a5491 +color2=#237986 +color3=#a03b1e +color4=#484d79 +color5=#c59820 +color6=#b02f30 +color7=#9ea7a6 +color8=#3f4944 +color9=#2a5491 +color10=#237986 +color11=#a03b1e +color12=#484d79 +color13=#c59820 +color14=#b02f30 +color15=#b5d8f6 + +color16=#43820d +color17=#c98344 +color18=#1c3657 +color19=#2a343a +color20=#84898c +color21=#a7cfa3 diff --git a/app/src/main/assets/termux-colors/base16-colors-dark.properties b/app/src/main/assets/termux-colors/base16-colors-dark.properties new file mode 100644 index 000000000..46ae2d5c1 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-colors-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-colors.dark.256.xresources +# Base16 Colors +# Scheme: mrmrs (http://clrs.cc) +foreground=#bbbbbb +background=#111111 +cursor=#bbbbbb + +color0=#111111 +color1=#ff4136 +color2=#2ecc40 +color3=#ffdc00 +color4=#0074d9 +color5=#b10dc9 +color6=#7fdbff +color7=#bbbbbb +color8=#777777 +color9=#ff4136 +color10=#2ecc40 +color11=#ffdc00 +color12=#0074d9 +color13=#b10dc9 +color14=#7fdbff +color15=#ffffff + +color16=#ff851b +color17=#85144b +color18=#333333 +color19=#555555 +color20=#999999 +color21=#dddddd diff --git a/app/src/main/assets/termux-colors/base16-colors-light.properties b/app/src/main/assets/termux-colors/base16-colors-light.properties new file mode 100644 index 000000000..95a2eeea7 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-colors-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-colors.light.256.xresources +# Base16 Colors +# Scheme: mrmrs (http://clrs.cc) +foreground=#555555 +background=#ffffff +cursor=#555555 + +color0=#111111 +color1=#ff4136 +color2=#2ecc40 +color3=#ffdc00 +color4=#0074d9 +color5=#b10dc9 +color6=#7fdbff +color7=#bbbbbb +color8=#777777 +color9=#ff4136 +color10=#2ecc40 +color11=#ffdc00 +color12=#0074d9 +color13=#b10dc9 +color14=#7fdbff +color15=#ffffff + +color16=#ff851b +color17=#85144b +color18=#333333 +color19=#555555 +color20=#999999 +color21=#dddddd diff --git a/app/src/main/assets/termux-colors/base16-default-dark.properties b/app/src/main/assets/termux-colors/base16-default-dark.properties new file mode 100644 index 000000000..47bc18206 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-default-dark.properties @@ -0,0 +1,32 @@ +# https=//github.com/chriskempson/base16-xresources/blob/master/base16-default.dark.256.xresources +# Base16 Default +# Scheme=Chris Kempson (http=//chriskempson.com) + +foreground=#d8d8d8 +background=#181818 +cursor=#d8d8d8 + +color0=#181818 +color1=#ab4642 +color2=#a1b56c +color3=#f7ca88 +color4=#7cafc2 +color5=#ba8baf +color6=#86c1b9 +color7=#d8d8d8 + +color8=#585858 +color9=#ab4642 +color10=#a1b56c +color11=#f7ca88 +color12=#7cafc2 +color13=#ba8baf +color14=#86c1b9 +color15=#f8f8f8 + +color16=#dc9656 +color17=#a16946 +color18=#282828 +color19=#383838 +color20=#b8b8b8 +color21=#e8e8e8 \ No newline at end of file diff --git a/app/src/main/assets/termux-colors/base16-default-light.properties b/app/src/main/assets/termux-colors/base16-default-light.properties new file mode 100644 index 000000000..f85446365 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-default-light.properties @@ -0,0 +1,28 @@ +foreground= #383838 +background= #f8f8f8 +cursor= #383838 + +color0= #181818 +color1= #ab4642 +color2= #a1b56c +color3= #f7ca88 +color4= #7cafc2 +color5= #ba8baf +color6= #86c1b9 +color7= #d8d8d8 + +color8= #585858 +color9= #ab4642 +color10= #a1b56c +color11= #f7ca88 +color12= #7cafc2 +color13= #ba8baf +color14= #86c1b9 +color15= #f8f8f8 + +color16= #dc9656 +color17= #a16946 +color18= #282828 +color19= #383838 +color20= #b8b8b8 +color21= #e8e8e8 \ No newline at end of file diff --git a/app/src/main/assets/termux-colors/base16-eighties-dark.properties b/app/src/main/assets/termux-colors/base16-eighties-dark.properties new file mode 100644 index 000000000..90b6ff0b5 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-eighties-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-eighties.dark.256.xresources +# Base16 Eighties +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#d3d0c8 +background=#2d2d2d +cursor=#d3d0c8 + +color0=#2d2d2d +color1=#f2777a +color2=#99cc99 +color3=#ffcc66 +color4=#6699cc +color5=#cc99cc +color6=#66cccc +color7=#d3d0c8 +color8=#747369 +color9=#f2777a +color10=#99cc99 +color11=#ffcc66 +color12=#6699cc +color13=#cc99cc +color14=#66cccc +color15=#f2f0ec + +color16=#f99157 +color17=#d27b53 +color18=#393939 +color19=#515151 +color20=#a09f93 +color21=#e8e6df diff --git a/app/src/main/assets/termux-colors/base16-eighties-light.properties b/app/src/main/assets/termux-colors/base16-eighties-light.properties new file mode 100644 index 000000000..8e5f29b45 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-eighties-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-eighties.light.256.xresources +# Base16 Eighties +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#515151 +background=#f2f0ec +cursor=#515151 + +color0=#2d2d2d +color1=#f2777a +color2=#99cc99 +color3=#ffcc66 +color4=#6699cc +color5=#cc99cc +color6=#66cccc +color7=#d3d0c8 +color8=#747369 +color9=#f2777a +color10=#99cc99 +color11=#ffcc66 +color12=#6699cc +color13=#cc99cc +color14=#66cccc +color15=#f2f0ec + +color16=#f99157 +color17=#d27b53 +color18=#393939 +color19=#515151 +color20=#a09f93 +color21=#e8e6df diff --git a/app/src/main/assets/termux-colors/base16-embers-dark.properties b/app/src/main/assets/termux-colors/base16-embers-dark.properties new file mode 100644 index 000000000..8372901a1 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-embers-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-embers.dark.256.xresources +# Base16 Embers +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#A39A90 +background=#16130F +cursor=#A39A90 + +color0=#16130F +color1=#826D57 +color2=#57826D +color3=#6D8257 +color4=#6D5782 +color5=#82576D +color6=#576D82 +color7=#A39A90 +color8=#5A5047 +color9=#826D57 +color10=#57826D +color11=#6D8257 +color12=#6D5782 +color13=#82576D +color14=#576D82 +color15=#DBD6D1 + +color16=#828257 +color17=#825757 +color18=#2C2620 +color19=#433B32 +color20=#8A8075 +color21=#BEB6AE diff --git a/app/src/main/assets/termux-colors/base16-embers-light.properties b/app/src/main/assets/termux-colors/base16-embers-light.properties new file mode 100644 index 000000000..d60ac4a88 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-embers-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-embers.light.256.xresources +# Base16 Embers +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#433B32 +background=#DBD6D1 +cursor=#433B32 + +color0=#16130F +color1=#826D57 +color2=#57826D +color3=#6D8257 +color4=#6D5782 +color5=#82576D +color6=#576D82 +color7=#A39A90 +color8=#5A5047 +color9=#826D57 +color10=#57826D +color11=#6D8257 +color12=#6D5782 +color13=#82576D +color14=#576D82 +color15=#DBD6D1 + +color16=#828257 +color17=#825757 +color18=#2C2620 +color19=#433B32 +color20=#8A8075 +color21=#BEB6AE diff --git a/app/src/main/assets/termux-colors/base16-flat-dark.properties b/app/src/main/assets/termux-colors/base16-flat-dark.properties new file mode 100644 index 000000000..3e63f3d37 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-flat-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-flat.dark.256.xresources +# Base16 Flat +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#e0e0e0 +background=#2C3E50 +cursor=#e0e0e0 + +color0=#2C3E50 +color1=#E74C3C +color2=#2ECC71 +color3=#F1C40F +color4=#3498DB +color5=#9B59B6 +color6=#1ABC9C +color7=#e0e0e0 +color8=#95A5A6 +color9=#E74C3C +color10=#2ECC71 +color11=#F1C40F +color12=#3498DB +color13=#9B59B6 +color14=#1ABC9C +color15=#ECF0F1 + +color16=#E67E22 +color17=#be643c +color18=#34495E +color19=#7F8C8D +color20=#BDC3C7 +color21=#f5f5f5 diff --git a/app/src/main/assets/termux-colors/base16-flat-light.properties b/app/src/main/assets/termux-colors/base16-flat-light.properties new file mode 100644 index 000000000..040cd23be --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-flat-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-flat.light.256.xresources +# Base16 Flat +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#7F8C8D +background=#ECF0F1 +cursor=#7F8C8D + +color0=#2C3E50 +color1=#E74C3C +color2=#2ECC71 +color3=#F1C40F +color4=#3498DB +color5=#9B59B6 +color6=#1ABC9C +color7=#e0e0e0 +color8=#95A5A6 +color9=#E74C3C +color10=#2ECC71 +color11=#F1C40F +color12=#3498DB +color13=#9B59B6 +color14=#1ABC9C +color15=#ECF0F1 + +color16=#E67E22 +color17=#be643c +color18=#34495E +color19=#7F8C8D +color20=#BDC3C7 +color21=#f5f5f5 diff --git a/app/src/main/assets/termux-colors/base16-google-dark.properties b/app/src/main/assets/termux-colors/base16-google-dark.properties new file mode 100644 index 000000000..659b2cd50 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-google-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-google.dark.256.xresources +# Base16 Google +# Scheme: Seth Wright (http://sethawright.com) +foreground=#c5c8c6 +background=#1d1f21 +cursor=#c5c8c6 + +color0=#1d1f21 +color1=#CC342B +color2=#198844 +color3=#FBA922 +color4=#3971ED +color5=#A36AC7 +color6=#3971ED +color7=#c5c8c6 +color8=#969896 +color9=#CC342B +color10=#198844 +color11=#FBA922 +color12=#3971ED +color13=#A36AC7 +color14=#3971ED +color15=#ffffff + +color16=#F96A38 +color17=#3971ED +color18=#282a2e +color19=#373b41 +color20=#b4b7b4 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-google-light.properties b/app/src/main/assets/termux-colors/base16-google-light.properties new file mode 100644 index 000000000..36e96b81a --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-google-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-google.light.256.xresources +# Base16 Google +# Scheme: Seth Wright (http://sethawright.com) +foreground=#373b41 +background=#ffffff +cursor=#373b41 + +color0=#1d1f21 +color1=#CC342B +color2=#198844 +color3=#FBA922 +color4=#3971ED +color5=#A36AC7 +color6=#3971ED +color7=#c5c8c6 +color8=#969896 +color9=#CC342B +color10=#198844 +color11=#FBA922 +color12=#3971ED +color13=#A36AC7 +color14=#3971ED +color15=#ffffff + +color16=#F96A38 +color17=#3971ED +color18=#282a2e +color19=#373b41 +color20=#b4b7b4 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-grayscale-dark.properties b/app/src/main/assets/termux-colors/base16-grayscale-dark.properties new file mode 100644 index 000000000..3d0d6c283 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-grayscale-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-grayscale.dark.256.xresources +# Base16 Grayscale +# Scheme: Alexandre Gavioli (https://github.com/Alexx2/) +foreground=#b9b9b9 +background=#101010 +cursor=#b9b9b9 + +color0=#101010 +color1=#7c7c7c +color2=#8e8e8e +color3=#a0a0a0 +color4=#686868 +color5=#747474 +color6=#868686 +color7=#b9b9b9 +color8=#525252 +color9=#7c7c7c +color10=#8e8e8e +color11=#a0a0a0 +color12=#686868 +color13=#747474 +color14=#868686 +color15=#f7f7f7 + +color16=#999999 +color17=#5e5e5e +color18=#252525 +color19=#464646 +color20=#ababab +color21=#e3e3e3 diff --git a/app/src/main/assets/termux-colors/base16-grayscale-light.properties b/app/src/main/assets/termux-colors/base16-grayscale-light.properties new file mode 100644 index 000000000..6512c4164 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-grayscale-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-grayscale.light.256.xresources +# Base16 Grayscale +# Scheme: Alexandre Gavioli (https://github.com/Alexx2/) +foreground=#464646 +background=#f7f7f7 +cursor=#464646 + +color0=#101010 +color1=#7c7c7c +color2=#8e8e8e +color3=#a0a0a0 +color4=#686868 +color5=#747474 +color6=#868686 +color7=#b9b9b9 +color8=#525252 +color9=#7c7c7c +color10=#8e8e8e +color11=#a0a0a0 +color12=#686868 +color13=#747474 +color14=#868686 +color15=#f7f7f7 + +color16=#999999 +color17=#5e5e5e +color18=#252525 +color19=#464646 +color20=#ababab +color21=#e3e3e3 diff --git a/app/src/main/assets/termux-colors/base16-greenscreen-dark.properties b/app/src/main/assets/termux-colors/base16-greenscreen-dark.properties new file mode 100644 index 000000000..09212b4af --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-greenscreen-dark.properties @@ -0,0 +1,28 @@ +foreground: #00bb00 +background: #001100 +cursor: #00bb00 + +color0: #001100 +color1: #007700 +color2: #00bb00 +color3: #007700 +color4: #009900 +color5: #00bb00 +color6: #005500 +color7: #00bb00 + +color8: #007700 +color9: #007700 +color10: #00bb00 +color11: #007700 +color12: #009900 +color13: #00bb00 +color14: #005500 +color15: #00ff00 + +color16: #009900 +color17: #005500 +color18: #003300 +color19: #005500 +color20: #009900 +color21: #00dd00 diff --git a/app/src/main/assets/termux-colors/base16-greenscreen-light.properties b/app/src/main/assets/termux-colors/base16-greenscreen-light.properties new file mode 100644 index 000000000..7196747e0 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-greenscreen-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-greenscreen.light.256.xresources +# Base16 Green Screen +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#005500 +background=#00ff00 +cursor=#005500 + +color0=#001100 +color1=#007700 +color2=#00bb00 +color3=#007700 +color4=#009900 +color5=#00bb00 +color6=#005500 +color7=#00bb00 +color8=#007700 +color9=#007700 +color10=#00bb00 +color11=#007700 +color12=#009900 +color13=#00bb00 +color14=#005500 +color15=#00ff00 + +color16=#009900 +color17=#005500 +color18=#003300 +color19=#005500 +color20=#009900 +color21=#00dd00 diff --git a/app/src/main/assets/termux-colors/base16-harmonic16-dark.properties b/app/src/main/assets/termux-colors/base16-harmonic16-dark.properties new file mode 100644 index 000000000..14e150d29 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-harmonic16-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-harmonic16.dark.256.xresources +# Base16 harmonic16 +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#cbd6e2 +background=#0b1c2c +cursor=#cbd6e2 + +color0=#0b1c2c +color1=#bf8b56 +color2=#56bf8b +color3=#8bbf56 +color4=#8b56bf +color5=#bf568b +color6=#568bbf +color7=#cbd6e2 +color8=#627e99 +color9=#bf8b56 +color10=#56bf8b +color11=#8bbf56 +color12=#8b56bf +color13=#bf568b +color14=#568bbf +color15=#f7f9fb + +color16=#bfbf56 +color17=#bf5656 +color18=#223b54 +color19=#405c79 +color20=#aabcce +color21=#e5ebf1 diff --git a/app/src/main/assets/termux-colors/base16-harmonic16-light.properties b/app/src/main/assets/termux-colors/base16-harmonic16-light.properties new file mode 100644 index 000000000..64c26ced5 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-harmonic16-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-harmonic16.light.256.xresources +# Base16 harmonic16 +# Scheme: Jannik Siebert (https://github.com/janniks) +foreground=#405c79 +background=#f7f9fb +cursor=#405c79 + +color0=#0b1c2c +color1=#bf8b56 +color2=#56bf8b +color3=#8bbf56 +color4=#8b56bf +color5=#bf568b +color6=#568bbf +color7=#cbd6e2 +color8=#627e99 +color9=#bf8b56 +color10=#56bf8b +color11=#8bbf56 +color12=#8b56bf +color13=#bf568b +color14=#568bbf +color15=#f7f9fb + +color16=#bfbf56 +color17=#bf5656 +color18=#223b54 +color19=#405c79 +color20=#aabcce +color21=#e5ebf1 diff --git a/app/src/main/assets/termux-colors/base16-isotope-dark.properties b/app/src/main/assets/termux-colors/base16-isotope-dark.properties new file mode 100644 index 000000000..fe61d97ac --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-isotope-dark.properties @@ -0,0 +1,32 @@ +# Base16 Isotope +# Scheme: Jan T. Sott + +foreground: #d0d0d0 +background: #000000 +cursor: #d0d0d0 + +color0: #000000 +color1: #ff0000 +color2: #33ff00 +color3: #ff0099 +color4: #0066ff +color5: #cc00ff +color6: #00ffff +color7: #d0d0d0 + +color8: #808080 +color9: #ff0000 +color10: #33ff00 +color11: #ff0099 +color12: #0066ff +color13: #cc00ff +color14: #00ffff +color15: #ffffff + +color16: #ff9900 +color17: #3300ff +color18: #404040 +color19: #606060 +color20: #c0c0c0 +color21: #e0e0e0 + diff --git a/app/src/main/assets/termux-colors/base16-isotope-light.properties b/app/src/main/assets/termux-colors/base16-isotope-light.properties new file mode 100644 index 000000000..3856f3f47 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-isotope-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-isotope.light.256.xresources +# Base16 Isotope +# Scheme: Jan T. Sott +foreground=#606060 +background=#ffffff +cursor=#606060 + +color0=#000000 +color1=#ff0000 +color2=#33ff00 +color3=#ff0099 +color4=#0066ff +color5=#cc00ff +color6=#00ffff +color7=#d0d0d0 +color8=#808080 +color9=#ff0000 +color10=#33ff00 +color11=#ff0099 +color12=#0066ff +color13=#cc00ff +color14=#00ffff +color15=#ffffff + +color16=#ff9900 +color17=#3300ff +color18=#404040 +color19=#606060 +color20=#c0c0c0 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-londontube-dark.properties b/app/src/main/assets/termux-colors/base16-londontube-dark.properties new file mode 100644 index 000000000..a4376497d --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-londontube-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-londontube.dark.256.xresources +# Base16 London Tube +# Scheme: Jan T. Sott +foreground=#d9d8d8 +background=#231f20 +cursor=#d9d8d8 + +color0=#231f20 +color1=#ee2e24 +color2=#00853e +color3=#ffd204 +color4=#009ddc +color5=#98005d +color6=#85cebc +color7=#d9d8d8 +color8=#737171 +color9=#ee2e24 +color10=#00853e +color11=#ffd204 +color12=#009ddc +color13=#98005d +color14=#85cebc +color15=#ffffff + +color16=#f386a1 +color17=#b06110 +color18=#1c3f95 +color19=#5a5758 +color20=#959ca1 +color21=#e7e7e8 diff --git a/app/src/main/assets/termux-colors/base16-londontube-light.properties b/app/src/main/assets/termux-colors/base16-londontube-light.properties new file mode 100644 index 000000000..0c27029f9 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-londontube-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-londontube.light.256.xresources +# Base16 London Tube +# Scheme: Jan T. Sott +foreground=#5a5758 +background=#ffffff +cursor=#5a5758 + +color0=#231f20 +color1=#ee2e24 +color2=#00853e +color3=#ffd204 +color4=#009ddc +color5=#98005d +color6=#85cebc +color7=#d9d8d8 +color8=#737171 +color9=#ee2e24 +color10=#00853e +color11=#ffd204 +color12=#009ddc +color13=#98005d +color14=#85cebc +color15=#ffffff + +color16=#f386a1 +color17=#b06110 +color18=#1c3f95 +color19=#5a5758 +color20=#959ca1 +color21=#e7e7e8 diff --git a/app/src/main/assets/termux-colors/base16-marrakesh-dark.properties b/app/src/main/assets/termux-colors/base16-marrakesh-dark.properties new file mode 100644 index 000000000..115eae5be --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-marrakesh-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-marrakesh.dark.256.xresources +# Base16 Marrakesh +# Scheme: Alexandre Gavioli (http://github.com/Alexx2/) +foreground=#948e48 +background=#201602 +cursor=#948e48 + +color0=#201602 +color1=#c35359 +color2=#18974e +color3=#a88339 +color4=#477ca1 +color5=#8868b3 +color6=#75a738 +color7=#948e48 +color8=#6c6823 +color9=#c35359 +color10=#18974e +color11=#a88339 +color12=#477ca1 +color13=#8868b3 +color14=#75a738 +color15=#faf0a5 + +color16=#b36144 +color17=#b3588e +color18=#302e00 +color19=#5f5b17 +color20=#86813b +color21=#ccc37a diff --git a/app/src/main/assets/termux-colors/base16-marrakesh-light.properties b/app/src/main/assets/termux-colors/base16-marrakesh-light.properties new file mode 100644 index 000000000..bf68121ff --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-marrakesh-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-marrakesh.light.256.xresources +# Base16 Marrakesh +# Scheme: Alexandre Gavioli (http://github.com/Alexx2/) +foreground=#5f5b17 +background=#faf0a5 +cursor=#5f5b17 + +color0=#201602 +color1=#c35359 +color2=#18974e +color3=#a88339 +color4=#477ca1 +color5=#8868b3 +color6=#75a738 +color7=#948e48 +color8=#6c6823 +color9=#c35359 +color10=#18974e +color11=#a88339 +color12=#477ca1 +color13=#8868b3 +color14=#75a738 +color15=#faf0a5 + +color16=#b36144 +color17=#b3588e +color18=#302e00 +color19=#5f5b17 +color20=#86813b +color21=#ccc37a diff --git a/app/src/main/assets/termux-colors/base16-materia.properties b/app/src/main/assets/termux-colors/base16-materia.properties new file mode 100644 index 000000000..37644bea3 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-materia.properties @@ -0,0 +1,21 @@ +foreground=#c9ccd3 +background=#263238 +cursor=#c9ccd3 + +color0=#2c393f +color1=#8bd649 +color2=#82aaff +color3=#89ddff +color4=#ea9560 +color5=#ec5f67 +color6=#ec5f67 +color7=#cdd3de + +color8=#707880 +color9=#8bd649 +color10=#82aaff +color11=#89ddff +color12=#ea9560 +color13=#ec5f67 +color14=#ffffff +color15=#d5dbe6 diff --git a/app/src/main/assets/termux-colors/base16-mocha-dark.properties b/app/src/main/assets/termux-colors/base16-mocha-dark.properties new file mode 100644 index 000000000..21039031e --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-mocha-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-mocha.dark.256.xresources +# Base16 Mocha +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#d0c8c6 +background=#3B3228 +cursor=#d0c8c6 + +color0=#3B3228 +color1=#cb6077 +color2=#beb55b +color3=#f4bc87 +color4=#8ab3b5 +color5=#a89bb9 +color6=#7bbda4 +color7=#d0c8c6 +color8=#7e705a +color9=#cb6077 +color10=#beb55b +color11=#f4bc87 +color12=#8ab3b5 +color13=#a89bb9 +color14=#7bbda4 +color15=#f5eeeb + +color16=#d28b71 +color17=#bb9584 +color18=#534636 +color19=#645240 +color20=#b8afad +color21=#e9e1dd diff --git a/app/src/main/assets/termux-colors/base16-mocha-light.properties b/app/src/main/assets/termux-colors/base16-mocha-light.properties new file mode 100644 index 000000000..9ad25a66a --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-mocha-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-mocha.light.256.xresources +# Base16 Mocha +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#645240 +background=#f5eeeb +cursor=#645240 + +color0=#3B3228 +color1=#cb6077 +color2=#beb55b +color3=#f4bc87 +color4=#8ab3b5 +color5=#a89bb9 +color6=#7bbda4 +color7=#d0c8c6 +color8=#7e705a +color9=#cb6077 +color10=#beb55b +color11=#f4bc87 +color12=#8ab3b5 +color13=#a89bb9 +color14=#7bbda4 +color15=#f5eeeb + +color16=#d28b71 +color17=#bb9584 +color18=#534636 +color19=#645240 +color20=#b8afad +color21=#e9e1dd diff --git a/app/src/main/assets/termux-colors/base16-monokai-dark.properties b/app/src/main/assets/termux-colors/base16-monokai-dark.properties new file mode 100644 index 000000000..553df1aa7 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-monokai-dark.properties @@ -0,0 +1,29 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/xresources/base16-monokai-256.Xresources +foreground= #f8f8f2 +background= #272822 +cursor= #f8f8f2 + +color0= #272822 +color1= #f92672 +color2= #a6e22e +color3= #f4bf75 +color4= #66d9ef +color5= #ae81ff +color6= #a1efe4 +color7= #f8f8f2 + +color8= #75715e +color9= #f92672 +color10= #a6e22e +color11= #f4bf75 +color12= #66d9ef +color13= #ae81ff +color14= #a1efe4 +color15= #f9f8f5 + +color16= #fd971f +color17= #cc6633 +color18= #383830 +color19= #49483e +color20= #a59f85 +color21= #f5f4f1 diff --git a/app/src/main/assets/termux-colors/base16-monokai-light.properties b/app/src/main/assets/termux-colors/base16-monokai-light.properties new file mode 100644 index 000000000..4b6939295 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-monokai-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-monokai.light.256.xresources +# Base16 Monokai +# Scheme: Wimer Hazenberg (http://www.monokai.nl) +foreground=#49483e +background=#f9f8f5 +cursor=#49483e + +color0=#272822 +color1=#f92672 +color2=#a6e22e +color3=#f4bf75 +color4=#66d9ef +color5=#ae81ff +color6=#a1efe4 +color7=#f8f8f2 +color8=#75715e +color9=#f92672 +color10=#a6e22e +color11=#f4bf75 +color12=#66d9ef +color13=#ae81ff +color14=#a1efe4 +color15=#f9f8f5 + +color16=#fd971f +color17=#cc6633 +color18=#383830 +color19=#49483e +color20=#a59f85 +color21=#f5f4f1 diff --git a/app/src/main/assets/termux-colors/base16-ocean-dark.properties b/app/src/main/assets/termux-colors/base16-ocean-dark.properties new file mode 100644 index 000000000..b94d312ca --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-ocean-dark.properties @@ -0,0 +1,28 @@ +foreground= #c0c5ce +background= #2b303b +cursor= #c0c5ce + +color0= #2b303b +color1= #bf616a +color2= #a3be8c +color3= #ebcb8b +color4= #8fa1b3 +color5= #b48ead +color6= #96b5b4 +color7= #c0c5ce + +color8= #65737e +color9= #bf616a +color10= #a3be8c +color11= #ebcb8b +color12= #8fa1b3 +color13= #b48ead +color14= #96b5b4 +color15= #eff1f5 + +color16= #d08770 +color17= #ab7967 +color18= #343d46 +color19= #4f5b66 +color20= #a7adba +color21= #dfe1e8 \ No newline at end of file diff --git a/app/src/main/assets/termux-colors/base16-ocean-light.properties b/app/src/main/assets/termux-colors/base16-ocean-light.properties new file mode 100644 index 000000000..c6a2f36ae --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-ocean-light.properties @@ -0,0 +1,30 @@ +foreground: #4f5b66 +background: #eff1f5 +cursor: #4f5b66 + +color0: #2b303b +color1: #bf616a +color2: #a3be8c +color3: #ebcb8b +color4: #8fa1b3 +color5: #b48ead +color6: #96b5b4 +color7: #c0c5ce + +color8: #65737e +color9: #bf616a +color10: #a3be8c +color11: #ebcb8b +color12: #8fa1b3 +color13: #b48ead +color14: #96b5b4 +color15: #eff1f5 + +! Note: colors beyond 15 might not be loaded (e.g., xterm, urxvt), +! use 'shell' template to set these if necessary +color16: #d08770 +color17: #ab7967 +color18: #343d46 +color19: #4f5b66 +color20: #a7adba +color21: #dfe1e8 diff --git a/app/src/main/assets/termux-colors/base16-one-dark.properties b/app/src/main/assets/termux-colors/base16-one-dark.properties new file mode 100644 index 000000000..b32ba5a81 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-one-dark.properties @@ -0,0 +1,31 @@ +# https://github.com/aaron-williamson/base16-alacritty/blob/master/colors/base16-onedark-256.yml +# Base16 One dark +# Daniel Pfeifer (http://github.com/purpleKarrot) +foreground=#abb2bf +background=#282c34 +cursor=#abb2bf + +color0=#282c34 +color1=#e06c75 +color2=#98c379 +color3=#e5c07b +color4=#61afef +color5=#c678dd +color6=#56b6c2 +color7=#abb2bf + +color8=#545862 +color9=#e06c75 +color10=#98c379 +color11=#e5c07b +color12=#61afef +color13=#c678dd +color14=#56b6c2 +color15=#c8ccd4 + +color16=#d19a66 +color17=#be5046 +color18=#353b45 +color19=#3e4451 +color20=#565c64 +color21=#b6bdca diff --git a/app/src/main/assets/termux-colors/base16-one-light.properties b/app/src/main/assets/termux-colors/base16-one-light.properties new file mode 100644 index 000000000..fc0f64b95 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-one-light.properties @@ -0,0 +1,31 @@ +# https://github.com/aaron-williamson/base16-alacritty/blob/master/colors/base16-one-light-256.yml +# Base16 One light +# Daniel Pfeifer (http://github.com/purpleKarrot) +foreground=#383a42 +background=#fafafa +cursor=#383a42 + +color0=#fafafa +color1=#ca1243 +color2=#50a14f +color3=#c18401 +color4=#4078f2 +color5=#a626a4 +color6=#0184bc +color7=#383a42 + +color8=#a0a1a7 +color9=#ca1243 +color10=#50a14f +color11=#c18401 +color12=#4078f2 +color13=#a626a4 +color14=#0184bc +color15=#090a0b + +color16=#d75f00 +color17=#986801 +color18=#f0f0f1 +color19=#e5e5e6 +color20=#696c77 +color21=#202227 diff --git a/app/src/main/assets/termux-colors/base16-paraiso-dark.properties b/app/src/main/assets/termux-colors/base16-paraiso-dark.properties new file mode 100644 index 000000000..ed1c833fc --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-paraiso-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-paraiso.dark.256.xresources +# Base16 Paraiso +# Scheme: Jan T. Sott +foreground=#a39e9b +background=#2f1e2e +cursor=#a39e9b + +color0=#2f1e2e +color1=#ef6155 +color2=#48b685 +color3=#fec418 +color4=#06b6ef +color5=#815ba4 +color6=#5bc4bf +color7=#a39e9b +color8=#776e71 +color9=#ef6155 +color10=#48b685 +color11=#fec418 +color12=#06b6ef +color13=#815ba4 +color14=#5bc4bf +color15=#e7e9db + +color16=#f99b15 +color17=#e96ba8 +color18=#41323f +color19=#4f424c +color20=#8d8687 +color21=#b9b6b0 diff --git a/app/src/main/assets/termux-colors/base16-paraiso-light.properties b/app/src/main/assets/termux-colors/base16-paraiso-light.properties new file mode 100644 index 000000000..a1e43d70b --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-paraiso-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-paraiso.light.256.xresources +# Base16 Paraiso +# Scheme: Jan T. Sott +foreground=#4f424c +background=#e7e9db +cursor=#4f424c + +color0=#2f1e2e +color1=#ef6155 +color2=#48b685 +color3=#fec418 +color4=#06b6ef +color5=#815ba4 +color6=#5bc4bf +color7=#a39e9b +color8=#776e71 +color9=#ef6155 +color10=#48b685 +color11=#fec418 +color12=#06b6ef +color13=#815ba4 +color14=#5bc4bf +color15=#e7e9db + +color16=#f99b15 +color17=#e96ba8 +color18=#41323f +color19=#4f424c +color20=#8d8687 +color21=#b9b6b0 diff --git a/app/src/main/assets/termux-colors/base16-railscasts-dark.properties b/app/src/main/assets/termux-colors/base16-railscasts-dark.properties new file mode 100644 index 000000000..f194dfaee --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-railscasts-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-railscasts.dark.256.xresources +# Base16 Railscasts +# Scheme: Ryan Bates (http://railscasts.com) +foreground=#e6e1dc +background=#2b2b2b +cursor=#e6e1dc + +color0=#2b2b2b +color1=#da4939 +color2=#a5c261 +color3=#ffc66d +color4=#6d9cbe +color5=#b6b3eb +color6=#519f50 +color7=#e6e1dc +color8=#5a647e +color9=#da4939 +color10=#a5c261 +color11=#ffc66d +color12=#6d9cbe +color13=#b6b3eb +color14=#519f50 +color15=#f9f7f3 + +color16=#cc7833 +color17=#bc9458 +color18=#272935 +color19=#3a4055 +color20=#d4cfc9 +color21=#f4f1ed diff --git a/app/src/main/assets/termux-colors/base16-railscasts-light.properties b/app/src/main/assets/termux-colors/base16-railscasts-light.properties new file mode 100644 index 000000000..55fc91283 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-railscasts-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-railscasts.light.256.xresources +# Base16 Railscasts +# Scheme: Ryan Bates (http://railscasts.com) +foreground=#3a4055 +background=#f9f7f3 +cursor=#3a4055 + +color0=#2b2b2b +color1=#da4939 +color2=#a5c261 +color3=#ffc66d +color4=#6d9cbe +color5=#b6b3eb +color6=#519f50 +color7=#e6e1dc +color8=#5a647e +color9=#da4939 +color10=#a5c261 +color11=#ffc66d +color12=#6d9cbe +color13=#b6b3eb +color14=#519f50 +color15=#f9f7f3 + +color16=#cc7833 +color17=#bc9458 +color18=#272935 +color19=#3a4055 +color20=#d4cfc9 +color21=#f4f1ed diff --git a/app/src/main/assets/termux-colors/base16-shapeshifter-dark.properties b/app/src/main/assets/termux-colors/base16-shapeshifter-dark.properties new file mode 100644 index 000000000..0a1ced3f4 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-shapeshifter-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-shapeshifter.dark.256.xresources +# Base16 shapeshifter +# Scheme: Tyler Benziger (http://tybenz.com) +foreground=#ababab +background=#000000 +cursor=#ababab + +color0=#000000 +color1=#e92f2f +color2=#0ed839 +color3=#dddd13 +color4=#3b48e3 +color5=#f996e2 +color6=#23edda +color7=#ababab +color8=#343434 +color9=#e92f2f +color10=#0ed839 +color11=#dddd13 +color12=#3b48e3 +color13=#f996e2 +color14=#23edda +color15=#f9f9f9 + +color16=#e09448 +color17=#69542d +color18=#040404 +color19=#102015 +color20=#555555 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-shapeshifter-light.properties b/app/src/main/assets/termux-colors/base16-shapeshifter-light.properties new file mode 100644 index 000000000..0d14d1b3d --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-shapeshifter-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-shapeshifter.light.256.xresources +# Base16 shapeshifter +# Scheme: Tyler Benziger (http://tybenz.com) +foreground=#102015 +background=#f9f9f9 +cursor=#102015 + +color0=#000000 +color1=#e92f2f +color2=#0ed839 +color3=#dddd13 +color4=#3b48e3 +color5=#f996e2 +color6=#23edda +color7=#ababab +color8=#343434 +color9=#e92f2f +color10=#0ed839 +color11=#dddd13 +color12=#3b48e3 +color13=#f996e2 +color14=#23edda +color15=#f9f9f9 + +color16=#e09448 +color17=#69542d +color18=#040404 +color19=#102015 +color20=#555555 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-snazzy.properties b/app/src/main/assets/termux-colors/base16-snazzy.properties new file mode 100644 index 000000000..aff68cc76 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-snazzy.properties @@ -0,0 +1,30 @@ +# Base16 Snazzy +# Based on https://github.com/aaron-williamson/base16-alacritty/blob/025688c7158eb8a3421cb2b3612e77915dce8c2a/colors/base16-snazzy-256.yml +foreground=#e2e4e5 +background=#282a36 +cursor=#e2e4e5 + +color0=#282a36 +color1=#ff5c57 +color2=#5af78e +color3=#f3f99d +color4=#57c7ff +color5=#ff6ac1 +color6=#9aedfe +color7=#e2e4e5 + +color8=#78787e +color9=#ff5c57 +color10=#5af78e +color11=#f3f99d +color12=#57c7ff +color13=#ff6ac1 +color14=#9aedfe +color15=#f1f1f0 + +color16=#ff9f43 +color17=#b2643c +color18=#34353e +color19=#43454f +color20=#a5a5a9 +color21=#eff0eb diff --git a/app/src/main/assets/termux-colors/base16-solarized-dark.properties b/app/src/main/assets/termux-colors/base16-solarized-dark.properties new file mode 100644 index 000000000..80d27be7e --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-solarized-dark.properties @@ -0,0 +1,31 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-solarized.dark.xresources +# Base16 Solarized +# Scheme: Ethan Schoonover (http=//ethanschoonover.com/solarized) +background=#002b36 +foreground=#93a1a1 +cursor=#93a1a1 + +color0=#002b36 +color1=#dc322f +color2=#859900 +color3=#b58900 +color4=#268bd2 +color5=#6c71c4 +color6=#2aa198 +color7=#93a1a1 +color8=#657b83 +color9=#dc322f +color10=#859900 +color11=#b58900 +color12=#268bd2 +color13=#6c71c4 +color14=#2aa198 +color15=#fdf6e3 + +color16=#cb4b16 +color17=#d33682 +color18=#073642 +color19=#586e75 +color20=#839496 +color21=#eee8d5 + diff --git a/app/src/main/assets/termux-colors/base16-solarized-light.properties b/app/src/main/assets/termux-colors/base16-solarized-light.properties new file mode 100644 index 000000000..4e629f485 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-solarized-light.properties @@ -0,0 +1,31 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-solarized.light.xresources +# Base16 Solarized +# Scheme: Ethan Schoonover (http://ethanschoonover.com/solarized) +foreground=#586e75 +background=#fdf6e3 +cursor=#586e75 + +color0=#002b36 +color1=#dc322f +color2=#859900 +color3=#b58900 +color4=#268bd2 +color5=#6c71c4 +color6=#2aa198 +color7=#93a1a1 +color8=#657b83 +color9=#cb4b16 +color10=#073642 +color11=#586e75 +color12=#839496 +color13=#eee8d5 +color14=#d33682 +color15=#fdf6e3 + +color16=#cb4b16 +color17=#d33682 +color18=#073642 +color19=#586e75 +color20=#839496 +color21=#eee8d5 + diff --git a/app/src/main/assets/termux-colors/base16-summerfruit-dark.properties b/app/src/main/assets/termux-colors/base16-summerfruit-dark.properties new file mode 100644 index 000000000..02cac9b24 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-summerfruit-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-summerfruit.dark.256.xresources +# Base16 Summerfruit +# Scheme: Christopher Corley (http://cscorley.github.io/) +foreground=#D0D0D0 +background=#151515 +cursor=#D0D0D0 + +color0=#151515 +color1=#FF0086 +color2=#00C918 +color3=#ABA800 +color4=#3777E6 +color5=#AD00A1 +color6=#1faaaa +color7=#D0D0D0 +color8=#505050 +color9=#FF0086 +color10=#00C918 +color11=#ABA800 +color12=#3777E6 +color13=#AD00A1 +color14=#1faaaa +color15=#FFFFFF + +color16=#FD8900 +color17=#cc6633 +color18=#202020 +color19=#303030 +color20=#B0B0B0 +color21=#E0E0E0 diff --git a/app/src/main/assets/termux-colors/base16-summerfruit-light.properties b/app/src/main/assets/termux-colors/base16-summerfruit-light.properties new file mode 100644 index 000000000..36fff3338 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-summerfruit-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-summerfruit.light.256.xresources +# Base16 Summerfruit +# Scheme: Christopher Corley (http://cscorley.github.io/) +foreground=#303030 +background=#FFFFFF +cursor=#303030 + +color0=#151515 +color1=#FF0086 +color2=#00C918 +color3=#ABA800 +color4=#3777E6 +color5=#AD00A1 +color6=#1faaaa +color7=#D0D0D0 +color8=#505050 +color9=#FF0086 +color10=#00C918 +color11=#ABA800 +color12=#3777E6 +color13=#AD00A1 +color14=#1faaaa +color15=#FFFFFF + +color16=#FD8900 +color17=#cc6633 +color18=#202020 +color19=#303030 +color20=#B0B0B0 +color21=#E0E0E0 diff --git a/app/src/main/assets/termux-colors/base16-tomorrow-dark.properties b/app/src/main/assets/termux-colors/base16-tomorrow-dark.properties new file mode 100644 index 000000000..0aadabec0 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-tomorrow-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-tomorrow.dark.256.xresources +# Base16 Tomorrow +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#c5c8c6 +background=#1d1f21 +cursor=#c5c8c6 + +color0=#1d1f21 +color1=#cc6666 +color2=#b5bd68 +color3=#f0c674 +color4=#81a2be +color5=#b294bb +color6=#8abeb7 +color7=#c5c8c6 +color8=#969896 +color9=#cc6666 +color10=#b5bd68 +color11=#f0c674 +color12=#81a2be +color13=#b294bb +color14=#8abeb7 +color15=#ffffff + +color16=#de935f +color17=#a3685a +color18=#282a2e +color19=#373b41 +color20=#b4b7b4 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-tomorrow-light.properties b/app/src/main/assets/termux-colors/base16-tomorrow-light.properties new file mode 100644 index 000000000..8b09191e5 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-tomorrow-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-tomorrow.light.256.xresources +# Base16 Tomorrow +# Scheme: Chris Kempson (http://chriskempson.com) +foreground=#373b41 +background=#ffffff +cursor=#373b41 + +color0=#1d1f21 +color1=#cc6666 +color2=#b5bd68 +color3=#f0c674 +color4=#81a2be +color5=#b294bb +color6=#8abeb7 +color7=#c5c8c6 +color8=#969896 +color9=#cc6666 +color10=#b5bd68 +color11=#f0c674 +color12=#81a2be +color13=#b294bb +color14=#8abeb7 +color15=#ffffff + +color16=#de935f +color17=#a3685a +color18=#282a2e +color19=#373b41 +color20=#b4b7b4 +color21=#e0e0e0 diff --git a/app/src/main/assets/termux-colors/base16-twilight-dark.properties b/app/src/main/assets/termux-colors/base16-twilight-dark.properties new file mode 100644 index 000000000..9678a13dc --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-twilight-dark.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-twilight.dark.256.xresources +# Base16 Twilight +# Scheme: David Hart (http://hart-dev.com) +foreground=#a7a7a7 +background=#1e1e1e +cursor=#a7a7a7 + +color0=#1e1e1e +color1=#cf6a4c +color2=#8f9d6a +color3=#f9ee98 +color4=#7587a6 +color5=#9b859d +color6=#afc4db +color7=#a7a7a7 +color8=#5f5a60 +color9=#cf6a4c +color10=#8f9d6a +color11=#f9ee98 +color12=#7587a6 +color13=#9b859d +color14=#afc4db +color15=#ffffff + +color16=#cda869 +color17=#9b703f +color18=#323537 +color19=#464b50 +color20=#838184 +color21=#c3c3c3 diff --git a/app/src/main/assets/termux-colors/base16-twilight-light.properties b/app/src/main/assets/termux-colors/base16-twilight-light.properties new file mode 100644 index 000000000..e2f38d2a9 --- /dev/null +++ b/app/src/main/assets/termux-colors/base16-twilight-light.properties @@ -0,0 +1,30 @@ +# https://github.com/chriskempson/base16-xresources/blob/master/base16-twilight.light.256.xresources +# Base16 Twilight +# Scheme: David Hart (http://hart-dev.com) +foreground=#464b50 +background=#ffffff +cursor=#464b50 + +color0=#1e1e1e +color1=#cf6a4c +color2=#8f9d6a +color3=#f9ee98 +color4=#7587a6 +color5=#9b859d +color6=#afc4db +color7=#a7a7a7 +color8=#5f5a60 +color9=#cf6a4c +color10=#8f9d6a +color11=#f9ee98 +color12=#7587a6 +color13=#9b859d +color14=#afc4db +color15=#ffffff + +color16=#cda869 +color17=#9b703f +color18=#323537 +color19=#464b50 +color20=#838184 +color21=#c3c3c3 diff --git a/app/src/main/assets/termux-colors/black-on-white.properties b/app/src/main/assets/termux-colors/black-on-white.properties new file mode 100644 index 000000000..4532f9002 --- /dev/null +++ b/app/src/main/assets/termux-colors/black-on-white.properties @@ -0,0 +1,259 @@ +background: #FFFFFF +foreground: #000000 + +color0:#000000 +color1:#000000 +color2:#000000 +color3:#000000 +color4:#000000 +color5:#000000 +color6:#000000 +color7:#000000 +color8:#000000 +color9:#000000 +color10:#000000 +color11:#000000 +color12:#000000 +color13:#000000 +color14:#000000 +color15:#000000 +color16:#000000 +color17:#000000 +color18:#000000 +color19:#000000 +color20:#000000 +color21:#000000 +color22:#000000 +color23:#000000 +color24:#000000 +color25:#000000 +color26:#000000 +color27:#000000 +color28:#000000 +color29:#000000 +color30:#000000 +color31:#000000 +color32:#000000 +color33:#000000 +color34:#000000 +color35:#000000 +color36:#000000 +color37:#000000 +color38:#000000 +color39:#000000 +color40:#000000 +color41:#000000 +color42:#000000 +color43:#000000 +color44:#000000 +color45:#000000 +color46:#000000 +color47:#000000 +color48:#000000 +color49:#000000 +color50:#000000 +color51:#000000 +color52:#000000 +color53:#000000 +color54:#000000 +color55:#000000 +color56:#000000 +color57:#000000 +color58:#000000 +color59:#000000 +color60:#000000 +color61:#000000 +color62:#000000 +color63:#000000 +color64:#000000 +color65:#000000 +color66:#000000 +color67:#000000 +color68:#000000 +color69:#000000 +color70:#000000 +color71:#000000 +color72:#000000 +color73:#000000 +color74:#000000 +color75:#000000 +color76:#000000 +color77:#000000 +color78:#000000 +color79:#000000 +color80:#000000 +color81:#000000 +color82:#000000 +color83:#000000 +color84:#000000 +color85:#000000 +color86:#000000 +color87:#000000 +color88:#000000 +color89:#000000 +color90:#000000 +color91:#000000 +color92:#000000 +color93:#000000 +color94:#000000 +color95:#000000 +color96:#000000 +color97:#000000 +color98:#000000 +color99:#000000 +color100:#000000 +color101:#000000 +color102:#000000 +color103:#000000 +color104:#000000 +color105:#000000 +color106:#000000 +color107:#000000 +color108:#000000 +color109:#000000 +color110:#000000 +color111:#000000 +color112:#000000 +color113:#000000 +color114:#000000 +color115:#000000 +color116:#000000 +color117:#000000 +color118:#000000 +color119:#000000 +color120:#000000 +color121:#000000 +color122:#000000 +color123:#000000 +color124:#000000 +color125:#000000 +color126:#000000 +color127:#000000 +color128:#000000 +color129:#000000 +color130:#000000 +color131:#000000 +color132:#000000 +color133:#000000 +color134:#000000 +color135:#000000 +color136:#000000 +color137:#000000 +color138:#000000 +color139:#000000 +color140:#000000 +color141:#000000 +color142:#000000 +color143:#000000 +color144:#000000 +color145:#000000 +color146:#000000 +color147:#000000 +color148:#000000 +color149:#000000 +color150:#000000 +color151:#000000 +color152:#000000 +color153:#000000 +color154:#000000 +color155:#000000 +color156:#000000 +color157:#000000 +color158:#000000 +color159:#000000 +color160:#000000 +color161:#000000 +color162:#000000 +color163:#000000 +color164:#000000 +color165:#000000 +color166:#000000 +color167:#000000 +color168:#000000 +color169:#000000 +color170:#000000 +color171:#000000 +color172:#000000 +color173:#000000 +color174:#000000 +color175:#000000 +color176:#000000 +color177:#000000 +color178:#000000 +color179:#000000 +color180:#000000 +color181:#000000 +color182:#000000 +color183:#000000 +color184:#000000 +color185:#000000 +color186:#000000 +color187:#000000 +color188:#000000 +color189:#000000 +color190:#000000 +color191:#000000 +color192:#000000 +color193:#000000 +color194:#000000 +color195:#000000 +color196:#000000 +color197:#000000 +color198:#000000 +color199:#000000 +color200:#000000 +color201:#000000 +color202:#000000 +color203:#000000 +color204:#000000 +color205:#000000 +color206:#000000 +color207:#000000 +color208:#000000 +color209:#000000 +color210:#000000 +color211:#000000 +color212:#000000 +color213:#000000 +color214:#000000 +color215:#000000 +color216:#000000 +color217:#000000 +color218:#000000 +color219:#000000 +color220:#000000 +color221:#000000 +color222:#000000 +color223:#000000 +color224:#000000 +color225:#000000 +color226:#000000 +color227:#000000 +color228:#000000 +color229:#000000 +color230:#000000 +color231:#000000 +color232:#000000 +color233:#000000 +color234:#000000 +color235:#000000 +color236:#000000 +color237:#000000 +color238:#000000 +color239:#000000 +color240:#000000 +color241:#000000 +color242:#000000 +color243:#000000 +color244:#000000 +color245:#000000 +color246:#000000 +color247:#000000 +color248:#000000 +color249:#000000 +color250:#000000 +color251:#000000 +color252:#000000 +color253:#000000 +color254:#000000 +color255:#000000 diff --git a/app/src/main/assets/termux-colors/catppuccin-frappe.properties b/app/src/main/assets/termux-colors/catppuccin-frappe.properties new file mode 100644 index 000000000..f134930da --- /dev/null +++ b/app/src/main/assets/termux-colors/catppuccin-frappe.properties @@ -0,0 +1,25 @@ +# https://github.com/catppuccin/catppuccin +foreground=#c6d0f5 +background=#303446 +cursor=#f2d5cf + +color0=#51576d +color1=#e78284 +color2=#a6d189 +color3=#e5c890 +color4=#8caaee +color5=#f4b8e4 +color6=#81c8be +color7=#b5bfe2 + +color8=#626880 +color9=#e78284 +color10=#a6d189 +color11=#e5c890 +color12=#8caaee +color13=#f4b8e4 +color14=#81c8be +color15=#a5adce + +color16=#ef9f76 +color17=#f2d5cf diff --git a/app/src/main/assets/termux-colors/catppuccin-latte.properties b/app/src/main/assets/termux-colors/catppuccin-latte.properties new file mode 100644 index 000000000..6296e30b4 --- /dev/null +++ b/app/src/main/assets/termux-colors/catppuccin-latte.properties @@ -0,0 +1,25 @@ +# https://github.com/catppuccin/catppuccin +foreground=#4c4f69 +background=#eff1f5 +cursor=#dc8a78 + +color0=#5c5f77 +color1=#d20f39 +color2=#40a02b +color3=#df8e1d +color4=#1e66f5 +color5=#ea76cb +color6=#179299 +color7=#acb0be + +color8=#acb0be +color9=#d20f39 +color10=#40a02b +color11=#df8e1d +color12=#1e66f5 +color13=#ea76cb +color14=#179299 +color15=#bcc0cc + +color16=#fe640b +color17=#dc8a78 diff --git a/app/src/main/assets/termux-colors/catppuccin-macchiato.properties b/app/src/main/assets/termux-colors/catppuccin-macchiato.properties new file mode 100644 index 000000000..dd6bfb08b --- /dev/null +++ b/app/src/main/assets/termux-colors/catppuccin-macchiato.properties @@ -0,0 +1,25 @@ +# https://github.com/catppuccin/catppuccin +foreground=#cad3f5 +background=#24273a +cursor=#f4dbd6 + +color0=#494d64 +color1=#ed8796 +color2=#a6da95 +color3=#eed49f +color4=#8aadf4 +color5=#f5bde6 +color6=#8bd5ca +color7=#b8c0e0 + +color8=#5b6078 +color9=#ed8796 +color10=#a6da95 +color11=#eed49f +color12=#8aadf4 +color13=#f5bde6 +color14=#8bd5ca +color15=#a5adcb + +color16=#f5a97f +color17=#f4dbd6 diff --git a/app/src/main/assets/termux-colors/catppuccin-mocha.properties b/app/src/main/assets/termux-colors/catppuccin-mocha.properties new file mode 100644 index 000000000..acc9f3dbe --- /dev/null +++ b/app/src/main/assets/termux-colors/catppuccin-mocha.properties @@ -0,0 +1,25 @@ +# https://github.com/catppuccin/catppuccin +foreground=#cdd6f4 +background=#1e1e2e +cursor=#f5e0dc + +color0=#45475a +color1=#f38ba8 +color2=#a6e3a1 +color3=#f9e2af +color4=#89b4fa +color5=#f5c2e7 +color6=#94e2d5 +color7=#bac2de + +color8=#585b70 +color9=#f38ba8 +color10=#a6e3a1 +color11=#f9e2af +color12=#89b4fa +color13=#f5c2e7 +color14=#94e2d5 +color15=#a6adc8 + +color16=#fab387 +color17=#f5e0dc diff --git a/app/src/main/assets/termux-colors/dracula.properties b/app/src/main/assets/termux-colors/dracula.properties new file mode 100644 index 000000000..f35e85782 --- /dev/null +++ b/app/src/main/assets/termux-colors/dracula.properties @@ -0,0 +1,31 @@ +# https://draculatheme.com/ +# https://github.com/dracula/xresources/blob/master/Xresources +# special +foreground=#f8f8f2 +cursor=#f8f8f2 +background=#282a36 +# black +color0=#000000 +color8=#4d4d4d +# red +color1=#ff5555 +color9=#ff6e67 +# green +color2=#50fa7b +color10=#5af78e +# yellow +color3=#f1fa8c +color11=#f4f99d +# blue +color4=#bd93f9 +color12=#caa9fa +# magenta +color5=#ff79c6 +color13=#ff92d0 +# cyan +color6=#8be9fd +color14=#9aedfe +# white +color7=#bfbfbf +color15=#e6e6e6 + diff --git a/app/src/main/assets/termux-colors/e-ink-color.properties b/app/src/main/assets/termux-colors/e-ink-color.properties new file mode 100644 index 000000000..345d1b168 --- /dev/null +++ b/app/src/main/assets/termux-colors/e-ink-color.properties @@ -0,0 +1,21 @@ +background=#ffffff +foreground=#000000 +cursor=#c0c0c0 + +color0=#000000 +color1=#800000 +color2=#008000 +color3=#808000 +color4=#000080 +color5=#800080 +color6=#008080 +color7=#c0c0c0 + +color8=#808080 +color9=#ff0000 +color10=#00ff00 +color11=#ffff00 +color12=#0000ff +color13=#ff00ff +color14=#00ffff +color15=#ffffff diff --git a/app/src/main/assets/termux-colors/e-ink.properties b/app/src/main/assets/termux-colors/e-ink.properties new file mode 100644 index 000000000..d51c326d6 --- /dev/null +++ b/app/src/main/assets/termux-colors/e-ink.properties @@ -0,0 +1,20 @@ +background: #FFFFFF +foreground: #000000 +cursor=#c0c0c0 + +color0=#101010 +color1=#7c7c7c +color2=#8e8e8e +color3=#a0a0a0 +color4=#686868 +color5=#747474 +color6=#868686 +color7=#b9b9b9 +color8=#525252 +color9=#7c7c7c +color10=#8e8e8e +color11=#a0a0a0 +color12=#686868 +color13=#747474 +color14=#868686 +color15=#f7f7f7 diff --git a/app/src/main/assets/termux-colors/gnometerm-new.properties b/app/src/main/assets/termux-colors/gnometerm-new.properties new file mode 100644 index 000000000..350d0b38d --- /dev/null +++ b/app/src/main/assets/termux-colors/gnometerm-new.properties @@ -0,0 +1,19 @@ +# Gnome terminal 42 coloscheme +# https://github.com/termux/termux-styling/issues/164 + +color0: #171421 +color1: #c01c28 +color2: #26a269 +color3: #a2734c +color4: #12488b +color5: #a347ba +color6: #2aa1b3 +color7: #d0cfcc +color8: #5e5c64 +color9: #f66151 +color10: #33d17a +color11: #e9ad0c +color12: #2a7bde +color13: #c061cb +color14: #33c7de +color15: #ffffff diff --git a/app/src/main/assets/termux-colors/gnometerm.properties b/app/src/main/assets/termux-colors/gnometerm.properties new file mode 100644 index 000000000..21ad2814b --- /dev/null +++ b/app/src/main/assets/termux-colors/gnometerm.properties @@ -0,0 +1,33 @@ +# http://www.xcolors.net/dl/gnometerm + +# Black +color0: #000000 +color8: #555753 + +# Red +color1: #cc0000 +color9: #ef2929 + +# Green +color2: #4e9a06 +color10: #8ae234 + +# Yellow +color3: #c4a000 +color11: #fce94f + +# Blue +color4: #3465a4 +color12: #729fcf + +# Magenta +color5: #75507b +color13: #ad7fa8 + +# Cyan +color6: #06989a +color14: #34e2e2 + +# White +color7: #d3d7cf +color15: #eeeeec diff --git a/app/src/main/assets/termux-colors/gotham.properties b/app/src/main/assets/termux-colors/gotham.properties new file mode 100644 index 000000000..9fd0b4573 --- /dev/null +++ b/app/src/main/assets/termux-colors/gotham.properties @@ -0,0 +1,28 @@ +# https://github.com/whatyouhide/gotham-contrib/blob/master/xresources/gotham +foreground: #98d1ce +background: #0a0f14 +cursor: #98d1ce + +color0: #0a0f14 +color8: #10151b + +color1: #c33027 +color9: #d26939 + +color2: #26a98b +color10: #081f2d + +color3: #edb54b +color11: #245361 + +color4: #195465 +color12: #093748 + +color5: #4e5165 +color13: #888ba5 + +color6: #33859d +color14: #599caa + +color7: #98d1ce +color15: #d3ebe9 diff --git a/app/src/main/assets/termux-colors/gruvbox-dark.properties b/app/src/main/assets/termux-colors/gruvbox-dark.properties new file mode 100644 index 000000000..8b5bd4358 --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-dark.properties @@ -0,0 +1,37 @@ +! ----------------------------------------------------------------------------- +! File: gruvbox-dark.xresources +! Description: Retro groove colorscheme generalized +! Author: morhetz +! Source: https://github.com/morhetz/gruvbox-generalized +! Last Modified: 6 Sep 2014 +! ----------------------------------------------------------------------------- + +! hard contrast: background: #1d2021 +background: #1d2021 +! background: #282828 +! soft contrast: background: #32302f +foreground: #ebdbb2 +! Black + DarkGrey +color0: #282828 +color8: #928374 +! DarkRed + Red +color1: #cc241d +color9: #fb4934 +! DarkGreen + Green +color2: #98971a +color10: #b8bb26 +! DarkYellow + Yellow +color3: #d79921 +color11: #fabd2f +! DarkBlue + Blue +color4: #458588 +color12: #83a598 +! DarkMagenta + Magenta +color5: #b16286 +color13: #d3869b +! DarkCyan + Cyan +color6: #689d6a +color14: #8ec07c +! LightGrey + White +color7: #a89984 +color15: #ebdbb2 \ No newline at end of file diff --git a/app/src/main/assets/termux-colors/gruvbox-light.properties b/app/src/main/assets/termux-colors/gruvbox-light.properties new file mode 100644 index 000000000..7283412ac --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-light.properties @@ -0,0 +1,37 @@ +! ----------------------------------------------------------------------------- +! File: gruvbox-light.xresources +! Description: Retro groove colorscheme generalized +! Author: morhetz +! Source: https://github.com/morhetz/gruvbox-generalized +! Last Modified: 6 Sep 2014 +! ----------------------------------------------------------------------------- + +! hard contrast: background: #f9f5d7 +background: #f9f5d7 +! background: #fbf1c7 +! soft contrast: background: #f2e5bc +foreground: #3c3836 +! Black + DarkGrey +color0: #fdf4c1 +color8: #928374 +! DarkRed + Red +color1: #cc241d +color9: #9d0006 +! DarkGreen + Green +color2: #98971a +color10: #79740e +! DarkYellow + Yellow +color3: #d79921 +color11: #b57614 +! DarkBlue + Blue +color4: #458588 +color12: #076678 +! DarkMagenta + Magenta +color5: #b16286 +color13: #8f3f71 +! DarkCyan + Cyan +color6: #689d6a +color14: #427b58 +! LightGrey + White +color7: #7c6f64 +color15: #3c3836 \ No newline at end of file diff --git a/app/src/main/assets/termux-colors/gruvbox-material-dark-hard.properties b/app/src/main/assets/termux-colors/gruvbox-material-dark-hard.properties new file mode 100644 index 000000000..bef1b8a1e --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-material-dark-hard.properties @@ -0,0 +1,26 @@ +background: #1D2021 +foreground: #D4BE98 + +color0: #665C54 +color8: #928374 + +color1: #EA6962 +color9: #EA6962 + +color2: #A9B665 +color10: #A9B665 + +color3: #D8A657 +color11: #D8A657 + +color4: #7DAEA3 +color12: #7DAEA3 + +color5: #D3869B +color13: #D3869B + +color6: #89B482 +color14: #89B482 + +color7: #D4BE98 +color15: #D4BE98 diff --git a/app/src/main/assets/termux-colors/gruvbox-material-dark-medium.properties b/app/src/main/assets/termux-colors/gruvbox-material-dark-medium.properties new file mode 100644 index 000000000..77e442116 --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-material-dark-medium.properties @@ -0,0 +1,26 @@ +background: #282828 +foreground: #D4BE98 + +color0: #665C54 +color8: #928374 + +color1: #EA6962 +color9: #EA6962 + +color2: #A9B665 +color10: #A9B665 + +color3: #D8A657 +color11: #D8A657 + +color4: #7DAEA3 +color12: #7DAEA3 + +color5: #D3869B +color13: #D3869B + +color6: #89B482 +color14: #89B482 + +color7: #D4BE98 +color15: #D4BE98 diff --git a/app/src/main/assets/termux-colors/gruvbox-material-dark-soft.properties b/app/src/main/assets/termux-colors/gruvbox-material-dark-soft.properties new file mode 100644 index 000000000..bc46d665e --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-material-dark-soft.properties @@ -0,0 +1,26 @@ +background: #32302F +foreground: #D4BE98 + +color0: #665C54 +color8: #928374 + +color1: #EA6962 +color9: #EA6962 + +color2: #A9B665 +color10: #A9B665 + +color3: #D8A657 +color11: #D8A657 + +color4: #7DAEA3 +color12: #7DAEA3 + +color5: #D3869B +color13: #D3869B + +color6: #89B482 +color14: #89B482 + +color7: #D4BE98 +color15: #D4BE98 diff --git a/app/src/main/assets/termux-colors/gruvbox-material-light-hard.properties b/app/src/main/assets/termux-colors/gruvbox-material-light-hard.properties new file mode 100644 index 000000000..2163cf4ca --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-material-light-hard.properties @@ -0,0 +1,26 @@ +background: #F9F5D7 +foreground: #654735 + +color0: #504945 +color8: #504945 + +color1: #C14A4A +color9: #C14A4A + +color2: #6C782E +color10: #6C782E + +color3: #B47109 +color11: #B47109 + +color4: #45707A +color12: #45707A + +color5: #945E80 +color13: #945E80 + +color6: #4C7A5D +color14: #4C7A5D + +color7: #D4BE98 +color15: #D4BE98 diff --git a/app/src/main/assets/termux-colors/gruvbox-material-light-medium.properties b/app/src/main/assets/termux-colors/gruvbox-material-light-medium.properties new file mode 100644 index 000000000..90566e36e --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-material-light-medium.properties @@ -0,0 +1,26 @@ +background: #FBF1C7 +foreground: #654735 + +color0: #504945 +color8: #504945 + +color1: #C14A4A +color9: #C14A4A + +color2: #6C782E +color10: #6C782E + +color3: #B47109 +color11: #B47109 + +color4: #45707A +color12: #45707A + +color5: #945E80 +color13: #945E80 + +color6: #4C7A5D +color14: #4C7A5D + +color7: #D4BE98 +color15: #D4BE98 diff --git a/app/src/main/assets/termux-colors/gruvbox-material-light-soft.properties b/app/src/main/assets/termux-colors/gruvbox-material-light-soft.properties new file mode 100644 index 000000000..1450ae6db --- /dev/null +++ b/app/src/main/assets/termux-colors/gruvbox-material-light-soft.properties @@ -0,0 +1,26 @@ +background: #F2E5BC +foreground: #654735 + +color0: #504945 +color8: #504945 + +color1: #C14A4A +color9: #C14A4A + +color2: #6C782E +color10: #6C782E + +color3: #B47109 +color11: #B47109 + +color4: #45707A +color12: #45707A + +color5: #945E80 +color13: #945E80 + +color6: #4C7A5D +color14: #4C7A5D + +color7: #D4BE98 +color15: #D4BE98 diff --git a/app/src/main/assets/termux-colors/iceberg.properties b/app/src/main/assets/termux-colors/iceberg.properties new file mode 100644 index 000000000..160ae9a5e --- /dev/null +++ b/app/src/main/assets/termux-colors/iceberg.properties @@ -0,0 +1,40 @@ +# Iceberg colorscheme (https://github.com/cocopon/iceberg.vim) +# Based on the Xresources file for the colorscheme at: +# https://gist.github.com/cocopon/1d481941907d12db7a0df2f8806cfd41 + +# special +foreground=#c6c8d1 +background=#161821 +cursor=#c6c8d1 + +# black +color0=#161821 +color8=#6b7089 + +# red +color1=#e27878 +color9=#e98989 + +# green +color2=#b4be82 +color10=#c0ca8e + +# yellow +color3=#e2a478 +color11=#e9b189 + +# blue +color4=#84a0c6 +color12=#91acd1 + +# magenta +color5=#a093c7 +color13=#ada0d3 + +# cyan +color6=#89b8c2 +color14=#95c4ce + +# white +color7=#c6c8d1 +color15=#d2d4de diff --git a/app/src/main/assets/termux-colors/material.properties b/app/src/main/assets/termux-colors/material.properties new file mode 100644 index 000000000..c34a4424b --- /dev/null +++ b/app/src/main/assets/termux-colors/material.properties @@ -0,0 +1,19 @@ +background : #263238 +foreground : #eceff1 + +color0 : #263238 +color8 : #37474f +color1 : #ff9800 +color9 : #ffa74d +color2 : #8bc34a +color10 : #9ccc65 +color3 : #ffc107 +color11 : #ffa000 +color4 : #03a9f4 +color12 : #81d4fa +color5 : #e91e63 +color13 : #ad1457 +color6 : #009688 +color14 : #26a69a +color7 : #cfd8dc +color15 : #eceff1 diff --git a/app/src/main/assets/termux-colors/nancy.properties b/app/src/main/assets/termux-colors/nancy.properties new file mode 100644 index 000000000..3f53ede36 --- /dev/null +++ b/app/src/main/assets/termux-colors/nancy.properties @@ -0,0 +1,263 @@ +# http://www.xcolors.net/dl/nancy + +foreground: #fff +background: #010101 +cursor: #e5e5e5 + +color0: #1b1d1e +color1: #f92672 +color2: #82b414 +color3: #fd971f +color4: #4e82aa +color5: #8c54fe +color6: #465457 +color7: #ccccc6 +color8: #505354 +color9: #ff5995 +color10: #b6e354 +color11: #feed6c +color12: #0c73c2 +color13: #9e6ffe +color14: #899ca1 +color15: #f8f8f2 + +color16: #000000 +color17: #00005f +color18: #000087 +color19: #0000af +color20: #0000d7 +color21: #0000ff +color22: #005f00 +color23: #005f5f +color24: #005f87 +color25: #005faf +color26: #005fd7 +color27: #005fff +color28: #008700 +color29: #00875f +color30: #008787 +color31: #0087af +color32: #0087d7 +color33: #0087ff +color34: #00af00 +color35: #00af5f +color36: #00af87 +color37: #00afaf +color38: #00afd7 +color39: #00afff +color40: #00d700 +color41: #00d75f +color42: #00d787 +color43: #00d7af +color44: #00d7d7 +color45: #00d7ff +color46: #00ff00 +color47: #00ff5f +color48: #00ff87 +color49: #00ffaf +color50: #00ffd7 +color51: #00ffff +color52: #131324 +color53: #5f005f +color54: #5f0087 +color55: #5f00af +color56: #5f00d7 +color57: #5f00ff +color58: #5f5f00 +color59: #5f5f5f +color60: #5f5f87 +color61: #5f5faf +color62: #5f5fd7 +color63: #5f5fff +color64: #5f8700 +color65: #5f875f +color66: #5f8787 +color67: #5f87af +color68: #5f87d7 +color69: #5f87ff +color70: #5faf00 +color71: #5faf5f +color72: #5faf87 +color73: #5fafaf +color74: #5fafd7 +color75: #5fafff +color76: #5fd700 +color77: #5fd75f +color78: #5fd787 +color79: #5fd7af +color80: #5fd7d7 +color81: #5fd7ff +color82: #5fff00 +color83: #5fff5f +color84: #5fff87 +color85: #a03040 +color86: #565941 +color87: #594459 +color88: #009bff +color89: #87005f +color90: #870087 +color91: #8700af +color92: #8700d7 +color93: #8700ff +color94: #875f00 +color95: #875f5f +color96: #875f87 +color97: #875faf +color98: #875fd7 +color99: #875fff +color100: #878700 +color101: #87875f +color102: #878787 +color103: #8787af +color104: #8787d7 +color105: #8787ff +color106: #87af00 +color107: #87af5f +color108: #87af87 +color109: #87afaf +color110: #87afd7 +color111: #87afff +color112: #87d700 +color113: #87d75f +color114: #87d787 +color115: #87d7af +color116: #87d7d7 +color117: #87d7ff +color118: #87ff00 +color119: #87ff5f +color120: #87ff87 +color121: #87ffaf +color122: #87ffd7 +color123: #87ffff +color124: #af0000 +color125: #af005f +color126: #af0087 +color127: #af00af +color128: #af00d7 +color129: #af00ff +color130: #af5f00 +color131: #af5f5f +color132: #af5f87 +color133: #af5faf +color134: #af5fd7 +color135: #af5fff +color136: #af8700 +color137: #af875f +color138: #af8787 +color139: #af87af +color140: #af87d7 +color141: #af87ff +color142: #afaf00 +color143: #afaf5f +color144: #afaf87 +color145: #afafaf +color146: #afafd7 +color147: #afafff +color148: #afd700 +color149: #afd75f +color150: #afd787 +color151: #afd7af +color152: #afd7d7 +color153: #afd7ff +color154: #afff00 +color155: #afff5f +color156: #afff87 +color157: #afffaf +color158: #afffd7 +color159: #afffff +color160: #d70000 +color161: #d7005f +color162: #d70087 +color163: #d700af +color164: #d700d7 +color165: #d700ff +color166: #d75f00 +color167: #d75f5f +color168: #d75f87 +color169: #d75faf +color170: #d75fd7 +color171: #d75fff +color172: #d78700 +color173: #d7875f +color174: #d78787 +color175: #d787af +color176: #d787d7 +color177: #d787ff +color178: #d7af00 +color179: #d7af5f +color180: #d7af87 +color181: #d7afaf +color182: #d7afd7 +color183: #d7afff +color184: #d7d700 +color185: #ffff00 +color186: #d7d787 +color187: #d7d7af +color188: #d7d7d7 +color189: #d7d7ff +color190: #d7ff00 +color191: #d7ff5f +color192: #d7ff87 +color193: #d7ffaf +color194: #d7ffd7 +color195: #d7ffff +color196: #ff0000 +color197: #ff005f +color198: #ff0087 +color199: #ff00af +color200: #ff00d7 +color201: #ff00ff +color202: #ff5f00 +color203: #ff5f5f +color204: #ff5f87 +color205: #ff5faf +color206: #ff5fd7 +color207: #ff5fff +color208: #ff8700 +color209: #ff875f +color210: #ff8787 +color211: #ff87af +color212: #ff87d7 +color213: #ff87ff +color214: #ffaf00 +color215: #ffaf5f +color216: #ffaf87 +color217: #ffafaf +color218: #ffafd7 +color219: #ffafff +color220: #ffd700 +color221: #ffd75f +color222: #ffd787 +color223: #ffd7af +color224: #ffd7d7 +color225: #ffd7ff +color226: #ffff00 +color227: #ffff5f +color228: #ffff87 +color229: #ffffaf +color230: #ffffd7 +color231: #060000 +color232: #080808 +color233: #121212 +color234: #1c1c1c +color235: #262626 +color236: #303030 +color237: #3a3a3a +color238: #444444 +color239: #4e4e4e +color240: #585858 +color241: #626262 +color242: #6c6c6c +color243: #767676 +color244: #808080 +color245: #8a8a8a +color246: #949494 +color247: #9e9e9e +color248: #a8a8a8 +color249: #b2b2b2 +color250: #bcbcbc +color251: #c6c6c6 +color252: #d0d0d0 +color253: #dadada +color254: #e4e4e4 +color255: #eeeeee diff --git a/app/src/main/assets/termux-colors/neon.properties b/app/src/main/assets/termux-colors/neon.properties new file mode 100644 index 000000000..3e1633f18 --- /dev/null +++ b/app/src/main/assets/termux-colors/neon.properties @@ -0,0 +1,27 @@ +# http=//xcolors.net/dl/neon +background=#171717 +foreground=#F8F8F8 +# black +color0=#171717 +color8=#38252C +# red +color1=#D81765 +color9=#FF0000 +# green +color2=#97D01A +color10=#76B639 +# yellow +color3=#FFA800 +color11=#E1A126 +# blue +color4=#16B1FB +color12=#289CD5 +# magenta +color5=#FF2491 +color13=#FF2491 +# cyan +color6=#0FDCB6 +color14=#0A9B81 +# white +color7=#EBEBEB +color15=#F8F8F8 diff --git a/app/src/main/assets/termux-colors/nord.properties b/app/src/main/assets/termux-colors/nord.properties new file mode 100644 index 000000000..7a71bf267 --- /dev/null +++ b/app/src/main/assets/termux-colors/nord.properties @@ -0,0 +1,22 @@ +# https://git.io/nord +foreground=#d8dee9 +background=#2e3440 +cursor=#d8dee9 + +color0=#3b4252 +color1=#bf616a +color2=#a3be8c +color3=#ebcb8b +color4=#81a1c1 +color5=#b48ead +color6=#88c0d0 +color7=#e5e8f0 + +color8=#4c566a +color9=#bf616a +color10=#a3be8c +color11=#ebcb8b +color12=#81a1c1 +color13=#b48ead +color14=#8fbcbb +color15=#eceff4 diff --git "a/app/src/main/assets/termux-colors/ros\303\251-pine-dawn.properties" "b/app/src/main/assets/termux-colors/ros\303\251-pine-dawn.properties" new file mode 100644 index 000000000..f657bd59c --- /dev/null +++ "b/app/src/main/assets/termux-colors/ros\303\251-pine-dawn.properties" @@ -0,0 +1,31 @@ +# Scheme: Rosé Pine Dawn (http://rosepinetheme.com) +# Author: ThatOneCalculator (https://github.com/thatonecalculator) + +background=#faf4ed +foreground=#575279 +cursor=#797593 + +# black +color0=#f2e9e1 +color8=#797593 +# red +color1=#b4637a +color9=#b4637a +# green +color2=#286983 +color10=#286983 +# yellow +color3=#ea9d34 +color11=#ea9d34 +# blue +color4=#56949f +color12=#56949f +# magenta +color5=#907aa9 +color13=#907aa9 +# cyan +color6=#d7827e +color14=#d7827e +# white +color7=#575279 +color15=#575279 diff --git "a/app/src/main/assets/termux-colors/ros\303\251-pine-moon.properties" "b/app/src/main/assets/termux-colors/ros\303\251-pine-moon.properties" new file mode 100644 index 000000000..5f4903686 --- /dev/null +++ "b/app/src/main/assets/termux-colors/ros\303\251-pine-moon.properties" @@ -0,0 +1,31 @@ +# Scheme: Rosé Pine Moon (http://rosepinetheme.com) +# Author: ThatOneCalculator (https://github.com/thatonecalculator) + +background=#232136 +foreground=#e0def4 +cursor=#6e6a86 + +# black +color0=#393552 +color8=#6e6a86 +# red +color1=#eb6f92 +color9=#eb6f92 +# green +color2=#3e8fb0 +color10=#3e8fb0 +# yellow +color3=#f6c177 +color11=#f6c177 +# blue +color4=#9ccfd8 +color12=#9ccfd8 +# magenta +color5=#c4a7e7 +color13=#c4a7e7 +# cyan +color6=#ea9a97 +color14=#ea9a97 +# white +color7=#e0def4 +color15=#e0def4 diff --git "a/app/src/main/assets/termux-colors/ros\303\251-pine.properties" "b/app/src/main/assets/termux-colors/ros\303\251-pine.properties" new file mode 100644 index 000000000..15e74c54a --- /dev/null +++ "b/app/src/main/assets/termux-colors/ros\303\251-pine.properties" @@ -0,0 +1,31 @@ +# Scheme: Rosé Pine (http://rosepinetheme.com) +# Author: ThatOneCalculator (https://github.com/thatonecalculator) + +background=#191724 +foreground=#e0def4 +cursor=#6e6a86 + +# black +color0=#26233a +color8=#6e6a86 +# red +color1=#eb6f92 +color9=#eb6f92 +# green +color2=#31748f +color10=#31748f +# yellow +color3=#f6c177 +color11=#f6c177 +# blue +color4=#9ccfd8 +color12=#9ccfd8 +# magenta +color5=#c4a7e7 +color13=#c4a7e7 +# cyan +color6=#ebbcba +color14=#ebbcba +# white +color7=#e0def4 +color15=#e0def4 diff --git a/app/src/main/assets/termux-colors/rydgel.properties b/app/src/main/assets/termux-colors/rydgel.properties new file mode 100644 index 000000000..9f7d40e04 --- /dev/null +++ b/app/src/main/assets/termux-colors/rydgel.properties @@ -0,0 +1,33 @@ +# Black +color0: #303430 +color8: #cdb5cd + +# Red +color1: #bf7979 +color9: #f4a45f + +# Green +color2: #97b26b +color10: #c5f779 + +# Yellow +color3: #cdcdc1 +color11: #ffffed + +# Blue +color4: #86a2be +color12: #98afd9 + +# Magenta +color5: #d9b798 +color13: #d7d998 + +# Cyan +color6: #a1b5cd +color14: #a1b5cd + +# White +color7: #ffffff +color15: #dedede + +# vim: et sw=2 syn=xdefaults diff --git a/app/src/main/assets/termux-colors/smyck.properties b/app/src/main/assets/termux-colors/smyck.properties new file mode 100644 index 000000000..4bd70316c --- /dev/null +++ b/app/src/main/assets/termux-colors/smyck.properties @@ -0,0 +1,21 @@ +#https://github.com/hukl/Smyck-Color-Scheme/blob/master/colors +background=#212121 +foreground=#f7f7f7 +cursor=#218693 + +color0=#000000 +color1=#c75646 +color2=#8eb33b +color3=#d0b03c +color4=#4e90a7 +color5=#c8a0d1 +color6=#218693 +color7=#b0b0b0 +color9=#e09690 +color8=#5d5d5d +color10=#cdee69 +color11=#ffe377 +color12=#9cd9f0 +color13=#fbb1f9 +color14=#77dfd8 +color15=#f7f7f7 diff --git a/app/src/main/assets/termux-colors/solarized-dark.properties b/app/src/main/assets/termux-colors/solarized-dark.properties new file mode 100644 index 000000000..2173fc632 --- /dev/null +++ b/app/src/main/assets/termux-colors/solarized-dark.properties @@ -0,0 +1,22 @@ +# https://github.com/altercation/solarized/blob/master/xresources/solarized +background=#002b36 +foreground=#839496 +cursor=#93a1a1 + +color0=#073642 +color1=#dc322f +color2=#859900 +color3=#b58900 +color4=#268bd2 +color5=#d33682 +color6=#2aa198 +color7=#eee8d5 +color9=#cb4b16 +color8=#002b36 +color10=#586e75 +color11=#657b83 +color12=#839496 +color13=#6c71c4 +color14=#93a1a1 +color15=#fdf6e3 + diff --git a/app/src/main/assets/termux-colors/solarized-light.properties b/app/src/main/assets/termux-colors/solarized-light.properties new file mode 100644 index 000000000..e966e6870 --- /dev/null +++ b/app/src/main/assets/termux-colors/solarized-light.properties @@ -0,0 +1,21 @@ +# https://github.com/altercation/solarized/blob/master/xresources/solarized +background=#fdf6e3 +foreground=#657b83 +cursor=#586e75 + +color0=#073642 +color1=#dc322f +color2=#859900 +color3=#b58900 +color4=#268bd2 +color5=#d33682 +color6=#2aa198 +color7=#eee8d5 +color8=#002b36 +color9=#cb4b16 +color10=#586e75 +color11=#657b83 +color12=#839496 +color13=#6c71c4 +color14=#93a1a1 +color15=#fdf6e3 diff --git a/app/src/main/assets/termux-colors/spacemacs.properties b/app/src/main/assets/termux-colors/spacemacs.properties new file mode 100644 index 000000000..6b2935132 --- /dev/null +++ b/app/src/main/assets/termux-colors/spacemacs.properties @@ -0,0 +1,25 @@ +foreground=#adb0a2 +background=#292b2e +cursor=#eead0e +color0=#1C2023 +color1=#C7AE95 +color2=#cc5279 +color3=#6690da +color4=#000000 +color5=#C795AE +color6=#4f97d7 +color7=#C7CCD1 +color8=#747C84 +color9=#C7AE95 +color10=#95C7AE +color11=#AEC795 +color12=#bb6dc4 +color13=#C795AE +color14=#95AEC7 +color15=#F3F4F5 +color16=#C7C795 +color17=#C79595 +color18=#393F45 +color19=#565E65 +color20=#ADB3BA +color21=#DFE2E5 diff --git a/app/src/main/assets/termux-colors/tokyonight-dark.properties b/app/src/main/assets/termux-colors/tokyonight-dark.properties new file mode 100644 index 000000000..ceaf782da --- /dev/null +++ b/app/src/main/assets/termux-colors/tokyonight-dark.properties @@ -0,0 +1,33 @@ +## Name: Tokyo Night Dark + +# Special +foreground = #c0caf5 +cursor = #c0caf5 +background = #1a1b26 +# Black +color0 = #15161E +color8 = #414868 +# Red +color1 = #f7768e +color9 = #f7768e +color17 = #db4b4b +# Green +color2 = #9ece6a +color10 = #9ece6a +# Yellow +color3 = #e0af68 +color11 = #e0af68 +# Blue +color4 = #7aa2f7 +color12 = #7aa2f7 +# Purple +color5 = #bb9af7 +color13 = #bb9af7 +# Cyan +color14 = #7dcfff +color6 = #7dcfff +# White +color7 = #a9b1d6 +color15 = #c0caf5 +# Orange +color16 = #ff9e64 diff --git a/app/src/main/assets/termux-colors/tokyonight-day.properties b/app/src/main/assets/termux-colors/tokyonight-day.properties new file mode 100644 index 000000000..2b8dbaae6 --- /dev/null +++ b/app/src/main/assets/termux-colors/tokyonight-day.properties @@ -0,0 +1,32 @@ +## name: Tokyo Night Day + +# Special + background = #e1e2e7 + foreground = #3760bf + cursor = #3760bf +# White +color0 = #e9e9ed +color8 = #a1a6c5 +# Red +color1 = #f52a65 +color9 = #f52a65 +color17 = #c64343 +# Green +color2 = #587539 +color10 = #587539 +# Yellow +color3 = #8c6c3e +color11 = #8c6c3e +# Blue +color4 = #2e7de9 +color12 = #2e7de9 +color7 = #6172b0 +color15 = #3760bf +# Purple +color5 = #9854f1 +color13 = #9854f1 +# Cyan +color6 = #007197 +color14 = #007197 +# Orange +color16 = #b15c00 diff --git a/app/src/main/assets/termux-colors/tomorrow-night.properties b/app/src/main/assets/termux-colors/tomorrow-night.properties new file mode 100644 index 000000000..da7c4479f --- /dev/null +++ b/app/src/main/assets/termux-colors/tomorrow-night.properties @@ -0,0 +1,21 @@ +# http://chriskempson.github.io/base16/#tomorrow +background=#1d1f21 +foreground=#c5c8c6 +cursor=#c5c8c6 + +color0=#1d1f21 +color1=#cc6666 +color2=#b5bd68 +color3=#f0c674 +color4=#81a2be +color5=#b294bb +color6=#8abeb7 +color7=#c5c8c6 +color9=#969896 +color8=#cc6666 +color10=#b5bd68 +color11=#f0c674 +color12=#81a2be +color13=#b294bb +color14=#8abeb7 +color15=#ffffff diff --git a/app/src/main/assets/termux-colors/ubuntu.properties b/app/src/main/assets/termux-colors/ubuntu.properties new file mode 100644 index 000000000..ba3cceb10 --- /dev/null +++ b/app/src/main/assets/termux-colors/ubuntu.properties @@ -0,0 +1,22 @@ +# https://github.com/Mayccoll/Gogh/blob/master/themes/clone-of-ubuntu.sh +background=#300a24 +foreground=#ffffff +cursor=#ffffff + +color0=#2E3436 +color1=#CC0000 +color2=#4E9A06 +color3=#C4A000 +color4=#3465A4 +color5=#75507B +color6=#06989A +color7=#D3D7CF + +color8=#555753 +color9=#EF2929 +color10=#8AE234 +color11=#FCE94F +color12=#729FCF +color13=#AD7FA8 +color14=#34E2E2 +color15=#EEEEEC diff --git a/app/src/main/assets/termux-colors/white-on-black.properties b/app/src/main/assets/termux-colors/white-on-black.properties new file mode 100644 index 000000000..a4a9aca00 --- /dev/null +++ b/app/src/main/assets/termux-colors/white-on-black.properties @@ -0,0 +1,258 @@ +background: #000000 +foreground: #FFFFFF + +color1:#FFFFFF +color2:#FFFFFF +color3:#FFFFFF +color4:#FFFFFF +color5:#FFFFFF +color6:#FFFFFF +color7:#FFFFFF +color8:#FFFFFF +color9:#FFFFFF +color10:#FFFFFF +color11:#FFFFFF +color12:#FFFFFF +color13:#FFFFFF +color14:#FFFFFF +color15:#FFFFFF +color16:#FFFFFF +color17:#FFFFFF +color18:#FFFFFF +color19:#FFFFFF +color20:#FFFFFF +color21:#FFFFFF +color22:#FFFFFF +color23:#FFFFFF +color24:#FFFFFF +color25:#FFFFFF +color26:#FFFFFF +color27:#FFFFFF +color28:#FFFFFF +color29:#FFFFFF +color30:#FFFFFF +color31:#FFFFFF +color32:#FFFFFF +color33:#FFFFFF +color34:#FFFFFF +color35:#FFFFFF +color36:#FFFFFF +color37:#FFFFFF +color38:#FFFFFF +color39:#FFFFFF +color40:#FFFFFF +color41:#FFFFFF +color42:#FFFFFF +color43:#FFFFFF +color44:#FFFFFF +color45:#FFFFFF +color46:#FFFFFF +color47:#FFFFFF +color48:#FFFFFF +color49:#FFFFFF +color50:#FFFFFF +color51:#FFFFFF +color52:#FFFFFF +color53:#FFFFFF +color54:#FFFFFF +color55:#FFFFFF +color56:#FFFFFF +color57:#FFFFFF +color58:#FFFFFF +color59:#FFFFFF +color60:#FFFFFF +color61:#FFFFFF +color62:#FFFFFF +color63:#FFFFFF +color64:#FFFFFF +color65:#FFFFFF +color66:#FFFFFF +color67:#FFFFFF +color68:#FFFFFF +color69:#FFFFFF +color70:#FFFFFF +color71:#FFFFFF +color72:#FFFFFF +color73:#FFFFFF +color74:#FFFFFF +color75:#FFFFFF +color76:#FFFFFF +color77:#FFFFFF +color78:#FFFFFF +color79:#FFFFFF +color80:#FFFFFF +color81:#FFFFFF +color82:#FFFFFF +color83:#FFFFFF +color84:#FFFFFF +color85:#FFFFFF +color86:#FFFFFF +color87:#FFFFFF +color88:#FFFFFF +color89:#FFFFFF +color90:#FFFFFF +color91:#FFFFFF +color92:#FFFFFF +color93:#FFFFFF +color94:#FFFFFF +color95:#FFFFFF +color96:#FFFFFF +color97:#FFFFFF +color98:#FFFFFF +color99:#FFFFFF +color100:#FFFFFF +color101:#FFFFFF +color102:#FFFFFF +color103:#FFFFFF +color104:#FFFFFF +color105:#FFFFFF +color106:#FFFFFF +color107:#FFFFFF +color108:#FFFFFF +color109:#FFFFFF +color110:#FFFFFF +color111:#FFFFFF +color112:#FFFFFF +color113:#FFFFFF +color114:#FFFFFF +color115:#FFFFFF +color116:#FFFFFF +color117:#FFFFFF +color118:#FFFFFF +color119:#FFFFFF +color120:#FFFFFF +color121:#FFFFFF +color122:#FFFFFF +color123:#FFFFFF +color124:#FFFFFF +color125:#FFFFFF +color126:#FFFFFF +color127:#FFFFFF +color128:#FFFFFF +color129:#FFFFFF +color130:#FFFFFF +color131:#FFFFFF +color132:#FFFFFF +color133:#FFFFFF +color134:#FFFFFF +color135:#FFFFFF +color136:#FFFFFF +color137:#FFFFFF +color138:#FFFFFF +color139:#FFFFFF +color140:#FFFFFF +color141:#FFFFFF +color142:#FFFFFF +color143:#FFFFFF +color144:#FFFFFF +color145:#FFFFFF +color146:#FFFFFF +color147:#FFFFFF +color148:#FFFFFF +color149:#FFFFFF +color150:#FFFFFF +color151:#FFFFFF +color152:#FFFFFF +color153:#FFFFFF +color154:#FFFFFF +color155:#FFFFFF +color156:#FFFFFF +color157:#FFFFFF +color158:#FFFFFF +color159:#FFFFFF +color160:#FFFFFF +color161:#FFFFFF +color162:#FFFFFF +color163:#FFFFFF +color164:#FFFFFF +color165:#FFFFFF +color166:#FFFFFF +color167:#FFFFFF +color168:#FFFFFF +color169:#FFFFFF +color170:#FFFFFF +color171:#FFFFFF +color172:#FFFFFF +color173:#FFFFFF +color174:#FFFFFF +color175:#FFFFFF +color176:#FFFFFF +color177:#FFFFFF +color178:#FFFFFF +color179:#FFFFFF +color180:#FFFFFF +color181:#FFFFFF +color182:#FFFFFF +color183:#FFFFFF +color184:#FFFFFF +color185:#FFFFFF +color186:#FFFFFF +color187:#FFFFFF +color188:#FFFFFF +color189:#FFFFFF +color190:#FFFFFF +color191:#FFFFFF +color192:#FFFFFF +color193:#FFFFFF +color194:#FFFFFF +color195:#FFFFFF +color196:#FFFFFF +color197:#FFFFFF +color198:#FFFFFF +color199:#FFFFFF +color200:#FFFFFF +color201:#FFFFFF +color202:#FFFFFF +color203:#FFFFFF +color204:#FFFFFF +color205:#FFFFFF +color206:#FFFFFF +color207:#FFFFFF +color208:#FFFFFF +color209:#FFFFFF +color210:#FFFFFF +color211:#FFFFFF +color212:#FFFFFF +color213:#FFFFFF +color214:#FFFFFF +color215:#FFFFFF +color216:#FFFFFF +color217:#FFFFFF +color218:#FFFFFF +color219:#FFFFFF +color220:#FFFFFF +color221:#FFFFFF +color222:#FFFFFF +color223:#FFFFFF +color224:#FFFFFF +color225:#FFFFFF +color226:#FFFFFF +color227:#FFFFFF +color228:#FFFFFF +color229:#FFFFFF +color230:#FFFFFF +color231:#FFFFFF +color232:#FFFFFF +color233:#FFFFFF +color234:#FFFFFF +color235:#FFFFFF +color236:#FFFFFF +color237:#FFFFFF +color238:#FFFFFF +color239:#FFFFFF +color240:#FFFFFF +color241:#FFFFFF +color242:#FFFFFF +color243:#FFFFFF +color244:#FFFFFF +color245:#FFFFFF +color246:#FFFFFF +color247:#FFFFFF +color248:#FFFFFF +color249:#FFFFFF +color250:#FFFFFF +color251:#FFFFFF +color252:#FFFFFF +color253:#FFFFFF +color254:#FFFFFF +color255:#FFFFFF diff --git a/app/src/main/assets/termux-colors/wild-cherry.properties b/app/src/main/assets/termux-colors/wild-cherry.properties new file mode 100644 index 000000000..b9b5a10a9 --- /dev/null +++ b/app/src/main/assets/termux-colors/wild-cherry.properties @@ -0,0 +1,21 @@ +# https://github.com/mashaal/wild-cherry +color0: #099BD7 +color10: #2AB250 +color11: #FFD16F +color12: #883CDB +color13: #099BD7 +color14: #4F5D95 +color15: #FFF8DE +color1: #D94085 +color2: #2AB250 +color3: #FFD16F +color4: #883CDC +color5: #0F9DBA +color6: #4F5D95 +color7: #FFF8DD +color8: #099BD7 +color9: #D94084 +background: #1F1626 +cursor: #0F9DBA +foreground: #FFFFFF + diff --git a/app/src/main/assets/termux-colors/zenburn.properties b/app/src/main/assets/termux-colors/zenburn.properties new file mode 100644 index 000000000..0761a1afd --- /dev/null +++ b/app/src/main/assets/termux-colors/zenburn.properties @@ -0,0 +1,21 @@ +# http://dotfiles.org/~jbromley/.Xresources +background=#000010 +foreground=#ffffff +cursor=#FF00FF + +color0=#000000 +color1=#9e1828 +color2=#aece92 +color3=#968a38 +color4=#414171 +color5=#963c59 +color6=#418179 +color7=#bebebe +color8=#666666 +color9=#cf6171 +color10=#c5f779 +color11=#fff796 +color12=#4186be +color13=#cf9ebe +color14=#71bebe +color15=#ffffff diff --git a/app/src/main/java/io/nekohasekai/sfa/Application.kt b/app/src/main/java/io/nekohasekai/sfa/Application.kt index 02b2467b9..b5f490a32 100644 --- a/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -9,13 +9,16 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.os.PowerManager +import android.util.Log import androidx.core.content.getSystemService -import go.Seq import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.SetupOptions import io.nekohasekai.sfa.bg.AppChangeReceiver +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.bg.UpdateProfileWork import io.nekohasekai.sfa.constant.Bugs +import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier import io.nekohasekai.sfa.utils.HookStatusClient @@ -39,13 +42,28 @@ class Application : Application() { AppLifecycleObserver.register(this) // Seq.setContext(this) - Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + runCatching { + Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + }.onFailure { + Log.d("Application", "set locale: ${it.message}") + } HookStatusClient.register(this) PrivilegeSettingsClient.register(this) + val baseDir = filesDir + baseDir.mkdirs() + val workingDir = getExternalFilesDir(null) + val tempDir = cacheDir + tempDir.mkdirs() + if (workingDir != null) { + workingDir.mkdirs() + CrashReportManager.install(workingDir, baseDir) + OOMReportManager.install(workingDir) + } + @Suppress("OPT_IN_USAGE") GlobalScope.launch(Dispatchers.IO) { - initialize() + initialize(baseDir, workingDir, tempDir) UpdateProfileWork.reconfigureUpdater() HookModuleUpdateNotifier.sync(this@Application) } @@ -62,24 +80,33 @@ class Application : Application() { } } - private fun initialize() { + private fun initialize(baseDir: File, workingDir: File?, tempDir: File) { + val actualWorkingDir = workingDir ?: return + setupLibbox(baseDir, actualWorkingDir, tempDir) + } + + fun reloadSetupOptions() { val baseDir = filesDir - baseDir.mkdirs() val workingDir = getExternalFilesDir(null) ?: return - workingDir.mkdirs() val tempDir = cacheDir - tempDir.mkdirs() - Libbox.setup( - SetupOptions().also { - it.basePath = baseDir.path - it.workingPath = workingDir.path - it.tempPath = tempDir.path - it.fixAndroidStack = Bugs.fixAndroidStack - it.logMaxLines = 3000 - it.debug = BuildConfig.DEBUG - }, - ) - Libbox.redirectStderr(File(workingDir, "stderr.log").path) + Libbox.reloadSetupOptions(createSetupOptions(baseDir, workingDir, tempDir)) + } + + private fun setupLibbox(baseDir: File, workingDir: File, tempDir: File) { + Libbox.setup(createSetupOptions(baseDir, workingDir, tempDir)) + } + + private fun createSetupOptions(baseDir: File, workingDir: File, tempDir: File): SetupOptions = SetupOptions().also { + it.basePath = baseDir.path + it.workingPath = workingDir.path + it.tempPath = tempDir.path + it.fixAndroidStack = Bugs.fixAndroidStack + it.logMaxLines = 3000 + it.debug = BuildConfig.DEBUG + it.crashReportSource = "Application" + it.oomKillerEnabled = Settings.oomKillerEnabled + it.oomKillerDisabled = Settings.oomKillerDisabled + it.oomMemoryLimit = Settings.oomMemoryLimitMB.toLong() * 1024L * 1024L } companion object { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt index 013406c99..6331123ee 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt @@ -21,6 +21,11 @@ class BootReceiver : BroadcastReceiver() { } GlobalScope.launch(Dispatchers.IO) { if (Settings.startedByUser) { + CrashReportManager.refresh() + if (CrashReportManager.unreadCount.value > 0) { + Settings.startedByUser = false + return@launch + } withContext(Dispatchers.Main) { BoxService.start() } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index a35486680..49fc8cef7 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -417,7 +417,16 @@ class BoxService(private val service: Service, private val platformInterface: Pl } } + override fun triggerNativeCrash() { + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + } + override fun writeDebugMessage(message: String?) { Log.d("sing-box", message!!) } + + override fun connectSSHAgent(): Int = -1 } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt new file mode 100644 index 000000000..cb2a27be1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/CrashReportManager.kt @@ -0,0 +1,251 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class CrashReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class CrashReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + GO_LOG, + JVM_LOG, + CONFIG, + } +} + +object CrashReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val GO_LOG_FILE_NAME = "go.log" + private const val JVM_LOG_FILE_NAME = "jvm.log" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val READ_MARKER_FILE_NAME = ".read" + private const val CRASH_REPORTS_DIR_NAME = "crash_reports" + private const val PENDING_JVM_CRASH_FILE_NAME = "CrashReport-JVM.log" + private const val PENDING_JVM_METADATA_FILE_NAME = "CrashReport-JVM-metadata.json" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + private lateinit var baseDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File, baseDir: File) { + this.workingDir = workingDir + this.baseDir = baseDir + archivePendingJvmCrashReport() + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + writePendingJvmCrashReport(thread, throwable) + previous?.uncaughtException(thread, throwable) + } + } + + private fun writePendingJvmCrashReport(thread: Thread, throwable: Throwable) { + try { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + File(workingDir, PENDING_JVM_CRASH_FILE_NAME).writeText(writer.toString()) + val metadata = JSONObject().apply { + put("source", "Application") + put("crashedAt", formatTimestampISO8601(Date())) + put("exceptionName", throwable.javaClass.name) + put("exceptionReason", throwable.message ?: "") + put("processName", Application.application.packageName) + put("appVersion", BuildConfig.VERSION_CODE.toString()) + put("appMarketingVersion", BuildConfig.VERSION_NAME) + runCatching { + put("coreVersion", Libbox.version()) + put("goVersion", Libbox.goVersion()) + } + } + File(workingDir, PENDING_JVM_METADATA_FILE_NAME).writeText(metadata.toString()) + } catch (_: Throwable) { + } + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanCrashReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun archivePendingJvmCrashReport() { + val crashFile = File(workingDir, PENDING_JVM_CRASH_FILE_NAME) + val metadataFile = File(workingDir, PENDING_JVM_METADATA_FILE_NAME) + val configFile = File(baseDir, CONFIG_FILE_NAME) + if (!crashFile.exists()) return + val content = crashFile.readText().trim() + if (content.isEmpty()) { + crashFile.delete() + metadataFile.delete() + configFile.delete() + return + } + val crashDate = Date(crashFile.lastModified()) + val reportDir = nextAvailableReportDir(crashDate) + reportDir.mkdirs() + crashFile.copyTo(File(reportDir, JVM_LOG_FILE_NAME), overwrite = true) + crashFile.delete() + if (metadataFile.exists()) { + metadataFile.copyTo(File(reportDir, METADATA_FILE_NAME), overwrite = true) + metadataFile.delete() + } + if (configFile.exists()) { + val configContent = runCatching { configFile.readText() }.getOrNull()?.trim() + if (!configContent.isNullOrEmpty()) { + configFile.copyTo(File(reportDir, CONFIG_FILE_NAME), overwrite = true) + } + configFile.delete() + } + } + + private fun scanCrashReports(): List { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + if (!crashReportsDir.isDirectory) return emptyList() + val directories = crashReportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + CrashReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: CrashReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val goLogFile = File(report.directory, GO_LOG_FILE_NAME) + if (goLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.GO_LOG, "Go Crash Log", goLogFile)) + } + val jvmLogFile = File(report.directory, JVM_LOG_FILE_NAME) + if (jvmLogFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.JVM_LOG, "JVM Crash Log", jvmLogFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(CrashReportFile(CrashReportFile.Kind.CONFIG, "Configuration", configFile)) + } + return files + } + + fun loadFileContent(file: CrashReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == CrashReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: CrashReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: CrashReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, CRASH_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: CrashReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: CrashReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, CRASH_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun nextAvailableReportDir(date: Date): File { + val crashReportsDir = File(workingDir, CRASH_REPORTS_DIR_NAME) + val baseName = timestampFormat.format(date) + var index = 0 + while (true) { + val suffix = if (index == 0) "" else "-$index" + val dir = File(crashReportsDir, baseName + suffix) + if (!dir.exists()) return dir + index++ + } + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } + + private fun formatTimestampISO8601(date: Date): String { + val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + return format.format(date) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt index 87fc72981..50af602a2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt @@ -1,6 +1,7 @@ package io.nekohasekai.sfa.bg import android.net.Network +import android.net.NetworkCapabilities import android.os.Build import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.sfa.Application @@ -17,7 +18,13 @@ object DefaultNetworkMonitor { checkDefaultInterfaceUpdate(it) } defaultNetwork = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Application.connectivity.activeNetwork + // getActiveNetwork() returns the per-app default, which may be the VPN + // that applies to this app. If the tun is already up when start() runs, + // seeding defaultNetwork with our own VPN makes LocalResolver query DNS + // back through the tun (auto_route) and loop. The NetworkRequest carries + // NET_CAPABILITY_NOT_VPN, but that does not apply to this direct getter, + // so filter the VPN transport out explicitly. + Application.connectivity.activeNetwork?.takeUnless(::isVpn) } else { DefaultNetworkListener.get() } @@ -40,6 +47,10 @@ object DefaultNetworkMonitor { checkDefaultInterfaceUpdate(defaultNetwork) } + private fun isVpn(network: Network): Boolean = + Application.connectivity.getNetworkCapabilities(network) + ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true + private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { val listener = listener ?: return if (newNetwork != null) { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt b/app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt new file mode 100644 index 000000000..f8d7abf41 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/IntArrayIterator.kt @@ -0,0 +1,13 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Int32Iterator + +class IntArrayIterator(private val array: IntArray) : Int32Iterator { + private var index = 0 + + override fun len(): Int = array.size + + override fun hasNext(): Boolean = index < array.size + + override fun next(): Int = array[index++] +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java b/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java new file mode 100644 index 000000000..97c97ad23 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/NeighborEntry.java @@ -0,0 +1,49 @@ +package io.nekohasekai.sfa.bg; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; + +public class NeighborEntry implements Parcelable { + @NonNull public final String address; + @NonNull public final String macAddress; + @NonNull public final String hostname; + + public NeighborEntry( + @NonNull String address, @NonNull String macAddress, @NonNull String hostname) { + this.address = address; + this.macAddress = macAddress; + this.hostname = hostname; + } + + protected NeighborEntry(Parcel in) { + address = in.readString(); + macAddress = in.readString(); + hostname = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(address); + dest.writeString(macAddress); + dest.writeString(hostname); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = + new Creator<>() { + @Override + public NeighborEntry createFromParcel(Parcel in) { + return new NeighborEntry(in); + } + + @Override + public NeighborEntry[] newArray(int size) { + return new NeighborEntry[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt new file mode 100644 index 000000000..183b19e45 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/OOMReportManager.kt @@ -0,0 +1,165 @@ +package io.nekohasekai.sfa.bg + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +data class OOMReport( + val id: String, + val date: Date, + val directory: File, + val isRead: Boolean, +) + +data class OOMReportFile( + val kind: Kind, + val displayName: String, + val file: File, +) { + enum class Kind { + METADATA, + CONFIG, + PROFILE, + } +} + +object OOMReportManager { + private const val METADATA_FILE_NAME = "metadata.json" + private const val CONFIG_FILE_NAME = "configuration.json" + private const val CMDLINE_FILE_NAME = "cmdline" + private const val READ_MARKER_FILE_NAME = ".read" + private const val OOM_REPORTS_DIR_NAME = "oom_reports" + + private val timestampFormat = SimpleDateFormat("yyyy-MM-dd'T'HH-mm-ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private lateinit var workingDir: File + + private val _reports = MutableStateFlow>(emptyList()) + val reports: StateFlow> = _reports + private val _unreadCount = MutableStateFlow(0) + val unreadCount: StateFlow = _unreadCount + + fun install(workingDir: File) { + this.workingDir = workingDir + } + + suspend fun refresh() = withContext(Dispatchers.IO) { + val reports = scanReports() + _reports.value = reports + _unreadCount.value = reports.count { !it.isRead } + } + + private fun scanReports(): List { + val reportsDir = File(workingDir, OOM_REPORTS_DIR_NAME) + if (!reportsDir.isDirectory) return emptyList() + val directories = reportsDir.listFiles { file -> file.isDirectory } ?: return emptyList() + return directories.mapNotNull { dir -> + val date = parseTimestamp(dir.name) ?: return@mapNotNull null + OOMReport( + id = dir.name, + date = date, + directory = dir, + isRead = File(dir, READ_MARKER_FILE_NAME).exists(), + ) + }.sortedByDescending { it.date } + } + + fun availableFiles(report: OOMReport): List { + val files = mutableListOf() + val metadataFile = File(report.directory, METADATA_FILE_NAME) + if (metadataFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.METADATA, "Metadata", metadataFile)) + } + val configFile = File(report.directory, CONFIG_FILE_NAME) + if (configFile.exists()) { + files.add(OOMReportFile(OOMReportFile.Kind.CONFIG, "Configuration", configFile)) + } + report.directory.listFiles()?.filter { file -> + file.isFile && + file.name != METADATA_FILE_NAME && + file.name != CONFIG_FILE_NAME && + file.name != CMDLINE_FILE_NAME && + file.name != READ_MARKER_FILE_NAME + }?.sortedBy { it.name }?.forEach { file -> + files.add(OOMReportFile(OOMReportFile.Kind.PROFILE, file.name, file)) + } + return files + } + + fun loadFileContent(file: OOMReportFile): String { + if (!file.file.exists()) return "" + val content = file.file.readText() + if (file.kind == OOMReportFile.Kind.METADATA) { + return runCatching { + JSONObject(content).toString(2) + }.getOrDefault(content) + } + return content + } + + fun markAsRead(report: OOMReport) { + File(report.directory, READ_MARKER_FILE_NAME).createNewFile() + val updated = _reports.value.map { + if (it.id == report.id) it.copy(isRead = true) else it + } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun delete(report: OOMReport) = withContext(Dispatchers.IO) { + report.directory.deleteRecursively() + val updated = _reports.value.filter { it.id != report.id } + _reports.value = updated + _unreadCount.value = updated.count { !it.isRead } + } + + suspend fun deleteAll() = withContext(Dispatchers.IO) { + File(workingDir, OOM_REPORTS_DIR_NAME).deleteRecursively() + _reports.value = emptyList() + _unreadCount.value = 0 + } + + fun hasConfigFile(report: OOMReport): Boolean = File(report.directory, CONFIG_FILE_NAME).exists() + + suspend fun createZipArchive(report: OOMReport, includeConfig: Boolean): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, OOM_REPORTS_DIR_NAME) + cacheDir.mkdirs() + val zipFile = File(cacheDir, "${report.id}.zip") + zipFile.delete() + val strippedDir = File(cacheDir, report.id) + strippedDir.deleteRecursively() + report.directory.copyRecursively(strippedDir, overwrite = true) + File(strippedDir, READ_MARKER_FILE_NAME).delete() + if (!includeConfig) { + File(strippedDir, CONFIG_FILE_NAME).delete() + } + Libbox.createZipArchive(strippedDir.path, zipFile.path) + zipFile + } + + private fun parseTimestamp(name: String): Date? { + val components = name.split("-") + val baseName = if (components.size > 5 && components.last().toIntOrNull() != null) { + components.dropLast(1).joinToString("-") + } else { + name + } + return try { + timestampFormat.parse(baseName) + } catch (_: ParseException) { + null + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index 7a0be3ce0..1e2afa07d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -3,7 +3,9 @@ package io.nekohasekai.sfa.bg import android.annotation.SuppressLint import android.net.NetworkCapabilities import android.os.Build +import android.os.ParcelFileDescriptor import android.os.Process +import android.provider.Settings import android.system.OsConstants import android.util.Log import androidx.annotation.RequiresApi @@ -11,12 +13,21 @@ import io.nekohasekai.libbox.ConnectionOwner import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LocalDNSTransport +import io.nekohasekai.libbox.NeighborEntryIterator +import io.nekohasekai.libbox.NeighborUpdateListener import io.nekohasekai.libbox.NetworkInterfaceIterator import io.nekohasekai.libbox.PlatformInterface +import io.nekohasekai.libbox.PlatformUser +import io.nekohasekai.libbox.ShellSession import io.nekohasekai.libbox.StringIterator import io.nekohasekai.libbox.TunOptions import io.nekohasekai.libbox.WIFIState import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.ktx.toList +import io.nekohasekai.sfa.ktx.toStringIterator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import java.io.File import java.net.Inet6Address import java.net.InetSocketAddress import java.net.InterfaceAddress @@ -24,8 +35,11 @@ import java.net.NetworkInterface import java.security.KeyStore import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +import io.nekohasekai.libbox.NeighborEntry as LibboxNeighborEntry import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface +private var neighborCallback: INeighborTableCallback.Stub? = null + interface PlatformInterfaceWrapper : PlatformInterface { override fun usePlatformAutoDetectInterfaceControl(): Boolean = true @@ -172,6 +186,166 @@ interface PlatformInterfaceWrapper : PlatformInterface { return StringArray(certificates.iterator()) } + override fun startNeighborMonitor(listener: NeighborUpdateListener?) { + if (listener == null) return + val callback = object : INeighborTableCallback.Stub() { + override fun onNeighborTableUpdated(entries: ParceledListSlice<*>?) { + if (entries == null) return + @Suppress("UNCHECKED_CAST") + val list = entries.list as List + listener.updateNeighborTable( + NeighborEntryArray( + list.map { entry -> + LibboxNeighborEntry().apply { + address = entry.address + macAddress = entry.macAddress + hostname = entry.hostname + } + }.iterator(), + ), + ) + } + } + neighborCallback = callback + runBlocking(Dispatchers.IO) { + RootClient.registerNeighborTableCallback(callback) + } + } + + override fun usePlatformShell(): Boolean = true + + override fun checkPlatformShell() { + val available = RootClient.rootAvailable.value ?: runBlocking(Dispatchers.IO) { + RootClient.checkRootAvailable() + } + if (!available) { + error("missing root permission") + } + } + + override fun openShellSession( + user: PlatformUser?, + command: String?, + environ: StringIterator?, + term: String?, + rows: Int, + cols: Int, + ): ShellSession { + user!! + val envList = environ?.toList().orEmpty() + if (user.uid == Process.myUid()) { + val resolved = ResolvedUser(user.username, user.uid, user.gid, user.homeDir) + val shell = UserResolver.findShell(resolved) + val shellEnv = buildBasicEnvironment(envList.toTypedArray(), shell, resolved.homeDir, term) + val args = if (command.isNullOrEmpty()) { + arrayOf("-" + File(shell).name) + } else { + arrayOf(File(shell).name, "-c", command) + } + val argsIter = args.asIterable().toStringIterator() + val envIter = shellEnv.asIterable().toStringIterator() + return if (term.isNullOrEmpty()) { + Libbox.openNativePipeSession( + shell, + resolved.homeDir, + argsIter, + envIter, + -1, + -1, + null, + ) + } else { + Libbox.openNativeShellSession( + shell, + resolved.homeDir, + argsIter, + envIter, + term, + rows, + cols, + -1, + -1, + null, + ) + } + } + val rootSession = runBlocking(Dispatchers.IO) { + RootClient.openShellSession( + user.username, + command, + envList.toTypedArray(), + term, + rows, + cols, + ) + } + return RootShellSessionWrapper(rootSession) + } + + override fun readSystemSSHHostKey(): String { + error("not supported") + } + + override fun lookupSFTPServer(): String = runBlocking(Dispatchers.IO) { + RootClient.lookupSFTPServer() + } + + override fun tailscaleHostname(): String = Settings.Global.getString( + Application.application.contentResolver, + Settings.Global.DEVICE_NAME, + )?.takeIf { it.isNotBlank() } + ?: "${Build.MANUFACTURER} ${Build.MODEL}" + + override fun lookupUser(username: String?): io.nekohasekai.libbox.PlatformUser { + val resolved = UserResolver.resolve(Application.packageManager, username!!) + val platformUser = io.nekohasekai.libbox.PlatformUser() + platformUser.username = resolved.packageName + platformUser.uid = resolved.uid + platformUser.gid = resolved.gid + platformUser.homeDir = resolved.homeDir + return platformUser + } + + override fun registerMyInterface(name: String?) { + } + + override fun closeNeighborMonitor(listener: NeighborUpdateListener?) { + val callback = neighborCallback ?: return + neighborCallback = null + runBlocking(Dispatchers.IO) { + RootClient.unregisterNeighborTableCallback(callback) + } + } + + private class RootShellSessionWrapper( + private val rootSession: IRootShellSession, + ) : ShellSession { + private val masterPfd: ParcelFileDescriptor = rootSession.masterFD + + override fun masterFD(): Int = masterPfd.fd + + override fun resize(rows: Int, cols: Int) { + rootSession.resize(rows, cols) + } + + override fun signal(signal: Int) { + rootSession.signal(signal) + } + + override fun waitExit(): Int = rootSession.waitFor() + + override fun close() { + masterPfd.close() + rootSession.close() + } + } + + private class NeighborEntryArray(private val iterator: Iterator) : NeighborEntryIterator { + override fun hasNext(): Boolean = iterator.hasNext() + + override fun next(): LibboxNeighborEntry = iterator.next() + } + private class InterfaceArray(private val iterator: Iterator) : NetworkInterfaceIterator { override fun hasNext(): Boolean = iterator.hasNext() diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt index bd372ad86..fe2977d96 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -135,6 +135,48 @@ object RootClient { } } + suspend fun registerNeighborTableCallback(callback: INeighborTableCallback) { + val svc = bindService() + try { + svc.registerNeighborTableCallback(callback) + } catch (e: RemoteException) { + throw e.rethrowAsRuntime() + } + } + + suspend fun lookupSFTPServer(): String { + val svc = bindService() + try { + return svc.lookupSFTPServer() + } catch (e: RemoteException) { + throw e.rethrowAsRuntime() + } + } + + suspend fun openShellSession( + user: String, + command: String?, + env: Array, + term: String?, + rows: Int, + cols: Int, + ): IRootShellSession { + val svc = bindService() + try { + return svc.openShellSession(user, command, env, term, rows, cols) + } catch (e: RemoteException) { + throw e.rethrowAsRuntime() + } + } + + suspend fun unregisterNeighborTableCallback(callback: INeighborTableCallback) { + try { + service?.unregisterNeighborTableCallback(callback) + } catch (e: RemoteException) { + throw e.rethrowAsRuntime() + } + } + private fun RemoteException.rethrowAsRuntime(): RuntimeException = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { rethrowFromSystemServer() } else { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt index 352d15963..38eaf8d66 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt @@ -2,15 +2,40 @@ package io.nekohasekai.sfa.bg import android.content.Intent import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build import android.os.IBinder import android.os.ParcelFileDescriptor +import android.os.RemoteCallbackList +import android.util.Log import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NeighborEntryIterator +import io.nekohasekai.libbox.NeighborSubscription +import io.nekohasekai.libbox.NeighborUpdateListener +import io.nekohasekai.libbox.ShellSession import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.ktx.toStringIterator import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils +import java.io.File import java.io.IOException +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors class RootServer : RootService() { + private val neighborCallbacks = RemoteCallbackList() + private var neighborSubscription: NeighborSubscription? = null + + private val hostnameByMAC = ConcurrentHashMap() + + @Volatile + private var lastNeighborEntries: List>? = null + + private var tetheringCallback: Any? = null + private var tetheringManager: Any? = null + private val binder = object : IRootService.Stub() { override fun destroy() { stopSelf() @@ -31,7 +56,360 @@ class RootServer : RootService() { outputPath!!, BuildConfig.APPLICATION_ID, ) + + override fun registerNeighborTableCallback(callback: INeighborTableCallback?) { + if (callback == null) return + neighborCallbacks.register(callback) + synchronized(neighborCallbacks) { + if (neighborSubscription == null) { + try { + neighborSubscription = + Libbox.subscribeNeighborTable(object : NeighborUpdateListener { + override fun updateNeighborTable(entries: NeighborEntryIterator?) { + if (entries == null) return + val rawList = mutableListOf>() + while (entries.hasNext()) { + val entry = entries.next() + rawList.add(entry.address to entry.macAddress) + } + lastNeighborEntries = rawList + broadcastEnrichedEntries(rawList) + } + }) + } catch (e: Exception) { + Log.e("RootServer", "subscribeNeighborTable failed", e) + } + startTetheringMonitor() + } + } + } + + override fun unregisterNeighborTableCallback(callback: INeighborTableCallback?) { + if (callback == null) return + neighborCallbacks.unregister(callback) + synchronized(neighborCallbacks) { + if (neighborCallbacks.registeredCallbackCount == 0) { + neighborSubscription?.close() + neighborSubscription = null + stopTetheringMonitor() + } + } + } + + override fun openShellSession( + user: String?, + command: String?, + env: Array?, + term: String?, + rows: Int, + cols: Int, + ): IRootShellSession { + val resolved = UserResolver.resolve(packageManager, user!!) + val shell: String + val shellEnv: Array + val cwd: String + if (resolved.packageName == UserResolver.TERMUX_PACKAGE) { + val termuxPrefix = File(UserResolver.TERMUX_PREFIX) + val actualShell = UserResolver.findTermuxShell(termuxPrefix, resolved.homeDir) + cwd = resolved.homeDir + shellEnv = + buildTermuxEnvironment(env, actualShell, cwd, termuxPrefix.absolutePath, term) + shell = if (command.isNullOrEmpty()) { + val loginBin = File(termuxPrefix, "bin/login") + if (loginBin.canExecute()) loginBin.absolutePath else actualShell + } else { + actualShell + } + } else if (resolved.uid == 0) { + val termuxPrefix = File(UserResolver.TERMUX_PREFIX) + val termuxAvailable = File(termuxPrefix, "bin").isDirectory + if (termuxAvailable) { + shell = UserResolver.findTermuxShell(termuxPrefix, UserResolver.TERMUX_HOME) + cwd = UserResolver.TERMUX_HOME + shellEnv = buildTermuxEnvironment( + env, + shell, + cwd, + termuxPrefix.absolutePath, + term, + ) + } else { + shell = "/system/bin/sh" + cwd = "/data/local" + shellEnv = buildBasicEnvironment(env, shell, cwd, term) + } + } else { + shell = UserResolver.findShell(resolved) + cwd = resolved.homeDir + shellEnv = buildBasicEnvironment(env, shell, cwd, term) + } + val args: Array + if (command.isNullOrEmpty()) { + args = arrayOf("-" + File(shell).name) + } else { + args = arrayOf(File(shell).name, "-c", command) + } + val supplementaryGids = if (resolved.packageName == "root" || resolved.packageName == "shell") { + intArrayOf() + } else { + packageManager.getPackageGids(resolved.packageName) + } + val argsIter = args.asIterable().toStringIterator() + val envIter = shellEnv.asIterable().toStringIterator() + val groupsIter = IntArrayIterator(supplementaryGids) + val isPipe = term.isNullOrEmpty() + val session = if (isPipe) { + Libbox.openNativePipeSession( + shell, + cwd, + argsIter, + envIter, + resolved.uid, + resolved.gid, + groupsIter, + ) + } else { + Libbox.openNativeShellSession( + shell, cwd, argsIter, envIter, + term, rows, cols, + resolved.uid, resolved.gid, groupsIter, + ) + } + return RootShellSession(session) + } + + override fun lookupSFTPServer(): String { + val termuxPrefix = File(UserResolver.TERMUX_PREFIX) + for (name in arrayOf("libexec/sftp-server", "lib/openssh/sftp-server")) { + val candidate = File(termuxPrefix, name) + if (candidate.canExecute()) return candidate.absolutePath + } + throw IOException("sftp-server not found, install openssh in Termux") + } + } + + private fun buildTermuxEnvironment( + sshEnv: Array?, + shell: String, + home: String, + prefix: String, + term: String?, + ): Array { + val env = parseEnvArray(sshEnv) + env["HOME"] = home + env["PREFIX"] = prefix + env["PATH"] = "$prefix/bin" + env["TMPDIR"] = "$prefix/tmp" + env["SHELL"] = shell + env["LANG"] = "en_US.UTF-8" + env["COLORTERM"] = "truecolor" + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + env["LD_LIBRARY_PATH"] = "$prefix/lib" + } else { + env.remove("LD_LIBRARY_PATH") + } + val termuxExec = File("$prefix/lib/libtermux-exec.so") + if (termuxExec.exists()) { + env["LD_PRELOAD"] = termuxExec.absolutePath + } + if (!term.isNullOrEmpty()) { + env["TERM"] = term + } + addAndroidSystemEnvironment(env) + return env.map { (k, v) -> "$k=$v" }.toTypedArray() + } + + private class RootShellSession( + private val session: ShellSession, + ) : IRootShellSession.Stub() { + + override fun getMasterFD(): ParcelFileDescriptor = ParcelFileDescriptor.fromFd(session.masterFD()) + + override fun resize(rows: Int, cols: Int) { + session.resize(rows, cols) + } + + override fun signal(sig: Int) { + session.signal(sig) + } + + override fun waitFor(): Int = session.waitExit() + + override fun close() { + session.close() + } + } + + private fun broadcastEnrichedEntries(rawList: List>) { + val list = rawList.map { (address, mac) -> + NeighborEntry(address, mac, hostnameByMAC[mac.uppercase()] ?: "") + } + Log.d("RootServer", "neighborTable updated: ${list.size} entries") + val slice = ParceledListSlice(list) + val count = neighborCallbacks.beginBroadcast() + try { + repeat(count) { i -> + try { + neighborCallbacks.getBroadcastItem(i).onNeighborTableUpdated(slice) + } catch (_: Exception) { + } + } + } finally { + neighborCallbacks.finishBroadcast() + } + } + + // TetheringManager reflection (API 30+) + + private val classTetheredClient by lazy { + Class.forName("android.net.TetheredClient") + } + private val getMacAddress by lazy { + classTetheredClient.getDeclaredMethod("getMacAddress") + } + private val getAddresses by lazy { + classTetheredClient.getDeclaredMethod("getAddresses") + } + private val classAddressInfo by lazy { + Class.forName("android.net.TetheredClient\$AddressInfo") + } + private val getHostname by lazy { + classAddressInfo.getDeclaredMethod("getHostname") + } + + private fun startTetheringMonitor() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return + try { + val manager = getSystemService("tethering") ?: return + tetheringManager = manager + val callbackClass = + Class.forName("android.net.TetheringManager\$TetheringEventCallback") + val registerMethod = manager.javaClass.getMethod( + "registerTetheringEventCallback", + java.util.concurrent.Executor::class.java, + callbackClass, + ) + val proxy = Proxy.newProxyInstance( + callbackClass.classLoader, + arrayOf(callbackClass), + ) { proxyObject, method, args -> + when (method.name) { + "hashCode" -> System.identityHashCode(proxyObject) + "equals" -> proxyObject === args?.get(0) + "toString" -> + proxyObject.javaClass.name + "@" + + Integer.toHexString(System.identityHashCode(proxyObject)) + "onClientsChanged" -> { + if (args != null) { + @Suppress("UNCHECKED_CAST") + handleClientsChanged(args[0] as Collection<*>) + } + null + } + else -> null + } + } + tetheringCallback = proxy + registerMethod.invoke(manager, Executors.newSingleThreadExecutor(), proxy) + Log.d("RootServer", "TetheringManager monitor started") + } catch (e: Exception) { + Log.e("RootServer", "startTetheringMonitor failed", e) + } + } + + private fun stopTetheringMonitor() { + val manager = tetheringManager ?: return + val callback = tetheringCallback ?: return + try { + val callbackClass = + Class.forName("android.net.TetheringManager\$TetheringEventCallback") + val unregisterMethod = manager.javaClass.getMethod( + "unregisterTetheringEventCallback", + callbackClass, + ) + unregisterMethod.invoke(manager, callback) + } catch (e: Exception) { + Log.e("RootServer", "stopTetheringMonitor failed", e) + } + tetheringCallback = null + tetheringManager = null + hostnameByMAC.clear() + } + + private fun handleClientsChanged(clients: Collection<*>) { + hostnameByMAC.clear() + for (client in clients) { + if (client == null) continue + try { + val mac = getMacAddress.invoke(client).toString().uppercase() + + @Suppress("UNCHECKED_CAST") + val addresses = getAddresses.invoke(client) as List<*> + for (info in addresses) { + if (info == null) continue + val hostname = getHostname.invoke(info) as? String + if (!hostname.isNullOrEmpty()) { + hostnameByMAC[mac] = hostname + } + } + } catch (e: Exception) { + Log.e("RootServer", "handleClientsChanged reflection error", e) + } + } + Log.d("RootServer", "tethered clients updated: ${hostnameByMAC.size} hostnames") + lastNeighborEntries?.let { broadcastEnrichedEntries(it) } } override fun onBind(intent: Intent): IBinder = binder + + override fun onDestroy() { + stopTetheringMonitor() + neighborSubscription?.close() + neighborSubscription = null + neighborCallbacks.kill() + super.onDestroy() + } +} + +internal fun parseEnvArray(sshEnv: Array?): MutableMap { + val env = mutableMapOf() + sshEnv?.forEach { entry -> + val idx = entry.indexOf('=') + if (idx > 0) env[entry.substring(0, idx)] = entry.substring(idx + 1) + } + return env +} + +internal fun buildBasicEnvironment( + sshEnv: Array?, + shell: String, + home: String, + term: String?, +): Array { + val env = parseEnvArray(sshEnv) + env["HOME"] = home + env["PATH"] = "/system/bin:/system/xbin:/vendor/bin" + env["SHELL"] = shell + env["TMPDIR"] = "/data/local/tmp" + if (!term.isNullOrEmpty()) { + env["TERM"] = term + } + addAndroidSystemEnvironment(env) + return env.map { (k, v) -> "$k=$v" }.toTypedArray() +} + +internal fun addAndroidSystemEnvironment(env: MutableMap) { + val androidVars = arrayOf( + "ANDROID_ASSETS", "ANDROID_DATA", "ANDROID_ROOT", "ANDROID_STORAGE", + "EXTERNAL_STORAGE", "ASEC_MOUNTPOINT", "LOOP_MOUNTPOINT", + "ANDROID_RUNTIME_ROOT", "ANDROID_ART_ROOT", + "ANDROID_I18N_ROOT", "ANDROID_TZDATA_ROOT", + "BOOTCLASSPATH", "DEX2OATBOOTCLASSPATH", "SYSTEMSERVERCLASSPATH", + ) + for (name in androidVars) { + val value = System.getenv(name) + if (value != null) { + env[name] = value + } + } } diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt index bd1d7fb4c..c111d639f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt @@ -46,7 +46,7 @@ class ServiceNotification(private val status: MutableLiveData, private v @OptIn(DelicateCoroutinesApi::class) private val commandClient = - CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this) + CommandClient(GlobalScope, CommandClient.ConnectionType.Status, this, localOnly = true) private var receiverRegistered = false private val notificationBuilder by lazy { diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt b/app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt new file mode 100644 index 000000000..2e839ebb3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/bg/UserResolver.kt @@ -0,0 +1,60 @@ +package io.nekohasekai.sfa.bg + +import android.content.pm.PackageManager +import android.os.Process +import io.nekohasekai.sfa.BuildConfig +import java.io.File + +data class ResolvedUser( + val packageName: String, + val uid: Int, + val gid: Int, + val homeDir: String, +) + +object UserResolver { + + const val TERMUX_PACKAGE = "com.termux" + const val TERMUX_PREFIX = "/data/data/com.termux/files/usr" + const val TERMUX_HOME = "/data/data/com.termux/files/home" + + fun resolve(pm: PackageManager, username: String): ResolvedUser = when (username) { + "root" -> ResolvedUser("root", Process.ROOT_UID, Process.ROOT_UID, "/") + "shell" -> ResolvedUser("shell", Process.SHELL_UID, Process.SHELL_UID, "/data/local") + "termux" -> resolvePackage(pm, TERMUX_PACKAGE) + "sing-box" -> resolvePackage(pm, BuildConfig.APPLICATION_ID) + else -> resolvePackage(pm, username) + } + + private fun resolvePackage(pm: PackageManager, packageName: String): ResolvedUser { + val appInfo = pm.getApplicationInfo(packageName, 0) + val homeDir = when (packageName) { + TERMUX_PACKAGE -> TERMUX_HOME + else -> appInfo.dataDir + } + return ResolvedUser(packageName, appInfo.uid, appInfo.uid, homeDir) + } + + fun findShell(resolved: ResolvedUser): String { + if (resolved.packageName == TERMUX_PACKAGE) { + return findTermuxShell( + File(TERMUX_PREFIX), + resolved.homeDir, + ) + } + return "/system/bin/sh" + } + + fun findTermuxShell(prefix: File, homeDir: String): String { + val dotTermuxShell = File(homeDir, ".termux/shell") + if (dotTermuxShell.canExecute()) { + return dotTermuxShell.canonicalPath + } + val binDir = File(prefix, "bin") + for (name in arrayOf("bash", "zsh", "fish", "sh")) { + val candidate = File(binDir, name) + if (candidate.canExecute()) return candidate.absolutePath + } + return "/system/bin/sh" + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index 6b4c81453..942ae835b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -7,6 +7,7 @@ import android.net.VpnService import android.os.Build import android.os.IBinder import android.util.Log +import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.TunOptions import io.nekohasekai.sfa.database.Settings @@ -83,7 +84,12 @@ class VPNService : } if (options.autoRoute) { - builder.addDnsServer(options.dnsServerAddress.value) + if (options.dnsMode.value != Libbox.DNSModeDisabled) { + val dnsServerAddress = options.dnsServerAddress + while (dnsServerAddress.hasNext()) { + builder.addDnsServer(dnsServerAddress.next()) + } + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val inet4RouteAddress = options.inet4RouteAddress diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt index 56e8639c3..10db89244 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LinkOff import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.UnfoldLess @@ -87,6 +88,9 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager import io.nekohasekai.sfa.bg.ServiceConnection import io.nekohasekai.sfa.bg.ServiceNotification import io.nekohasekai.sfa.compat.WindowSizeClassCompat @@ -94,6 +98,7 @@ import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.component.RemoteStatusBar import io.nekohasekai.sfa.compose.component.ServiceStatusBar import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.component.UptimeText @@ -111,6 +116,8 @@ import io.nekohasekai.sfa.compose.screen.dashboard.DashboardViewModel import io.nekohasekai.sfa.compose.screen.dashboard.GroupsCard import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel import io.nekohasekai.sfa.compose.screen.log.LogViewModel +import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHSharedViewModel +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel import io.nekohasekai.sfa.compose.theme.SFATheme import io.nekohasekai.sfa.compose.topbar.LocalTopBarController import io.nekohasekai.sfa.compose.topbar.TopBarController @@ -123,9 +130,11 @@ import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.hasPermission import io.nekohasekai.sfa.ktx.launchCustomTab import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.utils.RemoteControlManager import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -193,6 +202,7 @@ class MainActivity : enableEdgeToEdge() connection.reconnect() + RemoteControlManager.restore() UpdateState.loadFromCache() if (Settings.checkUpdateEnabled) { @@ -327,6 +337,89 @@ class MainActivity : // Snackbar state val snackbarHostState = remember { SnackbarHostState() } + // Error dialog state for UiEvent.ShowError + var showErrorDialog by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf("") } + var pendingApplyServiceChangeMode by remember { mutableStateOf(null) } + var activeApplyServiceChangeMode by remember { mutableStateOf(null) } + var applyServiceChangeJob by remember { mutableStateOf(null) } + + fun mergeApplyServiceChangeMode( + current: UiEvent.ApplyServiceChange.Mode?, + incoming: UiEvent.ApplyServiceChange.Mode, + ): UiEvent.ApplyServiceChange.Mode = when { + current == UiEvent.ApplyServiceChange.Mode.Restart || + incoming == UiEvent.ApplyServiceChange.Mode.Restart -> { + UiEvent.ApplyServiceChange.Mode.Restart + } + + else -> incoming + } + + fun enqueueApplyServiceChange(mode: UiEvent.ApplyServiceChange.Mode) { + if (currentServiceStatus != Status.Started) { + return + } + + pendingApplyServiceChangeMode = mergeApplyServiceChangeMode(pendingApplyServiceChangeMode, mode) + + val activeMode = activeApplyServiceChangeMode + if (activeMode != null && + mergeApplyServiceChangeMode(activeMode, mode) != activeMode + ) { + snackbarHostState.currentSnackbarData?.dismiss() + } + + if (applyServiceChangeJob?.isActive == true) { + return + } + + applyServiceChangeJob = + scope.launch { + while (true) { + val modeToShow = pendingApplyServiceChangeMode ?: break + pendingApplyServiceChangeMode = null + activeApplyServiceChangeMode = modeToShow + val (message, actionLabel) = + when (modeToShow) { + UiEvent.ApplyServiceChange.Mode.Reload -> { + getString(R.string.service_reload_required) to + getString(R.string.action_reload) + } + + UiEvent.ApplyServiceChange.Mode.Restart -> { + getString(R.string.service_restart_required) to + getString(R.string.action_restart) + } + } + val result = + snackbarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = androidx.compose.material3.SnackbarDuration.Short, + ) + activeApplyServiceChangeMode = null + if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) { + try { + when (modeToShow) { + UiEvent.ApplyServiceChange.Mode.Reload -> { + withContext(Dispatchers.IO) { + Libbox.newStandaloneCommandClient().serviceReload() + } + } + + UiEvent.ApplyServiceChange.Mode.Restart -> { + restartServiceForApplyChange() + } + } + } catch (e: Exception) { + errorMessage = e.message ?: e.toString() + showErrorDialog = true + } + } + } + } + } // Groups Sheet state var showGroupsSheet by remember { mutableStateOf(false) } @@ -335,8 +428,6 @@ class MainActivity : var showConnectionsSheet by remember { mutableStateOf(false) } // Error dialog state for UiEvent.ShowError - var showErrorDialog by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf("") } val pendingIntentError = pendingIntentErrorMessage LaunchedEffect(pendingIntentError) { if (pendingIntentError != null) { @@ -608,6 +699,11 @@ class MainActivity : ) } + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val remoteStartedAt by RemoteControlManager.startedAt.collectAsState() + val isRemote = remoteServer != null + // Initialize the dashboard view model and store reference val dashboardViewModel: DashboardViewModel = viewModel() if (!::dashboardViewModel.isInitialized) { @@ -616,11 +712,13 @@ class MainActivity : val dashboardUiState by dashboardViewModel.uiState.collectAsState() val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true + val isToolsSubScreen = currentRoute?.startsWith("tools/") == true val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true val isProfileRoute = currentRoute?.startsWith("profile/") == true val currentRootRoute = when { isSettingsSubScreen -> Screen.Settings.route + isToolsSubScreen -> Screen.Tools.route currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route isProfileRoute -> Screen.Dashboard.route @@ -630,7 +728,7 @@ class MainActivity : val isGroupsRoute = currentRootRoute == Screen.Groups.route val isLogRoute = currentRootRoute == Screen.Log.route - val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute + val isSubScreen = isSettingsSubScreen || isToolsSubScreen || isConnectionsDetail || isProfileRoute // Get LogViewModel instance if we're on the Log screen val logViewModel: LogViewModel? = if (isLogRoute) { @@ -660,9 +758,23 @@ class MainActivity : null } + val tailscaleSSHSharedViewModel: TailscaleSSHSharedViewModel = viewModel() + + val isToolsRoute = currentRootRoute == Screen.Tools.route + val tailscaleStatusViewModel: TailscaleStatusViewModel? = + if (isToolsRoute) { + viewModel() + } else { + null + } + val showGroupsInNav = dashboardUiState.hasGroups val showConnectionsInNav = - currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + if (isRemote) { + remoteConnected + } else { + currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + } val railScreens = buildList { @@ -674,6 +786,7 @@ class MainActivity : add(Screen.Connections) } add(Screen.Log) + add(Screen.Tools) add(Screen.Settings) } @@ -681,6 +794,7 @@ class MainActivity : buildSet { add(Screen.Dashboard.route) add(Screen.Log.route) + add(Screen.Tools.route) add(Screen.Settings.route) if (useNavigationRail && showGroupsInNav) { add(Screen.Groups.route) @@ -739,24 +853,13 @@ class MainActivity : } } - is UiEvent.RestartToTakeEffect -> { - if (currentServiceStatus == Status.Started) { - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - val result = - snackbarHostState.showSnackbar( - message = "Restart to take effect", - actionLabel = "Restart", - duration = androidx.compose.material3.SnackbarDuration.Short, - ) - if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) { - withContext(Dispatchers.IO) { - Libbox.newStandaloneCommandClient().serviceReload() - } - } - } + is UiEvent.Navigate -> { + navController.navigate(event.route) { + launchSingleTop = true } } + + is UiEvent.ApplyServiceChange -> enqueueApplyServiceChange(event.mode) } } } @@ -771,11 +874,12 @@ class MainActivity : .fillMaxSize() .padding(paddingValues), ) { - // Service Status Bar (shown when service is running or stopping) + // Service Status Bar (shown when service is running or stopping); + // remote control replaces it with the remote session bar. val serviceRunning = currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting - val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping - val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L + val showStatusBar = isRemote || serviceRunning || currentServiceStatus == Status.Stopping + val showStartFab = !isRemote && !serviceRunning && dashboardUiState.selectedProfileId != -1L SFANavHost( navController = navController, @@ -789,21 +893,39 @@ class MainActivity : logViewModel = logViewModel, groupsViewModel = groupsViewModel, connectionsViewModel = connectionsViewModel, + tailscaleStatusViewModel = tailscaleStatusViewModel, + tailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel, modifier = Modifier.fillMaxSize(), ) if (!useNavigationRail) { - ServiceStatusBar( - visible = showStatusBar && !isSubScreen, - serviceStatus = currentServiceStatus, - startTime = dashboardUiState.serviceStartTime, - groupsCount = dashboardUiState.groupsCount, - hasGroups = dashboardUiState.hasGroups, - onGroupsClick = { showGroupsSheet = true }, - connectionsCount = dashboardUiState.connectionsCount, - onConnectionsClick = { showConnectionsSheet = true }, - onStopClick = { dashboardViewModel.toggleService() }, - modifier = Modifier.align(Alignment.BottomCenter), - ) + if (isRemote) { + RemoteStatusBar( + visible = !isSubScreen, + serverName = remoteServer?.displayName ?: "", + isConnected = remoteConnected, + startTime = remoteStartedAt, + groupsCount = dashboardUiState.groupsCount, + hasGroups = dashboardUiState.hasGroups, + onGroupsClick = { showGroupsSheet = true }, + connectionsCount = dashboardUiState.connectionsCount, + onConnectionsClick = { showConnectionsSheet = true }, + onDisconnectClick = { RemoteControlManager.exitRemoteControl() }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } else { + ServiceStatusBar( + visible = showStatusBar && !isSubScreen, + serviceStatus = currentServiceStatus, + startTime = dashboardUiState.serviceStartTime, + groupsCount = dashboardUiState.groupsCount, + hasGroups = dashboardUiState.hasGroups, + onGroupsClick = { showGroupsSheet = true }, + connectionsCount = dashboardUiState.connectionsCount, + onConnectionsClick = { showConnectionsSheet = true }, + onStopClick = { dashboardViewModel.toggleService() }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar) @@ -819,7 +941,35 @@ class MainActivity : val isRunning = currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting val isStopping = currentServiceStatus == Status.Stopping - if (currentServiceStatus == Status.Stopped) { + if (isRemote) { + ExtendedFloatingActionButton( + onClick = { RemoteControlManager.exitRemoteControl() }, + icon = { + Icon( + imageVector = Icons.Default.LinkOff, + contentDescription = stringResource(R.string.remote_disconnect), + ) + }, + text = { + if (remoteConnected && remoteStartedAt != null) { + UptimeText(startTime = remoteStartedAt!!) + } else { + Text( + text = + if (remoteConnected) { + remoteServer?.displayName ?: "" + } else { + stringResource(R.string.remote_connecting) + }, + style = MaterialTheme.typography.labelLarge, + ) + } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.height(64.dp), + ) + } else if (currentServiceStatus == Status.Stopped) { FloatingActionButton( onClick = { startService() }, containerColor = MaterialTheme.colorScheme.primaryContainer, @@ -895,7 +1045,8 @@ class MainActivity : } else { // Start FAB (shown when service is stopped and a profile is selected) androidx.compose.animation.AnimatedVisibility( - visible = currentServiceStatus == Status.Stopped && + visible = !isRemote && + currentServiceStatus == Status.Stopped && dashboardUiState.selectedProfileId != -1L && !isSubScreen, enter = scaleIn(), @@ -919,6 +1070,18 @@ class MainActivity : } } + val crashReportUnreadCount by CrashReportManager.unreadCount.collectAsState() + val oomReportUnreadCount by OOMReportManager.unreadCount.collectAsState() + // The crash/OOM report entries are hidden in remote control mode. + val toolsUnreadCount = if (isRemote) 0 else crashReportUnreadCount + oomReportUnreadCount + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + CrashReportManager.refresh() + OOMReportManager.refresh() + } + } + CompositionLocalProvider(LocalTopBarController provides topBarController) { if (useNavigationRail) { Row(modifier = Modifier.fillMaxSize()) { @@ -936,6 +1099,10 @@ class MainActivity : BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { Icon(screen.icon, contentDescription = null) } + } else if (screen == Screen.Tools && toolsUnreadCount > 0) { + BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) { + Icon(screen.icon, contentDescription = null) + } } else { Icon(screen.icon, contentDescription = null) } @@ -980,6 +1147,10 @@ class MainActivity : BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { Icon(screen.icon, contentDescription = null) } + } else if (screen == Screen.Tools && toolsUnreadCount > 0) { + BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.error) { Text("$toolsUnreadCount") } }) { + Icon(screen.icon, contentDescription = null) + } } else { Icon(screen.icon, contentDescription = null) } @@ -1192,6 +1363,30 @@ class MainActivity : showBackgroundLocationDialog = true } + private suspend fun restartServiceForApplyChange() { + if (currentServiceStatus != Status.Started) { + return + } + + BoxService.stop() + while (true) { + when (currentServiceStatus) { + Status.Stopped -> { + startService() + return + } + + Status.Starting -> { + return + } + + Status.Started, Status.Stopping -> { + delay(100L) + } + } + } + } + override fun onDestroy() { connection.disconnect() super.onDestroy() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt new file mode 100644 index 000000000..7f3161755 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/ApplyServiceChangeNotifier.kt @@ -0,0 +1,16 @@ +package io.nekohasekai.sfa.compose.base + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import io.nekohasekai.sfa.constant.Status + +@Composable +fun rememberApplyServiceChangeNotifier( + serviceStatus: Status, +): (UiEvent.ApplyServiceChange.Mode) -> Unit = remember(serviceStatus) { + { mode -> + if (serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.ApplyServiceChange(mode)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt index 6b7467a32..2c2955279 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -15,11 +15,18 @@ sealed class UiEvent { data class EditProfile(val profileId: Long) : UiEvent() + data class Navigate(val route: String) : UiEvent() + object RequestStartService : UiEvent() object RequestReconnectService : UiEvent() - object RestartToTakeEffect : UiEvent() + data class ApplyServiceChange(val mode: Mode) : UiEvent() { + enum class Mode { + Reload, + Restart, + } + } } /** diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteControlMenuItems.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteControlMenuItems.kt new file mode 100644 index 000000000..5d54152be --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteControlMenuItems.kt @@ -0,0 +1,139 @@ +package io.nekohasekai.sfa.compose.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.GlobalEventBus +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.database.RemoteServer +import io.nekohasekai.sfa.database.RemoteServerManager +import io.nekohasekai.sfa.utils.RemoteControlManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +const val REMOTE_CONTROL_ROUTE = "settings/remote_control" + +@Composable +fun rememberRemoteServers(): State> { + val scope = rememberCoroutineScope() + val servers = remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + servers.value = withContext(Dispatchers.IO) { RemoteServerManager.list() } + } + DisposableEffect(Unit) { + val callback: () -> Unit = { + scope.launch { + servers.value = withContext(Dispatchers.IO) { RemoteServerManager.list() } + } + } + RemoteServerManager.registerCallback(callback) + onDispose { + RemoteServerManager.unregisterCallback(callback) + } + } + return servers +} + +@Composable +fun RemoteControlMenuItems(servers: List, onAction: () -> Unit, leadingDivider: Boolean = true) { + val scope = rememberCoroutineScope() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + + if (servers.isEmpty()) { + return + } + + if (leadingDivider) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + Text( + text = stringResource(R.string.remote_control), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + SelectableMenuItem( + label = stringResource(R.string.remote_local_device), + selected = remoteServer == null, + onClick = { + onAction() + RemoteControlManager.exitRemoteControl() + }, + ) + servers.forEach { server -> + val isActive = remoteServer?.id == server.id + SelectableMenuItem( + label = server.displayName, + selected = isActive, + onClick = { + onAction() + if (!isActive) { + RemoteControlManager.enterRemoteControl(server) + } + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.remote_manage_servers)) }, + leadingIcon = { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { + onAction() + scope.launch { + GlobalEventBus.emit(UiEvent.Navigate(REMOTE_CONTROL_ROUTE)) + } + }, + ) +} + +@Composable +private fun SelectableMenuItem(label: String, selected: Boolean, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(label) }, + leadingIcon = { + Icon( + imageVector = + if (selected) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + onClick = onClick, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteStatusBar.kt b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteStatusBar.kt new file mode 100644 index 000000000..d0e02ab43 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/component/RemoteStatusBar.kt @@ -0,0 +1,163 @@ +package io.nekohasekai.sfa.compose.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.outlined.Cable +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +// Mirrors the remote status pill of the Apple clients: server name (or +// connecting state), groups/connections shortcuts, the remote service uptime, +// and a disconnect button. +@Composable +fun RemoteStatusBar( + visible: Boolean, + serverName: String, + isConnected: Boolean, + startTime: Long?, + groupsCount: Int, + hasGroups: Boolean, + onGroupsClick: () -> Unit, + connectionsCount: Int, + onConnectionsClick: () -> Unit, + onDisconnectClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + modifier = modifier, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 3.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + if (isConnected) { + serverName + } else { + stringResource(R.string.remote_connecting) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + modifier = Modifier.weight(1f), + ) + + if (isConnected) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable(onClick = onConnectionsClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = connectionsCount.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Outlined.Cable, + contentDescription = stringResource(R.string.title_connections), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + + if (hasGroups) { + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable(onClick = onGroupsClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = groupsCount.toString(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.Folder, + contentDescription = stringResource(R.string.title_groups), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = onDisconnectClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (isConnected && startTime != null) { + UptimeText(startTime = startTime) + Spacer(modifier = Modifier.width(4.dp)) + } + Icon( + imageVector = Icons.Default.LinkOff, + contentDescription = stringResource(R.string.remote_disconnect), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt index 27456b99c..9ae23cc11 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.Terminal import androidx.compose.ui.graphics.vector.ImageVector import io.nekohasekai.sfa.R @@ -35,6 +36,12 @@ sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: I icon = Icons.Default.SwapVert, ) + object Tools : Screen( + route = "tools", + titleRes = R.string.title_tools, + icon = Icons.Default.Terminal, + ) + object Settings : Screen( route = "settings", titleRes = R.string.title_settings, @@ -46,5 +53,6 @@ val bottomNavigationScreens = listOf( Screen.Dashboard, Screen.Log, + Screen.Tools, Screen.Settings, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt index d6e5f22b1..afd523976 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -28,11 +29,35 @@ import io.nekohasekai.sfa.compose.screen.profile.EditProfileRoute import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScreen import io.nekohasekai.sfa.compose.screen.settings.AppSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.CoreSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.EditRemoteServerScreen import io.nekohasekai.sfa.compose.screen.settings.FDroidMirrorScreen import io.nekohasekai.sfa.compose.screen.settings.PrivilegeSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen +import io.nekohasekai.sfa.compose.screen.settings.RemoteControlScreen import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.TailscaleFontPickerScreen +import io.nekohasekai.sfa.compose.screen.settings.TailscaleTerminalConfigScreen +import io.nekohasekai.sfa.compose.screen.settings.TailscaleThemePickerScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportDetailScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportFileContentScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportListScreen +import io.nekohasekai.sfa.compose.screen.tools.CrashReportMetadataScreen +import io.nekohasekai.sfa.compose.screen.tools.NetworkQualityScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportDetailScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportFileContentScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportListScreen +import io.nekohasekai.sfa.compose.screen.tools.OOMReportMetadataScreen +import io.nekohasekai.sfa.compose.screen.tools.OutboundPickerScreen +import io.nekohasekai.sfa.compose.screen.tools.STUNTestScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleEndpointScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleExitNodePickerScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscalePeerScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHPromptScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHSharedViewModel +import io.nekohasekai.sfa.compose.screen.tools.TailscaleSSHTerminalScreen +import io.nekohasekai.sfa.compose.screen.tools.TailscaleStatusViewModel +import io.nekohasekai.sfa.compose.screen.tools.ToolsScreen import io.nekohasekai.sfa.constant.Status private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { @@ -64,6 +89,8 @@ fun SFANavHost( logViewModel: LogViewModel? = null, groupsViewModel: GroupsViewModel? = null, connectionsViewModel: ConnectionsViewModel? = null, + tailscaleStatusViewModel: TailscaleStatusViewModel? = null, + tailscaleSSHSharedViewModel: TailscaleSSHSharedViewModel? = null, modifier: Modifier = Modifier, ) { NavHost( @@ -210,6 +237,233 @@ fun SFANavHost( } } + composable(Screen.Tools.route) { + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + val sshSharedViewModel: TailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel ?: viewModel() + ToolsScreen(navController = navController, serviceStatus = serviceStatus, tailscaleViewModel = tailscaleViewModel, sshSharedViewModel = sshSharedViewModel) + } + + // Tools subscreens with slide animations + composable( + route = "tools/network_quality", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + NetworkQualityScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/stun_test", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + STUNTestScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/outbound_picker/{selectedOutbound}", + arguments = listOf(navArgument("selectedOutbound") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val selectedOutbound = Uri.decode(backStackEntry.arguments?.getString("selectedOutbound") ?: "") + OutboundPickerScreen(navController = navController, selectedOutbound = selectedOutbound) + } + + composable( + route = "tools/tailscale/{endpointTag}", + arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + val sshSharedViewModel: TailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel ?: viewModel() + TailscaleEndpointScreen(navController = navController, viewModel = tailscaleViewModel, sshSharedViewModel = sshSharedViewModel, endpointTag = endpointTag) + } + + composable( + route = "tools/tailscale/{endpointTag}/exit_node", + arguments = listOf(navArgument("endpointTag") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscaleExitNodePickerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag) + } + + composable( + route = "tools/tailscale/{endpointTag}/peer/{peerId}", + arguments = listOf( + navArgument("endpointTag") { type = NavType.StringType }, + navArgument("peerId") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val peerId = Uri.decode(backStackEntry.arguments?.getString("peerId") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscalePeerScreen(navController = navController, viewModel = tailscaleViewModel, endpointTag = endpointTag, peerId = peerId) + } + + composable( + route = "tools/tailscale/{endpointTag}/peer/{peerId}/ssh", + arguments = listOf( + navArgument("endpointTag") { type = NavType.StringType }, + navArgument("peerId") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val endpointTag = Uri.decode(backStackEntry.arguments?.getString("endpointTag") ?: return@composable) + val peerId = Uri.decode(backStackEntry.arguments?.getString("peerId") ?: return@composable) + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + val sshSharedViewModel: TailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel ?: viewModel() + TailscaleSSHPromptScreen( + navController = navController, + sharedViewModel = sshSharedViewModel, + viewModel = tailscaleViewModel, + endpointTag = endpointTag, + peerId = peerId, + ) + } + + composable( + route = "tools/tailscale/{endpointTag}/peer/{peerId}/terminal", + arguments = listOf( + navArgument("endpointTag") { type = NavType.StringType }, + navArgument("peerId") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val sshSharedViewModel: TailscaleSSHSharedViewModel = tailscaleSSHSharedViewModel ?: viewModel() + val tailscaleViewModel: TailscaleStatusViewModel = tailscaleStatusViewModel ?: viewModel() + TailscaleSSHTerminalScreen( + navController = navController, + sharedViewModel = sshSharedViewModel, + tailscaleViewModel = tailscaleViewModel, + ) + } + + composable( + route = "tools/crash_report", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + CrashReportListScreen(navController = navController) + } + + composable( + route = "tools/crash_report/{reportId}", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + CrashReportDetailScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/crash_report/{reportId}/metadata", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + CrashReportMetadataScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/crash_report/{reportId}/file/{fileKind}", + arguments = listOf( + navArgument("reportId") { type = NavType.StringType }, + navArgument("fileKind") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable + CrashReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind) + } + + composable( + route = "tools/oom_report", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + OOMReportListScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "tools/oom_report/{reportId}", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + OOMReportDetailScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/oom_report/{reportId}/metadata", + arguments = listOf(navArgument("reportId") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + OOMReportMetadataScreen(navController = navController, reportId = reportId) + } + + composable( + route = "tools/oom_report/{reportId}/file/{fileKind}", + arguments = listOf( + navArgument("reportId") { type = NavType.StringType }, + navArgument("fileKind") { type = NavType.StringType }, + ), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val reportId = backStackEntry.arguments?.getString("reportId") ?: return@composable + val fileKind = backStackEntry.arguments?.getString("fileKind") ?: return@composable + OOMReportFileContentScreen(navController = navController, reportId = reportId, fileKind = fileKind) + } + composable(Screen.Settings.route) { SettingsScreen(navController = navController) } @@ -222,7 +476,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - AppSettingsScreen(navController = navController) + AppSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -252,7 +506,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ServiceSettingsScreen(navController = navController) + ServiceSettingsScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -262,7 +516,7 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - ProfileOverrideScreen(navController = navController) + ProfileOverrideScreen(navController = navController, serviceStatus = serviceStatus) } composable( @@ -272,7 +526,39 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PerAppProxyScreen(onBack = { navController.navigateUp() }) + PerAppProxyScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) + } + + composable( + route = "settings/remote_control", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + RemoteControlScreen(navController = navController) + } + + composable( + route = "settings/remote_control/new", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + EditRemoteServerScreen(navController = navController) + } + + composable( + route = "settings/remote_control/edit/{serverId}", + arguments = listOf(navArgument("serverId") { type = NavType.LongType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val serverId = backStackEntry.arguments?.getLong("serverId") ?: -1L + EditRemoteServerScreen(navController = navController, serverId = serverId) } composable( @@ -292,7 +578,39 @@ fun SFANavHost( popEnterTransition = slideInFromLeft, popExitTransition = slideOutToRight, ) { - PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) + PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }, serviceStatus = serviceStatus) + } + + composable( + route = "settings/tailscale/terminal_config", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + TailscaleTerminalConfigScreen(navController = navController) + } + + composable( + route = "settings/tailscale/theme_picker/{isDark}", + arguments = listOf(navArgument("isDark") { type = NavType.StringType }), + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { backStackEntry -> + val isDarkStr = backStackEntry.arguments?.getString("isDark") ?: "false" + TailscaleThemePickerScreen(navController = navController, isDark = isDarkStr == "true") + } + + composable( + route = "settings/tailscale/font_picker", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + TailscaleFontPickerScreen(navController = navController) } composable( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt index a525ad57e..7d59032ca 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,6 +46,7 @@ import androidx.compose.ui.unit.dp import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.model.Connection +import io.nekohasekai.sfa.utils.RemoteControlManager private fun Drawable.toBitmap(): Bitmap { if (this is BitmapDrawable) return bitmap @@ -82,7 +84,16 @@ private fun rememberAppInfo(packageName: String): AppInfo? { @Composable fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { var showContextMenu by remember { mutableStateOf(false) } - val packageName = connection.processInfo?.packageNames?.firstOrNull() + // In remote control mode the reported packages belong to the remote device, + // so resolving them against the local package manager would be wrong. + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val isRemote = remoteServer != null + val packageName = + if (isRemote) { + null + } else { + connection.processInfo?.packageNames?.firstOrNull() + } val appInfo = packageName?.let { rememberAppInfo(it) } Box(modifier = modifier) { @@ -101,19 +112,21 @@ fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> U horizontalArrangement = Arrangement.spacedBy(12.dp), ) { // Column 1: App icon - if (appInfo != null) { - Image( - bitmap = appInfo.icon, - contentDescription = null, - modifier = Modifier.size(32.dp), - ) - } else { - Icon( - imageVector = Icons.Outlined.Circle, - contentDescription = null, - modifier = Modifier.size(32.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (!isRemote) { + if (appInfo != null) { + Image( + bitmap = appInfo.icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + } else { + Icon( + imageVector = Icons.Outlined.Circle, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } // Content column diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt index c54087c0b..911809793 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt @@ -3,7 +3,6 @@ package io.nekohasekai.sfa.compose.screen.connections import androidx.lifecycle.viewModelScope import io.nekohasekai.libbox.ConnectionEvents import io.nekohasekai.libbox.Connections -import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.ScreenEvent import io.nekohasekai.sfa.compose.model.Connection @@ -13,6 +12,8 @@ import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.ktx.toList import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -64,6 +65,8 @@ class ConnectionsViewModel : val screenOn: Boolean, val visibleCount: Int, val status: Status, + val remoteServerId: Long?, + val remoteConnected: Boolean, ) init { @@ -73,11 +76,17 @@ class ConnectionsViewModel : AppLifecycleObserver.isScreenOn, _visibleCount, _serviceStatus, - ) { foreground, screenOn, visibleCount, status -> - ConnectionState(foreground, screenOn, visibleCount, status) + combine( + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + ) { remoteServer, remoteConnected -> remoteServer?.id to remoteConnected }, + ) { foreground, screenOn, visibleCount, status, (remoteServerId, remoteConnected) -> + ConnectionState(foreground, screenOn, visibleCount, status, remoteServerId, remoteConnected) }.collect { state -> + val serviceReady = + if (state.remoteServerId != null) state.remoteConnected else state.status == Status.Started val shouldConnect = state.foreground && state.screenOn && - state.visibleCount > 0 && state.status == Status.Started + state.visibleCount > 0 && serviceReady if (shouldConnect) { updateState { copy(isLoading = true) } commandClient.connect() @@ -98,6 +107,9 @@ class ConnectionsViewModel : } private suspend fun handleServiceStatusChange(status: Status) { + if (RemoteControlManager.remoteServer.value != null) { + return + } if (status != Status.Started) { withContext(Dispatchers.Default) { connectionsMutex.withLock { @@ -151,7 +163,7 @@ class ConnectionsViewModel : fun closeConnection(connectionId: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().closeConnection(connectionId) + CommandTarget.standaloneClient().closeConnection(connectionId) withContext(Dispatchers.Main) { sendEvent(ConnectionsEvent.ConnectionClosed(connectionId)) } @@ -164,7 +176,7 @@ class ConnectionsViewModel : fun closeAllConnections() { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().closeConnections() + CommandTarget.standaloneClient().closeConnections() withContext(Dispatchers.Main) { sendEvent(ConnectionsEvent.AllConnectionsClosed) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt index b13ea3825..3d9d89755 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -23,7 +28,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -31,9 +40,12 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems +import io.nekohasekai.sfa.compose.component.rememberRemoteServers import io.nekohasekai.sfa.compose.navigation.NewProfileArgs import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.launch data class CardRenderItem(val cards: List, val isRow: Boolean) @@ -48,16 +60,46 @@ fun DashboardScreen( viewModel: DashboardViewModel = viewModel(), ) { val uiState by viewModel.uiState.collectAsState() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val isRemote = remoteServer != null + val remoteServers by rememberRemoteServers() + var showOthersMenu by remember { mutableStateOf(false) } OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_dashboard)) }, actions = { - IconButton(onClick = { viewModel.toggleCardSettingsDialog() }) { - Icon( - imageVector = Icons.Default.MoreVert, - contentDescription = stringResource(R.string.title_others), - ) + Box { + IconButton(onClick = { showOthersMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.title_others), + ) + } + DropdownMenu( + expanded = showOthersMenu, + onDismissRequest = { showOthersMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.dashboard_items)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.GridView, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + onClick = { + showOthersMenu = false + viewModel.toggleCardSettingsDialog() + }, + ) + RemoteControlMenuItems( + servers = remoteServers, + onAction = { showOthersMenu = false }, + ) + } } }, ) @@ -120,6 +162,16 @@ fun DashboardScreen( ) } + if (isRemote && !remoteConnected) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + return + } + Box( modifier = Modifier.fillMaxSize(), ) { @@ -143,8 +195,17 @@ fun DashboardScreen( // Filter cards based on availability val actuallyVisibleCards = uiState.visibleCards.filter { cardGroup -> - when (cardGroup) { - CardGroup.Profiles -> true // Profiles card is always available + when { + // The remote dashboard only renders cards backed by the + // command protocol: profiles and system proxy are + // operations on the local device. + isRemote -> + cardGroup != CardGroup.Profiles && + cardGroup != CardGroup.SystemProxy && + serviceRunning && + isCardAvailableWhenServiceRunning(cardGroup, uiState) + + cardGroup == CardGroup.Profiles -> true // Profiles card is always available else -> serviceRunning && isCardAvailableWhenServiceRunning(cardGroup, uiState) } }.toSet() diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt index 32f2ed066..c434a89b0 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -14,12 +14,16 @@ import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.database.TypedProfile import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget import io.nekohasekai.sfa.utils.HTTPClient +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray @@ -156,9 +160,19 @@ class DashboardViewModel : ProfileManager.registerCallback(::onProfilesChanged) viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (_serviceStatus.value != Status.Started) return@collect - if (foreground) { + combine( + AppLifecycleObserver.isForeground, + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + _serviceStatus, + ) { foreground, remoteServer, remoteConnected, status -> + SessionTarget( + connect = foreground && + if (remoteServer != null) remoteConnected else status == Status.Started, + remoteServerId = remoteServer?.id, + ) + }.distinctUntilChanged().collect { target -> + if (target.connect) { commandClient.connect() } else { commandClient.disconnect() @@ -167,6 +181,8 @@ class DashboardViewModel : } } + private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?) + override fun onCleared() { super.onCleared() ProfileManager.unregisterCallback(::onProfilesChanged) @@ -439,7 +455,12 @@ class DashboardViewModel : updateState { copy( serviceStatus = status, - isStatusVisible = status == Status.Starting || status == Status.Started, + isStatusVisible = + if (RemoteControlManager.remoteServer.value != null) { + isStatusVisible + } else { + status == Status.Starting || status == Status.Started + }, ) } handleServiceStatusChange(status) @@ -447,18 +468,21 @@ class DashboardViewModel : } private fun handleServiceStatusChange(status: Status) { + val isRemote = RemoteControlManager.remoteServer.value != null when (status) { Status.Started -> { checkDeprecatedNotes() - if (AppLifecycleObserver.isForeground.value) { - commandClient.connect() + if (isRemote) { + return } reloadSystemProxyStatus() reloadStartedAt() } Status.Stopped -> { - commandClient.disconnect() + if (isRemote) { + return + } updateState { copy( hasGroups = false, @@ -545,7 +569,7 @@ class DashboardViewModel : fun selectClashMode(mode: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().setClashMode(mode) + CommandTarget.standaloneClient().setClashMode(mode) // Update UI state directly without reconnecting withContext(Dispatchers.Main) { updateState { @@ -562,6 +586,12 @@ class DashboardViewModel : override fun onConnected() { viewModelScope.launch(Dispatchers.Main) { updateState { copy(isStatusVisible = true) } + // Returning from remote control skipped the local reloads that + // normally run when the service starts. + if (RemoteControlManager.remoteServer.value == null && _serviceStatus.value == Status.Started) { + reloadSystemProxyStatus() + reloadStartedAt() + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt index 5d1c0f83b..8b8dd42ca 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt @@ -1,7 +1,6 @@ package io.nekohasekai.sfa.compose.screen.dashboard.groups import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.sfa.compose.base.BaseViewModel import io.nekohasekai.sfa.compose.base.ScreenEvent @@ -11,9 +10,13 @@ import io.nekohasekai.sfa.compose.model.toList import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -54,9 +57,19 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (lastServiceStatus != Status.Started) return@collect - if (foreground) { + combine( + AppLifecycleObserver.isForeground, + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + _serviceStatus, + ) { foreground, remoteServer, remoteConnected, status -> + SessionTarget( + connect = foreground && + if (remoteServer != null) remoteConnected else status == Status.Started, + remoteServerId = remoteServer?.id, + ) + }.distinctUntilChanged().collect { target -> + if (target.connect) { if (isUsingSharedClient) { commandClient.addHandler(this@GroupsViewModel) } else { @@ -74,6 +87,8 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } } + private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?) + override fun createInitialState() = GroupsUiState() override fun onCleared() { @@ -86,15 +101,10 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } private fun handleServiceStatusChange(status: Status) { - if (status == Status.Started) { - if (!isUsingSharedClient && AppLifecycleObserver.isForeground.value) { - updateState { copy(isLoading = true) } - commandClient.connect() - } - } else { - if (!isUsingSharedClient) { - commandClient.disconnect() - } + if (RemoteControlManager.remoteServer.value != null) { + return + } + if (status != Status.Started) { updateState { copy( groups = emptyList(), @@ -127,7 +137,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : } viewModelScope.launch(Dispatchers.IO) { runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded) + CommandTarget.standaloneClient().setGroupExpand(groupTag, newExpanded) } } } @@ -148,7 +158,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : viewModelScope.launch(Dispatchers.IO) { groups.forEach { group -> runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded) + CommandTarget.standaloneClient().setGroupExpand(group.tag, newExpanded) } } } @@ -165,7 +175,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : viewModelScope.launch(Dispatchers.IO) { try { // Select the new outbound immediately - Libbox.newStandaloneCommandClient().selectOutbound(groupTag, itemTag) + CommandTarget.standaloneClient().selectOutbound(groupTag, itemTag) // Update local state and show snackbar withContext(Dispatchers.Main) { @@ -193,7 +203,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : fun closeConnections() { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().closeConnections() + CommandTarget.standaloneClient().closeConnections() withContext(Dispatchers.Main) { dismissCloseConnectionsSnackbar() } @@ -215,7 +225,7 @@ class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : fun urlTest(groupTag: String) { viewModelScope.launch(Dispatchers.IO) { try { - Libbox.newStandaloneCommandClient().urlTest(groupTag) + CommandTarget.standaloneClient().urlTest(groupTag) } catch (e: Exception) { sendError(e) } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt index e4ab1f49b..326096113 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt @@ -94,8 +94,11 @@ import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R import io.nekohasekai.sfa.compat.WindowSizeClassCompat import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat +import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems +import io.nekohasekai.sfa.compose.component.rememberRemoteServers import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -124,6 +127,8 @@ fun LogScreen( val listState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() val resolvedTitle = title ?: stringResource(R.string.title_log) + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteServers by rememberRemoteServers() val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty) OverrideTopBar { @@ -471,10 +476,14 @@ fun LogScreen( ) { Text( text = if (showStatusInfo) { - when (serviceStatus) { - Status.Started -> stringResource(R.string.status_started) - Status.Starting -> stringResource(R.string.status_starting) - Status.Stopping -> stringResource(R.string.status_stopping) + when { + remoteServer != null && !uiState.isConnected -> + stringResource(R.string.remote_connecting) + + remoteServer != null -> stringResource(R.string.status_started) + serviceStatus == Status.Started -> stringResource(R.string.status_started) + serviceStatus == Status.Starting -> stringResource(R.string.status_starting) + serviceStatus == Status.Stopping -> stringResource(R.string.status_stopping) else -> stringResource(R.string.status_default) } } else { @@ -828,6 +837,13 @@ fun LogScreen( }, ) } + + if (showStatusInfo) { + RemoteControlMenuItems( + servers = remoteServers, + onAction = { resolvedViewModel.toggleOptionsMenu() }, + ) + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt index ecaf56de0..6ec9826fc 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt @@ -1,13 +1,17 @@ package io.nekohasekai.sfa.compose.screen.log import androidx.lifecycle.viewModelScope -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry import io.nekohasekai.sfa.compose.util.AnsiColorUtils import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.utils.AppLifecycleObserver import io.nekohasekai.sfa.utils.CommandClient +import io.nekohasekai.sfa.utils.CommandTarget +import io.nekohasekai.sfa.utils.RemoteControlManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -28,12 +32,23 @@ class LogViewModel : handler = this, ) private var lastServiceStatus: Status = Status.Stopped + private val serviceStatusFlow = MutableStateFlow(Status.Stopped) init { viewModelScope.launch { - AppLifecycleObserver.isForeground.collect { foreground -> - if (lastServiceStatus != Status.Started) return@collect - if (foreground) { + combine( + AppLifecycleObserver.isForeground, + RemoteControlManager.remoteServer, + RemoteControlManager.isConnected, + serviceStatusFlow, + ) { foreground, remoteServer, remoteConnected, status -> + SessionTarget( + connect = foreground && + if (remoteServer != null) remoteConnected else status == Status.Started, + remoteServerId = remoteServer?.id, + ) + }.distinctUntilChanged().collect { target -> + if (target.connect) { commandClient.connect() } else { commandClient.disconnect() @@ -42,6 +57,8 @@ class LogViewModel : } } + private data class SessionTarget(val connect: Boolean, val remoteServerId: Long?) + private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default return ProcessedLogEntry( @@ -53,17 +70,14 @@ class LogViewModel : override fun updateServiceStatus(status: Status) { lastServiceStatus = status + serviceStatusFlow.value = status _uiState.update { it.copy(serviceStatus = status) } + if (RemoteControlManager.remoteServer.value != null) { + return + } when (status) { - Status.Started -> { - if (AppLifecycleObserver.isForeground.value) { - commandClient.connect() - } - } - Status.Stopped, Status.Stopping -> { - commandClient.disconnect() _uiState.update { it.copy(isConnected = false) } } @@ -100,7 +114,7 @@ class LogViewModel : viewModelScope.launch { withContext(Dispatchers.IO) { runCatching { - Libbox.newStandaloneCommandClient().clearLogs() + CommandTarget.standaloneClient().clearLogs() } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt index 8734b431c..46b170f0d 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -53,11 +53,14 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.utils.PrivilegeSettingsClient @@ -95,10 +98,14 @@ private enum class RiskCategory { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { +fun PrivilegeSettingsManageScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var sortMode by remember { mutableStateOf(SortMode.NAME) } var sortReverse by remember { mutableStateOf(false) } @@ -176,6 +183,8 @@ fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { } if (failure != null) { syncErrorMessage = failure.message ?: failure.toString() + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt index def7b1f53..8d59caad8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -449,20 +450,23 @@ fun EditProfileContentScreen( setBackgroundColor( androidx.core.content.ContextCompat.getColor(context, android.R.color.transparent), ) - // Set up the editor with read-only state - this handles all configuration - viewModel.setEditor(this, uiState.isReadOnly) + viewModel.attachEditor(this) } }, - update = { textProcessor -> - // Re-apply configuration when read-only state changes - viewModel.setEditor(textProcessor, uiState.isReadOnly) - }, modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background), ) + LaunchedEffect(uiState.isReadOnly) { + viewModel.setReadOnly(uiState.isReadOnly) + } + + DisposableEffect(Unit) { + onDispose { viewModel.detachEditor() } + } + // Simple loading indicator at the top if (uiState.isLoading) { LinearProgressIndicator( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt index 51a082e6d..401c5bb18 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt @@ -1,5 +1,6 @@ package io.nekohasekai.sfa.compose.screen.profile +import android.text.TextWatcher import androidx.core.widget.addTextChangedListener import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider @@ -21,7 +22,6 @@ import java.io.File data class EditProfileContentUiState( val isLoading: Boolean = false, - val content: String = "", val originalContent: String = "", val hasUnsavedChanges: Boolean = false, val canUndo: Boolean = false, @@ -49,107 +49,103 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly private var profile: Profile? = null private var editor: ManualScrollTextProcessor? = null + private var textWatcher: TextWatcher? = null private var configCheckJob: Job? = null - fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) { - val isNewEditor = editor != textProcessor + private val readOnlyKeyListener = android.view.View.OnKeyListener { _, _, _ -> true } + + private val readOnlySelectionCallback = + object : android.view.ActionMode.Callback { + override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean = true + + override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean { + menu?.let { m -> + m.removeItem(android.R.id.cut) + m.removeItem(android.R.id.paste) + m.removeItem(android.R.id.pasteAsPlainText) + m.removeItem(android.R.id.replaceText) + m.removeItem(android.R.id.undo) + m.removeItem(android.R.id.redo) + m.removeItem(android.R.id.autofill) + m.removeItem(android.R.id.textAssist) + } + return true + } + + override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean = false + + override fun onDestroyActionMode(mode: android.view.ActionMode?) {} + } + + fun attachEditor(textProcessor: ManualScrollTextProcessor) { editor = textProcessor textProcessor.resumeAutoScroll() - // Always keep these for scrolling, focus, and selection textProcessor.isEnabled = true textProcessor.isFocusable = true textProcessor.isFocusableInTouchMode = true - - // Allow text selection for copying textProcessor.setTextIsSelectable(true) - - // Multi-line configuration textProcessor.setSingleLine(false) textProcessor.maxLines = Integer.MAX_VALUE textProcessor.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS textProcessor.isCursorVisible = true + textProcessor.isLongClickable = true + + textWatcher = textProcessor.addTextChangedListener { editable -> + val length = editable?.length ?: 0 + val original = _uiState.value.originalContent + val hasUnsavedChanges = when { + length != original.length -> true + editable == null -> original.isNotEmpty() + else -> editable.toString() != original + } + _uiState.update { state -> + state.copy( + canUndo = textProcessor.canUndo(), + canRedo = textProcessor.canRedo(), + hasUnsavedChanges = hasUnsavedChanges, + ) + } + scheduleConfigurationCheck() + } + } + fun setReadOnly(isReadOnly: Boolean) { + val textProcessor = editor ?: return if (isReadOnly) { - // Use a custom OnKeyListener that blocks all key input - textProcessor.setOnKeyListener { _, _, _ -> true } // Return true to consume all key events - // Enable long click for selection - textProcessor.isLongClickable = true - - // Customize text selection to remove Cut and Paste options - textProcessor.customSelectionActionModeCallback = - object : android.view.ActionMode.Callback { - override fun onCreateActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean { - // Allow the action mode to be created - return true - } - - override fun onPrepareActionMode(mode: android.view.ActionMode?, menu: android.view.Menu?): Boolean { - // Remove editing-related menu items, keep only Copy and Select All - menu?.let { m -> - // Remove all editing-related items - m.removeItem(android.R.id.cut) - m.removeItem(android.R.id.paste) - m.removeItem(android.R.id.pasteAsPlainText) - m.removeItem(android.R.id.replaceText) - m.removeItem(android.R.id.undo) - m.removeItem(android.R.id.redo) - m.removeItem(android.R.id.autofill) - m.removeItem(android.R.id.textAssist) - } - return true - } - - override fun onActionItemClicked(mode: android.view.ActionMode?, item: android.view.MenuItem?): Boolean { - // Let the default implementation handle allowed actions (copy, select all) - return false - } - - override fun onDestroyActionMode(mode: android.view.ActionMode?) { - // No special cleanup needed - } - } + textProcessor.setOnKeyListener(readOnlyKeyListener) + textProcessor.customSelectionActionModeCallback = readOnlySelectionCallback } else { - // For editable mode, remove the blocking listener textProcessor.setOnKeyListener(null) - // Remove the custom selection callback to allow all text operations textProcessor.customSelectionActionModeCallback = null - - // Only add text change listener for new editors in editable mode - if (isNewEditor) { - textProcessor.addTextChangedListener { editable -> - val currentText = editable?.toString() ?: "" - _uiState.update { state -> - state.copy( - content = currentText, - canUndo = textProcessor.canUndo(), - canRedo = textProcessor.canRedo(), - hasUnsavedChanges = currentText != state.originalContent, - ) - } - - // Schedule background configuration check - scheduleConfigurationCheck(currentText) - } - } } } - private fun scheduleConfigurationCheck(content: String) { - // Cancel previous check + fun detachEditor() { + val textProcessor = editor ?: return + textWatcher?.let { textProcessor.removeTextChangedListener(it) } + textWatcher = null + editor = null configCheckJob?.cancel() + configCheckJob = null + } - // Clear error immediately when user is typing - _uiState.update { it.copy(configurationError = null) } + private fun scheduleConfigurationCheck() { + configCheckJob?.cancel() + + if (_uiState.value.configurationError != null) { + _uiState.update { it.copy(configurationError = null) } + } - // Schedule new check after 2 seconds of inactivity configCheckJob = viewModelScope.launch { - delay(2000) // Wait 2 seconds - - // Check configuration in background + delay(2000) + val content = + withContext(Dispatchers.Main) { + editor?.text?.toString().orEmpty() + } checkConfigurationInBackground(content) } } @@ -206,7 +202,6 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly } _uiState.update { it.copy( - content = content, originalContent = content, hasUnsavedChanges = false, isLoading = false, @@ -480,21 +475,28 @@ class EditProfileContentViewModel(private val profileId: Long, initialIsReadOnly fun insertSymbol(symbol: String) { editor?.let { textProcessor -> - val start = textProcessor.selectionStart - val end = textProcessor.selectionEnd - val text = textProcessor.text + val text = textProcessor.text ?: return@let + val rawStart = textProcessor.selectionStart + val rawEnd = textProcessor.selectionEnd + // selectionStart/End can be reversed (backward drag-selection) or -1 (no cursor) + val start: Int + val end: Int + if (rawStart < 0 || rawEnd < 0) { + start = text.length + end = text.length + } else { + start = minOf(rawStart, rawEnd).coerceIn(0, text.length) + end = maxOf(rawStart, rawEnd).coerceIn(0, text.length) + } - if (text != null) { - val newText = - StringBuilder(text) - .replace(start, end, symbol) - .toString() + val newText = + StringBuilder(text) + .replace(start, end, symbol) + .toString() - textProcessor.resumeAutoScroll() - textProcessor.setTextContent(newText) - // Place cursor after the inserted symbol - textProcessor.setSelection(start + symbol.length) - } + textProcessor.resumeAutoScroll() + textProcessor.setTextContent(newText) + textProcessor.setSelection(start + symbol.length) } } diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt index 74b89866b..3c553b27b 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -77,11 +77,14 @@ import androidx.compose.ui.window.DialogProperties import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.shared.AppSelectionCard import io.nekohasekai.sfa.compose.shared.PackageCache import io.nekohasekai.sfa.compose.shared.SortMode import io.nekohasekai.sfa.compose.shared.buildDisplayPackages import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.vendor.PackageQueryManager @@ -106,10 +109,14 @@ private sealed class ScanResult { @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PerAppProxyScreen(onBack: () -> Unit) { +fun PerAppProxyScreen( + onBack: () -> Unit, + serviceStatus: Status = Status.Stopped, +) { val context = LocalContext.current val focusManager = LocalFocusManager.current val coroutineScope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } var sortMode by remember { mutableStateOf(SortMode.NAME) } @@ -164,7 +171,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { fun saveSelectedApplications(newUids: Set) { coroutineScope.launch { - Settings.perAppProxyList = buildPackageList(newUids) + withContext(Dispatchers.IO) { + Settings.perAppProxyList = buildPackageList(newUids) + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } @@ -323,7 +333,10 @@ fun PerAppProxyScreen(onBack: () -> Unit) { onModeChange = { mode -> proxyMode = mode coroutineScope.launch { - Settings.perAppProxyMode = mode + withContext(Dispatchers.IO) { + Settings.perAppProxyMode = mode + } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } }, onSortModeChange = { mode -> diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt index 352efddf5..d98651efe 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Terminal import androidx.compose.material.icons.outlined.AdminPanelSettings import androidx.compose.material.icons.outlined.Autorenew import androidx.compose.material.icons.outlined.DeleteForever @@ -88,8 +89,11 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.clipboardText import io.nekohasekai.sfa.update.UpdateCheckException @@ -110,7 +114,10 @@ import android.provider.Settings as AndroidSettings @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun AppSettingsScreen(navController: NavController) { +fun AppSettingsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.title_app_settings)) }, @@ -156,6 +163,7 @@ fun AppSettingsScreen(navController: NavController) { var notificationEnabled by remember { mutableStateOf(true) } var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } var showDisableNotificationDialog by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) var showLanguageDialog by remember { mutableStateOf(false) } val availableLocales = remember { getSupportedLocales(context) } @@ -612,6 +620,50 @@ fun AppSettingsScreen(navController: NavController) { Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tailscale), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_terminal_config), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { navController.navigate("settings/tailscale/terminal_config") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( text = stringResource(R.string.notification_settings), style = MaterialTheme.typography.labelLarge, @@ -680,6 +732,9 @@ fun AppSettingsScreen(navController: NavController) { dynamicNotification = checked scope.launch(Dispatchers.IO) { Settings.dynamicNotification = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt new file mode 100644 index 000000000..957aa7128 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/EditRemoteServerScreen.kt @@ -0,0 +1,197 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.RemoteServer +import io.nekohasekai.sfa.database.RemoteServerManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditRemoteServerScreen(navController: NavController, serverId: Long = -1L) { + val isNewServer = serverId == -1L + + OverrideTopBar { + TopAppBar( + title = { + Text( + stringResource( + if (isNewServer) { + R.string.remote_new_server + } else { + R.string.remote_edit_server + }, + ), + ) + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val scope = rememberCoroutineScope() + var origin by remember { mutableStateOf(null) } + var name by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var secret by remember { mutableStateOf("") } + var secretVisible by remember { mutableStateOf(false) } + var urlError by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(!isNewServer) } + + LaunchedEffect(serverId) { + if (!isNewServer) { + val server = withContext(Dispatchers.IO) { RemoteServerManager.get(serverId) } + if (server == null) { + navController.navigateUp() + return@LaunchedEffect + } + origin = server + name = server.name + url = server.url + secret = server.secret + isLoading = false + } + } + + if (isLoading) { + return + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.profile_name)) }, + placeholder = { Text(stringResource(R.string.remote_optional)) }, + singleLine = true, + ) + + OutlinedTextField( + value = url, + onValueChange = { + url = it + urlError = false + }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.profile_url)) }, + placeholder = { Text(stringResource(R.string.profile_input_required)) }, + isError = urlError, + supportingText = + if (urlError) { + { Text(stringResource(R.string.remote_invalid_url, url)) } + } else { + null + }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + ) + + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(R.string.remote_secret)) }, + placeholder = { Text(stringResource(R.string.remote_optional)) }, + singleLine = true, + visualTransformation = + if (secretVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { secretVisible = !secretVisible }) { + Icon( + imageVector = + if (secretVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + + Button( + onClick = { + val validatedURL = RemoteServer.validateURL(url) + if (validatedURL == null) { + urlError = true + return@Button + } + scope.launch(Dispatchers.IO) { + val server = origin ?: RemoteServer() + server.name = name.trim() + server.url = validatedURL + server.secret = secret + if (origin != null) { + RemoteServerManager.update(server) + } else { + RemoteServerManager.create(server) + } + withContext(Dispatchers.Main) { + navController.navigateUp() + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.save)) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt index dbcf6bc42..0187c20f8 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -62,9 +62,9 @@ import androidx.core.content.FileProvider import androidx.navigation.NavController import io.nekohasekai.libbox.Libbox import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.compose.base.GlobalEventBus import io.nekohasekai.sfa.compose.base.SelectableMessageDialog import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings @@ -101,6 +101,7 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status val context = LocalContext.current val scope = rememberCoroutineScope() + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val systemHookStatus by HookStatusClient.status.collectAsState() var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } @@ -198,8 +199,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -608,8 +609,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (checked && serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, @@ -716,8 +717,8 @@ fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status messageDialogTitle = context.getString(R.string.error_title) messageDialogMessage = failure.message ?: failure.toString() showMessageDialog = true - } else if (serviceStatus == Status.Started) { - GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } }, diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt index 22370e8a5..6d1688bbe 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -57,8 +57,11 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.vendor.PackageQueryManager import kotlinx.coroutines.Dispatchers @@ -67,7 +70,10 @@ import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ProfileOverrideScreen(navController: NavController) { +fun ProfileOverrideScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.profile_override)) }, @@ -89,8 +95,9 @@ fun ProfileOverrideScreen(navController: NavController) { var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } var isScanning by remember { mutableStateOf(false) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) - fun scanAndSaveManagedList() { + fun scanAndSaveManagedList(shouldNotify: Boolean = false) { isScanning = true scope.launch { val chinaApps = PerAppProxyScanner.scanAllChinaApps() @@ -98,6 +105,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyManagedList = chinaApps } isScanning = false + if (shouldNotify) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } @@ -169,7 +179,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } } @@ -227,6 +239,7 @@ fun ProfileOverrideScreen(navController: NavController) { withContext(Dispatchers.IO) { Settings.autoRedirect = true } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } else { Toast.makeText( context, @@ -239,6 +252,9 @@ fun ProfileOverrideScreen(navController: NavController) { autoRedirect = false scope.launch(Dispatchers.IO) { Settings.autoRedirect = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -364,9 +380,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = checked scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = checked + if (!checked || !managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (checked && managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } } }, @@ -475,11 +496,14 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = true } - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } else { managedModeEnabled = false scope.launch(Dispatchers.IO) { Settings.perAppProxyManagedMode = false + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } } }, @@ -515,9 +539,14 @@ fun ProfileOverrideScreen(navController: NavController) { perAppProxyEnabled = true scope.launch(Dispatchers.IO) { Settings.perAppProxyEnabled = true + if (!managedModeEnabled) { + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } + } } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) } }, ) { @@ -593,7 +622,9 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = true } if (managedModeEnabled) { - scanAndSaveManagedList() + scanAndSaveManagedList(shouldNotify = true) + } else { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) } } else { showRootDialog = false @@ -652,6 +683,7 @@ fun ProfileOverrideScreen(navController: NavController) { Settings.perAppProxyEnabled = false } } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( @@ -672,6 +704,7 @@ fun ProfileOverrideScreen(navController: NavController) { scope.launch(Dispatchers.IO) { Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT } + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) showModeDialog = false }, colors = ListItemDefaults.colors( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt new file mode 100644 index 000000000..b026933d1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/RemoteControlScreen.kt @@ -0,0 +1,243 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.RemoteServer +import io.nekohasekai.sfa.database.RemoteServerManager +import io.nekohasekai.sfa.utils.RemoteControlManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun RemoteControlScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.remote_control)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + IconButton(onClick = { navController.navigate("settings/remote_control/new") }) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.remote_new_server), + ) + } + }, + ) + } + + val scope = rememberCoroutineScope() + var servers by remember { mutableStateOf>(emptyList()) } + val activeRemoteServer by RemoteControlManager.remoteServer.collectAsState() + + LaunchedEffect(Unit) { + servers = withContext(Dispatchers.IO) { RemoteServerManager.list() } + } + DisposableEffect(Unit) { + val callback: () -> Unit = { + scope.launch { + servers = withContext(Dispatchers.IO) { RemoteServerManager.list() } + } + } + RemoteServerManager.registerCallback(callback) + onDispose { + RemoteServerManager.unregisterCallback(callback) + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.remote_servers), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + if (servers.isEmpty()) { + Text( + text = stringResource(R.string.remote_no_servers), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp), + ) + } else { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + servers.forEachIndexed { index, server -> + val shape = + when { + servers.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == servers.size - 1 -> + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + + else -> RoundedCornerShape(0.dp) + } + var showMenu by remember { mutableStateOf(false) } + Box { + ListItem( + headlineContent = { + Text( + server.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = + if (server.name.isNotEmpty()) { + { + Text( + server.url, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + null + }, + trailingContent = + if (activeRemoteServer?.id == server.id) { + { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = + Modifier + .clip(shape) + .combinedClickable( + onClick = { + navController.navigate( + "settings/remote_control/edit/${server.id}", + ) + }, + onLongClick = { showMenu = true }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit)) }, + leadingIcon = { + Icon(Icons.Outlined.Edit, contentDescription = null) + }, + onClick = { + showMenu = false + navController.navigate( + "settings/remote_control/edit/${server.id}", + ) + }, + ) + DropdownMenuItem( + text = { + Text( + stringResource(R.string.menu_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showMenu = false + scope.launch(Dispatchers.IO) { + if (RemoteControlManager.remoteServer.value?.id == server.id) { + withContext(Dispatchers.Main) { + RemoteControlManager.exitRemoteControl() + } + } + RemoteServerManager.delete(server) + } + }, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt index b6038d932..2171997f9 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -57,15 +57,23 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import io.nekohasekai.sfa.R import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.launchCustomTab import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) { +fun ServiceSettingsScreen( + navController: NavController, + serviceConnection: ServiceConnection? = null, + serviceStatus: Status = Status.Stopped, +) { OverrideTopBar { TopAppBar( title = { Text(stringResource(R.string.service)) }, @@ -84,6 +92,7 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi val scope = rememberCoroutineScope() var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } var allowBypass by remember { mutableStateOf(Settings.allowBypass) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) val requestBatteryOptimizationLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), @@ -255,6 +264,9 @@ fun ServiceSettingsScreen(navController: NavController, serviceConnection: Servi allowBypass = checked scope.launch(Dispatchers.IO) { Settings.allowBypass = checked + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Reload) + } } }, ) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt index b7442c9bf..aabcea4a2 100644 --- a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.SettingsRemote import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Badge import androidx.compose.material3.Card @@ -185,6 +186,29 @@ fun SettingsScreen(navController: NavController) { ), ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.remote_control), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SettingsRemote, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { navController.navigate("settings/remote_control") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + ListItem( headlineContent = { Text( diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleFontPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleFontPickerScreen.kt new file mode 100644 index 000000000..f5cfb0eb3 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleFontPickerScreen.kt @@ -0,0 +1,212 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.graphics.Typeface +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.terminal.ImportedFont +import io.nekohasekai.sfa.terminal.ImportedFontStore + +private val knownMonospaceFamilies = listOf( + "monospace", + "Droid Sans Mono", + "Courier New", + "Courier", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleFontPickerScreen(navController: NavController) { + val context = LocalContext.current + var selectedFamily by remember { mutableStateOf(Settings.tailscaleSSHFontFamily) } + var selectedCustomPath by remember { mutableStateOf(Settings.tailscaleSSHCustomFontPath) } + var importedFonts by remember { mutableStateOf(ImportedFontStore.listImportedFonts(context)) } + + val systemFonts = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + enumerateSystemMonospaceFonts() + } else { + knownMonospaceFamilies + } + } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri -> + if (uri != null) { + val imported = ImportedFontStore.importFont(context, uri) + if (imported != null) { + importedFonts = ImportedFontStore.listImportedFonts(context) + selectedFamily = "" + selectedCustomPath = imported.path + Settings.tailscaleSSHFontFamily = "" + Settings.tailscaleSSHCustomFontPath = imported.path + } + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tailscale_terminal_font)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + LazyColumn(modifier = Modifier.weight(1f)) { + item { + Text( + text = stringResource(R.string.tailscale_terminal_font_system), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + } + item { + val isDefault = selectedFamily.isBlank() && selectedCustomPath.isBlank() + ListItem( + headlineContent = { Text(stringResource(R.string.tailscale_terminal_font_default)) }, + leadingContent = { + RadioButton(selected = isDefault, onClick = null) + }, + modifier = Modifier.clickable { + selectedFamily = "" + selectedCustomPath = "" + Settings.tailscaleSSHFontFamily = "" + Settings.tailscaleSSHCustomFontPath = "" + }, + ) + } + items(systemFonts) { family -> + val isSelected = selectedFamily == family && selectedCustomPath.isBlank() + ListItem( + headlineContent = { Text(family) }, + leadingContent = { + RadioButton(selected = isSelected, onClick = null) + }, + modifier = Modifier.clickable { + selectedFamily = family + selectedCustomPath = "" + Settings.tailscaleSSHFontFamily = family + Settings.tailscaleSSHCustomFontPath = "" + }, + ) + } + + if (importedFonts.isNotEmpty()) { + item { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tailscale_terminal_font_imported), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + } + items(importedFonts) { font -> + val isSelected = selectedCustomPath == font.path + ListItem( + headlineContent = { Text(font.name) }, + leadingContent = { + RadioButton(selected = isSelected, onClick = null) + }, + trailingContent = { + IconButton(onClick = { + ImportedFontStore.deleteFont(context, font.name) + importedFonts = ImportedFontStore.listImportedFonts(context) + if (isSelected) { + selectedFamily = "" + selectedCustomPath = "" + Settings.tailscaleSSHFontFamily = "" + Settings.tailscaleSSHCustomFontPath = "" + } + }) { + Icon( + Icons.Outlined.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + }, + modifier = Modifier.clickable { + selectedFamily = "" + selectedCustomPath = font.path + Settings.tailscaleSSHFontFamily = "" + Settings.tailscaleSSHCustomFontPath = font.path + }, + ) + } + } + } + + Button( + onClick = { importLauncher.launch("font/*") }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text(stringResource(R.string.tailscale_terminal_import_font)) + } + } +} + +private fun enumerateSystemMonospaceFonts(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return knownMonospaceFamilies + + val families = mutableSetOf() + try { + val monoReference = Typeface.MONOSPACE + for (family in knownMonospaceFamilies) { + val typeface = Typeface.create(family, Typeface.NORMAL) + if (typeface != Typeface.DEFAULT) { + families.add(family) + } + } + } catch (_: Exception) { + } + return families.sorted() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleTerminalConfigScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleTerminalConfigScreen.kt new file mode 100644 index 000000000..a5f6224d1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleTerminalConfigScreen.kt @@ -0,0 +1,224 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleTerminalConfigScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tailscale_terminal_config)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + var lightTheme by remember { mutableStateOf(Settings.tailscaleSSHLightTheme) } + var darkTheme by remember { mutableStateOf(Settings.tailscaleSSHDarkTheme) } + var fontFamily by remember { mutableStateOf(Settings.tailscaleSSHFontFamily) } + var customFontPath by remember { mutableStateOf(Settings.tailscaleSSHCustomFontPath) } + var fontSize by remember { mutableIntStateOf(Settings.tailscaleSSHFontSize) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.tailscale_terminal_color_theme), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { Text(stringResource(R.string.tailscale_terminal_light_theme)) }, + trailingContent = { + Text( + lightTheme.ifBlank { "Default" }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { + navController.navigate("settings/tailscale/theme_picker/${Uri.encode("false")}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { Text(stringResource(R.string.tailscale_terminal_dark_theme)) }, + trailingContent = { + Text( + darkTheme.ifBlank { "Default" }, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + navController.navigate("settings/tailscale/theme_picker/${Uri.encode("true")}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.tailscale_terminal_font_config), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { Text(stringResource(R.string.tailscale_terminal_font)) }, + trailingContent = { + val fontDisplayName = when { + customFontPath.isNotBlank() -> java.io.File(customFontPath).nameWithoutExtension + fontFamily.isNotBlank() -> fontFamily + else -> stringResource(R.string.tailscale_terminal_font_default) + } + Text( + fontDisplayName, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { + navController.navigate("settings/tailscale/font_picker") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { Text(stringResource(R.string.tailscale_terminal_font_size)) }, + trailingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + onClick = { + if (fontSize > 8) { + fontSize-- + Settings.tailscaleSSHFontSize = fontSize + } + }, + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(32.dp), + ) { + IconButton(onClick = { + if (fontSize > 8) { + fontSize-- + Settings.tailscaleSSHFontSize = fontSize + } + }) { + Icon(Icons.Default.Remove, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + Text( + "$fontSize", + style = MaterialTheme.typography.bodyMedium, + ) + Surface( + onClick = { + if (fontSize < 32) { + fontSize++ + Settings.tailscaleSSHFontSize = fontSize + } + }, + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = Modifier.size(32.dp), + ) { + IconButton(onClick = { + if (fontSize < 32) { + fontSize++ + Settings.tailscaleSSHFontSize = fontSize + } + }) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleThemePickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleThemePickerScreen.kt new file mode 100644 index 000000000..c7866ac24 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/TailscaleThemePickerScreen.kt @@ -0,0 +1,126 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.terminal.TerminalColorSchemeLoader + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleThemePickerScreen( + navController: NavController, + isDark: Boolean, +) { + val context = LocalContext.current + var searchQuery by remember { mutableStateOf("") } + var selectedTheme by remember { + mutableStateOf(if (isDark) Settings.tailscaleSSHDarkTheme else Settings.tailscaleSSHLightTheme) + } + var schemes by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + schemes = TerminalColorSchemeLoader.listSchemes(context, isDark) + } + + val filteredSchemes = if (searchQuery.isBlank()) { + schemes + } else { + schemes.filter { it.contains(searchQuery, ignoreCase = true) } + } + + OverrideTopBar { + TopAppBar( + title = { + Text( + if (isDark) { + stringResource(R.string.tailscale_terminal_dark_theme) + } else { + stringResource(R.string.tailscale_terminal_light_theme) + }, + ) + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + ) { + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = searchQuery, + onQueryChange = { searchQuery = it }, + onSearch = {}, + expanded = false, + onExpandedChange = {}, + placeholder = { Text(stringResource(R.string.tailscale_terminal_search_themes)) }, + ) + }, + expanded = false, + onExpandedChange = {}, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) {} + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(filteredSchemes) { scheme -> + ListItem( + headlineContent = { Text(scheme) }, + leadingContent = { + RadioButton( + selected = scheme == selectedTheme, + onClick = null, + ) + }, + modifier = Modifier.clickable { + selectedTheme = scheme + if (isDark) { + Settings.tailscaleSSHDarkTheme = scheme + } else { + Settings.tailscaleSSHLightTheme = scheme + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt new file mode 100644 index 000000000..bd7d71267 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportDetailScreen.kt @@ -0,0 +1,459 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReport +import io.nekohasekai.sfa.bg.CrashReportFile +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportDetailScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = CrashReportManager.availableFiles(report) + } + CrashReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && CrashReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = CrashReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + CrashReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + CrashReportFile.Kind.METADATA -> Icons.Default.DataObject + CrashReportFile.Kind.GO_LOG -> Icons.Default.Terminal + CrashReportFile.Kind.JVM_LOG -> Icons.Outlined.BugReport + CrashReportFile.Kind.CONFIG -> Icons.Outlined.Settings + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .clickable { + if (file.kind == CrashReportFile.Kind.METADATA) { + navController.navigate("tools/crash_report/$reportId/metadata") + } else { + navController.navigate("tools/crash_report/$reportId/file/${file.kind.name}") + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportMetadataScreen(navController: NavController, reportId: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp, + ) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + key, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(value) + }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } + } + } +} + +private fun loadMetadataEntries(report: CrashReport): List> { + val metadataFile = CrashReportManager.availableFiles(report) + .find { it.kind == CrashReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by CrashReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { CrashReportFile.Kind.valueOf(fileKind) }.getOrNull() + val displayName = when (kind) { + CrashReportFile.Kind.GO_LOG -> stringResource(R.string.crash_report_go_log) + CrashReportFile.Kind.JVM_LOG -> stringResource(R.string.crash_report_jvm_log) + CrashReportFile.Kind.CONFIG -> stringResource(R.string.report_configuration) + else -> fileKind + } + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = CrashReportManager.availableFiles(report).find { it.kind == kind } + content = if (file != null) CrashReportManager.loadFileContent(file) else "" + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt new file mode 100644 index 000000000..aa238a00a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/CrashReportListScreen.kt @@ -0,0 +1,263 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.FlashOn +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CrashReportListScreen(navController: NavController) { + val reports by CrashReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + var crashTriggerExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + CrashReportManager.refresh() + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.crash_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (reports.isNotEmpty() || BuildConfig.DEBUG) { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { + menuExpanded = false + crashTriggerExpanded = false + }, + ) { + if (BuildConfig.DEBUG) { + DropdownMenuItem( + text = { Text("Crash Trigger") }, + leadingIcon = { + Icon( + Icons.Outlined.FlashOn, + contentDescription = null, + ) + }, + onClick = { crashTriggerExpanded = !crashTriggerExpanded }, + ) + if (crashTriggerExpanded) { + DropdownMenuItem( + text = { + Text( + "Go Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Libbox.triggerGoPanic() + }, + ) + DropdownMenuItem( + text = { + Text( + "Native Crash", + modifier = Modifier.padding(start = 16.dp), + ) + }, + onClick = { + menuExpanded = false + crashTriggerExpanded = false + Thread { + Thread.sleep(200) + throw RuntimeException("debug native crash") + }.start() + }, + ) + } + } + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { + CrashReportManager.deleteAll() + } + }, + ) + } + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/crash_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.crash_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + } + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt new file mode 100644 index 000000000..8e44750cf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityScreen.kt @@ -0,0 +1,475 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NetworkQualityScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: NetworkQualityViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val serviceAvailable = remoteServer != null || serviceStatus == Status.Started + val vpnRunning = + if (remoteServer != null) remoteConnected else serviceStatus == Status.Started + val context = LocalContext.current + + var showConfigURLDialog by remember { mutableStateOf(false) } + var showMaxRuntimeDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.network_quality)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (state.showMeteredWarning) { + AlertDialog( + onDismissRequest = { viewModel.dismissMeteredWarning() }, + title = { Text(stringResource(R.string.network_quality_metered_title)) }, + text = { Text(stringResource(R.string.network_quality_metered_message)) }, + confirmButton = { + TextButton(onClick = { viewModel.confirmMeteredStart(serviceAvailable) }) { + Text(stringResource(R.string.network_quality_metered_continue)) + } + }, + dismissButton = { + TextButton(onClick = { viewModel.dismissMeteredWarning() }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (showConfigURLDialog) { + ConfigURLDialog( + currentURL = state.configURL, + onURLChanged = { viewModel.updateConfigURL(it) }, + onDismiss = { showConfigURLDialog = false }, + ) + } + + if (showMaxRuntimeDialog) { + MaxRuntimeDialog( + currentOption = state.maxRuntime, + onOptionSelected = { + viewModel.setMaxRuntime(it) + showMaxRuntimeDialog = false + }, + onDismiss = { showMaxRuntimeDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showConfigURLDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.network_quality_url), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.configURL, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_serial), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.serial, + onCheckedChange = { viewModel.setSerial(it) }, + enabled = !state.isRunning, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.network_quality_http3), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = state.http3, + onCheckedChange = { viewModel.setHttp3(it) }, + enabled = !state.isRunning, + ) + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = !state.isRunning) { showMaxRuntimeDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.network_quality_max_runtime), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + stringResource(state.maxRuntime.labelRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.network_quality_cancel)) + } + } else { + Button( + onClick = { viewModel.requestStartTest(context, serviceAvailable) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.network_quality_start)) + } + } + + if (state.phase >= 0) { + val phaseDownload = Libbox.NetworkQualityPhaseDownload.toInt() + val phaseUpload = Libbox.NetworkQualityPhaseUpload.toInt() + val downloadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseDownload + val uploadActive = + (state.isRunning && !state.serial && state.phase in phaseDownload..phaseUpload) || state.phase == phaseUpload + val done = state.phase == Libbox.NetworkQualityPhaseDone.toInt() + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.network_quality_idle_latency), + value = if (state.idleLatencyMs > 0) "${state.idleLatencyMs} ms" else null, + isActive = state.phase == Libbox.NetworkQualityPhaseIdle.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download), + value = if (state.downloadCapacity > 0) Libbox.formatBitrate(state.downloadCapacity) else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_download_rpm), + value = if (state.downloadRPM > 0) "${state.downloadRPM}" else null, + isActive = downloadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.downloadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.downloadRPMAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload), + value = if (state.uploadCapacity > 0) Libbox.formatBitrate(state.uploadCapacity) else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadCapacityAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadCapacityAccuracy).second else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.network_quality_upload_rpm), + value = if (state.uploadRPM > 0) "${state.uploadRPM}" else null, + isActive = uploadActive, + isRunning = state.isRunning, + accuracy = if (done) accuracyLabel(state.uploadRPMAccuracy).first else null, + accuracyColor = if (done) accuracyLabel(state.uploadRPMAccuracy).second else null, + ) + } + } + } + } +} + +@Composable +private fun accuracyLabel(value: Int): Pair = when (value) { + Libbox.NetworkQualityAccuracyHigh -> stringResource(R.string.network_quality_confidence_high) to Color.Green + Libbox.NetworkQualityAccuracyMedium -> stringResource(R.string.network_quality_confidence_medium) to Color.Yellow + else -> stringResource(R.string.network_quality_confidence_low) to Color.Red +} + +@Composable +private fun ConfigURLDialog( + currentURL: String, + onURLChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentURL) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_url)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onURLChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun MaxRuntimeDialog( + currentOption: MaxRuntimeOption, + onOptionSelected: (MaxRuntimeOption) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.network_quality_max_runtime)) }, + text = { + Column { + MaxRuntimeOption.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onOptionSelected(option) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentOption == option, + onClick = { onOptionSelected(option) }, + ) + Text( + text = stringResource(option.labelRes), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt new file mode 100644 index 000000000..26285f5bf --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/NetworkQualityViewModel.kt @@ -0,0 +1,226 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Context +import android.net.ConnectivityManager +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.NetworkQualityProgress +import io.nekohasekai.libbox.NetworkQualityResult +import io.nekohasekai.libbox.NetworkQualityTestHandler +import io.nekohasekai.libbox.NetworkQualityTestSession +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +enum class MaxRuntimeOption(val seconds: Int, val labelRes: Int) { + THIRTY(30, R.string.network_quality_max_runtime_30s), + SIXTY(60, R.string.network_quality_max_runtime_60s), +} + +data class NetworkQualityState( + val phase: Int = -1, + val idleLatencyMs: Int = 0, + val downloadCapacity: Long = 0, + val uploadCapacity: Long = 0, + val downloadRPM: Int = 0, + val uploadRPM: Int = 0, + val downloadCapacityAccuracy: Int = 0, + val uploadCapacityAccuracy: Int = 0, + val downloadRPMAccuracy: Int = 0, + val uploadRPMAccuracy: Int = 0, + val isRunning: Boolean = false, + val configURL: String = Libbox.NetworkQualityDefaultConfigURL, + val serial: Boolean = false, + val http3: Boolean = false, + val maxRuntime: MaxRuntimeOption = MaxRuntimeOption.THIRTY, + val selectedOutbound: String = "", + val showMeteredWarning: Boolean = false, +) + +class NetworkQualityViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.NetworkQualityTest? = null + private var nqSession: NetworkQualityTestSession? = null + + override fun createInitialState() = NetworkQualityState() + + fun updateConfigURL(url: String) { + updateState { copy(configURL = url) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun setSerial(value: Boolean) { + updateState { copy(serial = value) } + } + + fun setHttp3(value: Boolean) { + updateState { copy(http3 = value) } + } + + fun setMaxRuntime(option: MaxRuntimeOption) { + updateState { copy(maxRuntime = option) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun requestStartTest(context: Context, vpnRunning: Boolean) { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (connectivityManager.isActiveNetworkMetered) { + updateState { copy(showMeteredWarning = true) } + } else { + startTest(vpnRunning) + } + } + + fun dismissMeteredWarning() { + updateState { copy(showMeteredWarning = false) } + } + + fun confirmMeteredStart(vpnRunning: Boolean) { + updateState { copy(showMeteredWarning = false) } + startTest(vpnRunning) + } + + private fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + idleLatencyMs = 0, + downloadCapacity = 0, + uploadCapacity = 0, + downloadRPM = 0, + uploadRPM = 0, + downloadCapacityAccuracy = 0, + uploadCapacityAccuracy = 0, + downloadRPMAccuracy = 0, + uploadRPMAccuracy = 0, + isRunning = true, + ) + } + + val configURL = currentState.configURL + val outboundTag = currentState.selectedOutbound + val serial = currentState.serial + val http3 = currentState.http3 + val maxRuntimeSeconds = currentState.maxRuntime.seconds + val handler = createHandler() + + if (vpnRunning) { + viewModelScope.launch(Dispatchers.IO) { + try { + nqSession = + CommandTarget.standaloneClient() + .startNetworkQualityTest( + configURL, + outboundTag, + serial, + maxRuntimeSeconds, + http3, + handler, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + nqSession = null + sendError(e) + } + } + } + } else { + val test = Libbox.newNetworkQualityTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(configURL, serial, maxRuntimeSeconds, http3, handler) + } + } + } + } + + fun cancelTest() { + try { + nqSession?.close() + } catch (_: Exception) { + } + nqSession = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + override fun onCleared() { + cancelTest() + super.onCleared() + } + + private fun createHandler(): NetworkQualityTestHandler { + return object : NetworkQualityTestHandler { + override fun onProgress(progress: NetworkQualityProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + idleLatencyMs = progress.idleLatencyMs.toInt(), + downloadCapacity = progress.downloadCapacity, + uploadCapacity = progress.uploadCapacity, + downloadRPM = progress.downloadRPM.toInt(), + uploadRPM = progress.uploadRPM.toInt(), + downloadCapacityAccuracy = progress.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = progress.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = progress.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = progress.uploadRPMAccuracy.toInt(), + ) + } + } + } + + override fun onResult(result: NetworkQualityResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.NetworkQualityPhaseDone.toInt(), + idleLatencyMs = result.idleLatencyMs.toInt(), + downloadCapacity = result.downloadCapacity, + uploadCapacity = result.uploadCapacity, + downloadRPM = result.downloadRPM.toInt(), + uploadRPM = result.uploadRPM.toInt(), + downloadCapacityAccuracy = result.downloadCapacityAccuracy.toInt(), + uploadCapacityAccuracy = result.uploadCapacityAccuracy.toInt(), + downloadRPMAccuracy = result.downloadRPMAccuracy.toInt(), + uploadRPMAccuracy = result.uploadRPMAccuracy.toInt(), + isRunning = false, + ) + } + standaloneTest = null + nqSession = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + nqSession = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt new file mode 100644 index 000000000..f7936acb9 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportDetailScreen.kt @@ -0,0 +1,451 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReport +import io.nekohasekai.sfa.bg.OOMReportFile +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.text.DateFormat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportDetailScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var files by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var shareMenuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val context = LocalContext.current + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + files = OOMReportManager.availableFiles(report) + } + OOMReportManager.markAsRead(report) + } + isLoading = false + } + + val title = if (report != null) { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(report.date) + } else { + reportId + } + + val hasConfig = report != null && OOMReportManager.hasConfigFile(report) + + fun shareReport(includeConfig: Boolean) { + val currentReport = report ?: return + scope.launch { + val zipFile = OOMReportManager.createZipArchive(currentReport, includeConfig) + val uri = FileProvider.getUriForFile(context, "${context.packageName}.cache", zipFile) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + if (!isLoading && files.isNotEmpty()) { + if (hasConfig) { + IconButton(onClick = { shareMenuExpanded = true }) { + Icon(Icons.Default.Share, contentDescription = null) + } + DropdownMenu( + expanded = shareMenuExpanded, + onDismissRequest = { shareMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = false) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.report_share_with_config)) }, + onClick = { + shareMenuExpanded = false + shareReport(includeConfig = true) + }, + ) + } + } else { + IconButton(onClick = { shareReport(includeConfig = false) }) { + Icon(Icons.Default.Share, contentDescription = null) + } + } + IconButton(onClick = { + scope.launch { + if (report != null) { + OOMReportManager.delete(report) + } + navController.navigateUp() + } + }) { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (files.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + stringResource(R.string.report_section_files), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + files.forEachIndexed { index, file -> + val shape = when { + files.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == files.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + val icon = when (file.kind) { + OOMReportFile.Kind.METADATA -> Icons.Default.DataObject + OOMReportFile.Kind.CONFIG -> Icons.Outlined.Settings + OOMReportFile.Kind.PROFILE -> Icons.Default.Terminal + } + ListItem( + headlineContent = { + Text( + file.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .then( + if (file.kind != OOMReportFile.Kind.PROFILE) { + Modifier.clickable { + if (file.kind == OOMReportFile.Kind.METADATA) { + navController.navigate("tools/oom_report/$reportId/metadata") + } else { + navController.navigate("tools/oom_report/$reportId/file/${file.kind.name}") + } + } + } else { + Modifier + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportMetadataScreen(navController: NavController, reportId: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var entries by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + LaunchedEffect(report) { + if (report != null) { + withContext(Dispatchers.IO) { + entries = loadOOMMetadataEntries(report) + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.report_metadata)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (entries.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + entries.forEachIndexed { index, (key, value) -> + val shape = when { + entries.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == entries.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text(key, style = MaterialTheme.typography.bodyLarge) + }, + supportingContent = { Text(value) }, + modifier = Modifier.clip(shape), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } +} + +private fun loadOOMMetadataEntries(report: OOMReport): List> { + val metadataFile = OOMReportManager.availableFiles(report) + .find { it.kind == OOMReportFile.Kind.METADATA } ?: return emptyList() + val content = metadataFile.file.readText() + val json = runCatching { JSONObject(content) }.getOrNull() ?: return emptyList() + return json.keys().asSequence() + .mapNotNull { key -> + val value = json.optString(key, "") + if (value.isNotBlank()) key to value else null + } + .toList() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportFileContentScreen(navController: NavController, reportId: String, fileKind: String) { + val reports by OOMReportManager.reports.collectAsState() + val report = reports.find { it.id == reportId } + var content by remember { mutableStateOf("") } + var displayName by remember { mutableStateOf(fileKind) } + var isLoading by remember { mutableStateOf(true) } + + val kind = runCatching { OOMReportFile.Kind.valueOf(fileKind) }.getOrNull() + + LaunchedEffect(report, kind) { + if (report != null && kind != null) { + withContext(Dispatchers.IO) { + val file = OOMReportManager.availableFiles(report).find { it.kind == kind } + if (file != null) { + displayName = file.displayName + content = OOMReportManager.loadFileContent(file) + } + } + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else if (content.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Text( + text = content, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt new file mode 100644 index 000000000..94baff384 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OOMReportListScreen.kt @@ -0,0 +1,416 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.DeleteSweep +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.base.rememberApplyServiceChangeNotifier +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.DateFormat +import java.util.Date + +private val memoryLimitOptions = listOf(50, 100, 200, 300, 500, 750, 1024) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OOMReportListScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, +) { + val reports by OOMReportManager.reports.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var menuExpanded by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var errorMessage by remember { mutableStateOf(null) } + val notifyApplyChange = rememberApplyServiceChangeNotifier(serviceStatus) + + var oomKillerEnabled by remember { mutableStateOf(Settings.oomKillerEnabled) } + var oomMemoryLimitMB by remember { mutableIntStateOf(Settings.oomMemoryLimitMB) } + var oomKillerKillConnections by remember { mutableStateOf(!Settings.oomKillerDisabled) } + var showMemoryLimitDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + OOMReportManager.refresh() + val storedLimit = Settings.oomMemoryLimitMB + if (!memoryLimitOptions.contains(storedLimit)) { + oomMemoryLimitMB = memoryLimitOptions.first() + Settings.oomMemoryLimitMB = oomMemoryLimitMB + } + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.oom_report)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { menuExpanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.oom_report_fetch)) }, + leadingIcon = { + Icon(Icons.Outlined.Memory, contentDescription = null) + }, + onClick = { + menuExpanded = false + if (serviceStatus != Status.Started) { + errorMessage = + Application.application.getString(R.string.service_not_started) + } else { + scope.launch { + val failure = + withContext(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().triggerOOMReport() + }.exceptionOrNull() + } + if (failure != null) { + errorMessage = failure.message ?: failure.toString() + } else { + delay(1000) + withContext(Dispatchers.IO) { + OOMReportManager.refresh() + } + } + } + } + }, + ) + if (reports.isNotEmpty()) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.report_delete_all), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Outlined.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + menuExpanded = false + scope.launch { OOMReportManager.deleteAll() } + }, + ) + } + } + }, + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Reports section + Text( + stringResource(R.string.report_section_reports), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + if (reports.isEmpty()) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.report_empty), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } else { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + reports.forEachIndexed { index, report -> + val shape = when { + reports.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == reports.lastIndex -> RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ) + else -> RoundedCornerShape(0.dp) + } + ListItem( + headlineContent = { + Text( + formatDate(report.date), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (report.isRead) FontWeight.Normal else FontWeight.SemiBold, + ) + }, + leadingContent = if (!report.isRead) { + { + Badge( + containerColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + null + }, + modifier = Modifier + .clip(shape) + .clickable { + navController.navigate("tools/oom_report/${report.id}") + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + Text( + stringResource(R.string.oom_report_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + // Settings section + Text( + stringResource(R.string.title_settings), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, end = 32.dp, top = 16.dp, bottom = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_enable_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_enable_memory_limit_description)) + }, + trailingContent = { + Switch( + checked = oomKillerEnabled, + onCheckedChange = { checked -> + oomKillerEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerEnabled = checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + AnimatedVisibility(visible = oomKillerEnabled) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_memory_limit), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(Libbox.formatMemoryBytes(oomMemoryLimitMB.toLong() * 1024L * 1024L)) + }, + modifier = Modifier.clickable { showMemoryLimitDialog = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report_kill_connections), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text(stringResource(R.string.oom_report_kill_connections_description)) + }, + trailingContent = { + Switch( + checked = oomKillerKillConnections, + onCheckedChange = { checked -> + oomKillerKillConnections = checked + scope.launch(Dispatchers.IO) { + Settings.oomKillerDisabled = !checked + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + } + } + } + } + + errorMessage?.let { message -> + AlertDialog( + onDismissRequest = { errorMessage = null }, + confirmButton = { + TextButton(onClick = { errorMessage = null }) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { Text(message) }, + ) + } + + if (showMemoryLimitDialog) { + AlertDialog( + onDismissRequest = { showMemoryLimitDialog = false }, + title = { Text(stringResource(R.string.oom_report_memory_limit)) }, + text = { + Column { + memoryLimitOptions.forEach { value -> + ListItem( + headlineContent = { + Text(Libbox.formatMemoryBytes(value.toLong() * 1024L * 1024L)) + }, + leadingContent = { + RadioButton( + selected = value == oomMemoryLimitMB, + onClick = null, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { + oomMemoryLimitMB = value + showMemoryLimitDialog = false + scope.launch(Dispatchers.IO) { + Settings.oomMemoryLimitMB = value + Application.application.reloadSetupOptions() + withContext(Dispatchers.Main) { + notifyApplyChange(UiEvent.ApplyServiceChange.Mode.Restart) + } + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + }, + confirmButton = {}, + ) + } +} + +private fun formatDate(date: Date): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt new file mode 100644 index 000000000..c20fa4cfa --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/OutboundPickerScreen.kt @@ -0,0 +1,280 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.model.GroupItem +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.utils.CommandClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class OutboundPickerViewModel : + ViewModel(), + CommandClient.Handler { + private val _outbounds = MutableStateFlow>(emptyList()) + val outbounds: StateFlow> = _outbounds.asStateFlow() + + private var commandClient: CommandClient? = null + + fun connect() { + disconnect() + commandClient = CommandClient( + viewModelScope, + CommandClient.ConnectionType.Outbounds, + this, + ) + commandClient?.connect() + } + + fun disconnect() { + commandClient?.disconnect() + commandClient = null + } + + override fun updateOutbounds(outbounds: List) { + _outbounds.value = outbounds.map { GroupItem(it) } + } + + override fun onCleared() { + disconnect() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutboundPickerScreen( + navController: NavController, + selectedOutbound: String, +) { + val viewModel: OutboundPickerViewModel = viewModel() + val outbounds by viewModel.outbounds.collectAsState() + var searchText by rememberSaveable { mutableStateOf("") } + + DisposableEffect(Unit) { + viewModel.connect() + onDispose { + viewModel.disconnect() + } + } + + val filteredOutbounds = if (searchText.isEmpty()) { + outbounds + } else { + outbounds.filter { it.tag.contains(searchText, ignoreCase = true) } + } + + fun selectOutbound(tag: String) { + navController.previousBackStackEntry?.savedStateHandle?.set("selected_outbound", tag) + navController.navigateUp() + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tool_outbound)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize(), + ) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(android.R.string.search_go)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + OutboundPickerItem( + tag = stringResource(R.string.tool_default_outbound), + isSelected = selectedOutbound.isEmpty(), + onClick = { selectOutbound("") }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + items(filteredOutbounds, key = { it.tag }) { item -> + OutboundPickerItem( + tag = item.tag, + type = Libbox.proxyDisplayType(item.type), + urlTestDelay = item.urlTestDelay, + isSelected = selectedOutbound == item.tag, + onClick = { selectOutbound(item.tag) }, + ) + } + } + } +} + +@Composable +private fun OutboundPickerItem( + tag: String, + type: String? = null, + urlTestDelay: Int = 0, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = tag, + style = MaterialTheme.typography.bodyLarge, + ) + if (type != null) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = type, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (urlTestDelay > 0) { + Text( + text = "${urlTestDelay}ms", + style = MaterialTheme.typography.bodySmall, + color = outboundDelayColor(urlTestDelay), + ) + } + } + } + } + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +fun OutboundPickerRow( + selectedOutbound: String, + onClick: () -> Unit, +) { + val displayText = if (selectedOutbound.isEmpty()) { + stringResource(R.string.tool_default_outbound) + } else { + selectedOutbound + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + stringResource(R.string.tool_outbound), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + displayText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun outboundDelayColor(delay: Int): Color { + val colorScheme = MaterialTheme.colorScheme + return when { + delay < 100 -> colorScheme.tertiary + delay < 300 -> colorScheme.primary + delay < 500 -> colorScheme.secondary + else -> colorScheme.error + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt new file mode 100644 index 000000000..24c02e4fd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ResultItem.kt @@ -0,0 +1,78 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +@Composable +fun ResultItem( + label: String, + value: String?, + isActive: Boolean, + isRunning: Boolean, + accuracy: String? = null, + valueColor: Color? = null, + accuracyColor: Color? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(label, style = MaterialTheme.typography.bodyLarge) + when { + value != null -> { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isRunning && isActive) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + Text( + value, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + color = valueColor ?: Color.Unspecified, + ) + if (accuracy != null) { + Text( + accuracy, + style = MaterialTheme.typography.labelSmall, + color = accuracyColor ?: MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + isRunning && isActive -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + else -> { + Text( + "-", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt new file mode 100644 index 000000000..538997464 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestScreen.kt @@ -0,0 +1,322 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.RemoteControlManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun STUNTestScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + viewModel: STUNTestViewModel = viewModel(), +) { + val state by viewModel.uiState.collectAsState() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + val remoteConnected by RemoteControlManager.isConnected.collectAsState() + val serviceAvailable = remoteServer != null || serviceStatus == Status.Started + val vpnRunning = + if (remoteServer != null) remoteConnected else serviceStatus == Status.Started + + var showServerDialog by remember { mutableStateOf(false) } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.stun_test)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + ) + } + + LaunchedEffect(vpnRunning) { + if (!vpnRunning) { + viewModel.onVpnDisconnected() + } + } + + val selectedOutboundResult = navController.currentBackStackEntry + ?.savedStateHandle + ?.getStateFlow("selected_outbound", state.selectedOutbound) + ?.collectAsState() + LaunchedEffect(selectedOutboundResult?.value) { + selectedOutboundResult?.value?.let { viewModel.selectOutbound(it) } + } + + DisposableEffect(Unit) { + onDispose { + if (state.isRunning) { + viewModel.cancelTest() + } + } + } + + if (showServerDialog) { + ServerEditDialog( + currentServer = state.server, + onServerChanged = { viewModel.updateServer(it) }, + onDismiss = { showServerDialog = false }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.tool_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { showServerDialog = true }, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.stun_server), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + state.server, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (vpnRunning) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + OutboundPickerRow( + selectedOutbound = state.selectedOutbound, + onClick = { + navController.navigate( + "tools/outbound_picker/${android.net.Uri.encode(state.selectedOutbound)}", + ) + }, + ) + } + } + } + + if (state.isRunning) { + Button( + onClick = { viewModel.cancelTest() }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.stun_cancel)) + } + } else { + Button( + onClick = { viewModel.startTest(serviceAvailable) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.stun_start)) + } + } + + if (state.phase >= 0) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(R.string.tool_results), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp), + ) + + ResultItem( + label = stringResource(R.string.stun_external_address), + value = state.externalAddr.ifEmpty { null }, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_latency), + value = if (state.latencyMs > 0) "${state.latencyMs} ms" else null, + isActive = state.phase == Libbox.STUNPhaseBinding.toInt(), + isRunning = state.isRunning, + ) + if (state.phase == Libbox.STUNPhaseDone.toInt() && !state.natTypeSupported) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_type_detection), + value = stringResource(R.string.stun_nat_not_supported), + isActive = false, + isRunning = false, + ) + } else { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_mapping), + value = if (state.natMapping > 0) Libbox.formatNATMapping(state.natMapping) else null, + isActive = state.phase == Libbox.STUNPhaseNATMapping.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natMapping > 0) natMappingColor(state.natMapping) else null, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + ResultItem( + label = stringResource(R.string.stun_nat_filtering), + value = if (state.natFiltering > 0) Libbox.formatNATFiltering(state.natFiltering) else null, + isActive = state.phase == Libbox.STUNPhaseNATFiltering.toInt(), + isRunning = state.isRunning, + valueColor = if (state.natFiltering > 0) natFilteringColor(state.natFiltering) else null, + ) + } + } + } + } + } +} + +private fun natMappingColor(value: Int): Color = when (value) { + Libbox.NATMappingEndpointIndependent.toInt() -> Color.Green + Libbox.NATMappingAddressDependent.toInt() -> Color.Yellow + Libbox.NATMappingAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +private fun natFilteringColor(value: Int): Color = when (value) { + Libbox.NATFilteringEndpointIndependent.toInt() -> Color.Green + Libbox.NATFilteringAddressDependent.toInt() -> Color.Yellow + Libbox.NATFilteringAddressAndPortDependent.toInt() -> Color.Red + else -> Color.Unspecified +} + +@Composable +private fun ServerEditDialog( + currentServer: String, + onServerChanged: (String) -> Unit, + onDismiss: () -> Unit, +) { + var text by remember { mutableStateOf(currentServer) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.stun_server)) }, + text = { + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton(onClick = { + onServerChanged(text) + onDismiss() + }) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt new file mode 100644 index 000000000..a550a9593 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/STUNTestViewModel.kt @@ -0,0 +1,156 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.STUNTestHandler +import io.nekohasekai.libbox.STUNTestProgress +import io.nekohasekai.libbox.STUNTestResult +import io.nekohasekai.libbox.STUNTestSession +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class STUNTestState( + val phase: Int = -1, + val externalAddr: String = "", + val latencyMs: Int = 0, + val natMapping: Int = 0, + val natFiltering: Int = 0, + val natTypeSupported: Boolean = false, + val isRunning: Boolean = false, + val server: String = Libbox.STUNDefaultServer, + val selectedOutbound: String = "", +) + +class STUNTestViewModel : BaseViewModel() { + private var standaloneTest: io.nekohasekai.libbox.STUNTest? = null + private var stunSession: STUNTestSession? = null + + override fun createInitialState() = STUNTestState() + + fun updateServer(server: String) { + updateState { copy(server = server) } + } + + fun selectOutbound(tag: String) { + updateState { copy(selectedOutbound = tag) } + } + + fun onVpnDisconnected() { + cancelTest() + updateState { copy(selectedOutbound = "") } + } + + fun startTest(vpnRunning: Boolean) { + updateState { + copy( + phase = -1, + externalAddr = "", + latencyMs = 0, + natMapping = 0, + natFiltering = 0, + natTypeSupported = false, + isRunning = true, + ) + } + + val server = currentState.server + val outboundTag = currentState.selectedOutbound + val handler = createHandler() + + if (vpnRunning) { + viewModelScope.launch(Dispatchers.IO) { + try { + stunSession = + CommandTarget.standaloneClient() + .startSTUNTest(server, outboundTag, handler) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + stunSession = null + sendError(e) + } + } + } + } else { + val test = Libbox.newSTUNTest() + standaloneTest = test + launch { + withContext(Dispatchers.IO) { + test.start(server, handler) + } + } + } + } + + fun cancelTest() { + try { + stunSession?.close() + } catch (_: Exception) { + } + stunSession = null + standaloneTest?.cancel() + standaloneTest = null + updateState { copy(isRunning = false) } + } + + override fun onCleared() { + cancelTest() + super.onCleared() + } + + private fun createHandler(): STUNTestHandler { + return object : STUNTestHandler { + override fun onProgress(progress: STUNTestProgress?) { + progress ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = progress.phase.toInt(), + externalAddr = progress.externalAddr, + latencyMs = progress.latencyMs.toInt(), + natMapping = progress.natMapping.toInt(), + natFiltering = progress.natFiltering.toInt(), + ) + } + } + } + + override fun onResult(result: STUNTestResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { + copy( + phase = Libbox.STUNPhaseDone.toInt(), + isRunning = false, + externalAddr = result.externalAddr, + latencyMs = result.latencyMs.toInt(), + natMapping = result.natMapping.toInt(), + natFiltering = result.natFiltering.toInt(), + natTypeSupported = result.natTypeSupported, + ) + } + standaloneTest = null + stunSession = null + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + standaloneTest = null + stunSession = null + if (message != null) { + sendErrorMessage(message) + } + } + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt new file mode 100644 index 000000000..bb34a1256 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleEndpointScreen.kt @@ -0,0 +1,525 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Intent +import android.net.Uri +import android.text.format.DateUtils +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.QRCodeGenerator + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleEndpointScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + sshSharedViewModel: TailscaleSSHSharedViewModel, + endpointTag: String, +) { + OverrideTopBar { + TopAppBar( + title = { Text(endpointTag) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + val state by viewModel.uiState.collectAsState() + val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag } + + if (endpoint == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + val context = LocalContext.current + var showAuthQRCode by remember { mutableStateOf(false) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + val hasThisDevice = endpoint.backendState == "Running" && endpoint.selfPeer != null + val hasExitNode = endpoint.backendState == "Running" && endpoint.hasExitNodeCandidates + val hasAuth = endpoint.authURL.isNotEmpty() + + // Status section + SectionHeader(stringResource(R.string.tailscale_status)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + val stateIsLast = !hasThisDevice && !hasExitNode && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_state), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Filled.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + supportingContent = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(stateColor(endpoint.backendState)), + ) + Text( + endpoint.backendState, + style = MaterialTheme.typography.bodyMedium, + color = stateColor(endpoint.backendState), + ) + } + }, + modifier = Modifier.clip( + if (stateIsLast) { + RoundedCornerShape(12.dp) + } else { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + if (endpoint.backendState == "Running" && endpoint.selfPeer != null) { + val thisDeviceIsLast = !hasExitNode && !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_this_device), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Filled.Computer, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + supportingContent = { + Text( + endpoint.selfPeer.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clip( + if (thisDeviceIsLast) { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + } else { + RoundedCornerShape(0.dp) + }, + ) + .clickable { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(endpoint.selfPeer.id)}", + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasExitNode) { + val exitNodeIsLast = !hasAuth + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_exit_node), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Filled.Router, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + supportingContent = { + Text( + endpoint.exitNode?.displayName ?: stringResource(R.string.disabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .clip( + if (exitNodeIsLast) { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + } else { + RoundedCornerShape(0.dp) + }, + ) + .clickable { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/exit_node", + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + if (hasAuth) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier.clickable { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(endpoint.authURL))) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_open_auth_url_qr_code), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { showAuthQRCode = true }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + + // User group sections + for (group in endpoint.userGroups) { + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(group.displayName.ifEmpty { group.loginName }) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + group.peers.forEachIndexed { index, peer -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + val canSSH = peer.online && peer.sshHostKeys.isNotEmpty() && + peer.tailscaleIPs.isNotEmpty() && peer.id != endpoint.selfPeer?.id + var showSSHMenu by remember { mutableStateOf(false) } + Box { + PeerItem( + peer = peer, + onClick = { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}", + ) + }, + onLongClick = if (canSSH) { + { showSSHMenu = true } + } else { + null + }, + modifier = when { + group.peers.size == 1 -> Modifier.clip(RoundedCornerShape(12.dp)) + index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + index == group.peers.lastIndex -> Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + else -> Modifier + }, + ) + DropdownMenu( + expanded = showSSHMenu, + onDismissRequest = { showSSHMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.tailscale_ssh_connect)) }, + leadingIcon = { + Icon(Icons.Default.Terminal, contentDescription = null) + }, + onClick = { + showSSHMenu = false + handleSSHNavigation( + navController, + sshSharedViewModel, + peer, + endpointTag, + ) + }, + ) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + if (showAuthQRCode && endpoint.authURL.isNotEmpty()) { + val qrBitmap = QRCodeGenerator.rememberBitmap(endpoint.authURL) + QRCodeDialog( + bitmap = qrBitmap, + onDismiss = { showAuthQRCode = false }, + ) + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +private fun PeerItem( + peer: TailscalePeerData, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val badges = peerBadges(peer) + val firstIP = peer.tailscaleIPs.firstOrNull() + Row( + modifier = modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier.height(24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + peer.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + if (firstIP != null) { + Text( + firstIP, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (badges.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + for (badge in badges) { + PeerBadgeView(badge) + } + } + } + } + Box( + modifier = Modifier.height(24.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private data class PeerBadge(val text: String, val color: Color) + +@Composable +private fun peerBadges(peer: TailscalePeerData): List { + if (!peer.online) return emptyList() + val badges = mutableListOf() + if (peer.shareeNode) { + badges += PeerBadge(stringResource(R.string.tailscale_shared_in), Color(0xFFF44336)) + } + if (peer.exitNodeOption) { + badges += PeerBadge(stringResource(R.string.tailscale_exit_node), Color(0xFF2196F3)) + } + when { + peer.expired -> { + badges += PeerBadge(stringResource(R.string.tailscale_expired), Color(0xFFF44336)) + } + peer.keyExpiry > 0 -> { + val expiryMs = peer.keyExpiry * 1000 + val now = System.currentTimeMillis() + val oneMonthMs = 30L * 24 * 60 * 60 * 1000 + if (expiryMs - now <= oneMonthMs) { + val rel = DateUtils.getRelativeTimeSpanString( + expiryMs, + now, + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE, + ).toString() + badges += PeerBadge(stringResource(R.string.tailscale_expires_relative, rel), Color.Gray) + } + } + else -> { + badges += PeerBadge(stringResource(R.string.tailscale_key_expiry_disabled), Color.Gray) + } + } + if (peer.sshHostKeys.isNotEmpty()) { + badges += PeerBadge(stringResource(R.string.tailscale_ssh), Color(0xFF4CAF50)) + } + return badges +} + +@Composable +private fun PeerBadgeView(badge: PeerBadge) { + Text( + text = badge.text, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + maxLines = 1, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(badge.color) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +private fun stateColor(state: String): Color = when (state) { + "Running" -> Color(0xFF4CAF50) + "NeedsLogin", "NeedsMachineAuth" -> Color(0xFFFF9800) + "Starting" -> Color(0xFFFFEB3B) + else -> Color.Gray +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt new file mode 100644 index 000000000..da4219aee --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleExitNodePickerScreen.kt @@ -0,0 +1,197 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleExitNodePickerScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, +) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.tailscale_exit_node)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val state by viewModel.uiState.collectAsState() + val endpoint = state.endpoints.firstOrNull { it.endpointTag == endpointTag } + + if (endpoint == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + var searchText by rememberSaveable { mutableStateOf("") } + + val selfStableID = endpoint.selfPeer?.stableID + val candidates = endpoint.userGroups + .flatMap { it.peers } + .filter { it.exitNodeOption && it.stableID != selfStableID } + .filter { searchText.isEmpty() || it.displayName.contains(searchText, ignoreCase = true) || it.hostName.contains(searchText, ignoreCase = true) } + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text(stringResource(android.R.string.search_go)) }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + + LazyColumn(modifier = Modifier.fillMaxSize()) { + item { + DisabledRow( + isSelected = endpoint.exitNode == null, + onClick = { + viewModel.setExitNode(endpointTag, "") + navController.navigateUp() + }, + ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + } + items(candidates, key = { it.stableID }) { peer -> + PeerRow( + peer = peer, + isSelected = endpoint.exitNode?.stableID == peer.stableID, + onClick = { + viewModel.setExitNode(endpointTag, peer.stableID) + navController.navigateUp() + }, + ) + } + } + } +} + +@Composable +private fun DisabledRow( + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.disabled), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun PeerRow( + peer: TailscalePeerData, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (peer.online) Color(0xFF4CAF50) else Color.Gray), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = peer.displayName, + style = MaterialTheme.typography.bodyLarge, + ) + val firstIP = peer.tailscaleIPs.firstOrNull() + if (firstIP != null) { + Text( + text = firstIP, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isSelected) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt new file mode 100644 index 000000000..d8ad4ea4a --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePeerScreen.kt @@ -0,0 +1,654 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.LineChart +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.ktx.clipboardText +import kotlinx.coroutines.delay + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscalePeerScreen( + navController: NavController, + viewModel: TailscaleStatusViewModel, + endpointTag: String, + peerId: String, +) { + val state by viewModel.uiState.collectAsState() + val peer = viewModel.peer(endpointTag, peerId) + val endpoint = viewModel.endpoint(endpointTag) + val isSelf = endpoint?.selfPeer?.id == peerId + val pingViewModel: TailscalePingViewModel = viewModel() + val pingState by pingViewModel.uiState.collectAsState() + + DisposableEffect(Unit) { + onDispose { + if (pingState.isRunning) { + pingViewModel.stopPing() + } + } + } + + OverrideTopBar { + TopAppBar( + title = { + Column { + Text( + peer?.displayName ?: "", + style = MaterialTheme.typography.titleMedium, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box( + modifier = Modifier + .size(6.dp) + .clip(CircleShape) + .background( + if (peer?.online == true) Color(0xFF4CAF50) else Color.Gray, + ), + ) + Text( + if (peer?.online == true) { + stringResource(R.string.tailscale_connected) + } else { + stringResource(R.string.tailscale_not_connected) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + if (peer == null) { + LaunchedEffect(Unit) { + navController.navigateUp() + } + return + } + + var copiedAddress by remember { mutableStateOf(null) } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Network section (self peer only): network name + logout + val networkName = endpoint?.networkName + val showLogout = isSelf && endpoint?.backendState == "Running" && endpoint?.keyAuth == false + if (isSelf && (!networkName.isNullOrEmpty() || showLogout)) { + SectionHeader(stringResource(R.string.tailscale_network)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column { + if (!networkName.isNullOrEmpty()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + AddressRow( + address = networkName, + label = stringResource(R.string.tailscale_network), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + } + if (showLogout) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_logout), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.Logout, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.clickable { + viewModel.logout(endpointTag) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + // Tailscale Addresses section + SectionHeader(stringResource(R.string.tailscale_addresses)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (peer.dnsName.isNotEmpty()) { + AddressRow( + address = Libbox.formatFQDN(peer.dnsName), + label = stringResource(R.string.tailscale_magic_dns), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + if (peer.hostName.isNotEmpty()) { + AddressRow( + address = peer.hostName, + label = stringResource(R.string.tailscale_hostname), + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + for (ip in peer.tailscaleIPs) { + AddressRow( + address = ip, + label = if (ip.contains(":")) { + stringResource(R.string.tailscale_ipv6) + } else { + stringResource(R.string.tailscale_ipv4) + }, + copied = copiedAddress, + onCopy = { copiedAddress = it }, + ) + } + } + } + + // Ping section (not for self peer) + if (!isSelf && peer.online && peer.tailscaleIPs.isNotEmpty()) { + val peerIP = peer.tailscaleIPs.first() + + Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.tailscale_ping), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + Surface( + onClick = { + if (pingState.isRunning) { + pingViewModel.stopPing() + } else { + pingViewModel.startPing(endpointTag, peerIP) + } + }, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.size(width = 44.dp, height = 32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (pingState.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = if (pingState.isRunning) { + stringResource(R.string.tailscale_ping_stop) + } else { + stringResource(R.string.tailscale_ping_start) + }, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + if (pingState.hasResult) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (pingState.isDirect) { + Text( + text = "\u2192 ", + color = Color(0xFF4CAF50), + ) + Text( + text = stringResource(R.string.tailscale_ping_direct), + color = Color(0xFF4CAF50), + ) + } else { + Text( + text = "\u21BB ", + color = Color(0xFFFF9800), + ) + Text( + text = stringResource(R.string.tailscale_ping_derp), + color = Color(0xFFFF9800), + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "${pingState.latencyMs.toInt()} ms", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + if (pingState.isDirect && pingState.endpoint.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + DetailRow( + label = stringResource(R.string.tailscale_ping_endpoint), + value = pingState.endpoint, + ) + } + if (!pingState.isDirect && pingState.derpRegionCode.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + DetailRow( + label = stringResource(R.string.tailscale_ping_derp_region), + value = pingState.derpRegionCode, + ) + } + if (pingState.isRunning && pingState.latencyHistory.size > 1) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + LineChart( + data = pingState.latencyHistory, + lineColor = if (pingState.isDirect) { + Color(0xFF4CAF50) + } else { + Color(0xFF2196F3) + }, + animate = false, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + val maxMs = ( + ( + pingState.latencyHistory.maxOrNull() + ?: 1f + ) * 1.2f + ).toInt().coerceAtLeast(1) + Column( + modifier = Modifier.height(80.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${maxMs}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs * 2 / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${maxMs / 3}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "0ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + if (pingState.error.isNotEmpty()) { + if (pingState.hasResult) { + Spacer(modifier = Modifier.height(8.dp)) + } + Text( + text = pingState.error, + color = MaterialTheme.colorScheme.error, + ) + } else if (pingState.isRunning && !pingState.hasResult) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.tailscale_ping_connecting), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else if (!pingState.hasResult) { + Text( + text = stringResource(R.string.tailscale_ping_no_data), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Details section + Spacer(modifier = Modifier.height(16.dp)) + SectionHeader(stringResource(R.string.tailscale_details)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + when { + peer.expired -> { + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = stringResource(R.string.tailscale_expired), + valueColor = MaterialTheme.colorScheme.error, + ) + } + peer.keyExpiry > 0 -> { + val expiryText = DateUtils.getRelativeTimeSpanString( + peer.keyExpiry * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + 0, + ).toString() + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = expiryText, + ) + } + else -> { + DetailRow( + label = stringResource(R.string.tailscale_key_expiry), + value = stringResource(R.string.disabled), + valueColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (peer.os.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_os), + value = peer.os, + ) + } + if (!peer.online && peer.lastSeen > 0) { + val lastSeenText = DateUtils.getRelativeTimeSpanString( + peer.lastSeen * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + 0, + ).toString() + DetailRow( + label = stringResource(R.string.tailscale_last_seen), + value = lastSeenText, + ) + } + if (peer.exitNode) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_active), + ) + } else if (peer.exitNodeOption) { + DetailRow( + label = stringResource(R.string.tailscale_exit_node), + value = stringResource(R.string.tailscale_available), + ) + } + if (peer.shareeNode) { + DetailRow( + label = stringResource(R.string.tailscale_shared_in), + value = stringResource(R.string.tailscale_yes), + ) + } + if (peer.sshHostKeys.isNotEmpty()) { + if (!peer.online || isSelf || peer.tailscaleIPs.isEmpty()) { + DetailRow( + label = stringResource(R.string.tailscale_ssh), + value = stringResource(R.string.tailscale_available), + ) + } + } + } + } + + if (peer.sshHostKeys.isNotEmpty() && peer.online && !isSelf && peer.tailscaleIPs.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.tailscale_ssh_connect), + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable { + navController.navigate( + "tools/tailscale/${android.net.Uri.encode(endpointTag)}/peer/${android.net.Uri.encode(peerId)}/ssh", + ) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } + + LaunchedEffect(copiedAddress) { + if (copiedAddress != null) { + delay(2000) + copiedAddress = null + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) +} + +@Composable +private fun DetailRow(label: String, value: String, valueColor: Color? = null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 16.dp), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + color = valueColor ?: MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.End, + ) + } +} + +@Composable +private fun AddressRow( + address: String, + label: String, + copied: String?, + onCopy: (String) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + address, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { + clipboardText = address + onCopy(address) + }) { + if (copied == address) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt new file mode 100644 index 000000000..b87e29faa --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscalePingViewModel.kt @@ -0,0 +1,107 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.TailscalePingHandler +import io.nekohasekai.libbox.TailscalePingResult +import io.nekohasekai.libbox.TailscalePingSession +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class TailscalePingState( + val isRunning: Boolean = false, + val hasResult: Boolean = false, + val latencyMs: Double = 0.0, + val isDirect: Boolean = false, + val derpRegionCode: String = "", + val endpoint: String = "", + val error: String = "", + val latencyHistory: List = emptyList(), +) + +class TailscalePingViewModel : BaseViewModel() { + private val maxHistorySize = 30 + private var pingSession: TailscalePingSession? = null + + override fun createInitialState() = TailscalePingState() + + fun startPing(endpointTag: String, peerIP: String) { + updateState { + copy( + isRunning = true, + hasResult = false, + error = "", + latencyHistory = emptyList(), + ) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + pingSession = + CommandTarget.standaloneClient() + .startTailscalePing( + endpointTag, + peerIP, + object : TailscalePingHandler { + override fun onPingResult(result: TailscalePingResult?) { + result ?: return + viewModelScope.launch { + if (!currentState.isRunning) return@launch + if (result.error.isNotEmpty()) { + updateState { copy(error = result.error) } + return@launch + } + val newHistory = currentState.latencyHistory.toMutableList() + newHistory.add(result.latencyMs.toFloat()) + if (newHistory.size > maxHistorySize) { + newHistory.removeFirstOrNull() + } + updateState { + copy( + hasResult = true, + latencyMs = result.latencyMs, + isDirect = result.isDirect, + derpRegionCode = result.derpRegionCode, + endpoint = result.endpoint, + error = "", + latencyHistory = newHistory, + ) + } + } + } + + override fun onError(message: String?) { + viewModelScope.launch { + if (!currentState.isRunning) return@launch + updateState { copy(isRunning = false) } + pingSession = null + } + } + }, + ) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (!currentState.isRunning) return@withContext + updateState { copy(isRunning = false) } + pingSession = null + } + } + } + } + + fun stopPing() { + try { + pingSession?.close() + } catch (_: Exception) { + } + pingSession = null + updateState { copy(isRunning = false) } + } + + override fun onCleared() { + super.onCleared() + stopPing() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHPromptScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHPromptScreen.kt new file mode 100644 index 000000000..61e9207cd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHPromptScreen.kt @@ -0,0 +1,218 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleSSHPromptScreen( + navController: NavController, + sharedViewModel: TailscaleSSHSharedViewModel, + viewModel: TailscaleStatusViewModel, + endpointTag: String, + peerId: String, +) { + val peer = viewModel.peer(endpointTag, peerId) + + if (peer == null) { + LaunchedEffect(Unit) { navController.navigateUp() } + return + } + + val rememberedUsernames = Settings.tailscaleSSHRememberedUsernames + val quickConnectPeers = Settings.tailscaleSSHQuickConnectPeers + + var username by remember { + mutableStateOf(rememberedUsernames[peer.stableID]?.takeIf { it.isNotBlank() } ?: "root") + } + var rememberOptions by remember { + mutableStateOf(quickConnectPeers.contains(peer.stableID)) + } + + OverrideTopBar { + TopAppBar( + title = { Text(peer.hostName) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.content_description_back)) + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + Text( + text = stringResource(R.string.tailscale_ssh_options), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + OutlinedTextField( + value = username, + onValueChange = { username = it }, + label = { Text(stringResource(R.string.tailscale_ssh_username)) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tailscale_ssh_terminal_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.tailscale_ssh_quick_connect), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { Text(stringResource(R.string.tailscale_ssh_remember_options)) }, + trailingContent = { + Switch( + checked = rememberOptions, + onCheckedChange = { checked -> + rememberOptions = checked + val peers = Settings.tailscaleSSHQuickConnectPeers.toMutableSet() + if (checked) { + peers.add(peer.stableID) + } else { + peers.remove(peer.stableID) + } + Settings.tailscaleSSHQuickConnectPeers = peers + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.tailscale_ssh_quick_connect_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + val trimmedUsername = username.trim() + val usernames = Settings.tailscaleSSHRememberedUsernames.toMutableMap() + if (trimmedUsername == "root") { + usernames.remove(peer.stableID) + } else { + usernames[peer.stableID] = trimmedUsername + } + Settings.tailscaleSSHRememberedUsernames = usernames + + sharedViewModel.setPendingSession( + TailscaleSSHPresentedSession( + endpointTag = endpointTag, + peerHostName = peer.hostName, + peerAddress = peer.tailscaleIPs.first(), + username = trimmedUsername, + hostKeys = peer.sshHostKeys, + ), + ) + navController.navigate( + "tools/tailscale/${android.net.Uri.encode(endpointTag)}/peer/${android.net.Uri.encode(peerId)}/terminal", + ) { + popUpTo( + "tools/tailscale/${android.net.Uri.encode(endpointTag)}/peer/${android.net.Uri.encode(peerId)}/ssh", + ) { + inclusive = true + } + } + }, + enabled = username.trim().isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text(stringResource(R.string.tailscale_ssh_connect_button)) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHSharedViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHSharedViewModel.kt new file mode 100644 index 000000000..6f8053345 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHSharedViewModel.kt @@ -0,0 +1,22 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession + +data class TailscaleSSHSharedState( + val pendingSession: TailscaleSSHPresentedSession? = null, +) + +class TailscaleSSHSharedViewModel : BaseViewModel() { + override fun createInitialState() = TailscaleSSHSharedState() + + fun setPendingSession(session: TailscaleSSHPresentedSession) { + updateState { copy(pendingSession = session) } + } + + fun consumePendingSession(): TailscaleSSHPresentedSession? { + val session = currentState.pendingSession + updateState { copy(pendingSession = null) } + return session + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt new file mode 100644 index 000000000..062f3a9a5 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalScreen.kt @@ -0,0 +1,437 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.Context +import android.graphics.Typeface +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.inputmethod.InputMethodManager +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient +import com.termux.view.TerminalView +import com.termux.view.TerminalViewClient +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.terminal.ImportedFontStore +import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession +import io.nekohasekai.sfa.terminal.TailscaleSSHTerminalSession +import io.nekohasekai.sfa.terminal.TerminalColorSchemeLoader +import io.nekohasekai.sfa.terminal.TerminalExtraKeysState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TailscaleSSHTerminalScreen( + navController: NavController, + sharedViewModel: TailscaleSSHSharedViewModel, + tailscaleViewModel: TailscaleStatusViewModel, +) { + val terminalViewModel: TailscaleSSHTerminalViewModel = viewModel() + val state by terminalViewModel.uiState.collectAsState() + val context = LocalContext.current + val isDark = isSystemInDarkTheme() + val keyboardController = LocalSoftwareKeyboardController.current + + val terminalViewRef = remember { TerminalViewRef() } + val extraKeysState = remember { TerminalExtraKeysState() } + val sessionClient = remember { createSessionClient(terminalViewModel, terminalViewRef) } + + LaunchedEffect(Unit) { + terminalViewModel.sessionClient = sessionClient + val pending = sharedViewModel.consumePendingSession() + if (pending != null && state.sessions.isEmpty()) { + terminalViewModel.addSession(pending) + } + } + + val activeSession = state.activeSession + val displayTitle = when { + activeSession == null -> "" + activeSession.terminalSession.phase == TailscaleSSHTerminalSession.Phase.CONNECTING -> + activeSession.presentedSession.peerHostName + else -> { + val termTitle = activeSession.terminalSession.title + if (termTitle.isNullOrBlank()) activeSession.presentedSession.peerHostName else termTitle + } + } + + var showSessionMenu by remember { mutableStateOf(false) } + var expandedNewSession by remember { mutableStateOf(false) } + + val tailscaleState by tailscaleViewModel.uiState.collectAsState() + val quickConnectPeerIDs = Settings.tailscaleSSHQuickConnectPeers + val otherQCPeers = remember(tailscaleState, quickConnectPeerIDs, activeSession) { + val currentAddress = activeSession?.presentedSession?.peerAddress + val currentEndpointTag = activeSession?.presentedSession?.endpointTag + tailscaleState.endpoints.flatMap { endpoint -> + endpoint.userGroups.flatMap { group -> + group.peers.filter { peer -> + peer.online && peer.sshHostKeys.isNotEmpty() && + peer.tailscaleIPs.isNotEmpty() && + peer.id != endpoint.selfPeer?.id && + quickConnectPeerIDs.contains(peer.stableID) && + !(endpoint.endpointTag == currentEndpointTag && peer.tailscaleIPs.firstOrNull() == currentAddress) + }.map { peer -> peer to endpoint.endpointTag } + } + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(displayTitle, style = MaterialTheme.typography.titleMedium) }, + navigationIcon = { + IconButton(onClick = { + keyboardController?.hide() + navController.navigateUp() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null) + } + }, + actions = { + Box { + IconButton(onClick = { showSessionMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = showSessionMenu, + onDismissRequest = { + showSessionMenu = false + expandedNewSession = false + }, + ) { + if (otherQCPeers.isEmpty()) { + DropdownMenuItem( + text = { Text(stringResource(R.string.tailscale_ssh_new_session)) }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + onClick = { + showSessionMenu = false + terminalViewModel.duplicateCurrentSession() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.tailscale_ssh_new_session)) }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + trailingIcon = { + Icon( + if (expandedNewSession) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + ) + }, + onClick = { expandedNewSession = !expandedNewSession }, + ) + if (expandedNewSession) { + if (activeSession != null) { + DropdownMenuItem( + text = { + Text( + activeSession.presentedSession.peerHostName, + modifier = Modifier.padding(start = 24.dp), + ) + }, + onClick = { + showSessionMenu = false + expandedNewSession = false + terminalViewModel.duplicateCurrentSession() + }, + ) + } + otherQCPeers.forEach { (peer, endpointTag) -> + val usernames = Settings.tailscaleSSHRememberedUsernames + DropdownMenuItem( + text = { + Text( + peer.hostName, + modifier = Modifier.padding(start = 24.dp), + ) + }, + onClick = { + showSessionMenu = false + expandedNewSession = false + terminalViewModel.addSession( + TailscaleSSHPresentedSession( + endpointTag = endpointTag, + peerHostName = peer.hostName, + peerAddress = peer.tailscaleIPs.first(), + username = usernames[peer.stableID]?.takeIf { it.isNotBlank() } ?: "root", + hostKeys = peer.sshHostKeys, + ), + ) + }, + ) + } + } + } + if (state.sessions.size > 1) { + HorizontalDivider() + state.sessions.forEach { session -> + val isActive = session.id == state.activeSessionId + val sessionTitle = when { + session.terminalSession.phase == TailscaleSSHTerminalSession.Phase.CONNECTING -> + session.presentedSession.peerHostName + else -> { + val termTitle = session.terminalSession.title + if (termTitle.isNullOrBlank()) session.presentedSession.peerHostName else termTitle + } + } + DropdownMenuItem( + text = { Text(sessionTitle) }, + onClick = { + showSessionMenu = false + terminalViewModel.switchSession(session.id) + }, + leadingIcon = { + RadioButton( + selected = isActive, + onClick = null, + ) + }, + ) + } + } + } + } + }, + ) + } + + Column(modifier = Modifier.fillMaxSize().imePadding()) { + if (activeSession != null) { + Box(modifier = Modifier.weight(1f)) { + val themeName = if (isDark) Settings.tailscaleSSHDarkTheme else Settings.tailscaleSSHLightTheme + val fontSize = Settings.tailscaleSSHFontSize + val fontFamily = Settings.tailscaleSSHFontFamily + val customFontPath = Settings.tailscaleSSHCustomFontPath + + AndroidView( + factory = { ctx -> + TerminalColorSchemeLoader.applyScheme(ctx, themeName) + TerminalView(ctx, null).apply { + isFocusable = true + isFocusableInTouchMode = true + terminalViewRef.view = this + setTerminalViewClient( + createViewClient(ctx, this, extraKeysState) { + val active = terminalViewModel.uiState.value.activeSession + if (active != null) { + terminalViewModel.removeSession(active.id) + if (terminalViewModel.uiState.value.sessions.isEmpty()) { + keyboardController?.hide() + navController.navigateUp() + } + } + }, + ) + attachSession(activeSession.terminalSession) + setTextSize(fontSize) + val typeface = resolveTypeface(fontFamily, customFontPath) + if (typeface != null) { + setTypeface(typeface) + } + post { + requestFocus() + val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + } + } + }, + update = { view -> + if (view.currentSession !== activeSession.terminalSession) { + TerminalColorSchemeLoader.applyScheme(context, themeName) + view.attachSession(activeSession.terminalSession) + view.onScreenUpdated() + } + }, + modifier = Modifier.fillMaxSize(), + ) + + if (activeSession.terminalSession.phase == TailscaleSSHTerminalSession.Phase.CONNECTING) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Text( + stringResource(R.string.tailscale_ssh_connecting), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 16.dp), + ) + } + } + } + } + TerminalExtraKeysBar( + terminalView = terminalViewRef.view, + extraKeysState = extraKeysState, + modifier = Modifier.fillMaxWidth(), + ) + } else if (state.sessions.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.tailscale_ssh_no_sessions), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + DisposableEffect(Unit) { + onDispose { + keyboardController?.hide() + } + } +} + +private class TerminalViewRef { + var view: TerminalView? = null +} + +private fun resolveTypeface(fontFamily: String, customFontPath: String): Typeface? { + if (customFontPath.isNotBlank()) { + val typeface = ImportedFontStore.loadTypeface(customFontPath) + if (typeface != null) return typeface + } + if (fontFamily.isNotBlank()) { + return Typeface.create(fontFamily, Typeface.NORMAL) + } + return null +} + +private fun createSessionClient(viewModel: TailscaleSSHTerminalViewModel, viewRef: TerminalViewRef): TerminalSessionClient = object : TerminalSessionClient { + override fun onTextChanged(changedSession: TerminalSession) { + viewRef.view?.onScreenUpdated() + } + override fun onTitleChanged(changedSession: TerminalSession) { + viewModel.onTitleChanged() + } + override fun onSessionFinished(finishedSession: TerminalSession) { + val state = viewModel.uiState.value + if (state.sessions.size > 1) { + val managed = state.sessions.firstOrNull { it.terminalSession === finishedSession } + if (managed != null) { + if (managed.id == state.activeSessionId) { + val sshSession = finishedSession as TailscaleSSHTerminalSession + if (sshSession.getSSHExitCode() != 0) { + return + } + } + viewModel.removeSession(managed.id) + } + } + } + override fun onCopyTextToClipboard(session: TerminalSession, text: String) {} + override fun onPasteTextFromClipboard(session: TerminalSession?) {} + override fun onBell(session: TerminalSession) {} + override fun onColorsChanged(session: TerminalSession) {} + override fun onTerminalCursorStateChange(state: Boolean) {} + override fun setTerminalShellPid(session: TerminalSession, pid: Int) {} + override fun getTerminalCursorStyle(): Int = 0 + override fun logError(tag: String, message: String) {} + override fun logWarn(tag: String, message: String) {} + override fun logInfo(tag: String, message: String) {} + override fun logDebug(tag: String, message: String) {} + override fun logVerbose(tag: String, message: String) {} + override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {} + override fun logStackTrace(tag: String, e: Exception) {} +} + +private fun createViewClient(context: Context, terminalView: TerminalView, extraKeysState: TerminalExtraKeysState, onDismissFinishedSession: () -> Unit): TerminalViewClient = object : TerminalViewClient { + override fun onScale(scale: Float): Float { + if (scale < 0.9f || scale > 1.1f) { + val increase = scale > 1f + var currentSize = Settings.tailscaleSSHFontSize + currentSize = if (increase) { + (currentSize + 1).coerceAtMost(48) + } else { + (currentSize - 1).coerceAtLeast(8) + } + Settings.tailscaleSSHFontSize = currentSize + terminalView.setTextSize(currentSize) + return 1.0f + } + return scale + } + override fun onSingleTapUp(e: MotionEvent) { + terminalView.requestFocus() + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.showSoftInput(terminalView, 0) + } + override fun shouldBackButtonBeMappedToEscape(): Boolean = false + override fun shouldEnforceCharBasedInput(): Boolean = true + override fun shouldUseCtrlSpaceWorkaround(): Boolean = false + override fun isTerminalViewSelected(): Boolean = true + override fun copyModeChanged(copyMode: Boolean) {} + override fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession): Boolean { + if (!session.isRunning) { + onDismissFinishedSession() + return true + } + return false + } + override fun onKeyUp(keyCode: Int, e: KeyEvent): Boolean = false + override fun onLongPress(event: MotionEvent): Boolean = false + override fun readControlKey(): Boolean = extraKeysState.isCtrlActive + override fun readAltKey(): Boolean = extraKeysState.isAltActive + override fun readShiftKey(): Boolean = false + override fun readFnKey(): Boolean = false + override fun onCodePoint(codePoint: Int, ctrlDown: Boolean, session: TerminalSession): Boolean { + if (!session.isRunning) { + onDismissFinishedSession() + return true + } + return false + } + override fun onEmulatorSet() {} + override fun logError(tag: String, message: String) {} + override fun logWarn(tag: String, message: String) {} + override fun logInfo(tag: String, message: String) {} + override fun logDebug(tag: String, message: String) {} + override fun logVerbose(tag: String, message: String) {} + override fun logStackTraceWithMessage(tag: String, message: String, e: Exception) {} + override fun logStackTrace(tag: String, e: Exception) {} +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt new file mode 100644 index 000000000..8bd22d8e1 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleSSHTerminalViewModel.kt @@ -0,0 +1,159 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient +import io.nekohasekai.libbox.StringIterator +import io.nekohasekai.libbox.TailscaleSSHHandler +import io.nekohasekai.libbox.TailscaleSSHOptions +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.terminal.ManagedSession +import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession +import io.nekohasekai.sfa.terminal.TailscaleSSHTerminalSession +import io.nekohasekai.sfa.utils.CommandTarget +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +data class TailscaleSSHTerminalState( + val sessions: List = emptyList(), + val activeSessionId: String? = null, + val version: Int = 0, +) { + val activeSession: ManagedSession? + get() = sessions.firstOrNull { it.id == activeSessionId } +} + +class TailscaleSSHTerminalViewModel : BaseViewModel() { + override fun createInitialState() = TailscaleSSHTerminalState() + + var sessionClient: TerminalSessionClient? = null + + fun addSession(presented: TailscaleSSHPresentedSession) { + val client = sessionClient ?: return + val terminalSession = TailscaleSSHTerminalSession(client) + val managed = ManagedSession( + terminalSession = terminalSession, + presentedSession = presented, + ) + + terminalSession.setPhaseCallback(object : TailscaleSSHTerminalSession.PhaseCallback { + override fun onPhaseChanged(phase: TailscaleSSHTerminalSession.Phase) { + updateState { copy(sessions = sessions.toList(), version = version + 1) } + } + + override fun onAuthBanner(message: String) { + } + }) + + updateState { + copy( + sessions = sessions + managed, + activeSessionId = managed.id, + ) + } + + startSSHConnection(managed) + } + + fun removeSession(id: String) { + val session = currentState.sessions.firstOrNull { it.id == id } ?: return + session.terminalSession.close() + disconnectClient(session) + val remaining = currentState.sessions.filter { it.id != id } + val newActiveId = if (currentState.activeSessionId == id) { + remaining.lastOrNull()?.id + } else { + currentState.activeSessionId + } + updateState { copy(sessions = remaining, activeSessionId = newActiveId) } + } + + fun switchSession(id: String) { + updateState { copy(activeSessionId = id) } + } + + fun duplicateCurrentSession() { + val current = currentState.activeSession ?: return + addSession(current.presentedSession) + } + + fun onTitleChanged() { + updateState { copy(version = version + 1) } + } + + private fun startSSHConnection(managed: ManagedSession) { + viewModelScope.launch(Dispatchers.IO) { + try { + val options = TailscaleSSHOptions().apply { + endpointTag = managed.presentedSession.endpointTag + peerAddress = managed.presentedSession.peerAddress + username = managed.presentedSession.username + terminalType = "xterm-256color" + columns = 80 + rows = 24 + widthPixels = 0 + heightPixels = 0 + hostKeys = object : StringIterator { + private var index = 0 + override fun hasNext(): Boolean = index < managed.presentedSession.hostKeys.size + override fun next(): String = managed.presentedSession.hostKeys[index++] + override fun len(): Int = managed.presentedSession.hostKeys.size + } + forwardAgent = false + } + + val commandClient = CommandTarget.ownedStandaloneClient() + managed.commandClient = commandClient + val sshSession = commandClient.startTailscaleSSHSession( + options, + object : TailscaleSSHHandler { + override fun onReady() { + managed.terminalSession.onReady() + } + + override fun onOutput(data: ByteArray) { + managed.terminalSession.feedOutput(data) + } + + override fun onAuthBanner(message: String) { + managed.terminalSession.onAuthBanner(message) + } + + override fun onExit(exitCode: Int, signal: String, errorMessage: String) { + managed.terminalSession.onExit(exitCode, signal, errorMessage) + } + + override fun onError(message: String) { + managed.terminalSession.onError(message) + } + }, + ) + managed.terminalSession.setSshSession(sshSession) + } catch (e: Exception) { + managed.terminalSession.onError(e.message ?: "SSH connection failed") + } + } + } + + private fun disconnectClient(session: ManagedSession) { + val commandClient = session.commandClient ?: return + session.commandClient = null + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { + runCatching { + commandClient.disconnect() + } + } + } + + override fun onCleared() { + for (session in currentState.sessions) { + session.terminalSession.close() + disconnectClient(session) + } + super.onCleared() + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt new file mode 100644 index 000000000..9525350e6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TailscaleStatusViewModel.kt @@ -0,0 +1,235 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.TailscaleStatusHandler +import io.nekohasekai.libbox.TailscaleStatusSubscription +import io.nekohasekai.libbox.TailscaleStatusUpdate +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.utils.CommandTarget +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +data class TailscalePeerData( + val id: String, + val stableID: String, + val hostName: String, + val dnsName: String, + val os: String, + val tailscaleIPs: List, + val sshHostKeys: List, + val online: Boolean, + val exitNode: Boolean, + val exitNodeOption: Boolean, + val shareeNode: Boolean, + val expired: Boolean, + val active: Boolean, + val rxBytes: Long, + val txBytes: Long, + val keyExpiry: Long, + val lastSeen: Long, +) { + val displayName: String get() = dnsName.substringBefore(".").ifEmpty { hostName } +} + +data class TailscaleUserGroupData( + val id: Long, + val loginName: String, + val displayName: String, + val profilePicURL: String, + val peers: List, +) + +data class TailscaleEndpointData( + val endpointTag: String, + val backendState: String, + val authURL: String, + val networkName: String, + val magicDNSSuffix: String, + val selfPeer: TailscalePeerData?, + val exitNode: TailscalePeerData?, + val userGroups: List, + val keyAuth: Boolean, +) { + val hasExitNodeCandidates: Boolean + get() { + if (exitNode != null) return true + val selfStableID = selfPeer?.stableID + return userGroups.any { group -> + group.peers.any { it.exitNodeOption && it.stableID != selfStableID } + } + } +} + +data class TailscaleStatusState( + val endpoints: List = emptyList(), + val isSubscribed: Boolean = false, +) + +class TailscaleStatusViewModel : BaseViewModel() { + private var statusSubscription: TailscaleStatusSubscription? = null + + override fun createInitialState() = TailscaleStatusState() + + fun subscribe() { + if (currentState.isSubscribed) return + updateState { copy(isSubscribed = true) } + + viewModelScope.launch(Dispatchers.IO) { + try { + statusSubscription = + CommandTarget.standaloneClient() + .subscribeTailscaleStatus(object : TailscaleStatusHandler { + override fun onStatusUpdate(status: TailscaleStatusUpdate) { + val endpoints = convertUpdate(status) + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = endpoints) } + } + } + + override fun onError(message: String) { + viewModelScope.launch { + if (!currentState.isSubscribed) return@launch + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + statusSubscription = null + sendErrorMessage(message) + } + } + }) + } catch (_: Exception) { + viewModelScope.launch { + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + statusSubscription = null + } + } + } + } + + fun cancel() { + try { + statusSubscription?.close() + } catch (_: Exception) { + } + statusSubscription = null + updateState { copy(endpoints = emptyList(), isSubscribed = false) } + } + + fun setExitNode(endpointTag: String, stableID: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + CommandTarget.standaloneClient().setTailscaleExitNode(endpointTag, stableID) + } catch (e: Exception) { + sendErrorMessage(e.message ?: "set exit node failed") + } + } + } + + fun logout(endpointTag: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + CommandTarget.standaloneClient().tailscaleLogout(endpointTag) + } catch (e: Exception) { + sendErrorMessage(e.message ?: "logout failed") + } + } + } + + fun endpoint(tag: String): TailscaleEndpointData? = currentState.endpoints.firstOrNull { it.endpointTag == tag } + + fun peer(endpointTag: String, peerId: String): TailscalePeerData? { + val ep = endpoint(endpointTag) ?: return null + if (ep.selfPeer?.id == peerId) return ep.selfPeer + for (group in ep.userGroups) { + val found = group.peers.firstOrNull { it.id == peerId } + if (found != null) return found + } + return null + } + + override fun onCleared() { + cancel() + super.onCleared() + } + + private fun convertUpdate(status: TailscaleStatusUpdate): List { + val endpoints = mutableListOf() + val iterator = status.endpoints() + while (iterator.hasNext()) { + endpoints.add(convertEndpoint(iterator.next())) + } + return endpoints + } + + private fun convertEndpoint( + endpoint: io.nekohasekai.libbox.TailscaleEndpointStatus, + ): TailscaleEndpointData { + val userGroups = mutableListOf() + val groupIterator = endpoint.userGroups() + while (groupIterator.hasNext()) { + userGroups.add(convertUserGroup(groupIterator.next())) + } + val self = endpoint.getSelf() + val exitNode = endpoint.exitNode + return TailscaleEndpointData( + endpointTag = endpoint.endpointTag, + backendState = endpoint.backendState, + authURL = endpoint.authURL, + networkName = endpoint.networkName, + magicDNSSuffix = endpoint.magicDNSSuffix, + selfPeer = if (self != null) convertPeer(self) else null, + exitNode = if (exitNode != null) convertPeer(exitNode) else null, + userGroups = userGroups, + keyAuth = endpoint.keyAuth, + ) + } + + private fun convertUserGroup( + group: io.nekohasekai.libbox.TailscaleUserGroup, + ): TailscaleUserGroupData { + val peers = mutableListOf() + val peerIterator = group.peers() + while (peerIterator.hasNext()) { + peers.add(convertPeer(peerIterator.next())) + } + return TailscaleUserGroupData( + id = group.userID, + loginName = group.loginName, + displayName = group.displayName, + profilePicURL = group.profilePicURL, + peers = peers, + ) + } + + private fun convertPeer(peer: io.nekohasekai.libbox.TailscalePeer): TailscalePeerData { + val ips = mutableListOf() + val ipIterator = peer.tailscaleIPs() + while (ipIterator.hasNext()) { + ips.add(ipIterator.next()) + } + val sshKeys = mutableListOf() + val keyIterator = peer.sshHostKeys() + while (keyIterator.hasNext()) { + sshKeys.add(keyIterator.next()) + } + val dnsName = peer.getDNSName() + return TailscalePeerData( + id = if (dnsName.isNotEmpty()) dnsName else peer.hostName, + stableID = peer.stableID, + hostName = peer.hostName, + dnsName = dnsName, + os = peer.getOS(), + tailscaleIPs = ips, + sshHostKeys = sshKeys, + online = peer.online, + exitNode = peer.exitNode, + exitNodeOption = peer.exitNodeOption, + shareeNode = peer.shareeNode, + expired = peer.expired, + active = peer.active, + rxBytes = peer.rxBytes, + txBytes = peer.txBytes, + keyExpiry = peer.keyExpiry, + lastSeen = peer.lastSeen, + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TerminalExtraKeysBar.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TerminalExtraKeysBar.kt new file mode 100644 index 000000000..920693ae2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/TerminalExtraKeysBar.kt @@ -0,0 +1,278 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.content.ClipboardManager +import android.content.Context +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.termux.view.TerminalView +import io.nekohasekai.sfa.terminal.TerminalExtraKeysState +import io.nekohasekai.sfa.terminal.TerminalExtraKeysState.StickyModifierState +import kotlinx.coroutines.delay + +@Composable +fun TerminalExtraKeysBar( + terminalView: TerminalView?, + extraKeysState: TerminalExtraKeysState, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val ctrlState by extraKeysState.ctrlState.collectAsState() + val altState by extraKeysState.altState.collectAsState() + + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 6.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ExtraKeyButton("ESC") { + terminalView?.handleKeyCode(KeyEvent.KEYCODE_ESCAPE, extraKeysState.currentKeyMod()) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + ExtraKeyButton("TAB") { + terminalView?.handleKeyCode(KeyEvent.KEYCODE_TAB, extraKeysState.currentKeyMod()) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + + StickyModifierButton("CTRL", ctrlState) { + extraKeysState.toggleCtrl(System.currentTimeMillis()) + terminalView?.requestFocus() + } + StickyModifierButton("ALT", altState) { + extraKeysState.toggleAlt(System.currentTimeMillis()) + terminalView?.requestFocus() + } + + Divider() + + RepeatableKeyButton("◀") { + terminalView?.handleKeyCode( + KeyEvent.KEYCODE_DPAD_LEFT, + extraKeysState.currentKeyMod(), + ) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + RepeatableKeyButton("▲") { + terminalView?.handleKeyCode( + KeyEvent.KEYCODE_DPAD_UP, + extraKeysState.currentKeyMod(), + ) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + RepeatableKeyButton("▼") { + terminalView?.handleKeyCode( + KeyEvent.KEYCODE_DPAD_DOWN, + extraKeysState.currentKeyMod(), + ) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + RepeatableKeyButton("▶") { + terminalView?.handleKeyCode( + KeyEvent.KEYCODE_DPAD_RIGHT, + extraKeysState.currentKeyMod(), + ) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + + Divider() + + for (ch in symbolKeys) { + ExtraKeyButton(ch.toString()) { + terminalView?.inputCodePoint( + TerminalView.KEY_EVENT_SOURCE_VIRTUAL_KEYBOARD, + ch.code, + false, + false, + ) + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + } + + IconKeyButton(Icons.Default.ContentPaste) { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = clipboard.primaryClip + if (clip != null && clip.itemCount > 0) { + val text = clip.getItemAt(0).coerceToText(context).toString() + if (text.isNotEmpty()) { + terminalView?.mEmulator?.paste(text) + } + } + extraKeysState.consumeModifiers() + terminalView?.requestFocus() + } + } + } +} + +private val symbolKeys = charArrayOf('|', '/', '~', '-', '_', '`', '\'', '"') + +@Composable +private fun ExtraKeyButton(label: String, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.defaultMinSize(minWidth = 42.dp).height(36.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 4.dp)) { + Text( + text = label, + fontSize = if (label.length > 1) 10.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + } +} + +@Composable +private fun StickyModifierButton( + label: String, + state: StickyModifierState, + onClick: () -> Unit, +) { + val backgroundColor = when (state) { + StickyModifierState.INACTIVE -> MaterialTheme.colorScheme.surfaceContainerHighest + StickyModifierState.ARMED -> MaterialTheme.colorScheme.primaryContainer + StickyModifierState.LOCKED -> MaterialTheme.colorScheme.primary + } + val textColor = when (state) { + StickyModifierState.INACTIVE -> MaterialTheme.colorScheme.onSurface + StickyModifierState.ARMED -> MaterialTheme.colorScheme.onPrimaryContainer + StickyModifierState.LOCKED -> MaterialTheme.colorScheme.onPrimary + } + + Surface( + onClick = onClick, + modifier = Modifier.defaultMinSize(minWidth = 42.dp).height(36.dp), + shape = RoundedCornerShape(8.dp), + color = backgroundColor, + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 4.dp)) { + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = FontFamily.Monospace, + color = textColor, + maxLines = 1, + ) + } + } +} + +@Composable +private fun RepeatableKeyButton(label: String, onAction: () -> Unit) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(isPressed) { + if (isPressed) { + delay(REPEAT_INITIAL_DELAY_MS) + while (true) { + onAction() + delay(REPEAT_INTERVAL_MS) + } + } + } + + Surface( + onClick = onAction, + interactionSource = interactionSource, + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = label, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + } +} + +@Composable +private fun IconKeyButton(icon: androidx.compose.ui.graphics.vector.ImageVector, onClick: () -> Unit) { + Surface( + onClick = onClick, + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun Divider() { + Box( + modifier = Modifier + .padding(horizontal = 2.dp) + .size(5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)), + ) +} + +private const val REPEAT_INITIAL_DELAY_MS = 400L +private const val REPEAT_INTERVAL_MS = 80L diff --git a/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt new file mode 100644 index 000000000..bdc2a5c9c --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/compose/screen/tools/ToolsScreen.kt @@ -0,0 +1,405 @@ +package io.nekohasekai.sfa.compose.screen.tools + +import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Hub +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.NetworkCheck +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.CrashReportManager +import io.nekohasekai.sfa.bg.OOMReportManager +import io.nekohasekai.sfa.compose.component.RemoteControlMenuItems +import io.nekohasekai.sfa.compose.component.rememberRemoteServers +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.terminal.TailscaleSSHPresentedSession +import io.nekohasekai.sfa.utils.RemoteControlManager + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ToolsScreen( + navController: NavController, + serviceStatus: Status = Status.Stopped, + tailscaleViewModel: TailscaleStatusViewModel, + sshSharedViewModel: TailscaleSSHSharedViewModel, +) { + val remoteServers by rememberRemoteServers() + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_tools)) }, + actions = { + if (remoteServers.isNotEmpty()) { + Box { + var showOthersMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showOthersMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.title_others), + ) + } + DropdownMenu( + expanded = showOthersMenu, + onDismissRequest = { showOthersMenu = false }, + ) { + RemoteControlMenuItems( + servers = remoteServers, + onAction = { showOthersMenu = false }, + leadingDivider = false, + ) + } + } + } + }, + ) + } + + val crashUnreadCount by CrashReportManager.unreadCount.collectAsState() + val oomUnreadCount by OOMReportManager.unreadCount.collectAsState() + val tailscaleState by tailscaleViewModel.uiState.collectAsState() + val remoteServer by RemoteControlManager.remoteServer.collectAsState() + + LaunchedEffect(remoteServer?.id) { + // Drop the previous target's subscription when switching between the + // local service and a remote server, or between two servers: a server + // without tailscale leaves no active stream to error out, so the + // subscription would stay stale without an explicit cancel. + tailscaleViewModel.cancel() + if (remoteServer != null || serviceStatus == Status.Started) { + tailscaleViewModel.subscribe() + } + } + + LaunchedEffect(serviceStatus) { + if (remoteServer != null) { + return@LaunchedEffect + } + if (serviceStatus == Status.Started) { + tailscaleViewModel.subscribe() + } else { + tailscaleViewModel.cancel() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + if (tailscaleState.endpoints.isNotEmpty()) { + Text( + text = stringResource(R.string.tailscale_endpoints), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + val endpoints = tailscaleState.endpoints + endpoints.forEachIndexed { index, endpoint -> + val shape = when { + endpoints.size == 1 -> RoundedCornerShape(12.dp) + index == 0 -> RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + index == endpoints.size - 1 -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + else -> RoundedCornerShape(0.dp) + } + var showSSHMenu by remember { mutableStateOf(false) } + val sshPeers = remember(endpoint) { + endpoint.userGroups.flatMap { it.peers }.filter { peer -> + peer.online && peer.sshHostKeys.isNotEmpty() && + peer.tailscaleIPs.isNotEmpty() && peer.id != endpoint.selfPeer?.id + } + } + Box { + ListItem( + headlineContent = { + Text( + if (endpoints.size == 1) { + stringResource(R.string.tailscale) + } else { + stringResource(R.string.tailscale_with_tag, endpoint.endpointTag) + }, + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Hub, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(shape) + .combinedClickable( + onClick = { + navController.navigate("tools/tailscale/${Uri.encode(endpoint.endpointTag)}") + }, + onLongClick = { + if (sshPeers.isNotEmpty()) { + showSSHMenu = true + } + }, + ), + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + DropdownMenu( + expanded = showSSHMenu, + onDismissRequest = { showSSHMenu = false }, + ) { + if (sshPeers.size == 1) { + DropdownMenuItem( + text = { Text(stringResource(R.string.tailscale_ssh_connect)) }, + leadingIcon = { + Icon(Icons.Default.Terminal, contentDescription = null) + }, + onClick = { + showSSHMenu = false + handleSSHNavigation( + navController, + sshSharedViewModel, + sshPeers[0], + endpoint.endpointTag, + ) + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.tailscale_ssh_connect)) }, + leadingIcon = { + Icon(Icons.Default.Terminal, contentDescription = null) + }, + enabled = false, + onClick = {}, + ) + sshPeers.forEach { peer -> + DropdownMenuItem( + text = { Text(peer.hostName) }, + onClick = { + showSSHMenu = false + handleSSHNavigation( + navController, + sshSharedViewModel, + peer, + endpoint.endpointTag, + ) + }, + ) + } + } + } + } + } + } + } + + Text( + text = stringResource(R.string.title_network), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.network_quality), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NetworkCheck, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/network_quality") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.stun_test), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SwapHoriz, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/stun_test") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + + // Crash/OOM reports read local files, which the remote control API + // does not reach. + if (remoteServer == null) { + Text( + text = stringResource(R.string.title_debug), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.crash_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (crashUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$crashUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("tools/crash_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.oom_report), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Memory, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (oomUnreadCount > 0) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { + Text("$oomUnreadCount") + } + } + }, + modifier = Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("tools/oom_report") }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + ) + } + } + } +} + +internal fun handleSSHNavigation( + navController: NavController, + sshSharedViewModel: TailscaleSSHSharedViewModel, + peer: TailscalePeerData, + endpointTag: String, +) { + val quickConnectPeers = Settings.tailscaleSSHQuickConnectPeers + if (quickConnectPeers.contains(peer.stableID)) { + val usernames = Settings.tailscaleSSHRememberedUsernames + sshSharedViewModel.setPendingSession( + TailscaleSSHPresentedSession( + endpointTag = endpointTag, + peerHostName = peer.hostName, + peerAddress = peer.tailscaleIPs.first(), + username = usernames[peer.stableID]?.takeIf { it.isNotBlank() } ?: "root", + hostKeys = peer.sshHostKeys, + ), + ) + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}/terminal", + ) + } else { + navController.navigate( + "tools/tailscale/${Uri.encode(endpointTag)}/peer/${Uri.encode(peer.id)}/ssh", + ) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 2f41fea39..81caf346f 100644 --- a/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -31,10 +31,27 @@ object SettingsKey { const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled" const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" + // OOM killer + const val OOM_KILLER_ENABLED = "oom_killer_enabled" + const val OOM_KILLER_DISABLED = "oom_killer_disabled" + const val OOM_MEMORY_LIMIT_MB = "oom_memory_limit_mb" + // dashboard const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" + // Remote Control + const val ACTIVE_REMOTE_SERVER_ID = "active_remote_server_id" + + // Tailscale SSH + const val TAILSCALE_SSH_REMEMBERED_USERNAMES = "tailscale_ssh_remembered_usernames" + const val TAILSCALE_SSH_QUICK_CONNECT_PEERS = "tailscale_ssh_quick_connect_peers" + const val TAILSCALE_SSH_LIGHT_THEME = "tailscale_ssh_light_theme" + const val TAILSCALE_SSH_DARK_THEME = "tailscale_ssh_dark_theme" + const val TAILSCALE_SSH_FONT_FAMILY = "tailscale_ssh_font_family" + const val TAILSCALE_SSH_FONT_SIZE = "tailscale_ssh_font_size" + const val TAILSCALE_SSH_CUSTOM_FONT_PATH = "tailscale_ssh_custom_font_path" + // cache const val STARTED_BY_USER = "started_by_user" const val CACHED_UPDATE_INFO = "cached_update_info" diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt index e7eee0f29..0dee3b53e 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt @@ -6,13 +6,15 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @Database( - entities = [Profile::class], - version = 2, + entities = [Profile::class, RemoteServer::class], + version = 3, exportSchema = true, ) abstract class ProfileDatabase : RoomDatabase() { abstract fun profileDao(): Profile.Dao + abstract fun remoteServerDao(): RemoteServer.Dao + companion object { val MIGRATION_1_2 = object : Migration(1, 2) { @@ -21,5 +23,19 @@ abstract class ProfileDatabase : RoomDatabase() { database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL") } } + + val MIGRATION_2_3 = + object : Migration(2, 3) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "CREATE TABLE IF NOT EXISTS `remote_servers` (" + + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`userOrder` INTEGER NOT NULL, " + + "`name` TEXT NOT NULL, " + + "`url` TEXT NOT NULL, " + + "`secret` TEXT NOT NULL)", + ) + } + } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt b/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt index b0e16434c..92e4c9184 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt @@ -28,7 +28,7 @@ object ProfileManager { ProfileDatabase::class.java, Path.PROFILES_DATABASE_PATH, ) - .addMigrations(ProfileDatabase.MIGRATION_1_2) + .addMigrations(ProfileDatabase.MIGRATION_1_2, ProfileDatabase.MIGRATION_2_3) .fallbackToDestructiveMigrationOnDowngrade() .enableMultiInstanceInvalidation() .setQueryExecutor { GlobalScope.launch { it.run() } } @@ -93,4 +93,6 @@ object ProfileManager { } suspend fun list(): List = instance.profileDao().list() + + fun remoteServerDao(): RemoteServer.Dao = instance.remoteServerDao() } diff --git a/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt new file mode 100644 index 000000000..6e4eb2568 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServer.kt @@ -0,0 +1,76 @@ +package io.nekohasekai.sfa.database + +import android.os.Parcelable +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Update +import kotlinx.parcelize.Parcelize +import java.net.URI + +@Entity( + tableName = "remote_servers", +) +@Parcelize +class RemoteServer( + @PrimaryKey(autoGenerate = true) var id: Long = 0L, + var userOrder: Long = 0L, + var name: String = "", + var url: String = "", + var secret: String = "", +) : Parcelable { + val displayName: String + get() = name.ifEmpty { url } + + companion object { + fun validateURL(urlString: String): String? { + var trimmed = urlString.trim() + if (trimmed.isEmpty()) { + return null + } + if (!trimmed.contains("://")) { + trimmed = "http://$trimmed" + } + val uri = + try { + URI(trimmed) + } catch (_: Exception) { + return null + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") { + return null + } + if (uri.host.isNullOrEmpty()) { + return null + } + return trimmed + } + } + + @androidx.room.Dao + interface Dao { + @Insert + fun insert(server: RemoteServer): Long + + @Update + fun update(server: RemoteServer): Int + + @Update + fun update(servers: List): Int + + @Delete + fun delete(server: RemoteServer): Int + + @Query("SELECT * FROM remote_servers WHERE id = :serverId") + fun get(serverId: Long): RemoteServer? + + @Query("SELECT * FROM remote_servers ORDER BY userOrder ASC") + fun list(): List + + @Query("SELECT MAX(userOrder) + 1 FROM remote_servers") + fun nextOrder(): Long? + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/RemoteServerManager.kt b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServerManager.kt new file mode 100644 index 000000000..27f6c6721 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/database/RemoteServerManager.kt @@ -0,0 +1,57 @@ +package io.nekohasekai.sfa.database + +@Suppress("RedundantSuspendModifier") +object RemoteServerManager { + private val callbacks = mutableListOf<() -> Unit>() + + fun registerCallback(callback: () -> Unit) { + callbacks.add(callback) + } + + fun unregisterCallback(callback: () -> Unit) { + callbacks.remove(callback) + } + + private fun notifyCallbacks() { + for (callback in callbacks.toList()) { + callback() + } + } + + suspend fun nextOrder(): Long = ProfileManager.remoteServerDao().nextOrder() ?: 0 + + suspend fun get(id: Long): RemoteServer? = ProfileManager.remoteServerDao().get(id) + + suspend fun create(server: RemoteServer): RemoteServer { + server.userOrder = nextOrder() + server.id = ProfileManager.remoteServerDao().insert(server) + notifyCallbacks() + return server + } + + suspend fun update(server: RemoteServer): Int { + try { + return ProfileManager.remoteServerDao().update(server) + } finally { + notifyCallbacks() + } + } + + suspend fun update(servers: List): Int { + try { + return ProfileManager.remoteServerDao().update(servers) + } finally { + notifyCallbacks() + } + } + + suspend fun delete(server: RemoteServer): Int { + try { + return ProfileManager.remoteServerDao().delete(server) + } finally { + notifyCallbacks() + } + } + + suspend fun list(): List = ProfileManager.remoteServerDao().list() +} diff --git a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 64f417f75..f4ee6d096 100644 --- a/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -14,6 +14,7 @@ import io.nekohasekai.sfa.database.preference.RoomPreferenceDataStore import io.nekohasekai.sfa.ktx.boolean import io.nekohasekai.sfa.ktx.int import io.nekohasekai.sfa.ktx.long +import io.nekohasekai.sfa.ktx.map import io.nekohasekai.sfa.ktx.string import io.nekohasekai.sfa.ktx.stringSet import kotlinx.coroutines.DelicateCoroutinesApi @@ -106,9 +107,24 @@ object Settings { ) { false } var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } + var oomKillerEnabled by dataStore.boolean(SettingsKey.OOM_KILLER_ENABLED) { false } + var oomKillerDisabled by dataStore.boolean(SettingsKey.OOM_KILLER_DISABLED) { true } + var oomMemoryLimitMB by dataStore.int(SettingsKey.OOM_MEMORY_LIMIT_MB) { 50 } + var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } + var activeRemoteServerId by dataStore.long(SettingsKey.ACTIVE_REMOTE_SERVER_ID) { 0L } + + // Tailscale SSH + var tailscaleSSHRememberedUsernames by dataStore.map(SettingsKey.TAILSCALE_SSH_REMEMBERED_USERNAMES) + var tailscaleSSHQuickConnectPeers by dataStore.stringSet(SettingsKey.TAILSCALE_SSH_QUICK_CONNECT_PEERS) + var tailscaleSSHLightTheme by dataStore.string(SettingsKey.TAILSCALE_SSH_LIGHT_THEME) { "base16-3024-light" } + var tailscaleSSHDarkTheme by dataStore.string(SettingsKey.TAILSCALE_SSH_DARK_THEME) { "argonaut" } + var tailscaleSSHFontFamily by dataStore.string(SettingsKey.TAILSCALE_SSH_FONT_FAMILY) + var tailscaleSSHFontSize by dataStore.int(SettingsKey.TAILSCALE_SSH_FONT_SIZE) { 14 } + var tailscaleSSHCustomFontPath by dataStore.string(SettingsKey.TAILSCALE_SSH_CUSTOM_FONT_PATH) + var cachedUpdateInfo by dataStore.string(SettingsKey.CACHED_UPDATE_INFO) { "" } var cachedApkPath by dataStore.string(SettingsKey.CACHED_APK_PATH) { "" } var lastShownUpdateVersion by dataStore.int(SettingsKey.LAST_SHOWN_UPDATE_VERSION) { 0 } diff --git a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt index bd0fcbd53..b51de90f3 100644 --- a/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt +++ b/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt @@ -1,6 +1,7 @@ package io.nekohasekai.sfa.ktx import androidx.preference.PreferenceDataStore +import kotlinx.serialization.json.Json import kotlin.reflect.KProperty fun PreferenceDataStore.string(name: String, defaultValue: () -> String = { "" }) = PreferenceProxy(name, defaultValue, ::getString, ::putString) @@ -31,6 +32,24 @@ fun PreferenceDataStore.stringToLong(name: String, defaultValue: () -> Long = { fun PreferenceDataStore.stringSet(name: String, defaultValue: () -> Set = { emptySet() }) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) +fun PreferenceDataStore.map(name: String, defaultValue: () -> Map = { emptyMap() }) = PreferenceProxy( + name, + defaultValue, + { key, default -> + val json = getString(key, null) + if (json.isNullOrBlank()) { + default + } else { + try { + Json.decodeFromString>(json) + } catch (_: Exception) { + default + } + } + }, + { key, value -> putString(key, Json.encodeToString(value)) }, +) + class PreferenceProxy( val name: String, val defaultValue: () -> T, diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/ImportedFontStore.kt b/app/src/main/java/io/nekohasekai/sfa/terminal/ImportedFontStore.kt new file mode 100644 index 000000000..d1e0e1838 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/ImportedFontStore.kt @@ -0,0 +1,65 @@ +package io.nekohasekai.sfa.terminal + +import android.content.Context +import android.graphics.Typeface +import android.net.Uri +import java.io.File + +data class ImportedFont( + val name: String, + val path: String, +) + +object ImportedFontStore { + + private fun fontsDir(context: Context): File = File(context.filesDir, "fonts").also { it.mkdirs() } + + fun listImportedFonts(context: Context): List { + val dir = fontsDir(context) + if (!dir.exists()) return emptyList() + return dir.listFiles() + ?.filter { it.extension in listOf("ttf", "otf", "ttc", "otc") } + ?.mapNotNull { file -> + try { + Typeface.createFromFile(file) + ImportedFont(name = file.nameWithoutExtension, path = file.absolutePath) + } catch (_: Exception) { + null + } + } + ?.sortedBy { it.name } + ?: emptyList() + } + + fun importFont(context: Context, uri: Uri): ImportedFont? { + val resolver = context.contentResolver + val inputStream = resolver.openInputStream(uri) ?: return null + val fileName = uri.lastPathSegment?.substringAfterLast('/') ?: "font.ttf" + val destFile = File(fontsDir(context), fileName) + inputStream.use { input -> + destFile.outputStream().use { output -> + input.copyTo(output) + } + } + return try { + Typeface.createFromFile(destFile) + ImportedFont(name = destFile.nameWithoutExtension, path = destFile.absolutePath) + } catch (_: Exception) { + destFile.delete() + null + } + } + + fun deleteFont(context: Context, name: String) { + val dir = fontsDir(context) + dir.listFiles() + ?.filter { it.nameWithoutExtension == name } + ?.forEach { it.delete() } + } + + fun loadTypeface(path: String): Typeface? = try { + Typeface.createFromFile(path) + } catch (_: Exception) { + null + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/TailscaleSSHPresentedSession.kt b/app/src/main/java/io/nekohasekai/sfa/terminal/TailscaleSSHPresentedSession.kt new file mode 100644 index 000000000..3dc260ad6 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/TailscaleSSHPresentedSession.kt @@ -0,0 +1,9 @@ +package io.nekohasekai.sfa.terminal + +data class TailscaleSSHPresentedSession( + val endpointTag: String, + val peerHostName: String, + val peerAddress: String, + val username: String, + val hostKeys: List, +) diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/TailscaleSSHTerminalSession.java b/app/src/main/java/io/nekohasekai/sfa/terminal/TailscaleSSHTerminalSession.java new file mode 100644 index 000000000..ea1052295 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/TailscaleSSHTerminalSession.java @@ -0,0 +1,217 @@ +package io.nekohasekai.sfa.terminal; + +import android.os.Handler; +import android.os.Looper; +import com.termux.terminal.TerminalEmulator; +import com.termux.terminal.TerminalSession; +import com.termux.terminal.TerminalSessionClient; +import io.nekohasekai.libbox.TailscaleSSHSession; +import java.util.Arrays; + +public class TailscaleSSHTerminalSession extends TerminalSession { + + public enum Phase { + CONNECTING, + RUNNING, + FINISHED + } + + public interface PhaseCallback { + void onPhaseChanged(Phase phase); + + void onAuthBanner(String message); + } + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private TailscaleSSHSession sshSession; + private PhaseCallback phaseCallback; + private volatile Phase phase = Phase.CONNECTING; + private int exitCode; + private String exitSignal; + private String exitErrorMessage; + private int lastColumns; + private int lastRows; + private int lastCellWidthPixels; + private int lastCellHeightPixels; + + public TailscaleSSHTerminalSession(TerminalSessionClient client) { + super("", "", new String[0], new String[0], null, client); + } + + public void setPhaseCallback(PhaseCallback callback) { + this.phaseCallback = callback; + } + + public void setSshSession(TailscaleSSHSession session) { + this.sshSession = session; + if (mEmulator != null && lastColumns > 0 && lastRows > 0) { + try { + session.sendResize(lastColumns, lastRows, lastCellWidthPixels, lastCellHeightPixels); + } catch (Exception ignored) { + } + } + } + + public Phase getPhase() { + return phase; + } + + public int getSSHExitCode() { + return exitCode; + } + + public String getSSHExitSignal() { + return exitSignal; + } + + public String getSSHExitErrorMessage() { + return exitErrorMessage; + } + + @Override + public void initializeEmulator(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + mEmulator = + new TerminalEmulator(this, columns, rows, cellWidthPixels, cellHeightPixels, null, mClient); + } + + @Override + public void updateSize(int columns, int rows, int cellWidthPixels, int cellHeightPixels) { + lastColumns = columns; + lastRows = rows; + lastCellWidthPixels = cellWidthPixels; + lastCellHeightPixels = cellHeightPixels; + if (mEmulator == null) { + initializeEmulator(columns, rows, cellWidthPixels, cellHeightPixels); + } else { + mEmulator.resize(columns, rows, cellWidthPixels, cellHeightPixels); + } + if (sshSession != null) { + try { + sshSession.sendResize(columns, rows, cellWidthPixels, cellHeightPixels); + } catch (Exception ignored) { + } + } + } + + @Override + public void write(byte[] data, int offset, int count) { + if (sshSession == null) return; + byte[] sanitized = sanitizeInput(data, offset, count); + try { + sshSession.sendInput(sanitized); + } catch (Exception ignored) { + } + } + + @Override + public synchronized boolean isRunning() { + return phase == Phase.RUNNING || phase == Phase.CONNECTING; + } + + @Override + public void finishIfRunning() { + close(); + } + + public void feedOutput(byte[] data) { + mainHandler.post( + () -> { + if (mEmulator != null) { + mEmulator.append(data, data.length); + notifyScreenUpdate(); + } + }); + } + + public void onReady() { + phase = Phase.RUNNING; + mainHandler.post( + () -> { + if (phaseCallback != null) { + phaseCallback.onPhaseChanged(Phase.RUNNING); + } + }); + } + + public void onAuthBanner(String message) { + mainHandler.post( + () -> { + if (phaseCallback != null) { + phaseCallback.onAuthBanner(message); + } + }); + } + + public void onExit(int exitCode, String signal, String errorMessage) { + this.exitCode = exitCode; + this.exitSignal = signal; + this.exitErrorMessage = errorMessage; + phase = Phase.FINISHED; + mainHandler.post( + () -> { + if (mEmulator != null) { + StringBuilder exitText = new StringBuilder("\r\n[Session ended"); + if (errorMessage != null && !errorMessage.isEmpty()) { + exitText.append(": ").append(errorMessage); + } else if (exitCode != 0) { + exitText.append(" (exit ").append(exitCode).append(")"); + } + if (signal != null && !signal.isEmpty()) { + exitText.append(" (signal ").append(signal).append(")"); + } + exitText.append(" - press any key]"); + byte[] bytes = exitText.toString().getBytes(); + mEmulator.append(bytes, bytes.length); + notifyScreenUpdate(); + } + if (phaseCallback != null) { + phaseCallback.onPhaseChanged(Phase.FINISHED); + } + mClient.onSessionFinished(TailscaleSSHTerminalSession.this); + }); + } + + public void onError(String message) { + onExit(-1, null, message); + } + + public void close() { + if (sshSession != null) { + try { + sshSession.close(); + } catch (Exception ignored) { + } + sshSession = null; + } + } + + private static byte[] sanitizeInput(byte[] data, int offset, int count) { + byte[] result = new byte[count]; + int writePos = 0; + for (int i = offset; i < offset + count; i++) { + byte b = data[i]; + if (b == 0x1b && i + 4 < offset + count) { + // Strip bracketed paste markers: ESC[200~ and ESC[201~ + if (data[i + 1] == '[' + && data[i + 2] == '2' + && data[i + 3] == '0' + && (data[i + 4] == '0' || data[i + 4] == '1') + && i + 5 < offset + count + && data[i + 5] == '~') { + i += 5; + continue; + } + } + // Convert LF to CR + if (b == 0x0A) { + result[writePos++] = 0x0D; + } else { + result[writePos++] = b; + } + } + if (writePos == count) { + return result; + } + return Arrays.copyOf(result, writePos); + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalColorSchemeLoader.kt b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalColorSchemeLoader.kt new file mode 100644 index 000000000..e8ede5315 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalColorSchemeLoader.kt @@ -0,0 +1,55 @@ +package io.nekohasekai.sfa.terminal + +import android.content.Context +import android.graphics.Color +import com.termux.terminal.TerminalColorScheme +import com.termux.terminal.TerminalColors +import java.util.Properties + +object TerminalColorSchemeLoader { + + fun listSchemes(context: Context): List { + val files = context.assets.list("termux-colors") ?: return emptyList() + return files + .filter { it.endsWith(".properties") } + .map { it.removeSuffix(".properties") } + .sorted() + } + + fun listSchemes(context: Context, isDark: Boolean): List { + return listSchemes(context).filter { name -> + val props = loadScheme(context, name) ?: return@filter false + val background = props.getProperty("background") ?: return@filter isDark + val luminance = backgroundLuminance(background) + if (isDark) luminance <= 128 else luminance > 128 + } + } + + fun loadScheme(context: Context, name: String): Properties? = try { + val props = Properties() + context.assets.open("termux-colors/$name.properties").use { props.load(it) } + props + } catch (_: Exception) { + null + } + + fun applyScheme(context: Context, name: String) { + val props = loadScheme(context, name) ?: return + TerminalColors.COLOR_SCHEME.updateWith(props) + } + + fun applySchemeToEmulator(emulator: com.termux.terminal.TerminalEmulator, context: Context, name: String) { + applyScheme(context, name) + emulator.mColors.reset() + } + + private fun backgroundLuminance(hex: String): Int = try { + val color = Color.parseColor(hex) + val r = Color.red(color) + val g = Color.green(color) + val b = Color.blue(color) + (0.299 * r + 0.587 * g + 0.114 * b).toInt() + } catch (_: Exception) { + 0 + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalExtraKeysState.kt b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalExtraKeysState.kt new file mode 100644 index 000000000..daa5271b2 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalExtraKeysState.kt @@ -0,0 +1,64 @@ +package io.nekohasekai.sfa.terminal + +import com.termux.terminal.KeyHandler +import kotlinx.coroutines.flow.MutableStateFlow + +class TerminalExtraKeysState { + + enum class StickyModifierState { INACTIVE, ARMED, LOCKED } + + val ctrlState = MutableStateFlow(StickyModifierState.INACTIVE) + val altState = MutableStateFlow(StickyModifierState.INACTIVE) + + val isCtrlActive: Boolean get() = ctrlState.value != StickyModifierState.INACTIVE + val isAltActive: Boolean get() = altState.value != StickyModifierState.INACTIVE + + private var lastCtrlTapTime = 0L + private var lastAltTapTime = 0L + + fun toggleCtrl(currentTimeMs: Long) { + ctrlState.value = toggleModifier(ctrlState.value, currentTimeMs, lastCtrlTapTime) + lastCtrlTapTime = currentTimeMs + } + + fun toggleAlt(currentTimeMs: Long) { + altState.value = toggleModifier(altState.value, currentTimeMs, lastAltTapTime) + lastAltTapTime = currentTimeMs + } + + fun consumeModifiers() { + if (ctrlState.value == StickyModifierState.ARMED) { + ctrlState.value = StickyModifierState.INACTIVE + } + if (altState.value == StickyModifierState.ARMED) { + altState.value = StickyModifierState.INACTIVE + } + } + + fun currentKeyMod(): Int { + var mod = 0 + if (isCtrlActive) mod = mod or KeyHandler.KEYMOD_CTRL + if (isAltActive) mod = mod or KeyHandler.KEYMOD_ALT + return mod + } + + private fun toggleModifier( + current: StickyModifierState, + currentTimeMs: Long, + lastTapTime: Long, + ): StickyModifierState = when (current) { + StickyModifierState.INACTIVE -> StickyModifierState.ARMED + StickyModifierState.ARMED -> { + if (currentTimeMs - lastTapTime < DOUBLE_TAP_THRESHOLD_MS) { + StickyModifierState.LOCKED + } else { + StickyModifierState.INACTIVE + } + } + StickyModifierState.LOCKED -> StickyModifierState.INACTIVE + } + + companion object { + private const val DOUBLE_TAP_THRESHOLD_MS = 300L + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt new file mode 100644 index 000000000..3a47ca525 --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/terminal/TerminalSessionManager.kt @@ -0,0 +1,13 @@ +package io.nekohasekai.sfa.terminal + +import java.util.UUID + +data class ManagedSession( + val id: String = UUID.randomUUID().toString(), + val terminalSession: TailscaleSSHTerminalSession, + val presentedSession: TailscaleSSHPresentedSession, +) { + // The session owns its command client (a dedicated connection in remote + // control mode) and must disconnect it when the session ends. + var commandClient: io.nekohasekai.libbox.CommandClient? = null +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index 3d5abf9fc..1680ba531 100644 --- a/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -1,8 +1,6 @@ package io.nekohasekai.sfa.utils import android.util.Log -import go.Seq -import io.nekohasekai.libbox.CommandClient import io.nekohasekai.libbox.CommandClientHandler import io.nekohasekai.libbox.CommandClientOptions import io.nekohasekai.libbox.ConnectionEvents @@ -10,25 +8,33 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LogEntry import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.libbox.OutboundGroupItemIterator import io.nekohasekai.libbox.OutboundGroupIterator import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.libbox.StringIterator import io.nekohasekai.sfa.ktx.toList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch open class CommandClient( private val scope: CoroutineScope, private val connectionTypes: List, private val handler: Handler, + private val localOnly: Boolean = false, ) { constructor( scope: CoroutineScope, connectionType: ConnectionType, handler: Handler, - ) : this(scope, listOf(connectionType), handler) + localOnly: Boolean = false, + ) : this(scope, listOf(connectionType), handler, localOnly) private val additionalHandlers = mutableListOf() private var cachedGroups: MutableList? = null + private var cachedOutbounds: List? = null fun addHandler(handler: Handler) { synchronized(additionalHandlers) { @@ -37,6 +43,9 @@ open class CommandClient( cachedGroups?.let { groups -> handler.updateGroups(groups) } + cachedOutbounds?.let { outbounds -> + handler.updateOutbounds(outbounds) + } } } } @@ -57,6 +66,16 @@ open class CommandClient( Log, ClashMode, Connections, + Outbounds, + } + + enum class ConnectionErrorKind { + // A connect attempt failed; retrying is not expected to succeed. + ConnectFailed, + + // An established connection dropped (app suspension, network change, + // server restart); reconnecting may recover. + ConnectionLost, } interface Handler { @@ -64,6 +83,8 @@ open class CommandClient( fun onDisconnected() {} + fun onConnectionError(kind: ConnectionErrorKind, message: String) {} + fun updateStatus(status: StatusMessage) {} fun setDefaultLogLevel(level: Int) {} @@ -74,6 +95,8 @@ open class CommandClient( fun updateGroups(newGroups: MutableList) {} + fun updateOutbounds(outbounds: List) {} + fun initializeClashMode(modeList: List, currentMode: String) {} fun updateClashMode(newMode: String) {} @@ -81,57 +104,126 @@ open class CommandClient( fun writeConnectionEvents(events: ConnectionEvents) {} } - private var commandClient: CommandClient? = null - private val clientHandler = ClientHandler() + private val access = Any() + private var connectionEpoch = 0 + private var commandClient: io.nekohasekai.libbox.CommandClient? = null fun connect() { - disconnect() - val options = CommandClientOptions() - connectionTypes.forEach { connectionType -> - val command = - when (connectionType) { - ConnectionType.Status -> Libbox.CommandStatus - ConnectionType.Groups -> Libbox.CommandGroup - ConnectionType.Log -> Libbox.CommandLog - ConnectionType.ClashMode -> Libbox.CommandClashMode - ConnectionType.Connections -> Libbox.CommandConnections - } - options.addCommand(command) + val epoch: Int + val previousClient: io.nekohasekai.libbox.CommandClient? + synchronized(access) { + epoch = ++connectionEpoch + previousClient = commandClient + commandClient = null } - options.statusInterval = 1 * 1000 * 1000 * 1000 - val commandClient = CommandClient(clientHandler, options) - try { - commandClient.connect() - } catch (e: Exception) { - Log.d("CommandClient", "connect failed", e) - return + // A remote connect dials over the network and blocks until the probe + // completes, so it must run off the main thread. + if (previousClient != null) { + // The dropped Go-side Disconnected callback is suppressed by the epoch + // bump, so the owner-initiated disconnect is reported deterministically. + getAllHandlers().forEach { it.onDisconnected() } + } + scope.launch(Dispatchers.IO) { + previousClient?.apply { + runCatching { + disconnect() + } + } + val options = CommandClientOptions() + connectionTypes.forEach { connectionType -> + val command = + when (connectionType) { + ConnectionType.Status -> Libbox.CommandStatus + ConnectionType.Groups -> Libbox.CommandGroup + ConnectionType.Log -> Libbox.CommandLog + ConnectionType.ClashMode -> Libbox.CommandClashMode + ConnectionType.Connections -> Libbox.CommandConnections + ConnectionType.Outbounds -> Libbox.CommandOutbounds + } + options.addCommand(command) + } + options.statusInterval = 1 * 1000 * 1000 * 1000 + val remoteServer = if (localOnly) null else CommandTarget.remoteServer + val newClient: io.nekohasekai.libbox.CommandClient + try { + newClient = + if (remoteServer != null) { + Libbox.newRemoteCommandClient( + ClientHandler(epoch), + options, + CommandTarget.libboxOptions(remoteServer), + ) + } else { + io.nekohasekai.libbox.CommandClient(ClientHandler(epoch), options) + } + newClient.connect() + } catch (e: Exception) { + Log.d("CommandClient", "connect failed", e) + if (isActiveEpoch(epoch)) { + handler.onConnectionError( + ConnectionErrorKind.ConnectFailed, + e.message ?: e.toString(), + ) + } + return@launch + } + val stale = + synchronized(access) { + if (epoch != connectionEpoch) { + true + } else { + commandClient = newClient + false + } + } + if (stale) { + runCatching { + newClient.disconnect() + } + } } - this.commandClient = commandClient } + @OptIn(DelicateCoroutinesApi::class) fun disconnect() { - commandClient?.apply { - runCatching { - disconnect() + val client: io.nekohasekai.libbox.CommandClient? + synchronized(access) { + connectionEpoch++ + client = commandClient + commandClient = null + } + if (client != null) { + getAllHandlers().forEach { it.onDisconnected() } + // The owning scope may already be cancelled when this is called from + // ViewModel.onCleared, so the connection is released independently. + GlobalScope.launch(Dispatchers.IO) { + runCatching { + client.disconnect() + } } -// Seq.destroyRef(refnum) } - commandClient = null } - private inner class ClientHandler : CommandClientHandler { + private fun isActiveEpoch(epoch: Int): Boolean = synchronized(access) { epoch == connectionEpoch } + + private inner class ClientHandler(private val epoch: Int) : CommandClientHandler { override fun connected() { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.onConnected() } Log.d("CommandClient", "connected") } override fun disconnected(message: String?) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.onDisconnected() } + if (message != null) { + handler.onConnectionError(ConnectionErrorKind.ConnectionLost, message) + } Log.d("CommandClient", "disconnected: $message") } override fun writeGroups(message: OutboundGroupIterator?) { - if (message == null) { + if (message == null || !isActiveEpoch(epoch)) { return } val groups = mutableListOf() @@ -142,16 +234,30 @@ open class CommandClient( getAllHandlers().forEach { it.updateGroups(groups) } } + override fun writeOutbounds(message: OutboundGroupItemIterator?) { + if (message == null || !isActiveEpoch(epoch)) { + return + } + val outbounds = mutableListOf() + while (message.hasNext()) { + outbounds.add(message.next()) + } + cachedOutbounds = outbounds + getAllHandlers().forEach { it.updateOutbounds(outbounds) } + } + override fun setDefaultLogLevel(level: Int) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.setDefaultLogLevel(level) } } override fun clearLogs() { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.clearLogs() } } override fun writeLogs(messageList: LogIterator?) { - if (messageList == null) { + if (messageList == null || !isActiveEpoch(epoch)) { return } val logs = messageList.toList() @@ -159,20 +265,23 @@ open class CommandClient( } override fun writeStatus(message: StatusMessage) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.updateStatus(message) } } override fun initializeClashMode(modeList: StringIterator, currentMode: String) { + if (!isActiveEpoch(epoch)) return val modes = modeList.toList() getAllHandlers().forEach { it.initializeClashMode(modes, currentMode) } } override fun updateClashMode(newMode: String) { + if (!isActiveEpoch(epoch)) return getAllHandlers().forEach { it.updateClashMode(newMode) } } override fun writeConnectionEvents(events: ConnectionEvents?) { - if (events == null) return + if (events == null || !isActiveEpoch(epoch)) return getAllHandlers().forEach { it.writeConnectionEvents(events) } } } diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt b/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt new file mode 100644 index 000000000..8187f09bd --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/CommandTarget.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sfa.utils + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.RemoteConnectionOptions +import io.nekohasekai.sfa.database.RemoteServer + +object CommandTarget { + private val access = Any() + private var activeRemoteServer: RemoteServer? = null + + // One gRPC channel per remote session: standalone calls reuse it instead of + // paying a TCP+TLS handshake (and leaking the connection) on every action. + private var sharedRemoteClient: io.nekohasekai.libbox.CommandClient? = null + + val remoteServer: RemoteServer? + get() = synchronized(access) { activeRemoteServer } + + val isRemote: Boolean + get() = remoteServer != null + + fun setRemoteServer(server: RemoteServer?) { + val previousClient: io.nekohasekai.libbox.CommandClient? + synchronized(access) { + previousClient = sharedRemoteClient + sharedRemoteClient = null + activeRemoteServer = server + } + previousClient?.apply { + runCatching { + disconnect() + } + } + } + + fun libboxOptions(server: RemoteServer): RemoteConnectionOptions { + val options = RemoteConnectionOptions() + options.setURL(server.url) + options.secret = server.secret + return options + } + + // Returns a client for one-shot calls and streamed sessions. In remote mode the + // client is shared for the whole session — callers must not disconnect it. + fun standaloneClient(): io.nekohasekai.libbox.CommandClient = synchronized(access) { + val server = activeRemoteServer ?: return Libbox.newStandaloneCommandClient() + sharedRemoteClient?.let { return it } + val client = Libbox.newStandaloneRemoteCommandClient(libboxOptions(server)) + sharedRemoteClient = client + client + } + + // Returns a dedicated client owned by the caller, who is responsible for + // disconnecting it (e.g. the SSH terminal closes its client on session end). + fun ownedStandaloneClient(): io.nekohasekai.libbox.CommandClient { + val server = remoteServer ?: return Libbox.newStandaloneCommandClient() + return Libbox.newStandaloneRemoteCommandClient(libboxOptions(server)) + } +} diff --git a/app/src/main/java/io/nekohasekai/sfa/utils/RemoteControlManager.kt b/app/src/main/java/io/nekohasekai/sfa/utils/RemoteControlManager.kt new file mode 100644 index 000000000..50a623b2f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sfa/utils/RemoteControlManager.kt @@ -0,0 +1,188 @@ +package io.nekohasekai.sfa.utils + +import android.os.SystemClock +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.GlobalEventBus +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.database.RemoteServer +import io.nekohasekai.sfa.database.RemoteServerManager +import io.nekohasekai.sfa.database.Settings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +object RemoteControlManager : CommandClient.Handler { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + private val _remoteServer = MutableStateFlow(null) + val remoteServer = _remoteServer.asStateFlow() + + private val _isConnected = MutableStateFlow(false) + val isConnected = _isConnected.asStateFlow() + + private val _startedAt = MutableStateFlow(null) + val startedAt = _startedAt.asStateFlow() + + // The monitor connection owns the session lifecycle: per-screen clients only + // connect while it reports connected, and its errors decide between silent + // reconnects and falling back to the local device. + private val monitorClient = CommandClient(scope, CommandClient.ConnectionType.Status, this) + + private var sessionHadConnected = false + private var sessionConnectedAt = 0L + private var reconnectAttempts = 0 + private var restored = false + + // A dropped session gets this many silent reconnect attempts before the + // failure is surfaced. The counter resets once a connection survives + // STABLE_CONNECTION_INTERVAL_MS, so only rapid connect-drop loops exhaust it. + private const val MAX_RECONNECT_ATTEMPTS = 3 + private const val STABLE_CONNECTION_INTERVAL_MS = 5000L + + fun restore() { + if (restored) { + return + } + restored = true + scope.launch { + val server = + withContext(Dispatchers.IO) { + val serverId = Settings.activeRemoteServerId + if (serverId == 0L) { + return@withContext null + } + val storedServer = runCatching { RemoteServerManager.get(serverId) }.getOrNull() + if (storedServer == null) { + Settings.activeRemoteServerId = 0L + } + storedServer + } + if (server != null && _remoteServer.value == null) { + enterRemoteControl(server) + } + // The initial state was already handled by enterRemoteControl. + AppLifecycleObserver.isForeground.drop(1).collect { foreground -> + if (_remoteServer.value == null) { + return@collect + } + if (foreground) { + // A connection cannot survive while the app is in the + // background, so resuming always restores the retry budget. + reconnectAttempts = 0 + sessionConnectedAt = 0L + monitorClient.connect() + } else { + monitorClient.disconnect() + } + } + } + } + + fun enterRemoteControl(server: RemoteServer) { + CommandTarget.setRemoteServer(server) + resetSessionState() + _remoteServer.value = server + if (AppLifecycleObserver.isForeground.value) { + monitorClient.connect() + } + scope.launch(Dispatchers.IO) { + Settings.activeRemoteServerId = server.id + } + } + + fun exitRemoteControl() { + if (_remoteServer.value == null) { + return + } + CommandTarget.setRemoteServer(null) + resetSessionState() + _remoteServer.value = null + monitorClient.disconnect() + scope.launch(Dispatchers.IO) { + Settings.activeRemoteServerId = 0L + } + } + + private fun resetSessionState() { + sessionHadConnected = false + sessionConnectedAt = 0L + reconnectAttempts = 0 + _isConnected.value = false + _startedAt.value = null + } + + override fun onConnected() { + scope.launch { + if (_remoteServer.value == null) { + return@launch + } + sessionHadConnected = true + sessionConnectedAt = SystemClock.elapsedRealtime() + _isConnected.value = true + val serviceStartedAt = + withContext(Dispatchers.IO) { + runCatching { CommandTarget.standaloneClient().startedAt }.getOrNull() + } + if (_isConnected.value) { + _startedAt.value = serviceStartedAt?.takeIf { it > 0 } + } + } + } + + override fun onDisconnected() { + scope.launch { + _isConnected.value = false + _startedAt.value = null + } + } + + override fun onConnectionError(kind: CommandClient.ConnectionErrorKind, message: String) { + scope.launch { + handleConnectionError(kind, message) + } + } + + // A remote session that cannot connect falls back to the local device + // immediately: leaving the app in remote mode would just make every command + // call fail at the point of use. A drop of an established session (app + // suspension, network change, server restart) is recoverable instead, so it + // reconnects silently and only surfaces the error once reconnecting fails too. + private suspend fun handleConnectionError(kind: CommandClient.ConnectionErrorKind, message: String) { + val server = _remoteServer.value ?: return + if (!AppLifecycleObserver.isForeground.value) { + // An alert shown now would be invisible and the connection is torn + // down anyway; recovery happens on the next foreground transition. + return + } + if (kind == CommandClient.ConnectionErrorKind.ConnectionLost) { + if (sessionConnectedAt != 0L && + SystemClock.elapsedRealtime() - sessionConnectedAt >= STABLE_CONNECTION_INTERVAL_MS + ) { + reconnectAttempts = 0 + } + sessionConnectedAt = 0L + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + reconnectAttempts++ + monitorClient.connect() + return + } + } + // A non-retryable connect failure, or a dropped session whose retry + // budget is spent: fall back to the local device, then surface the + // failure once. + val description = + if (sessionHadConnected) { + Application.application.getString(R.string.remote_disconnected_from, server.displayName) + } else { + Application.application.getString(R.string.remote_connect_failed, server.displayName) + } + exitRemoteControl() + GlobalEventBus.emit(UiEvent.ErrorMessage("$description\n$message")) + } +} diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index db997c6a9..22d699b5d 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -23,6 +23,8 @@ اقدام شروع لغو انتخاب + بارگذاری مجدد + راه‌اندازی مجدد باز کردن جمع کردن باز کردن همه @@ -200,6 +202,8 @@ پوشه کاری تنظیمات بتا غیرفعال‌کردن هشدارهای منسوخ + اندازه حافظه پنهان + پاک‌سازی حافظه پنهان اعلان‌ها فعال‌کردن اعلان نمایش سرعت بلادرنگ در اعلان @@ -279,6 +283,22 @@ نسخه جدید موجود است: %s به‌روزرسانی خودکار دانلود و نصب خودکار به‌روزرسانی‌ها در پس‌زمینه + منبع به‌روزرسانی + GitHub + F-Droid + آینه F-Droid + انتخاب خودکار بر اساس تأخیر + در حال تست… + %d ms + ناموفق + + افزودن آینه + نام + URL + سفارشی + URL نامعتبر + افزودن + حذف نصب بی‌صدا @@ -406,6 +426,139 @@ جمع کردن جستجو جستجوی لاگ‌ها + + ابزارها + شبکه + کیفیت شبکه + URL + ترتیبی + HTTP/3 + حداکثر زمان اجرا + 30s + 60s + شروع تست + لغو تست + تأخیر بیکاری + دانلود + آپلود + RPM دانلود + RPM آپلود + اطمینان بالا + اطمینان متوسط + اطمینان پایین + اتصال محدود + شما از اتصال محدود استفاده می‌کنید. این تست حجم قابل توجهی داده مصرف خواهد کرد. + ادامه + پیکربندی + نتایج + خروجی + پیش‌فرض + + + + نقاط اتصال + + وضعیت + وضعیت + شبکه + نام میزبان + باز کردن لینک احراز هویت + نمایش QR کد لینک احراز هویت + این دستگاه + خروج از حساب + متصل + متصل نیست + آدرس‌های Tailscale + جزئیات + انقضای کلید + گره خروجی + فعال + پینگ + شروع + توقف + اتصال مستقیم + اتصال از طریق DERP + نقطه پایانی + منطقه DERP + در حال اتصال… + بدون داده + به اشتراک گذاشته شده + منقضی شده + انقضا %1$s + انقضای کلید غیرفعال است + آخرین بازدید + در دسترس + بله + + + اتصال از طریق SSH + گزینه‌های SSH + نام کاربری + به خاطر سپردن گزینه‌های SSH + اتصال سریع + در صورت فعال بودن، می‌توانید از طریق منوی زمینه (فشار طولانی) روی ورودی Tailscale در ابزارها و ورودی‌های همتا در لیست همتاها به سرعت به این همتا متصل شوید.\n\nاین همتا همچنین در منوی جلسه جدید هنگام اتصال به همتاهای دیگر از طریق SSH ظاهر خواهد شد. + می‌توانید ظاهر ترمینال را در تنظیمات ← پیکربندی Termux سفارشی کنید. + اتصال + در حال اتصال… + جلسه فعالی وجود ندارد + جلسه جدید + + + پیکربندی Termux + تم رنگ + تم روشن + تم تیره + فونت + خانواده فونت + اندازه فونت + پیش‌فرض + فونت‌های سیستم + فونت‌های وارد شده + وارد کردن از فایل + جستجوی تم‌ها + + تست STUN + سرور + شروع تست + لغو تست + آدرس خارجی + تأخیر + نگاشت NAT + فیلتر NAT + تشخیص نوع NAT + پشتیبانی نمی‌شود توسط سرور + + + خالی + گزارش‌ها + فایل‌ها + حذف همه + حذف + اشتراک‌گذاری + اشتراک‌گذاری با پیکربندی + فراداده + پیکربندی + محلی + سرویس شروع نشده است + برای اعمال تغییرات، بارگذاری مجدد سرویس لازم است + برای اعمال تغییرات، راه‌اندازی مجدد سرویس لازم است + + + گزارش خرابی + Go Crash Log + JVM Crash Log + هنگام بروز خرابی گزارشی دریافت خواهید کرد. + + + گزارش کمبود حافظه + هنگامی که محدودیت حافظه فعال است، در صورت تجاوز حافظه سرویس از حد مجاز، گزارشی دریافت خواهید کرد. همچنین می‌توانید جمع‌آوری گزارش را به صورت دستی فعال کنید. + دریافت گزارش حافظه + فعال‌سازی محدودیت حافظه + یک محدودیت نرم حافظه برای سرویس تعیین کنید. سرویس چندین فرآیند را انجام خواهد داد تا سعی کند در محدوده این محدودیت حافظه باقی بماند. + محدودیت حافظه + قطع اتصالات + هنگام تجاوز حافظه سرویس از حد مجاز، تمام اتصالات را برای آزادسازی حافظه قطع کنید. + بهبود دسترسی ویژه برای sing-box @@ -442,4 +595,20 @@ نیاز به راه‌اندازی مجدد ماژول LSPosed به‌روزرسانی شد. برای اعمال تغییرات دستگاه را راه‌اندازی مجدد کنید. ماژول LSPosed + + + کنترل از راه دور + سرورها + بدون سرور + سرور جدید + ویرایش سرور + دستگاه محلی + مدیریت سرورها… + رمز + اختیاری + در حال اتصال… + قطع اتصال + نشانی سرور نامعتبر است: %1$s، قالب مورد انتظار host:port، http://host:port یا https://host:port است + اتصال به سرور راه دور %1$s ناموفق بود + اتصال با سرور راه دور %1$s قطع شد diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 274a39d7b..69b9b8ef9 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -23,6 +23,8 @@ Действие Начать Отменить выбор + Перезагрузить + Перезапустить Развернуть Свернуть Развернуть все @@ -200,6 +202,8 @@ Рабочая директория Бета-настройки Отключить предупреждения об устаревании + Размер кэша + Очистить кэш Уведомления Включить уведомления Отображать скорость в реальном времени в уведомлении @@ -279,6 +283,22 @@ Доступна новая версия: %s Автообновление Автоматически загружать и устанавливать обновления в фоне + Источник обновлений + GitHub + F-Droid + Зеркало F-Droid + Автовыбор по задержке + Тестирование… + %d мс + Ошибка + + Добавить зеркало + Имя + URL + Пользовательское + Недопустимый URL + Добавить + Удалить Тихая установка @@ -412,6 +432,139 @@ Свернуть поиск Поиск в логе + + Инструменты + Сеть + Качество сети + URL + Последовательно + HTTP/3 + Макс. время + 30s + 60s + Начать тест + Остановить тест + Задержка в простое + Загрузка + Отправка + Загрузка RPM + Отправка RPM + Высокая уверенность + Средняя уверенность + Низкая уверенность + Лимитное подключение + Вы используете лимитное подключение. Этот тест потребует значительного объёма трафика. + Продолжить + Конфигурация + Результаты + Исходящий + По умолчанию + + + + Точки подключения + + Статус + Состояние + Сеть + Имя хоста + Открыть URL авторизации + Показать QR-код авторизации + Это устройство + Выйти + Подключено + Не подключено + Адреса Tailscale + Подробности + Срок действия ключа + Выходной узел + Активен + Пинг + Начать + Остановить + Прямое соединение + Соединение через DERP + Конечная точка + Регион DERP + Подключение… + Нет данных + Поделено + Истёк + Истекает %1$s + Срок действия ключа отключён + Был(а) в сети + Доступно + Да + + + Подключение по SSH + Параметры SSH + Имя пользователя + Запомнить параметры SSH + Быстрое подключение + Если включено, вы можете быстро подключиться к этому узлу через контекстное меню (долгое нажатие) записи Tailscale в инструментах и записей узлов в списке узлов.\n\nЭтот узел также появится в меню «Новая сессия» при подключении к другим узлам по SSH. + Вы можете настроить внешний вид терминала в разделе Параметры → Конфигурация Termux. + Подключение + Подключение… + Нет активных сессий + Новая сессия + + + Конфигурация Termux + Цветовая тема + Светлая тема + Тёмная тема + Шрифт + Семейство шрифтов + Размер шрифта + По умолчанию + Системные шрифты + Импортированные шрифты + Импорт из файла + Поиск тем + + STUN-тест + Сервер + Начать тест + Остановить тест + Внешний адрес + Задержка + NAT-отображение + NAT-фильтрация + Определение типа NAT + Не поддерживается сервером + + + Пусто + Отчёты + Файлы + Удалить все + Удалить + Поделиться + Поделиться с конфигурацией + Метаданные + Конфигурация + Локальный + Служба не запущена + Для применения изменений необходимо перезагрузить сервис + Для применения изменений необходимо перезапустить сервис + + + Отчёт о сбое + Go Crash Log + JVM Crash Log + Вы получите отчёт при возникновении сбоя. + + + Отчёт о нехватке памяти + При включённом ограничении памяти вы получите отчёт, если память сервиса превысит лимит. Вы также можете вручную запросить сбор отчёта. + Получить отчёт о памяти + Включить ограничение памяти + Задайте мягкое ограничение памяти для сервиса. Сервис будет выполнять различные процессы, чтобы оставаться в пределах этого ограничения. + Ограничение памяти + Завершить соединения + Завершить все соединения для освобождения памяти при превышении лимита памяти сервиса. + Привилегированное расширение для sing-box @@ -448,4 +601,20 @@ Требуется перезагрузка Модуль LSPosed обновлён. Перезагрузите устройство, чтобы применить изменения. Модуль LSPosed + + + Удаленное управление + Серверы + Нет серверов + Новый сервер + Изменить сервер + Локальное устройство + Управление серверами… + Секрет + Необязательно + Подключение… + Отключить + Неверный URL сервера: %1$s, ожидается host:port, http://host:port или https://host:port + Не удалось подключиться к удаленному серверу %1$s + Отключено от удаленного сервера %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2f7df1a26..e48d8b256 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,6 +23,8 @@ 操作 启动 取消选择 + 重载 + 重启 展开 收起 全部展开 @@ -66,7 +68,7 @@ 已启动 - 仪表项目 + 仪表项 内存 协程 上传 @@ -86,7 +88,7 @@ 搜索连接… 关闭所有连接? 全部 - 活跃 + 活动 已关闭 日期 流量 @@ -421,6 +423,139 @@ 折叠搜索 搜索日志 + + 工具 + 网络 + 网络质量 + URL + 串行 + HTTP/3 + 最大运行时间 + 30s + 60s + 开始测试 + 取消测试 + 空闲延迟 + 下载 + 上传 + 下载 RPM + 上传 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量计费连接 + 您正在使用按流量计费的连接。此测试将消耗大量数据。 + 继续 + 配置 + 结果 + 出站 + 默认 + + + + 端点 + + 状态 + 状态 + 网络 + 主机名 + 打开认证链接 + 显示认证链接二维码 + 此设备 + 登出 + 已连接 + 未连接 + Tailscale 地址 + 详情 + 密钥过期 + 出口节点 + 活跃 + Ping + 启动 + 停止 + 直接连接 + DERP 中继连接 + 端点 + DERP 区域 + 连接中… + 无数据 + 共享至此 + 已过期 + %1$s过期 + 密钥过期已禁用 + 最后在线 + 可用 + + + + 通过 SSH 连接 + SSH 选项 + 用户名 + 记住 SSH 选项 + 快速连接 + 启用后,你可以通过工具中 Tailscale 条目和节点列表中节点条目的上下文菜单(长按)快速连接到此节点。\n\n当通过 SSH 连接到其他节点时,此节点也会出现在新建会话菜单中。 + 你可以在设置 → Termux 配置中自定义终端外观。 + 连接 + 连接中… + 无活动会话 + 新建会话 + + + Termux 配置 + 颜色主题 + 浅色主题 + 深色主题 + 字体 + 字体 + 字号 + 默认 + 系统字体 + 已导入的字体 + 从文件导入 + 搜索主题 + + STUN 测试 + 服务器 + 开始测试 + 取消测试 + 外部地址 + 延迟 + NAT 映射 + NAT 过滤 + NAT 类型检测 + 服务器不支持 + + + + 报告 + 文件 + 全部删除 + 删除 + 分享 + 附带配置分享 + 元数据 + 配置 + 本地 + 服务未启动 + 需要重载服务以应用更改 + 需要重启服务以应用更改 + + + 崩溃报告 + Go Crash Log + JVM Crash Log + 当遇到崩溃时,您将会收到报告。 + + + 内存不足报告 + 启用内存限制后,当服务内存超出限制时,您将会收到报告。您也可以手动触发收集报告。 + 获取内存报告 + 启用内存限制 + 为服务提供软内存限制。服务将执行多个进程以尝试保持在此内存限制范围内。 + 内存限制 + 终止连接 + 当服务内存超出限制时,终止所有连接以释放内存。 + sing-box 的特权增强 @@ -456,4 +591,20 @@ LSPosed 模块待更新 LSPosed 模块待降级 LSPosed 模块未激活 + + + 远程控制 + 服务器 + 无服务器 + 新建服务器 + 编辑服务器 + 本机 + 管理服务器… + 密钥 + 可选 + 连接中… + 断开连接 + 无效的服务器 URL: %1$s, 应为 host:port、http://host:port 或 https://host:port + 无法连接到远程服务器 %1$s + 已断开与远程服务器 %1$s 的连接 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index e0d79aa33..611aaf5ee 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -23,6 +23,8 @@ 操作 啟動 取消選擇 + 重新載入 + 重新啟動 展開 收合 全部展開 @@ -45,7 +47,7 @@ 預設 - 儀表板 + 儀表 設定檔 日誌 設定 @@ -66,7 +68,7 @@ 已啟動 - 儀表板項目 + 儀表項 記憶體 協程 上傳 @@ -86,7 +88,7 @@ 搜尋連線… 關閉所有連線? 全部 - 活躍 + 活動 已關閉 日期 流量 @@ -424,6 +426,139 @@ 收合搜尋 搜尋日誌 + + 工具 + 網路 + 網路品質 + URL + 序列 + HTTP/3 + 最大執行時間 + 30s + 60s + 開始測試 + 取消測試 + 閒置延遲 + 下載 + 上傳 + 下載 RPM + 上傳 RPM + 置信度高 + 置信度中 + 置信度低 + 按流量計費連線 + 您正在使用按流量計費的連線。此測試將消耗大量數據。 + 繼續 + 配置 + 結果 + 出站 + 默認 + + + + 端點 + + 狀態 + 狀態 + 網路 + 主機名 + 開啟認證連結 + 顯示認證連結 QR 碼 + 此裝置 + 登出 + 已連線 + 未連線 + Tailscale 位址 + 詳情 + 金鑰到期 + 出口節點 + 活躍 + Ping + 啟動 + 停止 + 直接連線 + DERP 中繼連線 + 端點 + DERP 區域 + 連線中… + 無資料 + 共享至此 + 已過期 + %1$s過期 + 金鑰到期已停用 + 最後上線 + 可用 + + + + 透過 SSH 連接 + SSH 選項 + 使用者名稱 + 記住 SSH 選項 + 快速連接 + 啟用後,你可以透過工具中 Tailscale 條目和節點列表中節點條目的上下文選單(長按)快速連接到此節點。\n\n當透過 SSH 連接到其他節點時,此節點也會出現在新建工作階段選單中。 + 你可以在設定 → Termux 配置中自定義終端外觀。 + 連接 + 連接中… + 無活動工作階段 + 新建工作階段 + + + Termux 配置 + 顏色主題 + 淺色主題 + 深色主題 + 字體 + 字型 + 字體大小 + 預設 + 系統字型 + 已匯入的字型 + 從檔案匯入 + 搜索主題 + + STUN 測試 + 伺服器 + 開始測試 + 取消測試 + 外部地址 + 延遲 + NAT 映射 + NAT 過濾 + NAT 類型偵測 + 伺服器不支援 + + + + 報告 + 檔案 + 全部刪除 + 刪除 + 分享 + 附帶配置分享 + 元數據 + 配置 + 本地 + 服務未啟動 + 需要重新載入服務以套用變更 + 需要重新啟動服務以套用變更 + + + 當機報告 + Go Crash Log + JVM Crash Log + 當發生當機時,您將會收到報告。 + + + 記憶體不足報告 + 啟用記憶體限制後,當服務記憶體超出限制時,您將會收到報告。您也可以手動觸發收集報告。 + 取得記憶體報告 + 啟用記憶體限制 + 為服務提供軟記憶體限制。服務將執行多個程序以嘗試保持在此記憶體限制範圍內。 + 記憶體限制 + 終止連線 + 當服務記憶體超出限制時,終止所有連線以釋放記憶體。 + sing-box 的特權強化 @@ -459,4 +594,20 @@ LSPosed 模組待更新 LSPosed 模組待降級 LSPosed 模組未啟用 + + + 遠端控制 + 伺服器 + 無伺服器 + 新建伺服器 + 編輯伺服器 + 本機 + 管理伺服器… + 密鑰 + 可選 + 連接中… + 斷開連接 + 無效的伺服器 URL: %1$s, 應為 host:port、http://host:port 或 https://host:port + 無法連接到遠端伺服器 %1$s + 已斷開與遠端伺服器 %1$s 的連接 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 064ccb764..0e68738d9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,8 @@ Action Start Deselect + Reload + Restart Expand Collapse Expand All @@ -424,6 +426,146 @@ Collapse search Search logs + + Tools + Network + Network Quality + URL + Serial + HTTP/3 + Max Runtime + 30s + 60s + Start Test + Cancel Test + Idle Latency + Download + Upload + Download RPM + Upload RPM + Confidence High + Confidence Medium + Confidence Low + Metered Connection + You\'re on a metered connection. This test will use a significant amount of data. + Continue + Configuration + Results + Outbound + Default + + + Tailscale + Tailscale: %s + Endpoints + + Status + State + Network + MagicDNS + Hostname + Open Auth URL + Show Auth URL QR Code + This Device + Log out + Connected + Not Connected + Tailscale Addresses + Details + Key Expiry + OS + Exit Node + Active + IPv4 + IPv6 + Ping + Start + Stop + Direct connection + DERP-relayed connection + Endpoint + DERP region + Connecting… + No data + Shared in + Expired + Expires %1$s + Key expiry disabled + Last Seen + SSH + Available + Yes + + + Connect via SSH + SSH Options + Username + Remember SSH Options + Quick Connect + If enabled, you can quickly connect to this peer via the context menu (long press) on the Tailscale entry in Tools and on peer entries in the peer list.\n\nThis peer will also appear in the New Session menu when connected to other peers via SSH. + You can customize the terminal appearance in Settings → Termux Configuration. + Connect + Connecting… + No active sessions + New session + + + Termux Configuration + Color Theme + Light Theme + Dark Theme + Font + Font Family + Font Size + Default + System Fonts + Imported Fonts + Import from File + Search themes + + + STUN Test + Server + Start Test + Cancel Test + External Address + Latency + NAT Mapping + NAT Filtering + NAT Type Detection + Not supported by server + + + Empty + Reports + Files + Delete All + Delete + Share + Share With Configuration + Metadata + Configuration + Local + Service not started + Reload service to apply changes + Restart service to apply changes + + + Crash Report + Go Crash Log + JVM Crash Log + You will receive a report when a crash occurs. + + + OOM Report + When memory limit is enabled, you will receive a report if the service memory exceeds the limit. You can also manually trigger report collection. + Fetch Memory Report + Enable Memory Limit + Provide a soft memory limit for the service. The service will perform multiple processes to try to stay within this memory limit. + Memory Limit + Kill Connections + Kill all connections to free memory when the service memory exceeds the limit. + Privileged Enhancement for sing-box @@ -460,4 +602,20 @@ Reboot required LSPosed module updated. Reboot to apply changes. LSPosed Module + + + Remote Control + Servers + No servers + New Server + Edit Server + Local Device + Manage Servers… + Secret + Optional + Connecting… + Disconnect + Invalid server URL: %1$s, expected host:port, http://host:port or https://host:port + Failed to connect to remote server %1$s + Disconnected from remote server %1$s diff --git a/gradle.properties b/gradle.properties index b36c76543..4fe1f223e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,3 +24,9 @@ android.nonTransitiveRClass=true # Keep explicit Kotlin Gradle plugins enabled for current build scripts/plugins. android.newDsl=false android.builtInKotlin=false + +# Properties used by third_party/termux-app modules +compileSdkVersion=36 +minSdkVersion=21 +targetSdkVersion=35 +ndkVersion=28.0.13004108 diff --git a/settings.gradle.kts b/settings.gradle.kts index 4044430a2..584193230 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,3 +18,7 @@ rootProject.name = "sing-box" include(":app") include(":libxposed-api") project(":libxposed-api").projectDir = file("third_party/libxposed-api") +include(":terminal-emulator") +project(":terminal-emulator").projectDir = file("third_party/termux-app/terminal-emulator") +include(":terminal-view") +project(":terminal-view").projectDir = file("third_party/termux-app/terminal-view") diff --git a/third_party/termux-app b/third_party/termux-app new file mode 160000 index 000000000..a2a0ff696 --- /dev/null +++ b/third_party/termux-app @@ -0,0 +1 @@ +Subproject commit a2a0ff696dd04eef41c479d544df7dee31a53511 diff --git a/version.properties b/version.properties index d4b887f16..5d739cd64 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -VERSION_CODE=678 -VERSION_NAME=1.13.13 -GO_VERSION=go1.25.10 +VERSION_CODE=681 +VERSION_NAME=1.14.0-alpha.31 +GO_VERSION=go1.25.11